1219 lines
45 KiB
Svelte
1219 lines
45 KiB
Svelte
<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];
|
||
|
||
// B–H 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}
|