898 lines
34 KiB
Svelte
898 lines
34 KiB
Svelte
<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}
|