update transport invoice
This commit is contained in:
@@ -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
BIN
src/lib/images/vb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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 1–4)
|
||||
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}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user