Files
vberp/src/routes/backoffice/timesheets/+page.svelte
2025-07-23 00:28:00 +08:00

1219 lines
45 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 { v4 as uuidv4 } from "uuid";
import * as XLSX from 'xlsx';
type Timesheets = {
id: number;
entered_by: string;
entered_name?: 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_name?: string;
approved_date?: Date;
created_at?: Date;
};
type TimesheetsJoined = Timesheets & {
vb_employee: { id: string; employee_name: string } | null;
villa_name?: string;
};
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 totalItems = 0;
let rowsPerPage = 10;
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newTsdata: Record<string, any> = {};
let selectedMonth: number | null = new Date().getMonth() + 1; // 1-12
let selectedYear: number | null = new Date().getFullYear(); // Current year
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(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
0,
rowsPerPage,
);
});
// Reactive variables for sorting
function toggleSort(column: string) {
if (sortColumn === column) {
sortOrder = sortOrder === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortOrder = "asc";
}
fetchTimeSheets(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
// 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) {
console.log("Going to page:", page);
if (page >= 1 && page <= totalPages) currentPage = page;
// Re-fetch timesheets with the current filters and pagination
console.log("Fetching timesheets for page:", currentPage);
fetchTimeSheets(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
// 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
async function fetchTimeSheets(
villaIdFilter: string | null = null,
searchTerm: string | null = null,
sortColumn: string | null = "created_at",
sortOrder: "asc" | "desc" = "desc",
offset: number = 0,
limit: number = 20,
) {
console.log("Fetching timesheets with filters:", {
villaIdFilter,
searchTerm,
sortColumn,
sortOrder,
offset,
limit,
});
const fromIndex = offset;
const toIndex = offset + limit - 1;
let query = supabase
.from("vb_timesheet_data")
.select(
'*',
{ count: "exact" },
)
.order(getDBColumn(sortColumn) || "created_at", {
ascending: sortOrder === "asc"
})
.range(fromIndex, toIndex);
if (typeof searchTerm === "string" && searchTerm.length > 4) {
// Supabase ilike only supports one column at a time, so use or for multiple columns
query = query.or(
`work_description.ilike.%${searchTerm}%,entered_name.ilike.%${searchTerm}%`
);
}
if (villaIdFilter) {
query = query.eq("villa_name", villaIdFilter);
}
// Jalankan query
const { data, count, error } = await query;
console.log("Fetched timesheets:", count);
console.log("Fetched timesheets data:", data);
if (error) {
console.error("Error fetching timesheets:", error);
return;
}
allRows = data.map((tsdata: TimesheetsJoined) => {
return {
id: tsdata.id,
name: tsdata.work_description,
staff_id: tsdata.entered_name || "Unknown Staff",
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: tsdata.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: tsdata.approved_name?.trim()
? tsdata.approved_name
: tsdata.approval === true
? "Auto Approve"
: "Not Approved",
approved_date: tsdata.approved_date
? new Date(tsdata.approved_date)
: undefined,
remarks: tsdata.remarks || "No remarks",
created_at: tsdata.created_at
? new Date(tsdata.created_at)
: undefined,
} as TimesheetDisplay;
});
totalItems = count || 0;
}
$: totalPages = Math.ceil(totalItems / rowsPerPage);
$: currentPage = 1;
function pageRange(totalPages: number, currentPage: number,): (number | string)[] {
const range: (number | string)[] = [];
const maxDisplay = 5;
if (totalPages <= maxDisplay + 2) {
for (let i = 1; i <= totalPages; i++) range.push(i);
} else {
const start = Math.max(2, currentPage - 2);
const end = Math.min(totalPages - 1, currentPage + 2);
range.push(1);
if (start > 2) range.push("...");
for (let i = start; i <= end; i++) {
range.push(i);
}
if (end < totalPages - 1) range.push("...");
range.push(totalPages);
}
return range;
}
function getDBColumn(key: string) {
switch (key) {
case "name": return "work_description";
case "staff_id": return "entered_by";
default: return key;
}
}
function changePage(page: number) {
if (page < 1 || page > totalPages || page === currentPage) return;
currentPage = page;
fetchTimeSheets(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
async function exportTimesheets() {
if (!selectedMonth || !selectedYear) {
alert("Please select a month and year to export.");
return;
}
try {
const response = await fetch("https://flow.catalis.app/webhook-test/villabugis-timesheets", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
month: selectedMonth,
year: selectedYear
})
});
if (!response.ok) {
throw new Error(`Export failed: ${response.statusText}`);
}
const randomUuid = uuidv4().toString();
// Jika response adalah file (application/octet-stream atau Excel)
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `timesheet-${selectedYear}-${String(selectedMonth).padStart(2, '0')}-${randomUuid}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Export error:", error);
alert("Failed to export timesheet.");
}
}
async function exportToExcelFromFrontend() {
if (!selectedMonth || !selectedYear) {
alert("Please select both month and year");
return;
}
const startDate = new Date(selectedYear, selectedMonth - 1, 1);
const endDate = new Date(selectedYear, selectedMonth, 0, 23, 59, 59);
// 1. Fetch timesheet data
const { data: timesheetData, error: timesheetErr } = await supabase
.from("vb_timesheet_data")
.select(`
id,
work_description,
total_work_hour,
type_of_work,
category_of_work,
approval,
datetime_in,
datetime_out,
remarks,
approved_date,
villa_name,
entered_name,
approved_name
`)
.gte("datetime_in", startDate.toISOString())
.lte("datetime_in", endDate.toISOString());
if (timesheetErr) {
console.error("Error fetching timesheet:", timesheetErr);
alert("Failed to fetch timesheet data.");
return;
}
// 2. Fetch active villas
const { data: villaData, error: villaErr } = await supabase
.from("vb_villas")
.select("villa_name")
.eq("villa_status", "Active");
if (villaErr) {
console.error("Error fetching villas:", villaErr);
alert("Failed to fetch villas.");
return;
}
const categoryHeaders = [
"Cleaning",
"Gardening/Pool",
"Maintenance",
"Supervision",
"Guest Service",
"Administration",
"Non Billable"
];
const sheet2Name = "Data";
const ws2 = XLSX.utils.json_to_sheet(timesheetData || []);
// 3. Build Sheet1 rows
const sheet1Rows: any[][] = [];
// A1:B1 → Period label
const periodText = `${startDate.toLocaleString("default", { month: "short" })}/${selectedYear}`;
sheet1Rows.push(["Period", periodText]);
// A2:I2 → Headers
const headers = ["Villa Name", ...categoryHeaders, "TOTAL"];
sheet1Rows.push(headers);
// A3:A(n) → Villa rows
villaData?.forEach((villa, idx) => {
const excelRow = idx + 3; // Excel row number
const row: any[] = [villa.villa_name];
// BH formulas
categoryHeaders.forEach((cat, catIdx) => {
const colLetter = String.fromCharCode(66 + catIdx); // 'B' = 66
const formula = `SUMIFS(${sheet2Name}!$C:$C, ${sheet2Name}!$K:$K, $A${excelRow}, ${sheet2Name}!$E:$E, ${colLetter}$2, ${sheet2Name}!$F:$F, TRUE)`;
row.push({ f: formula });
});
// I column (TOTAL)
row.push({ f: `SUM(B${excelRow}:H${excelRow})` });
sheet1Rows.push(row);
});
// Horizontal summary beside the villa table
const headerRowIdx = 1;
const officeRowIdx = 2;
const fbRowIdx = 3;
const villasOnlyRowIdx = 4;
const ensureCols = (row: any[], length: number) => {
while (row.length < length) row.push(null);
return row;
};
[headerRowIdx, officeRowIdx, fbRowIdx, villasOnlyRowIdx].forEach((idx) => {
sheet1Rows[idx] = sheet1Rows[idx] || [];
ensureCols(sheet1Rows[idx], 13); // at least to column M
});
// Insert labels and formulas
sheet1Rows[headerRowIdx][10] = "Total Work Hours"; // K2
sheet1Rows[headerRowIdx][11] = { f: `SUM(${sheet2Name}!$C:$C)` }; // L2
sheet1Rows[officeRowIdx][10] = "Office"; // K3
sheet1Rows[officeRowIdx][11] = { f: `SUMIFS(${sheet2Name}!$C:$C, ${sheet2Name}!$K:$K, K3)` }; // L3
sheet1Rows[fbRowIdx][10] = "FB"; // K4
sheet1Rows[fbRowIdx][11] = { f: `SUMIFS(${sheet2Name}!$C:$C, ${sheet2Name}!$K:$K, K4)` }; // L4
sheet1Rows[villasOnlyRowIdx][10] = "Villas Only"; // K5
sheet1Rows[villasOnlyRowIdx][11] = { f: `L2-L3` }; // L5
// Convert to worksheet
const ws1 = XLSX.utils.aoa_to_sheet(sheet1Rows);
// Bold header row (A2:I2)
for (let c = 0; c <= headers.length; c++) {
const cellRef = XLSX.utils.encode_cell({ r: 1, c });
if (ws1[cellRef]) {
ws1[cellRef].s = { font: { bold: true } };
}
}
// 4. Build Workbook
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws1, "Summary");
XLSX.utils.book_append_sheet(wb, ws2, sheet2Name);
// 5. Export
const filename = `timesheet_${selectedYear}-${String(selectedMonth).padStart(2, "0")}.xlsx`;
XLSX.writeFile(wb, filename);
}
// 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(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
}
// 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(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
}
// 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 {
form = {
...form,
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 || null, // Allow null for new entries
};
const { error: insertError } = await supabase
.from("vb_timesheet")
.insert([form]);
error = insertError;
}
if (error) {
alert("Failed to save timesheets: " + 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(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
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 work description or 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 max-h-[70vh]">
<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;"
on:click={() => toggleSort(col.key)}
>
{col.title}
{#if sortColumn === col.key}
{sortOrder === 'asc' ? ' 🔼' : ' 🔽'}
{/if}
</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 allRows 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(new Date(row[col.key] as string | number | Date).getTime())
? new Date(row[col.key] as string | number | Date).toLocaleString()
: "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] as string | number | Date).toLocaleString()
: "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()
: "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, totalItems)} of {totalItems} items
</div>
<div class="flex items-center space-x-4">
<!-- Export Controls -->
<div class="">
<!-- Month Selector -->
<label for="month" class="text-sm">Month:</label>
<select
id="month"
class="border border-gray-300 px-2 py-1 rounded text-sm"
bind:value={selectedMonth}
>
<option value="" disabled selected>Select</option>
{#each Array.from({ length: 12 }, (_, i) => i + 1) as m}
<option value={m}>{m}</option>
{/each}
</select>
<!-- Year Selector -->
<label for="year" class="text-sm ml-2">Year:</label>
<select
id="year"
class="border border-gray-300 px-2 py-1 rounded text-sm"
bind:value={selectedYear}
>
<option value="" disabled selected>Select</option>
{#each Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i) as y}
<option value={y}>{y}</option>
{/each}
</select>
<!-- Export Button -->
<button
class="bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 text-sm"
on:click={exportToExcelFromFrontend}
>
⬇️ Export
</button>
</div>
<div class="space-x-2">
<label for="rowsPerPage" class="text-sm">Rows per page:</label>
<select
id="rowsPerPage"
class="border border-gray-300 px-2 py-1 rounded text-sm"
bind:value={rowsPerPage}
on:change={() => {
currentPage = 1; // Reset to first page on change
fetchTimeSheets(
currentVillaFilter,
currentSearchTerm,
sortColumn,
sortOrder,
0,
rowsPerPage,
);
}}
>
{#each [10, 20, 50, 100] as option}
<option value={option}>{option}</option>
{/each}
</select>
<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 pageRange(totalPages, currentPage) as page}
{#if page === "..."}
<span class="px-2">...</span>
{:else}
<button
on:click={() => changePage(page as number)}
class="px-2 py-1 border rounded {page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white border-gray-300 hover:bg-gray-100'}"
>
{page}
</button>
{/if}
{/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>
</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}