update transport invoice

This commit is contained in:
2025-08-07 02:50:59 +08:00
parent df07292159
commit 52716b8630
5 changed files with 917 additions and 403 deletions

View File

@@ -26,6 +26,7 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8",
"@tailwindcss/vite": "^4.1.7",
"exceljs": "^4.4.0",
"svelte-select": "^5.8.3",
"svelte-table": "^0.6.4",
"tailwindcss": "^4.1.7",

BIN
src/lib/images/vb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,6 +2,9 @@
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
import { writable } from "svelte/store";
import CurrencyInput from "$lib/CurrencyInput.svelte";
import ExcelJS from "exceljs";
import logo from "$lib/images/vb.png";
type Transport = {
id: string;
@@ -146,6 +149,85 @@
requested_by: "",
vendor_email_address: "",
};
let showInvoiceModal = false;
let selectedTransportId: string | null = null;
let invoiceForm = {
invoice_number: "",
issued_date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
due_date: "",
price_fee: "",
payment_status: "Unpaid",
payment_date: "",
notes: "",
};
async function openInvoiceModal(transport: Transport) {
selectedTransportId = transport.id;
// Check if invoice already exists
const { data, error } = await supabase
.from("vb_transport_invoice")
.select("*")
.eq("id", selectedTransportId)
.single();
if (error && error.code !== "PGRST116") {
alert("Failed to check invoice: " + error.message);
return;
}
if (data) {
// Invoice exists — edit mode
invoiceForm = {
invoice_number: data.invoice_number,
issued_date: data.issued_date,
due_date: data.due_date,
price_fee: data.price_fee,
payment_status: data.payment_status,
payment_date: data.payment_date,
notes: data.notes || "",
};
} else {
// New invoice — create mode
invoiceForm = {
invoice_number: "",
issued_date: new Date().toISOString().slice(0, 10),
due_date: "",
price_fee: "",
payment_status: "Unpaid",
payment_date: "",
notes: "",
};
}
showInvoiceModal = true;
}
async function saveInvoice() {
if (!selectedTransportId) return;
const { error } = await supabase.from("vb_transport_invoice").upsert([
{
id: selectedTransportId,
issued_date: invoiceForm.issued_date,
due_date: invoiceForm.due_date,
price_fee: invoiceForm.price_fee,
payment_status: invoiceForm.payment_status,
payment_date: invoiceForm.payment_date,
notes: invoiceForm.notes,
// 🚫 No invoice_number sent — it's auto-generated by trigger
},
]);
if (error) {
alert("Error saving invoice: " + error.message);
} else {
alert("Invoice saved successfully!");
showInvoiceModal = false;
}
}
const excludedKeys = ["id", "actions", "created_at"];
const formColumns = columns.filter(
@@ -174,6 +256,108 @@
}
showModal = true;
}
async function fetchImageAsBase64(path: string): Promise<string> {
const res = await fetch(path);
const blob = await res.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = (reader.result as string).split(",")[1]; // extract base64
resolve(base64);
};
reader.readAsDataURL(blob);
});
}
async function printInvoiceWithExcelJS() {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Invoice");
// Set column widths
worksheet.columns = [
{ header: "", key: "label", width: 20 },
{ header: "", key: "value", width: 40 },
];
// 🖼 Add Logo (must convert to base64 buffer)
const base64 = await fetchImageAsBase64(logo);
const imageId = workbook.addImage({
base64: base64,
extension: "webp",
});
// Position the image above title (row 14)
worksheet.addImage(imageId, {
tl: { col: 0, row: 0 },
ext: { width: 100, height: 40 },
});
// Leave a few rows for image
worksheet.getRow(5).height = 20;
// 📝 Title row
worksheet.mergeCells("A6:B6");
worksheet.getCell("A6").value = "TRANSPORT INVOICE";
worksheet.getCell("A6").font = { size: 16, bold: true };
worksheet.getCell("A6").alignment = { horizontal: "center" };
worksheet.addRow([]);
const selectedTransport = allRows.find(t => t.id === selectedTransportId);
const guestName = selectedTransport?.guest_name || "-";
const area = selectedTransport?.area || "-";
const requestThings = selectedTransport?.request_things || "-";
// Invoice details
const rows = [
["Guest Name", guestName],
["Area", area],
["Request Things", requestThings],
["Invoice Number", invoiceForm.invoice_number],
["Issued Date", invoiceForm.issued_date],
["Due Date", invoiceForm.due_date],
["Price Fee", Number(invoiceForm.price_fee)],
["Payment Status", invoiceForm.payment_status],
["Payment Date", invoiceForm.payment_date],
["Notes", invoiceForm.notes || "-"],
];
rows.forEach(([label, value], index) => {
const row = worksheet.addRow({ label, value });
row.getCell(1).font = { bold: true };
row.getCell(1).alignment = { vertical: "middle" };
row.getCell(2).alignment = {
vertical: "middle",
horizontal: label === "Price Fee" ? "left" : "left",
};
if (label === "Price Fee") {
row.getCell(2).numFmt = '"IDR" #,##0';
}
[1, 2].forEach((col) => {
row.getCell(col).border = {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
};
});
});
// Export
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${invoiceForm.invoice_number || "invoice"}.xlsx`;
link.click();
}
async function saveIssue(event: Event) {
event.preventDefault();
@@ -389,6 +573,12 @@
</td>
{:else if col.key === "actions"}
<td class="px-4 py-2">
<button
class="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={() => openInvoiceModal(row)}
>
📄 Invoice
</button>
<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)}
@@ -565,3 +755,76 @@
</div>
</div>
{/if}
{#if showInvoiceModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-xl shadow-lg w-full max-w-md space-y-4">
<h2 class="text-lg font-semibold">🧾 Generate Invoice</h2>
<div>
<label class="block text-sm">Invoice Number</label>
{#if invoiceForm.invoice_number}
<!-- Editing existing invoice -->
<input
class="w-full border rounded p-2 bg-gray-100 text-gray-800"
value={invoiceForm.invoice_number}
readonly
/>
{:else}
<!-- Creating new invoice -->
<input
class="w-full border rounded p-2 bg-gray-100 text-gray-500 italic"
value="Auto-generated after saving"
readonly
/>
{/if}
</div>
<div>
<label class="block text-sm">Issued Date</label>
<input type="date" class="w-full border rounded p-2" bind:value={invoiceForm.issued_date} />
</div>
<div>
<label class="block text-sm">Due Date</label>
<input type="date" class="w-full border rounded p-2" bind:value={invoiceForm.due_date} />
</div>
<CurrencyInput
bind:value={invoiceForm.price_fee}
label="Price Fee"
/>
<div>
<label class="block text-sm">Payment Status</label>
<select class="w-full border rounded p-2" bind:value={invoiceForm.payment_status}>
<option>Unpaid</option>
<option>Paid</option>
<option>Overdue</option>
</select>
</div>
<div>
<label class="block text-sm">Payment Date</label>
<input type="date" class="w-full border rounded p-2" bind:value={invoiceForm.payment_date} />
</div>
<div>
<label class="block text-sm">Notes</label>
<textarea class="w-full border rounded p-2" bind:value={invoiceForm.notes}></textarea>
</div>
<div class="flex justify-end gap-2">
<button class="bg-gray-300 px-4 py-2 rounded" on:click={() => (showInvoiceModal = false)}>Cancel</button>
<button class="bg-blue-600 text-white px-4 py-2 rounded" on:click={saveInvoice}>Save</button>
{#if invoiceForm.invoice_number}
<div class="flex justify-end">
<button
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
on:click={printInvoiceWithExcelJS}
>
📥 Download Excel Invoice
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -116,6 +116,20 @@
alert("Please select a villa.");
return;
}
const datetimeIn = new Date(form.datetime_in);
const datetimeOut = new Date(form.datetime_out);
if (datetimeOut <= datetimeIn) {
alert("Date/Time Out must be greater than Date/Time In.");
return;
}
// Auto-approve if total_work_hour is between 0 and 1 inclusive, and approval is not null
if (form.total_work_hour >= 0 && form.total_work_hour <= 1 && form.approval !== null) {
form.approval = true;
}
if (form.approval) {
const today = new Date();
const yyyy = today.getFullYear();
@@ -125,7 +139,7 @@
} else {
form.approved_date = null;
}
form.approval = form.total_work_hour <= 1 ? true : null;
const { error } = await supabase.from("vb_timesheet").insert([form]);
if (error) {
@@ -143,9 +157,11 @@
total_work_hour: 0,
remarks: "",
approval: null,
approved_date: null,
};
}
}
</script>
<div class="min-h-screen bg-gray-100 flex items-center justify-center">

1038
yarn.lock

File diff suppressed because it is too large Load Diff