This commit is contained in:
AJISETIAJI
2025-07-28 09:10:34 +07:00
parent 8516bc5517
commit 582284230a
4 changed files with 1776 additions and 1000 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
import { v4 as uuidv4 } from "uuid";
import * as XLSX from 'xlsx';
import * as XLSX from "xlsx";
type Timesheets = {
id: number;
entered_by: string;
@@ -32,7 +32,7 @@
type TimesheetsJoined = Timesheets & {
vb_employee: { id: string; employee_name: string } | null;
villa_name?: string;
};
};
type TimesheetDisplay = {
id: number;
name: string;
@@ -241,9 +241,8 @@
}
// 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
@@ -272,8 +271,8 @@
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 ||
"",
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,
@@ -311,35 +310,21 @@
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" },
)
.select("*", { count: "exact" })
.order(getDBColumn(sortColumn) || "created_at", {
ascending: sortOrder === "asc"
ascending: sortOrder === "asc",
})
.range(fromIndex, toIndex);
.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}%`
`work_description.ilike.%${searchTerm}%,entered_name.ilike.%${searchTerm}%`,
);
}
@@ -353,8 +338,6 @@
console.log("Fetched timesheets:", count);
console.log("Fetched timesheets data:", data);
if (error) {
console.error("Error fetching timesheets:", error);
@@ -375,18 +358,19 @@
tsdata.approval == null
? "PENDING"
: tsdata.approval
? "APPROVED"
: "REJECTED",
? "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()
) /
(1000 * 60 * 60),
approved_by: tsdata.approved_name?.trim()
? tsdata.approved_name
: tsdata.approval === true
? "Auto Approve"
: "Not Approved",
? "Auto Approve"
: "Not Approved",
approved_date: tsdata.approved_date
? new Date(tsdata.approved_date)
: undefined,
@@ -403,7 +387,10 @@
$: totalPages = Math.ceil(totalItems / rowsPerPage);
$: currentPage = 1;
function pageRange(totalPages: number, currentPage: number,): (number | string)[] {
function pageRange(
totalPages: number,
currentPage: number,
): (number | string)[] {
const range: (number | string)[] = [];
const maxDisplay = 5;
@@ -426,12 +413,16 @@
return range;
}
function getDBColumn(key: string) {
switch (key) {
case "name": return "work_description";
case "staff_id": return "entered_by";
default: return 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;
@@ -448,43 +439,46 @@
async function exportTimesheets() {
if (!selectedMonth || !selectedYear) {
alert("Please select a month and year to export.");
return;
}
alert("Please select a month and year to export.");
return;
}
try {
const response = await fetch("https://flow.catalis.app/webhook-test/villabugis-timesheets", {
try {
const response = await fetch(
"https://flow.catalis.app/webhook-test/villabugis-timesheets",
{
method: "PATCH",
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
},
body: JSON.stringify({
month: selectedMonth,
year: selectedYear
})
});
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.");
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");
@@ -497,7 +491,8 @@
// 1. Fetch timesheet data
const { data: timesheetData, error: timesheetErr } = await supabase
.from("vb_timesheet_data")
.select(`
.select(
`
id,
work_description,
total_work_hour,
@@ -511,7 +506,8 @@
villa_name,
entered_name,
approved_name
`)
`,
)
.gte("datetime_in", startDate.toISOString())
.lte("datetime_in", endDate.toISOString());
@@ -540,7 +536,7 @@
"Supervision",
"Guest Service",
"Administration",
"Non Billable"
"Non Billable",
];
const sheet2Name = "Data";
@@ -586,20 +582,26 @@
return row;
};
[headerRowIdx, officeRowIdx, fbRowIdx, villasOnlyRowIdx].forEach((idx) => {
sheet1Rows[idx] = sheet1Rows[idx] || [];
ensureCols(sheet1Rows[idx], 13); // at least to column M
});
[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[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[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
@@ -687,8 +689,6 @@
let error = null;
if (isEditing && currentEditingId) {
const { error: updateError } = await supabase
.from("vb_timesheet")
.update({
@@ -755,8 +755,6 @@
showModal = false;
}
}
</script>
<div>
@@ -778,7 +776,9 @@
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();
currentSearchTerm = (
e.target as HTMLInputElement
).value.toLowerCase();
fetchTimeSheets(currentVillaFilter, currentSearchTerm);
}}
/>
@@ -786,7 +786,8 @@
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;
currentVillaFilter =
(e.target as HTMLSelectElement).value || null;
fetchTimeSheets(currentVillaFilter, currentSearchTerm);
}}
>
@@ -797,17 +798,21 @@
</select>
<button
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
on:click={() =>{
on:click={() => {
currentVillaFilter = null;
currentSearchTerm = null;
// Optional: reset UI elements if you use bind:value
const searchInput = document.querySelector('#search-input') as HTMLInputElement;
const searchInput = document.querySelector(
"#search-input",
) as HTMLInputElement;
if (searchInput) searchInput.value = "";
const villaSelect = document.querySelector('#villa-select') as HTMLSelectElement;
const villaSelect = document.querySelector(
"#villa-select",
) as HTMLSelectElement;
if (villaSelect) villaSelect.value = "";
fetchTimeSheets(null, null);
}}
>
@@ -831,22 +836,21 @@
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' ? ' 🔼' : ' 🔽'}
{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)}
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}
{#if sortColumn === col.key}
{sortOrder === "asc" ? " 🔼" : " 🔽"}
{/if}
</th>
{/if}
{/each}
@@ -902,8 +906,20 @@
{: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()
!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"}
@@ -913,12 +929,19 @@
{: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()
? 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;">
<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"}
@@ -948,7 +971,9 @@
{: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()
? new Date(
row[col.key],
).toLocaleString()
: "N/A"}
</td>
{:else}
@@ -975,35 +1000,35 @@
<!-- 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}
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}
<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}
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}
<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}
class="bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 text-sm"
on:click={exportToExcelFromFrontend}
>
⬇️ Export
⬇️ Export
</button>
</div>
<div class="space-x-2">
@@ -1041,7 +1066,8 @@
{:else}
<button
on:click={() => changePage(page as number)}
class="px-2 py-1 border rounded {page === currentPage
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'}"
>
@@ -1058,7 +1084,7 @@
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->

View File

@@ -227,14 +227,14 @@
</button>
</form>
<p class="text-center text-sm text-gray-500">
<!-- <p class="text-center text-sm text-gray-500">
Dont have an account?
<a
href="/register"
class="text-blue-600 hover:underline font-bold ml-1"
>Sign Up Now</a
>
</p>
</p> -->
</div>
</div>