732 lines
28 KiB
Svelte
732 lines
28 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from "svelte";
|
||
import { supabase } from "$lib/supabaseClient";
|
||
|
||
type Project = {
|
||
id: string;
|
||
issue_id: string;
|
||
project_number: string;
|
||
add_to_po: boolean;
|
||
input_by: string;
|
||
project_due_date: string;
|
||
picture_link: string;
|
||
villa_data?: string; // Optional, if not always present
|
||
};
|
||
|
||
type insetProject = {
|
||
issue_id: string;
|
||
input_by: string;
|
||
project_due_date: string;
|
||
picture_link: string;
|
||
};
|
||
|
||
type Projects = {
|
||
id: string;
|
||
name: string;
|
||
priority: string;
|
||
add_to_po: boolean;
|
||
description_of_the_issue: string;
|
||
picture_link: string;
|
||
need_approval: boolean;
|
||
area_of_villa: string;
|
||
input_by: string;
|
||
issue_number: string;
|
||
issue_id: string;
|
||
report_date: string;
|
||
project_due_date: string;
|
||
};
|
||
|
||
let allRows: Projects[] = [];
|
||
|
||
type columns = {
|
||
key: string;
|
||
title: string;
|
||
};
|
||
|
||
const columns: columns[] = [
|
||
{ key: "name", title: "Project Name" },
|
||
{ key: "priority", title: "Priority" },
|
||
{ key: "add_to_po", title: "Add to PO" },
|
||
{ key: "description_of_the_issue", title: "Description" },
|
||
{ key: "picture_link", title: "Picture Link" },
|
||
{ key: "need_approval", title: "Need Approval" },
|
||
{ key: "area_of_villa", title: "Area of Villa" },
|
||
{ key: "input_by", title: "Input By" },
|
||
{ key: "issue_number", title: "Issue Number" },
|
||
{ key: "villa_name", title: "Villa Name" },
|
||
{ key: "report_date", title: "Report Date" },
|
||
{ key: "project_due_date", title: "Project Due Date" },
|
||
{ key: "actions", title: "Actions" },
|
||
];
|
||
|
||
let selectedFile: File | null = null;
|
||
let imagePreviewUrl: string | null = null;
|
||
|
||
function handleFileChange(event: Event) {
|
||
const input = event.target as HTMLInputElement;
|
||
if (input.files && input.files.length > 0) {
|
||
selectedFile = input.files[0];
|
||
imagePreviewUrl = URL.createObjectURL(selectedFile);
|
||
}
|
||
}
|
||
|
||
let currentPage = 1;
|
||
let rowsPerPage = 10;
|
||
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
|
||
$: paginatedRows = allRows.slice(
|
||
(currentPage - 1) * rowsPerPage,
|
||
currentPage * rowsPerPage,
|
||
);
|
||
|
||
function goToPage(page: number) {
|
||
if (page >= 1 && page <= totalPages) currentPage = page;
|
||
}
|
||
|
||
async function fetchProjects(
|
||
filter: string | null = null,
|
||
searchTerm: string | null = null,
|
||
sortBy: string | null = null,
|
||
sortOrder: "asc" | "desc" = "asc",
|
||
offset: number = 0,
|
||
limit: number = 10,
|
||
) {
|
||
let query = supabase
|
||
.from("vb_projects_data")
|
||
.select("*", { count: "exact" })
|
||
.order(sortBy || "created_at", { ascending: sortOrder === "asc" })
|
||
.range(offset, offset + limit - 1);
|
||
// Apply filter if provided
|
||
if (filter) {
|
||
query = query.eq("priority", filter);
|
||
}
|
||
// Apply search term if provided
|
||
if (searchTerm) {
|
||
query = query.ilike("issue_name", `%${searchTerm}%`);
|
||
}
|
||
|
||
// Fetch projects
|
||
const { data, error } = await query;
|
||
if (error) {
|
||
console.error("Error fetching projects:", error);
|
||
return;
|
||
}
|
||
|
||
// ambil issue_id dari projects kemudian ambil data issue yang sesuai
|
||
const issueIds = data?.map((project: Project) => project.issue_id);
|
||
const { data: issueData, error: issueError } = await supabase
|
||
.from("vb_issues")
|
||
.select("*")
|
||
.in("id", issueIds || [])
|
||
.order("id", { ascending: false });
|
||
|
||
// gabungkan data projects dengan data issues
|
||
if (!data || !issueData) {
|
||
console.error("Error fetching projects or issues:", error);
|
||
return;
|
||
}
|
||
|
||
allRows = []; // Reset allRows before populating
|
||
|
||
// Set allRows to the combined data
|
||
allRows = data.map((project: Project) => {
|
||
const issue = issueData.find(
|
||
(issue) => issue.id === project.issue_id,
|
||
);
|
||
return {
|
||
...project,
|
||
id: project.id,
|
||
name: issue ? issue.name : "Unknown",
|
||
priority: issue ? issue.priority : "Unknown",
|
||
add_to_po: project.add_to_po,
|
||
description_of_the_issue: issue
|
||
? issue.description_of_the_issue
|
||
: "No description",
|
||
picture_link: project.picture_link,
|
||
need_approval: issue ? issue.need_approval : false,
|
||
area_of_villa: issue ? issue.area_of_villa : "Unknown",
|
||
input_by: project.input_by,
|
||
issue_number: issue ? issue.issue_number : "Unknown",
|
||
villa_name: issue ? project.villa_data : "Unknown",
|
||
report_date: issue ? issue.reported_date : "Unknown",
|
||
project_due_date: project.project_due_date,
|
||
};
|
||
});
|
||
|
||
// hanya valid uuid
|
||
|
||
if (issueError) {
|
||
console.error("Error fetching available issues:", issueError);
|
||
return;
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
fetchProjects();
|
||
});
|
||
|
||
$: currentPage = 1; // Reset to first page when allRows changes
|
||
|
||
let showModal = false;
|
||
let isEditing = false;
|
||
let currentEditingId: string | null = null;
|
||
let newProjects: Record<string, any> = {};
|
||
let projectForUpdate: Record<string, any> | null = null;
|
||
let dataIssueIds: Record<string, any>[] = [];
|
||
const excludedKeys = [
|
||
"id",
|
||
"issue_id",
|
||
"number_project",
|
||
"input_by",
|
||
"name",
|
||
"priority",
|
||
"description_of_the_issue",
|
||
"need_approval",
|
||
"area_of_villa",
|
||
"villa_name",
|
||
"report_date",
|
||
"actions",
|
||
"add_to_po",
|
||
"issue_number",
|
||
];
|
||
const formColumns = columns.filter(
|
||
(col) => !excludedKeys.includes(col.key),
|
||
);
|
||
|
||
function openModal(project: Projects | null = null) {
|
||
if (project) {
|
||
isEditing = true;
|
||
currentEditingId = project.id;
|
||
newProjects = { ...project };
|
||
} else {
|
||
isEditing = false;
|
||
currentEditingId = null;
|
||
newProjects = {};
|
||
}
|
||
showModal = true;
|
||
fetchIssueIds(); // Fetch issue IDs when opening the modal
|
||
}
|
||
|
||
//validation project make
|
||
function validateProjectCheckBox(project: insetProject): boolean {
|
||
if (!project.project_due_date) {
|
||
console.error("Project due date is required");
|
||
alert("Project due date is required");
|
||
return false;
|
||
}
|
||
|
||
if (!project.picture_link) {
|
||
console.error("Picture link is required");
|
||
alert("Picture link is required");
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
project.project_due_date &&
|
||
isNaN(Date.parse(project.project_due_date))
|
||
) {
|
||
console.error("Project due date must be a valid date");
|
||
alert("Project due date must be a valid date");
|
||
return false;
|
||
}
|
||
|
||
// if (
|
||
// project.picture_link &&
|
||
// !/^https?:\/\/.+\.(jpg|jpeg|png|gif)$/.test(project.picture_link)
|
||
// ) {
|
||
// console.error("Picture link must be a valid image URL");
|
||
// alert("Picture link must be a valid image URL");
|
||
// return false;
|
||
// }
|
||
return true;
|
||
}
|
||
|
||
//get all id dan name from issues
|
||
async function fetchIssueIds() {
|
||
const { data, error } = await supabase
|
||
.from("vb_issues")
|
||
.select("id, name")
|
||
.order("id", { ascending: false });
|
||
|
||
if (error) {
|
||
console.error("Error fetching issues:", error);
|
||
return;
|
||
}
|
||
|
||
dataIssueIds = data.map((issue) => ({
|
||
id: issue.id,
|
||
name: issue.name,
|
||
}));
|
||
}
|
||
|
||
async function addToPo(project: Project) {
|
||
if (!project.add_to_po) {
|
||
console.error("Project must be added to PO");
|
||
alert("Project must be added to PO");
|
||
return;
|
||
}
|
||
|
||
// Validate project before saving
|
||
if (!validateProjectCheckBox(project)) {
|
||
return;
|
||
}
|
||
projectForUpdate = {
|
||
add_to_po: project?.add_to_po,
|
||
input_by: project?.input_by,
|
||
project_due_date: project?.project_due_date,
|
||
picture_link: project?.picture_link,
|
||
};
|
||
|
||
const { data, error } = await supabase
|
||
.from("vb_projects")
|
||
.update(projectForUpdate)
|
||
.eq("id", currentEditingId);
|
||
|
||
if (error) {
|
||
console.error("Error updating project:", error);
|
||
return;
|
||
}
|
||
|
||
alert("Add To PO Successfully");
|
||
|
||
await fetchProjects();
|
||
|
||
// tambah ke Purchase Order
|
||
const { data: poData, error: poError } = await supabase
|
||
.from("vb_purchase_orders")
|
||
.insert({
|
||
issue_id: project.issue_id,
|
||
po_status: "REQUESTED",
|
||
});
|
||
if (poError) {
|
||
console.error("Error adding to Purchase Order:", poError);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// function get public URL for image supabase
|
||
async function getPublicUrl(path: string): Promise<string> {
|
||
const { data } = supabase.storage.from("villabugis").getPublicUrl(path);
|
||
return data.publicUrl;
|
||
}
|
||
|
||
async function saveProject(event: Event) {
|
||
const formData = new FormData(event.target as HTMLFormElement);
|
||
|
||
// Upload image if selected
|
||
if (selectedFile) {
|
||
const { data, error } = await supabase.storage
|
||
.from("villabugis")
|
||
.upload(`project/${selectedFile.name}`, selectedFile);
|
||
|
||
if (error) {
|
||
console.error("Error uploading image:", error);
|
||
return;
|
||
}
|
||
|
||
imagePreviewUrl = data?.path;
|
||
}
|
||
|
||
const projectUpdate: insetProject = {
|
||
issue_id: formData.get("issue_id") as string,
|
||
input_by: formData.get("input_by") as string,
|
||
project_due_date: formData.get("project_due_date") as string,
|
||
picture_link:
|
||
imagePreviewUrl || (formData.get("picture_link") as string),
|
||
};
|
||
|
||
// Validate project before saving
|
||
if (!validateProjectCheckBox(projectUpdate)) {
|
||
return;
|
||
}
|
||
|
||
console.log("current editing", currentEditingId);
|
||
|
||
if (isEditing && currentEditingId) {
|
||
const { data, error } = await supabase
|
||
.from("vb_projects")
|
||
.update(projectUpdate)
|
||
.eq("id", currentEditingId);
|
||
|
||
if (error) {
|
||
console.error("Error updating project:", error);
|
||
return;
|
||
} else {
|
||
alert("Project updated successfully");
|
||
}
|
||
} else {
|
||
const { data, error } = await supabase
|
||
.from("vb_projects")
|
||
.insert(projectUpdate);
|
||
|
||
if (error) {
|
||
console.error("Error inserting project:", error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await fetchProjects();
|
||
showModal = false;
|
||
}
|
||
|
||
async function deleteProject(id: string) {
|
||
const { error } = await supabase
|
||
.from("vb_projects")
|
||
.delete()
|
||
.eq("id", id);
|
||
|
||
if (error) {
|
||
console.error("Error deleting project:", error);
|
||
return;
|
||
}
|
||
|
||
await fetchProjects();
|
||
}
|
||
</script>
|
||
|
||
<div>
|
||
<div
|
||
class="p-6 bg-white shadow-md rounded-2xl mb-4 flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4"
|
||
>
|
||
<div>
|
||
<h2 class="text-lg font-semibold text-gray-800">📋 Project List</h2>
|
||
<p class="text-sm text-gray-600">
|
||
Manage your projects and tasks efficiently.
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Search by name..."
|
||
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-4 py-2 rounded-xl text-sm w-64 transition"
|
||
on:input={(e) => {
|
||
const searchTerm = (
|
||
e.target as HTMLInputElement
|
||
).value.toLowerCase();
|
||
fetchProjects(null, searchTerm, "created_at", "desc");
|
||
}}
|
||
/>
|
||
<select
|
||
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-4 py-2 rounded-xl text-sm w-48 transition"
|
||
on:change={(e) => {
|
||
const filter = (e.target as HTMLSelectElement).value;
|
||
fetchProjects(filter, null, null, "desc");
|
||
}}
|
||
>
|
||
<option value="">Filter by Priority</option>
|
||
<option value="High">High</option>
|
||
<option value="Medium">Medium</option>
|
||
<option value="Low">Low</option>
|
||
</select>
|
||
<button
|
||
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
|
||
on:click={() =>
|
||
fetchProjects(null, null, "created_at", "desc", 0, 10)}
|
||
>
|
||
🔄 Reset
|
||
</button>
|
||
<button
|
||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
|
||
on:click={() => openModal()}
|
||
>
|
||
➕ Add Project
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto rounded-lg shadow mb-4">
|
||
<table class="min-w-[1000px] divide-y divide-gray-200 text-sm w-max">
|
||
<thead class="bg-gray-100">
|
||
<tr>
|
||
{#each columns as col}
|
||
{#if col.key === "name"}
|
||
<th
|
||
class="sticky left-0 px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
|
||
style="background-color: #f0f8ff; z-index: 10;"
|
||
>
|
||
{col.title}
|
||
</th>
|
||
{:else}
|
||
<th
|
||
class="px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
|
||
>
|
||
{col.title}
|
||
</th>
|
||
{/if}
|
||
{/each}
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 bg-white">
|
||
{#each paginatedRows as row}
|
||
<tr class="hover:bg-gray-50 transition">
|
||
{#each columns as col}
|
||
{#if col.key === "name"}
|
||
<td
|
||
class="sticky left-0 px-4 py-2 font-medium text-blue-600"
|
||
style="background-color: #f0f8ff; cursor: pointer;"
|
||
>
|
||
{row[col.key]}
|
||
</td>
|
||
{:else if col.key === "actions"}
|
||
<td class="px-4 py-2">
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-blue-700"
|
||
on:click={() => openModal(row)}
|
||
>
|
||
✏️ Edit
|
||
</button>
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded bg-red-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-red-700"
|
||
on:click={() => deleteProject(row.id)}
|
||
>
|
||
🗑️ Delete
|
||
</button>
|
||
</td>
|
||
{:else if col.key === "add_to_po"}
|
||
<td class="px-4 py-2 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={row.add_to_po}
|
||
on:change={async (e) => {
|
||
const isChecked = (
|
||
e.target as HTMLInputElement
|
||
).checked;
|
||
row.add_to_po = isChecked;
|
||
|
||
if (isChecked) {
|
||
// map to project
|
||
const project: Project = {
|
||
id: row.id,
|
||
issue_id: row.issue_id,
|
||
project_number:
|
||
row.issue_number,
|
||
add_to_po: isChecked,
|
||
input_by: row.input_by,
|
||
project_due_date:
|
||
row.project_due_date,
|
||
picture_link:
|
||
row.picture_link,
|
||
};
|
||
currentEditingId = row.id;
|
||
await addToPo(project);
|
||
} else {
|
||
// uncheck
|
||
const { data, error } =
|
||
await supabase
|
||
.from("vb_projects")
|
||
.update({
|
||
add_to_po: false,
|
||
})
|
||
.eq("id", row.id);
|
||
if (error) {
|
||
console.error(
|
||
"Error updating project:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</td>
|
||
{:else if col.key === "need_approval"}
|
||
<td class="px-4 py-2">
|
||
{#if row[col.key as keyof Projects]}
|
||
✅
|
||
{:else}
|
||
❌
|
||
{/if}
|
||
</td>
|
||
{:else if col.key === "picture_link"}
|
||
<td class="px-4 py-2">
|
||
{#if row.picture_link}
|
||
{#await getPublicUrl(row.picture_link) then publicUrl}
|
||
<a
|
||
href={publicUrl}
|
||
target="_blank"
|
||
class="text-blue-600 hover:underline"
|
||
>View Picture</a
|
||
>
|
||
{:catch}
|
||
<span class="text-red-500"
|
||
>Error loading image</span
|
||
>
|
||
{/await}
|
||
{:else}
|
||
No Picture
|
||
{/if}
|
||
</td>
|
||
{:else}
|
||
<td class="px-4 py-2 text-gray-700"
|
||
>{row[col.key as keyof Projects]}</td
|
||
>
|
||
{/if}
|
||
{/each}
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Pagination controls -->
|
||
<div class="flex justify-between items-center text-sm">
|
||
<div>
|
||
Showing {(currentPage - 1) * rowsPerPage + 1}–
|
||
{Math.min(currentPage * rowsPerPage, allRows.length)} of {allRows.length}
|
||
</div>
|
||
<div class="space-x-2">
|
||
<button
|
||
class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-50"
|
||
on:click={() => goToPage(currentPage - 1)}
|
||
disabled={currentPage === 1}
|
||
>
|
||
Previous
|
||
</button>
|
||
{#each Array(totalPages)
|
||
.fill(0)
|
||
.map((_, i) => i + 1) as page}
|
||
<button
|
||
class="px-3 py-1 rounded border text-sm
|
||
{currentPage === page
|
||
? 'bg-blue-600 text-white border-blue-600'
|
||
: 'bg-white border-gray-300 hover:bg-gray-100'}"
|
||
on:click={() => goToPage(page)}
|
||
>
|
||
{page}
|
||
</button>
|
||
{/each}
|
||
<button
|
||
class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-50"
|
||
on:click={() => goToPage(currentPage + 1)}
|
||
disabled={currentPage === totalPages}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{#if showModal}
|
||
<div
|
||
class="fixed inset-0 flex items-center justify-center bg-gray-800 bg-opacity-50 z-50"
|
||
>
|
||
<div
|
||
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
|
||
>
|
||
<h3 class="text-lg font-semibold mb-4">
|
||
{isEditing ? "Edit Project" : "Add Project"}
|
||
</h3>
|
||
<form on:submit|preventDefault={saveProject}>
|
||
{#each formColumns as col}
|
||
{#if col.key === "project_due_date"}
|
||
<div class="mb-4">
|
||
<label
|
||
for={col.key}
|
||
class="block text-sm font-medium text-gray-700 mb-1"
|
||
>
|
||
{col.title}
|
||
</label>
|
||
<input
|
||
name={col.key}
|
||
type="date"
|
||
id={col.key}
|
||
bind:value={newProjects[col.key]}
|
||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
{:else if col.key === "picture_link"}
|
||
<!-- image upload -->
|
||
<div class="mb-4">
|
||
<label
|
||
for={col.key}
|
||
class="block text-sm font-medium text-gray-700 mb-1"
|
||
>
|
||
{col.title}
|
||
</label>
|
||
<input
|
||
name="picture_link"
|
||
class="w-full border px-3 py-2 rounded"
|
||
type="file"
|
||
accept="image/*"
|
||
on:change={handleFileChange}
|
||
/>
|
||
<p class="text-xs text-gray-500">
|
||
Upload an image related to the issue.
|
||
</p>
|
||
|
||
{#if imagePreviewUrl}
|
||
<img
|
||
src={imagePreviewUrl}
|
||
alt="Preview"
|
||
class="mt-2 max-h-48 rounded border"
|
||
/>
|
||
{:else if newProjects.picture_link}
|
||
{#await getPublicUrl(newProjects.picture_link) then url}
|
||
<img
|
||
src={url}
|
||
alt="Preview"
|
||
class="mt-2 max-h-48 rounded border"
|
||
/>
|
||
{/await}
|
||
{:else}
|
||
<p class="text-red-500 mt-2">
|
||
No image selected or uploaded.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="mb-4">
|
||
<label
|
||
for={col.key}
|
||
class="block text-sm font-medium text-gray-700 mb-1"
|
||
>
|
||
{col.title}
|
||
</label>
|
||
<input
|
||
name={col.key}
|
||
type="text"
|
||
id={col.key}
|
||
bind:value={newProjects[col.key]}
|
||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
<!-- list issue dan bisa mencari -->
|
||
<div class="mb-4">
|
||
<label
|
||
for="issue_id"
|
||
class="block text-sm font-medium text-gray-700 mb-1"
|
||
>
|
||
Issue ID
|
||
</label>
|
||
<select
|
||
id="issue_id"
|
||
name="issue_id"
|
||
required
|
||
bind:value={newProjects.issue_id}
|
||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
|
||
>
|
||
<option value="" disabled selected>Select Issue</option>
|
||
{#each dataIssueIds as issueId}
|
||
<option value={issueId.id}>{issueId.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<div class="flex justify-end space-x-2">
|
||
<button
|
||
type="button"
|
||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||
on:click={() => (showModal = false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|