penambahan child po item
This commit is contained in:
567
note.md
Normal file
567
note.md
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { supabase } from "$lib/supabaseClient";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
type Transport = {
|
||||||
|
id: string;
|
||||||
|
guest_name: string;
|
||||||
|
requested_date: Date;
|
||||||
|
area: string;
|
||||||
|
pickup_date: Date;
|
||||||
|
request_things: string;
|
||||||
|
additional_notes: string;
|
||||||
|
requested_by: string;
|
||||||
|
vendor_email_address: string;
|
||||||
|
created_at?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransportDisplay = {
|
||||||
|
guest_name: string;
|
||||||
|
requested_date: Date;
|
||||||
|
area: string;
|
||||||
|
pickup_date: Date;
|
||||||
|
request_things: string;
|
||||||
|
additional_notes: string;
|
||||||
|
requested_by: string;
|
||||||
|
vendor_email_address: string;
|
||||||
|
created_at?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const area = [
|
||||||
|
{ label: "Laksamana", value: "Laksamana" },
|
||||||
|
{ label: "Drupadi", value: "Drupadi" },
|
||||||
|
{ label: "Abimanyu", value: "Abimanyu" },
|
||||||
|
{ label: "Seminyak", value: "Seminyak" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const requestThings = [
|
||||||
|
{ label: "Airport pickup", value: "Airport pickup" },
|
||||||
|
{ label: "Airport transfer", value: "Airport transfer" },
|
||||||
|
{ label: "Day car charter", value: "Day car charter" },
|
||||||
|
{ label: "One way drop off", value: "One way drop off" },
|
||||||
|
{ label: "One way pickup", value: "One way pickup" },
|
||||||
|
{ label: "Other", value: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let allRows: Transport[] = [];
|
||||||
|
|
||||||
|
type columns = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: columns[] = [
|
||||||
|
{ key: "guest_name", title: "Guest Name" },
|
||||||
|
{ key: "requested_date", title: "Requested Date" },
|
||||||
|
{ key: "area", title: "Area" },
|
||||||
|
{ key: "pickup_date", title: "Pickup Date" },
|
||||||
|
{ key: "request_things", title: "Request Things" },
|
||||||
|
{ key: "additional_notes", title: "Additional Notes" },
|
||||||
|
{ key: "requested_by", title: "Requested By" },
|
||||||
|
{ key: "vendor_email_address", title: "Vendor Email Address" },
|
||||||
|
{ key: "created_at", title: "Created At" },
|
||||||
|
{ key: "actions", title: "Actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchTransport(
|
||||||
|
filter: string | null = null,
|
||||||
|
searchTerm: string | null = null,
|
||||||
|
sortColumn: string | null = "created_at",
|
||||||
|
sortOrder: "asc" | "desc" = "desc",
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 10,
|
||||||
|
) {
|
||||||
|
let query = supabase
|
||||||
|
.from("vb_transport")
|
||||||
|
.select("*", { count: "exact" })
|
||||||
|
.order(sortColumn || "created_at", {
|
||||||
|
ascending: sortOrder === "asc",
|
||||||
|
});
|
||||||
|
if (filter) {
|
||||||
|
query = query.eq("area", filter);
|
||||||
|
}
|
||||||
|
if (searchTerm) {
|
||||||
|
query = query.ilike("guest_name", `%${searchTerm}%`);
|
||||||
|
}
|
||||||
|
if (offset) {
|
||||||
|
query = query.range(offset, offset + limit - 1);
|
||||||
|
}
|
||||||
|
if (limit) {
|
||||||
|
query = query.limit(limit);
|
||||||
|
}
|
||||||
|
const { data: transportResponse, error } = await query;
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching timesheets:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allRows = transportResponse.map((row) => ({
|
||||||
|
...row,
|
||||||
|
requested_date: new Date(row.requested_date),
|
||||||
|
pickup_date: new Date(row.pickup_date),
|
||||||
|
created_at: row.created_at ? new Date(row.created_at) : undefined,
|
||||||
|
})) as Transport[];
|
||||||
|
}
|
||||||
|
let currentPage = 1;
|
||||||
|
const rowsPerPage = 10;
|
||||||
|
const totalRows = allRows.length;
|
||||||
|
const totalPages = Math.ceil(totalRows / rowsPerPage);
|
||||||
|
$: paginatedRows = allRows.slice(
|
||||||
|
(currentPage - 1) * rowsPerPage,
|
||||||
|
currentPage * rowsPerPage,
|
||||||
|
);
|
||||||
|
console.log("Total Rows:", totalRows);
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page >= 1 && page <= totalPages) currentPage = page;
|
||||||
|
fetchTransport(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"created_at",
|
||||||
|
"desc",
|
||||||
|
(currentPage - 1) * rowsPerPage,
|
||||||
|
rowsPerPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchTransport();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the first page
|
||||||
|
$: currentPage = 1;
|
||||||
|
|
||||||
|
let showModal = false;
|
||||||
|
let isEditing = false;
|
||||||
|
let currentEditingId: string | null = null;
|
||||||
|
let newTransport: Transport = {
|
||||||
|
id: "",
|
||||||
|
guest_name: "",
|
||||||
|
requested_date: new Date(),
|
||||||
|
area: "",
|
||||||
|
pickup_date: new Date(),
|
||||||
|
request_things: "",
|
||||||
|
additional_notes: "",
|
||||||
|
requested_by: "",
|
||||||
|
vendor_email_address: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedKeys = ["id", "actions", "created_at"];
|
||||||
|
const formColumns = columns.filter(
|
||||||
|
(col) => !excludedKeys.includes(col.key),
|
||||||
|
);
|
||||||
|
|
||||||
|
function openModal(transport?: Transport) {
|
||||||
|
if (transport) {
|
||||||
|
isEditing = true;
|
||||||
|
currentEditingId = transport.id;
|
||||||
|
newTransport = { ...transport };
|
||||||
|
} else {
|
||||||
|
isEditing = false;
|
||||||
|
currentEditingId = null;
|
||||||
|
newTransport = {
|
||||||
|
id: "",
|
||||||
|
guest_name: "",
|
||||||
|
requested_date: new Date(),
|
||||||
|
area: "",
|
||||||
|
pickup_date: new Date(),
|
||||||
|
request_things: "",
|
||||||
|
additional_notes: "",
|
||||||
|
requested_by: "",
|
||||||
|
vendor_email_address: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIssue(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(event.target as HTMLFormElement);
|
||||||
|
|
||||||
|
// Validate form data
|
||||||
|
if (!validateForm(formData)) {
|
||||||
|
console.error("Form validation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing && currentEditingId) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vb_tansport")
|
||||||
|
.update(newTransport)
|
||||||
|
.eq("id", currentEditingId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert("Error updating transport: " + error.message);
|
||||||
|
console.error("Error updating transport:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchTransport();
|
||||||
|
showModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTransport(id: string) {
|
||||||
|
if (confirm("Are you sure you want to delete this transport?")) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vb_transport")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id);
|
||||||
|
if (error) {
|
||||||
|
console.error("Error deleting issue:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchTransport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let formErrors = writable<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
function validateForm(formData: FormData): boolean {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
const requiredFields = [
|
||||||
|
"guest_name",
|
||||||
|
"requested_date",
|
||||||
|
"area",
|
||||||
|
"pickup_date",
|
||||||
|
"request_things",
|
||||||
|
"additional_notes",
|
||||||
|
"requested_by",
|
||||||
|
"vendor_email_address",
|
||||||
|
];
|
||||||
|
|
||||||
|
requiredFields.forEach((field) => {
|
||||||
|
if (!formData.get(field) || formData.get(field) === "") {
|
||||||
|
errors[field] = `${field.replace(/_/g, " ")} is required.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
formErrors.set(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorClass(field: string): string {
|
||||||
|
return $formErrors[field] ? "border-red-500" : "border";
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
🚗 Transport Request List
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Manage and track all transport requests efficiently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="🔍 Search by guest 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) => {
|
||||||
|
const searchTerm = (
|
||||||
|
e.target as HTMLInputElement
|
||||||
|
).value.toLowerCase();
|
||||||
|
fetchTransport(null, searchTerm, "created_at", "desc");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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) => {
|
||||||
|
const filter = (e.target as HTMLSelectElement).value;
|
||||||
|
fetchTransport(filter, null, null, "desc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Area</option>
|
||||||
|
{#each area as a}
|
||||||
|
<option value={a.value}>{a.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<!-- request things -->
|
||||||
|
<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) => {
|
||||||
|
const filter = (e.target as HTMLSelectElement).value;
|
||||||
|
fetchTransport(null, filter, "created_at", "desc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Requests</option>
|
||||||
|
{#each requestThings as r}
|
||||||
|
<option value={r.value}>{r.label}</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={() =>
|
||||||
|
fetchTransport(null, null, "created_at", "desc", 0, 10)}
|
||||||
|
>
|
||||||
|
🔄 Reset
|
||||||
|
</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 === "guest_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="px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{#each paginatedRows as row}
|
||||||
|
<tr class="hover:bg-gray-50 transition">
|
||||||
|
{#each columns as col}
|
||||||
|
{#if col.key === "guest_name"}
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-800">
|
||||||
|
{row[col.key as keyof Transport]}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "requested_date"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport]
|
||||||
|
? new Date(
|
||||||
|
row[
|
||||||
|
col.key as keyof Transport
|
||||||
|
] as string | number | Date,
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "pickup_date"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{new Date(
|
||||||
|
new Date(
|
||||||
|
row[col.key as keyof Transport] as
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| Date,
|
||||||
|
).toLocaleDateString() || "N/A",
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "created_at"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport]
|
||||||
|
? new Date(
|
||||||
|
row[
|
||||||
|
col.key as keyof Transport
|
||||||
|
] as string | number | Date,
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "area"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport]}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "request_things"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport]}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "additional_notes"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport] ||
|
||||||
|
"No additional notes"}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "requested_by"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof Transport]}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "vendor_email_address"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof TransportDisplay] ||
|
||||||
|
"No email provided"}
|
||||||
|
</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={() => deleteTransport(row.id)}
|
||||||
|
>
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof TransportDisplay]}
|
||||||
|
</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>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<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 Array(totalPages)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => i + 1) as page}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded border text-sm
|
||||||
|
{currentPage === page
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-white border-gray-300 hover:bg-gray-100'}"
|
||||||
|
on:click={() => goToPage(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
{/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>
|
||||||
|
|
||||||
|
{#if showModal}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
{isEditing ? "Edit Transport Request" : "New Transport Request"}
|
||||||
|
</h2>
|
||||||
|
<form on:submit={saveIssue}>
|
||||||
|
{#each formColumns as col}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium mb-1"
|
||||||
|
for={col.key}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</label>
|
||||||
|
{#if col.key === "requested_date" || col.key === "pickup_date"}
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name={col.key}
|
||||||
|
bind:value={newTransport[col.key]}
|
||||||
|
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
|
||||||
|
col.key,
|
||||||
|
)}"
|
||||||
|
/>
|
||||||
|
{:else if col.key === "area"}
|
||||||
|
<select
|
||||||
|
name={col.key}
|
||||||
|
bind:value={newTransport[col.key]}
|
||||||
|
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
|
||||||
|
col.key,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected
|
||||||
|
>Select Area</option
|
||||||
|
>
|
||||||
|
{#each area as a}
|
||||||
|
<option value={a.value}>{a.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if col.key === "request_things"}
|
||||||
|
<select
|
||||||
|
name={col.key}
|
||||||
|
bind:value={newTransport[col.key]}
|
||||||
|
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
|
||||||
|
col.key,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected
|
||||||
|
>Select Request</option
|
||||||
|
>
|
||||||
|
{#each requestThings as r}
|
||||||
|
<option value={r.value}>{r.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={col.key}
|
||||||
|
bind:value={
|
||||||
|
newTransport[col.key as keyof Transport]
|
||||||
|
}
|
||||||
|
placeholder={col.title}
|
||||||
|
class="w-full border rounded-xl px-4 py-
|
||||||
|
2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(col.key)}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if $formErrors[col.key]}
|
||||||
|
<p class="text-red-500 text-xs mt-1">
|
||||||
|
{$formErrors[col.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-1"
|
||||||
|
>Additional Notes</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
name="additional_notes"
|
||||||
|
bind:value={newTransport.additional_notes}
|
||||||
|
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
|
||||||
|
'additional_notes',
|
||||||
|
)}"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
></textarea>
|
||||||
|
{#if $formErrors.additional_notes}
|
||||||
|
<p class="text-red-500 text-xs mt-1">
|
||||||
|
{$formErrors.additional_notes}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300"
|
||||||
|
on:click={() => (showModal = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{isEditing ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -85,6 +85,14 @@
|
|||||||
icon: "📦",
|
icon: "📦",
|
||||||
url: "/backoffice/purchaseorder",
|
url: "/backoffice/purchaseorder",
|
||||||
roles: ["it", "guest", "accounting", "ga", "office", "hm", "vm"],
|
roles: ["it", "guest", "accounting", "ga", "office", "hm", "vm"],
|
||||||
|
sub: [
|
||||||
|
{
|
||||||
|
name: "PO Item",
|
||||||
|
icon: "📋",
|
||||||
|
url: "/backoffice/purchaseorder/poitem",
|
||||||
|
roles: ["it", "ga", "office", "hm", "vm", "accounting"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Timesheets",
|
name: "Timesheets",
|
||||||
|
|||||||
@@ -1270,7 +1270,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-x-2">
|
<div class="space-x-2">
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-50"
|
class="px-2 py-1 border rounded disabled:opacity-50"
|
||||||
on:click={() => goToPage(currentPage - 1)}
|
on:click={() => goToPage(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
>
|
||||||
@@ -1290,7 +1290,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-50"
|
class="px-2 py-1 border rounded disabled:opacity-50"
|
||||||
on:click={() => goToPage(currentPage + 1)}
|
on:click={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
|
|||||||
450
src/routes/backoffice/purchaseorder/poitem/+page.svelte
Normal file
450
src/routes/backoffice/purchaseorder/poitem/+page.svelte
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { supabase } from "$lib/supabaseClient";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
type POItem = {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
created_at?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransportDisplay = {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
created_at?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
let allRows: POItem[] = [];
|
||||||
|
|
||||||
|
type columns = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: columns[] = [
|
||||||
|
{ key: "id", title: "ID" },
|
||||||
|
{ key: "item_name", title: "Item Name" },
|
||||||
|
{ key: "created_at", title: "Created At" },
|
||||||
|
{ key: "actions", title: "Actions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
const rowsPerPage = 10;
|
||||||
|
let totalItems = 0;
|
||||||
|
|
||||||
|
async function fetchItemPo(
|
||||||
|
searchTerm: string | null = null,
|
||||||
|
sortColumn: string | null = "created_at",
|
||||||
|
sortOrder: "asc" | "desc" = "desc",
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 10,
|
||||||
|
) {
|
||||||
|
const fromIndex = offset;
|
||||||
|
const toIndex = offset + limit - 1;
|
||||||
|
|
||||||
|
// Inisialisasi query
|
||||||
|
let query = supabase
|
||||||
|
.from("vb_po_item")
|
||||||
|
.select("*", { count: "exact" })
|
||||||
|
.order(sortColumn || "created_at", {
|
||||||
|
ascending: sortOrder === "asc",
|
||||||
|
})
|
||||||
|
.range(fromIndex, toIndex); // Ini sudah termasuk offset & limit
|
||||||
|
|
||||||
|
// Tambahkan filter pencarian jika ada
|
||||||
|
if (searchTerm) {
|
||||||
|
query = query.ilike("item_name", `%${searchTerm}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jalankan query
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching PO items:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allRows = data as POItem[];
|
||||||
|
totalItems = count || 0;
|
||||||
|
|
||||||
|
console.log("Fetched PO Items:", allRows);
|
||||||
|
console.log("Total Items:", totalItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$: totalPages = Math.ceil(totalItems / rowsPerPage);
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
|
||||||
|
if (page < 1 || page > totalPages) return;
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
fetchItemPo(
|
||||||
|
null,
|
||||||
|
"created_at",
|
||||||
|
"desc",
|
||||||
|
(currentPage - 1) * rowsPerPage,
|
||||||
|
rowsPerPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 changePage(page: number) {
|
||||||
|
if (page < 1 || page > totalPages || page === currentPage) return;
|
||||||
|
currentPage = page;
|
||||||
|
fetchItemPo(
|
||||||
|
null,
|
||||||
|
"created_at",
|
||||||
|
"desc",
|
||||||
|
(currentPage - 1) * rowsPerPage,
|
||||||
|
rowsPerPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchItemPo();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the first page
|
||||||
|
$: currentPage = 1;
|
||||||
|
|
||||||
|
let showModal = false;
|
||||||
|
let isEditing = false;
|
||||||
|
let currentEditingId: number | null = null;
|
||||||
|
let newPOItem: POItem = {
|
||||||
|
id: 0,
|
||||||
|
item_name: "",
|
||||||
|
created_at: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedKeys = ["id", "actions", "created_at"];
|
||||||
|
const formColumns = columns.filter(
|
||||||
|
(col) => !excludedKeys.includes(col.key),
|
||||||
|
);
|
||||||
|
|
||||||
|
function openModal(newPOItem?: POItem) {
|
||||||
|
if (newPOItem) {
|
||||||
|
isEditing = true;
|
||||||
|
currentEditingId = newPOItem.id;
|
||||||
|
newPOItem = { ...newPOItem };
|
||||||
|
} else {
|
||||||
|
isEditing = false;
|
||||||
|
currentEditingId = null;
|
||||||
|
newPOItem = {
|
||||||
|
id: 0,
|
||||||
|
item_name: "",
|
||||||
|
created_at: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePOItem(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(event.target as HTMLFormElement);
|
||||||
|
|
||||||
|
// Validate form data
|
||||||
|
if (!validateForm(formData)) {
|
||||||
|
console.error("Form validation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPOItemInsert = {
|
||||||
|
item_name: newPOItem.item_name,
|
||||||
|
created_at: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing && currentEditingId) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vb_po_item")
|
||||||
|
.update(newPOItemInsert)
|
||||||
|
.eq("id", currentEditingId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert("Error updating New PO Item: " + error.message);
|
||||||
|
console.error("Error updating New PO Item:", error);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
alert("New PO Item updated successfully!");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vb_po_item")
|
||||||
|
.insert(newPOItemInsert);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert("Error creating New PO Item: " + error.message);
|
||||||
|
console.error("Error creating New PO Item:", error);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
alert("New PO Item created successfully!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchItemPo();
|
||||||
|
showModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePOItem(id: number) {
|
||||||
|
if (confirm("Are you sure you want to delete this PO Item?")) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vb_po_item")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id);
|
||||||
|
if (error) {
|
||||||
|
console.error("Error deleting issue:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchItemPo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let formErrors = writable<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
function validateForm(formData: FormData): boolean {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
const requiredFields = [
|
||||||
|
"item_name",
|
||||||
|
// Add other required fields here if necessary
|
||||||
|
];
|
||||||
|
|
||||||
|
requiredFields.forEach((field) => {
|
||||||
|
if (!formData.get(field) || formData.get(field) === "") {
|
||||||
|
errors[field] = `${field.replace(/_/g, " ")} is required.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
formErrors.set(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorClass(field: string): string {
|
||||||
|
return $formErrors[field] ? "border-red-500" : "border";
|
||||||
|
}
|
||||||
|
</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 flex items-center gap-2">
|
||||||
|
<span>📋</span>
|
||||||
|
PO Items List
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Manage your List Items for Purchase Order
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="🔍 Search by item 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) => {
|
||||||
|
const searchTerm = (
|
||||||
|
e.target as HTMLInputElement
|
||||||
|
).value.toLowerCase();
|
||||||
|
fetchItemPo(searchTerm, "created_at", "desc");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
|
||||||
|
on:click={() =>
|
||||||
|
fetchItemPo(null, "created_at", "desc", 0, 10)}
|
||||||
|
>
|
||||||
|
🔄 Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-blue-600 text-white px-4 py-2 rounded-xl hover:bg-blue-700 text-sm transition"
|
||||||
|
on:click={() => openModal()}
|
||||||
|
>
|
||||||
|
➕ New PO Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto rounded-lg shadow mb-4">
|
||||||
|
<table class="w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
{#each columns as col}
|
||||||
|
{#if col.key === "guest_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="px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{#each allRows as row}
|
||||||
|
<tr class="hover:bg-gray-50 transition">
|
||||||
|
{#each columns as col}
|
||||||
|
{#if col.key === "item_name"}
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-800">
|
||||||
|
{row[col.key as keyof POItem]}
|
||||||
|
</td>
|
||||||
|
{:else if col.key === "created_at"}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof POItem]
|
||||||
|
? new Date(
|
||||||
|
row[
|
||||||
|
col.key as keyof POItem
|
||||||
|
] as string | number | Date,
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</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={() => deletePOItem(row.id)}
|
||||||
|
>
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{row[col.key as keyof TransportDisplay]}
|
||||||
|
</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="space-x-2">
|
||||||
|
<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'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{#if showModal}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
{isEditing ? "Edit PO Item" : "New Item Request"}
|
||||||
|
</h2>
|
||||||
|
<form on:submit={savePOItem}>
|
||||||
|
{#each formColumns as col}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium mb-1"
|
||||||
|
for={col.key}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={col.key}
|
||||||
|
name={col.key}
|
||||||
|
bind:value={newPOItem[col.key as keyof POItem]}
|
||||||
|
class={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${errorClass(
|
||||||
|
col.key,
|
||||||
|
)}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if $formErrors[col.key]}
|
||||||
|
<p class="text-red-500 text-xs mt-1">
|
||||||
|
{$formErrors[col.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300"
|
||||||
|
on:click={() => (showModal = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{isEditing ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user