Files
vberp/src/routes/backoffice/project/+page.svelte
aji@catalis.app b98814c4d5 perbaikan data
2025-06-08 20:10:23 +07:00

732 lines
28 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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}