Files
vberp/src/routes/backoffice/timesheets/+page.svelte
2025-06-09 16:17:12 +07:00

770 lines
29 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 { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
import { writable } from "svelte/store";
type Timesheets = {
id: number;
entered_by: string;
work_description: string;
type_of_work: "Running" | "Periodic" | "Irregular";
category_of_work:
| "Cleaning"
| "Gardening/Pool"
| "Maintenance"
| "Supervision"
| "Guest Service"
| "Administration"
| "Non Billable";
villa_id: string;
datetime_in: string;
datetime_out: string;
total_work_hour: number;
remarks: string;
approval: boolean;
created_at?: Date;
};
type TimesheetDisplay = {
id: number;
name: string;
staff_id: string;
date_in: Date;
date_out: Date;
type_of_work: "Running" | "Periodic" | "Irregular";
category_of_work:
| "Cleaning"
| "Gardening/Pool"
| "Maintenance"
| "Supervision"
| "Guest Service"
| "Administration"
| "Non Billable";
villa_name?: string;
approval: string;
approved_by?: string;
approved_date?: Date;
total_hours_work: number;
remarks: string;
created_at?: Date;
};
type TimesheetsInsert = {
entered_by: string;
work_description: string;
type_of_work: "Running" | "Periodic" | "Irregular";
category_of_work:
| "Cleaning"
| "Gardening/Pool"
| "Maintenance"
| "Supervision"
| "Guest Service"
| "Administration"
| "Non Billable";
villa_id: string;
datetime_in: string;
datetime_out: string;
total_work_hour: number;
remarks: string;
approval: boolean;
};
const categoryOfWork = [
{ label: "Cleaning", value: "Cleaning" },
{ label: "Gardening/Pool", value: "Gardening/Pool" },
{ label: "Maintenance", value: "Maintenance" },
{ label: "Security", value: "Security" },
{ label: "Other", value: "Other" },
];
const typeOfWork = [
{ label: "Running", value: "Running" },
{ label: "Periodic", value: "Periodic" },
{ label: "Irregular", value: "Irregular" },
];
const reportedBy = [
{ label: "Admin", value: "Admin" },
{ label: "Staff", value: "Staff" },
{ label: "Manager", value: "Manager" },
{ label: "Guest", value: "Guest" },
];
type Villa = {
id: string;
name: string;
};
let dataVilla: Villa[] = [];
onMount(async () => {
const { data, error } = await supabase
.from("villas")
.select("id, name");
if (error) {
console.error("Error fetching villas:", error);
} else if (data) {
dataVilla = data;
}
});
let allRows: TimesheetDisplay[] = [];
type columns = {
key: string;
title: string;
};
const columns: columns[] = [
{ key: "name", title: "Name" },
{ key: "staff_id", title: "Staff Report" },
{ key: "date_in", title: "Date In" },
{ key: "date_out", title: "Date Out" },
{ key: "type_of_work", title: "Type of Work" },
{ key: "category_of_work", title: "Category of Work" },
{ key: "approval", title: "Approval" },
{ key: "villa_name", title: "Villa Name" },
{ key: "approved_by", title: "Approved By" },
{ key: "approved_date", title: "Approved Date" },
{ key: "total_hours_work", title: "Total Hours Work" },
{ key: "remarks", title: "Remarks" },
{ key: "vacant", title: "Vacant" },
{ key: "created_at", title: "Created At" },
{ key: "actions", title: "Actions" },
];
async function fetchTimeSheets(
filter: string | null = null,
searchTerm: string | null = null,
sortColumn: string | null = "created_at",
sortOrder: "asc" | "desc" = "desc",
offset: number = 0,
limit: number = 10,
) {
let query = supabase
.from("vb_timesheet")
.select("*", { count: "exact" })
.order(sortColumn || "created_at", {
ascending: sortOrder === "asc",
});
if (filter) {
query = query.eq("category_of_work", filter);
}
if (searchTerm) {
query = query.ilike("work_description", `%${searchTerm}%`);
}
if (offset) {
query = query.range(offset, offset + limit - 1);
}
if (limit) {
query = query.limit(limit);
}
const { data: timesheet, error } = await query;
if (error) {
console.error("Error fetching timesheets:", error);
return;
}
// Ambil semua villa_id unik dari issues
const villaIds = [
...new Set(timesheet.map((i: Timesheets) => i.villa_id)),
];
const { data: villas, error: villaError } = await supabase
.from("villas")
.select("*")
.in("id", villaIds);
if (villaError) {
console.error("Error fetching villas:", villaError);
return;
}
// Gabungkan data villa ke dalam setiap issue
allRows = timesheet.map((issue: Timesheets) => {
const villa = villas.find((v) => v.id === issue.villa_id);
return {
id: issue.id,
name: issue.work_description, // Map work_description to name
staff_id: issue.entered_by, // Map entered_by to staff_id
date_in: new Date(issue.datetime_in),
date_out: new Date(issue.datetime_out),
type_of_work: issue.type_of_work,
category_of_work: issue.category_of_work,
villa_name: villa ? villa.name : "Unknown Villa",
approval: issue.approval ? "APPROVED" : "PENDING", // or map as needed
approved_by: undefined, // Set as needed
approved_date: undefined, // Set as needed
total_hours_work:
Math.abs(
new Date(issue.datetime_out).getTime() -
new Date(issue.datetime_in).getTime(),
) /
(1000 * 60 * 60), // Convert milliseconds to hours
remarks: issue.remarks,
created_at: issue.created_at
? new Date(issue.created_at)
: undefined,
} as TimesheetDisplay;
});
// Sort the rows based on the sortColumn and sortOrder
}
let currentPage = 1;
let rowsPerPage = 5;
$: 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;
}
onMount(() => {
fetchTimeSheets();
});
// Initialize the first page
$: currentPage = 1;
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newIssue: Record<string, any> = {};
const excludedKeys = ["id"];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
function openModal(issue?: Record<string, any>) {
if (issue) {
isEditing = true;
currentEditingId = issue.id;
newIssue = { ...issue };
} else {
isEditing = false;
currentEditingId = null;
newIssue = {};
}
showModal = true;
}
async function saveIssue(event: Event) {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
// Validate form data
if (!validateForm(formData)) {
console.error("Form validation failed");
return;
}
if (isEditing && currentEditingId) {
const { error } = await supabase
.from("vb_issues")
.update(newIssue)
.eq("id", currentEditingId);
if (error) {
alert("Error updating issue: " + error.message);
console.error("Error updating issue:", error);
return;
}
} else {
const TimesheetsInsert: TimesheetsInsert = {
entered_by: formData.get("entered_by") as string,
work_description: formData.get("work_description") as string,
type_of_work: formData.get("type_of_work") as
| "Running"
| "Periodic"
| "Irregular",
category_of_work: formData.get("category_of_work") as
| "Cleaning"
| "Gardening/Pool"
| "Maintenance"
| "Supervision"
| "Guest Service"
| "Administration"
| "Non Billable",
villa_id: formData.get("villa_id") as string,
datetime_in: formData.get("date_in") as string,
datetime_out: formData.get("date_out") as string,
total_work_hour: Math.abs(
new Date(formData.get("date_out") as string).getTime() -
new Date(formData.get("date_in") as string).getTime(),
),
remarks: formData.get("remarks") as string,
approval: false, // Default to false for new entries
};
const { error } = await supabase
.from("vb_timesheet")
.insert([TimesheetsInsert]);
if (error) {
console.error("Error adding issue:", error);
return;
}
}
await fetchTimeSheets();
showModal = false;
}
async function deleteTimesheet(id: string) {
if (confirm("Are you sure you want to delete this issue?")) {
const { error } = await supabase
.from("vb_timesheet")
.delete()
.eq("id", id);
if (error) {
console.error("Error deleting issue:", error);
return;
}
await fetchTimeSheets();
}
}
export let formErrors = writable<{ [key: string]: string }>({});
function validateForm(formData: FormData): boolean {
const errors: { [key: string]: string } = {};
const requiredFields = [
"work_description",
"type_of_work",
"category_of_work",
"villa_id",
"date_in",
"date_out",
"remarks",
];
requiredFields.forEach((field) => {
if (!formData.get(field) || formData.get(field) === "") {
errors[field] = `${field.replace(/_/g, " ")} is required.`;
}
});
formErrors.set(errors);
return Object.keys(errors).length === 0;
}
function errorClass(field: string): string {
return $formErrors[field] ? "border-red-500" : "border";
}
async function updateApprovalStatus(
id: string,
status: string,
): Promise<void> {
const { error } = await supabase
.from("vb_timesheet")
.update({ approval: status })
.eq("id", id);
if (error) {
console.error("Error updating approval status:", error);
} else {
await fetchTimeSheets();
}
}
</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">
🕒 Timesheet List
</h2>
<p class="text-sm text-gray-600">
Manage and track timesheets for staff members.
</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();
fetchTimeSheets(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;
fetchTimeSheets(filter, null, null, "desc");
}}
>
<option value="">All Issues</option>
<option value="PROJECT">Project Issues</option>
<option value="PURCHASE_ORDER">Purchase Order Issues</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={() =>
fetchTimeSheets(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 Timesheet
</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 === "approval"}
<td class="px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium
{row[col.key] === 'APPROVED'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'}"
>
{row[col.key]}
</span>
{#if row.approval === "PENDING"}
<button
class="ml-2 inline-flex items-center gap-1 rounded bg-green-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-green-700"
on:click={() =>
updateApprovalStatus(
String(row.id),
"APPROVED",
)}
>
✅ Approve
</button>
{/if}
</td>
{:else if col.key === "approved_by"}
<td class="px-4 py-2">
{row[col.key] || "Not Approved"}
</td>
{:else if col.key === "approved_date"}
<td class="px-4 py-2">
{row[col.key] !== undefined
? new Date(
row[col.key]!,
).toLocaleDateString()
: "N/A"}
</td>
{:else if col.key === "total_hours_work"}
<td class="px-4 py-2">
{row[col.key].toFixed(2)} hours
</td>
{:else if col.key === "created_at"}
<td class="px-4 py-2">
{row[col.key] !== undefined
? new Date(
row[col.key]!,
).toLocaleDateString()
: "N/A"}
</td>
{:else if col.key === "villa_name"}
<td class="px-4 py-2">
{row[col.key] || "Unknown Villa"}
</td>
{:else if col.key === "staff_id"}
<td class="px-4 py-2">
{row[col.key] || "Unknown Staff"}
</td>
{:else if col.key === "remarks"}
<td class="px-4 py-2">
{row[col.key] || "No remarks"}
</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={() =>
deleteTimesheet(String(row.id))}
>
🗑️ Delete
</button>
</td>
{:else}
<td class="px-4 py-2">
{row[col.key as keyof TimesheetDisplay]}
</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>
<!-- Modal -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<form
on:submit|preventDefault={saveIssue}
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">
{isEditing ? "Edit Issue" : "Add New Issue"}
</h3>
{#each formColumns as col}
{#if col.key === "name"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<input
type="text"
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-2 rounded {errorClass(
col.key,
)}"
required
/>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else if col.key === "remarks"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<textarea
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-2 rounded {errorClass(
col.key,
)}"
required
></textarea>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else if col.key === "type_of_work" || col.key === "category_of_work"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<select
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-2 rounded {errorClass(
col.key,
)}"
required
>
{#each col.key === "type_of_work" ? typeOfWork : categoryOfWork as option}
<option value={option.value}>
{option.label}
</option>
{/each}
</select>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else if col.key === "villa_id"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<select
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-
2 rounded {errorClass(col.key)}"
required
>
<option value="" disabled selected
>Select Villa</option
>
{#each dataVilla as villa}
<option value={villa.id}>{villa.name}</option>
{/each}
</select>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else if col.key === "date_in" || col.key === "date_out"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<input
type="datetime-local"
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-2 rounded {errorClass(
col.key,
)}"
required
/>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else if col.key === "entered_by"}
<div>
<label class="block text-sm font-medium mb-1">
{col.title}
</label>
<select
name={col.key}
bind:value={newIssue[col.key]}
class="w-full border p-2 rounded {errorClass(
col.key,
)}"
required
>
{#each reportedBy as option}
<option value={option.value}>
{option.label}
</option>
{/each}
</select>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs mt-1">
{$formErrors[col.key]}
</p>
{/if}
</div>
{:else}
<!-- Handle other fields if necessary -->
{/if}
{/each}
<div>
<label class="block text-sm font-medium mb-1">
Total Work Hour
</label>
<input
type="number"
name="total_work_hour"
bind:value={newIssue.total_work_hour}
class="w-full border p-2 rounded {errorClass(
'total_work_hour',
)}"
readonly
/>
{#if $formErrors.total_work_hour}
<p class="text-red-500 text-xs mt-1">
{$formErrors.total_work_hour}
</p>
{/if}
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class="px-4 py-2 text-sm rounded bg-gray-200 hover:bg-gray-300"
on:click={() => (showModal = false)}
>
Cancel
</button>
<button
class="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
type="submit"
>
Save
</button>
</div>
</form>
</div>
{/if}