Files
vberp/src/routes/backoffice/timesheets/+page.svelte
2025-07-09 10:11:46 +14:00

898 lines
34 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";
import Pagination from "$lib/Pagination.svelte";
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;
approved_by?: string;
approved_date?: Date;
created_at?: Date;
};
type TimesheetsJoined = Timesheets & {
vb_employee: { id: string; employee_name: string } | null;
};
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 | null; // Allow null for new entries
};
type Villa = {
id: string;
villa_name: string;
};
type columns = {
key: string;
title: string;
};
type Employee = {
id: string;
name: string;
};
const categoryOfWork = [
{ label: "Cleaning", value: "Cleaning" },
{ label: "Gardening/Pool", value: "Gardening/Pool" },
{ label: "Maintenance", value: "Maintenance" },
{ label: "Supervision", value: "Supervision" },
{ label: "Guest Service", value: "Guest Service" },
{ label: "Administration", value: "Administration" },
{ label: "Non Billable", value: "Non Billable" },
];
const typeOfWork = [
{ label: "Running", value: "Running" },
{ label: "Periodic", value: "Periodic" },
{ label: "Irregular", value: "Irregular" },
];
const columns: columns[] = [
{ key: "villa_name", title: "Villa Name" },
{ key: "name", title: "Work Description" },
{ key: "staff_id", title: "Staff Name" },
{ 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: "approved_by", title: "Approved By" },
{ key: "approved_date", title: "Approved/Rejected Date" },
{ key: "total_hours_work", title: "Total Hours Work" },
{ key: "remarks", title: "Remarks" },
{ key: "created_at", title: "Created At" },
{ key: "actions", title: "Actions" },
];
const excludedKeys = ["id"];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
const typeOfWorkOptions = ["Running", "Periodic", "Irregular"];
const categoryOptions = [
"Cleaning",
"Gardening/Pool",
"Maintenance",
"Supervision",
"Guest Service",
"Administration",
"Non Billable",
];
// reactive variables
let sortColumn: string | null = "created_at";
let sortOrder: "asc" | "desc" = "desc";
let currentUserId: string | null = null;
let currentVillaFilter: string | null = null;
let currentSearchTerm: string | null = null;
let dataVilla: Villa[] = [];
let allRows: TimesheetDisplay[] = [];
let rowsPerPage = 20;
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
$: paginatedRows = allRows.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage,
);
$: currentPage = 1;
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newTsdata: Record<string, any> = {};
let employees: Employee[] = [];
let villas: Villa[] = [];
let form = {
entered_by: "",
work_description: "",
type_of_work: "Running",
category_of_work: "Cleaning",
villa_id: "",
datetime_in: "",
datetime_out: "",
total_work_hour: 0,
remarks: "",
approval: null, // Default null
};
// Fetch initial data on mount
onMount(async () => {
// get current user
const {
data: { user },
} = await supabase.auth.getUser();
currentUserId = user?.id ?? null;
// fetch employees
const { data: empData, error: empErr } = await supabase
.from("vb_employee")
.select("id, employee_name")
.eq("employee_status", "Active")
.order("employee_name", { ascending: true });
if (!empErr && empData) {
employees = empData.map((e) => ({
id: e.id,
name: e.employee_name,
}));
} else {
console.error("Failed to load employees", empErr);
}
// fetch villas
const { data: villaData, error: villaErr } = await supabase
.from("vb_villas")
.select("id, villa_name")
.eq("villa_status", "Active")
.order("villa_name", { ascending: true });
if (!villaErr && villaData) {
dataVilla = villaData;
villas = villaData;
} else {
console.error("Failed to load villas", villaErr);
}
fetchTimeSheets();
});
// Reactive variables for sorting
function toggleSort(column: string) {
if (sortColumn === column) {
sortOrder = sortOrder === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortOrder = "asc";
}
fetchTimeSheets(); // re-fetch with new sort
}
// Function to calculate total work hours
function calculateTotalHours() {
if (form.datetime_in && form.datetime_out) {
const start = new Date(form.datetime_in);
const end = new Date(form.datetime_out);
const diffInMs = end.getTime() - start.getTime();
const hours = diffInMs / (1000 * 60 * 60);
form.total_work_hour = Math.max(Number(hours.toFixed(2)), 0);
} else {
form.total_work_hour = 0;
}
}
// Function to go to a specific page
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) currentPage = page;
}
// Function to open the modal for adding or editing a timesheet
function openModal(tsdata?: Record<string, any>) {
if (tsdata) {
// Edit mode
isEditing = true;
currentEditingId = tsdata.id;
form = {
entered_by:
employees.find((e) => e.name === tsdata.staff_id)?.id || "",
work_description: tsdata.name,
type_of_work: tsdata.type_of_work,
category_of_work: tsdata.category_of_work,
villa_id:
villas.find((v) => v.villa_name === tsdata.villa_name)?.id ||
"",
datetime_in: tsdata.date_in?.toISOString().slice(0, 16),
datetime_out: tsdata.date_out?.toISOString().slice(0, 16),
total_work_hour: 0,
remarks: tsdata.remarks,
approval: null, // leave null or bring in if editing allowed
};
calculateTotalHours();
} else {
// Add mode
isEditing = false;
currentEditingId = null;
form = {
entered_by: "",
work_description: "",
type_of_work: "Running",
category_of_work: "Cleaning",
villa_id: "",
datetime_in: "",
datetime_out: "",
total_work_hour: 0,
remarks: "",
approval: null,
};
}
showModal = true;
}
// Function to fetch timesheets with optional filters and sorting
async function fetchTimeSheets(
villaIdFilter: string | null = null,
searchTerm: string | null = null,
offset: number = 0,
limit: number = 1000,
) {
let reportedBy: { label: string; value: string }[] = [];
const { data: staffData, error: staffError } = await supabase
.from("vb_employee")
.select("id, employee_name")
.eq("employee_status", "Active")
.order("employee_name", { ascending: true });
if (staffError) {
console.error("Error fetching staff:", staffError);
} else if (staffData) {
reportedBy = staffData.map((s) => ({
label: s.employee_name,
value: s.id,
}));
}
let query = supabase
.from("vb_timesheet")
.select(`*`)
.order(sortColumn || "created_at", {
ascending: sortOrder === "asc",
});
if (villaIdFilter) {
const { data: villaMatch } = await supabase
.from("vb_villas")
.select("id")
.eq("villa_name", villaIdFilter);
const matchedId = villaMatch?.[0]?.id;
if (matchedId) {
query = query.eq("villa_id", matchedId);
}
}
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;
}
const loweredSearch = searchTerm?.toLowerCase();
let filteredTimesheet = timesheet;
if (loweredSearch) {
filteredTimesheet = timesheet.filter((ts) => {
const workDesc = ts.work_description?.toLowerCase() || "";
const staffName = reportedBy.find((s) => s.value === ts.entered_by)?.label?.toLowerCase() || "";
return (
workDesc.includes(loweredSearch) ||
staffName.includes(loweredSearch)
);
});
}
const villaIds = [...new Set(filteredTimesheet.map((i: Timesheets) => i.villa_id))];
let villas = [];
if (villaIds.length > 0) {
const { data: villasData, error: villaError } = await supabase
.from("vb_villas")
.select("*")
.in("id", villaIds);
if (villaError) {
console.error("Error fetching villas:", villaError);
} else {
villas = villasData;
}
}
const { data: approvers, error: approverError } = await supabase
.from("vb_users")
.select("id, full_name");
if (approverError) {
console.error("Error fetching approvers:", approverError);
}
allRows = filteredTimesheet.map((tsdata: TimesheetsJoined) => {
const villa = villas.find((v) => v.id === tsdata.villa_id);
const approver = approvers?.find((u) => u.id === tsdata.approved_by);
return {
id: tsdata.id,
name: tsdata.work_description,
staff_id:
reportedBy.find((s) => s.value === tsdata.entered_by)?.label || "Unknown",
date_in: new Date(tsdata.datetime_in),
date_out: new Date(tsdata.datetime_out),
type_of_work: tsdata.type_of_work,
category_of_work: tsdata.category_of_work,
villa_name: villa ? villa.villa_name : "Unknown Villa",
approval:
tsdata.approval == null
? "PENDING"
: tsdata.approval
? "APPROVED"
: "REJECTED",
total_hours_work:
Math.abs(
new Date(tsdata.datetime_out).getTime() - new Date(tsdata.datetime_in).getTime()
) / (1000 * 60 * 60),
approved_by: approver?.full_name ?? "Not Approved",
approved_date: tsdata.approved_date,
remarks: tsdata.remarks,
// created_at: tsdata.created_at ? new Date(tsdata.created_at) : undefined,
} as TimesheetDisplay;
});
currentPage = 1;
console.log("Fetched rows:", allRows);
}
// Function to delete a timesheet
async function deleteTimesheet(id: string) {
if (confirm("Are you sure you want to delete this Timesheet?")) {
const { error } = await supabase
.from("vb_timesheet")
.delete()
.eq("id", id);
if (error) {
console.error("Error deleting Timesheet:", error);
return;
}
await fetchTimeSheets();
}
}
// Function to update the approval status of a timesheet
async function updateApprovalStatus(
id: string,
status: string,
): Promise<void> {
const approved = status === "true";
const approved_by = currentUserId;
const approved_date = new Date().toISOString();
const { error } = await supabase
.from("vb_timesheet")
.update({
approval: status,
approved_by,
approved_date,
})
.eq("id", id);
if (error) {
console.error("Error updating approval status:", error);
} else {
await fetchTimeSheets();
}
}
// Function to submit the form data
async function submitForm() {
calculateTotalHours();
if (!form.entered_by || !form.villa_id) {
alert("Please select an employee and villa.");
return;
}
let error = null;
if (isEditing && currentEditingId) {
const { error: updateError } = await supabase
.from("vb_timesheet")
.update({
entered_by: form.entered_by,
work_description: form.work_description,
type_of_work: form.type_of_work,
category_of_work: form.category_of_work,
villa_id: form.villa_id,
datetime_in: form.datetime_in,
datetime_out: form.datetime_out,
total_work_hour: form.total_work_hour,
remarks: form.remarks,
approval: form.approval,
})
.eq("id", currentEditingId);
error = updateError;
} else {
const { error: insertError } = await supabase
.from("vb_timesheet")
.insert([form]);
error = insertError;
}
if (error) {
alert("Failed to save timesheet: " + error.message);
} else {
alert("Timesheet saved successfully!");
form = {
entered_by: "",
work_description: "",
type_of_work: "Running",
category_of_work: "Cleaning",
villa_id: "",
datetime_in: "",
datetime_out: "",
total_work_hour: 0,
remarks: "",
approval: null,
};
await fetchTimeSheets();
showModal = false;
}
}
</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"
id="search-input"
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) => {
currentSearchTerm = (e.target as HTMLInputElement).value.toLowerCase();
fetchTimeSheets(currentVillaFilter, currentSearchTerm);
}}
/>
<select
id="villa-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) => {
currentVillaFilter = (e.target as HTMLSelectElement).value || null;
fetchTimeSheets(currentVillaFilter, currentSearchTerm);
}}
>
<option value="">All Villa</option>
{#each dataVilla as villa}
<option value={villa.villa_name}>{villa.villa_name}</option>
{/each}
</select>
<button
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
on:click={() =>{
currentVillaFilter = null;
currentSearchTerm = null;
// Optional: reset UI elements if you use bind:value
const searchInput = document.querySelector('#search-input') as HTMLInputElement;
if (searchInput) searchInput.value = "";
const villaSelect = document.querySelector('#villa-select') as HTMLSelectElement;
if (villaSelect) villaSelect.value = "";
fetchTimeSheets(null, null);
}}
>
🔄 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 === "villa_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="cursor-pointer px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap" on:click={() => toggleSort(col.key)}
>
{col.title}
{#if sortColumn === col.key}
{sortOrder === 'asc' ? ' 🔼' : ' 🔽'}
{/if}
</th>
{/if}
{/each}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white text-align-top">
{#each paginatedRows as row}
<tr class="hover:bg-gray-50 transition">
{#each columns as col}
{#if col.key === "name"}
<td
class="left-0 px-4 py-2 max-w-xs whitespace-normal break-words"
>
{row[col.key]}
</td>
{:else if col.key === "approval"}
<td class="px-4 py-2 align-top">
<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),
"true",
)}
>
✅ Approve
</button>
<button
class="ml-2 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={() =>
updateApprovalStatus(
String(row.id),
"false",
)}
>
❌ Reject
</button>
{/if}
</td>
{:else if col.key === "approved_by"}
<td class="px-4 py-2 align-text-top">
{row[col.key] || "Not Approved"}
</td>
{:else if col.key === "approved_date"}
<td class="px-4 py-2">
{row[col.key] &&
!isNaN(Date.parse(String(row[col.key])))
? new Date(
row[
col.key as keyof TimesheetDisplay
] as string | number | Date,
).toLocaleDateString("en-GB", {
timeZone: "Asia/Singapore",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "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(
"en-GB",
{
timeZone: "Asia/Singapore",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)
: "N/A"}
</td>
{:else if col.key === "villa_name"}
<td class="sticky left-0 px-4 py-2 font-medium text-blue-600 max-w-xs whitespace-normal align-top break-words"
style="background-color: #f0f8ff; cursor: pointer;">
{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 if col.key === "date_in" || col.key === "date_out"}
<td class="px-4 py-2">
{row[col.key]
? new Date(row[col.key]).toLocaleString(
"en-GB",
{
timeZone: "Asia/Singapore",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)
: "N/A"}
</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>
<Pagination {totalPages} {currentPage} {goToPage} />
</div>
</div>
<!-- Modal -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 z-50 overflow-y-auto py-10 px-4 flex justify-center items-start"
>
<form
on:submit|preventDefault={submitForm}
class="w-full max-w-lg bg-white p-6 rounded-2xl shadow-xl space-y-4"
>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">
{isEditing ? "Edit Timesheet" : "New Timesheet Entry"}
</h2>
<button
type="button"
class="text-gray-500 hover:text-gray-700"
on:click={() => (showModal = false)}
>
✖️
</button>
</div>
<div>
<label for="t_eb" class="block text-sm font-medium mb-1"
>Entered By</label
>
<select
id="t_eb"
class="w-full border p-2 rounded"
bind:value={form.entered_by}
required
>
<option value="" disabled selected>Select Employee</option>
{#each employees as employee}
<option value={employee.id}>{employee.name}</option>
{/each}
</select>
</div>
<div>
<label for="t_wd" class="block text-sm font-medium mb-1"
>Work Description</label
>
<textarea
id="t_wd"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.work_description}
placeholder="Describe the work"
required
></textarea>
</div>
<div>
<label for="t_ow" class="block text-sm font-medium mb-1"
>Type of Work</label
>
<select
id="t_ow"
class="w-full border p-2 rounded"
bind:value={form.type_of_work}
>
{#each typeOfWorkOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</div>
<div>
<label for="t_cow" class="block text-sm font-medium mb-1"
>Category of Work</label
>
<select
id="t_cow"
class="w-full border p-2 rounded"
bind:value={form.category_of_work}
>
{#each categoryOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</div>
<div>
<label for="t_vn" class="block text-sm font-medium mb-1"
>Villa</label
>
<select
id="t_vn"
class="w-full border p-2 rounded"
bind:value={form.villa_id}
required
>
<option value="" disabled selected>Select Villa</option>
{#each villas as villa}
<option value={villa.id}>{villa.villa_name}</option>
{/each}
</select>
</div>
<div>
<label for="tdto" class="block text-sm font-medium mb-1"
>Date/Time In</label
>
<input
id="tdto"
type="datetime-local"
class="w-full border p-2 rounded"
bind:value={form.datetime_in}
on:change={calculateTotalHours}
required
/>
</div>
<div>
<label for="dto" class="block text-sm font-medium mb-1"
>Date/Time Out</label
>
<input
id="dto"
type="datetime-local"
class="w-full border p-2 rounded"
bind:value={form.datetime_out}
on:change={calculateTotalHours}
required
/>
</div>
<div class="text-sm">
<label for="ttwo" class="block font-medium mb-1"
>Total Work Hours</label
>
<div id="ttwo" class="px-3 py-2">{form.total_work_hour}</div>
</div>
<div>
<label for="trmk" class="block text-sm font-medium mb-1"
>Remarks</label
>
<textarea
id="trmk"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.remarks}
placeholder="Optional remarks"
></textarea>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
>
{isEditing ? "Update Timesheet" : "New Entry"}
</button>
</form>
</div>
{/if}