Files
vberp/src/routes/backoffice/inventory/+page.svelte
2025-07-22 13:59:52 +07:00

868 lines
31 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from "svelte";
import { supabase } from "$lib/supabaseClient";
import { goto } from "$app/navigation";
type ConditionType =
| "NEW"
| "GOOD"
| "AVERAGE"
| "POOR"
| "SUBSTANDARD"
| "BROKEN";
type Inventory = {
id: string;
item_name: string;
villa_id: string;
villa_name: string;
item_location: string;
brand_color_material: string;
condition: ConditionType;
remarks: string;
"1": number;
"2": number;
"3": number;
"4": number;
"5": number;
"6": number;
"7": number;
"8": number;
"9": number;
"10": number;
"11": number;
"12": number;
created_at: string;
updated_at: string;
};
type InventoryInsert = {
item_name: string;
villa_id: string;
item_location: string;
brand_color_material: string;
condition: ConditionType;
remarks: string;
created_at: string;
updated_at: string;
};
const columns = [
{ key: "item_name", title: "Item Name" },
{ key: "villa_name", title: "Villa Name" },
{ key: "item_location", title: "Location" },
{ key: "brand_color_material", title: "Brand/Color/Material" },
{ key: "condition", title: "Condition" },
{ key: "remarks", title: "Remarks" },
{ key: "1", title: "Jan" },
{ key: "2", title: "Feb" },
{ key: "3", title: "Mar" },
{ key: "4", title: "Apr" },
{ key: "5", title: "May" },
{ key: "6", title: "Jun" },
{ key: "7", title: "Jul" },
{ key: "8", title: "Aug" },
{ key: "9", title: "Sep" },
{ key: "10", title: "Oct" },
{ key: "11", title: "Nov" },
{ key: "12", title: "Dec" },
{ key: "created_at", title: "Created At" },
{ key: "updated_at", title: "Updated At" },
{ key: "actions", title: "Actions" },
];
type Villa = {
id: string;
villa_name: string;
};
let inventoryItems: Inventory[] = [];
let villaItems: Villa[] = [];
let totalItems = 0;
let currentPage = 1;
let rowsPerPage = 10;
let offset = 0;
let limit = 10;
$: totalPages = Math.ceil(totalItems / rowsPerPage);
let sortBy = "created_at";
let sortOrder = "desc";
let searchQuery = "";
let selectedVillaId: string | null = null;
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let form: InventoryInsert = {
item_name: "",
villa_id: "",
item_location: "",
brand_color_material: "",
condition: "NEW",
remarks: "",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// validation
function validateForm() {
if (!form.item_name) {
alert("Item name is required");
return false;
}
if (!form.villa_id) {
alert("Villa name is required");
return false;
}
if (!form.item_location) {
alert("Item location is required");
return false;
}
if (!form.brand_color_material) {
alert("Brand/Color/Material is required");
return false;
}
if (!form.condition) {
alert("Condition is required");
return false;
}
if (form.remarks && form.remarks.length > 500) {
alert("Remarks cannot exceed 500 characters");
return false;
}
return true;
}
let searchTerm: string | null = null;
let newItem = {
item_name: "",
quantity: 0,
unit_price: 0,
description: "",
};
let selectedYear: number | null = null;
$: if (selectedYear) {
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}
export let data;
async function fetchInventory(
searchTerm: string | null = "",
searchedVillaId: string | null = null,
year: number | null = null,
sortBy = "created_at",
sortOrder = "desc",
offset = 0,
limit = 10,
) {
let query = supabase
.from("vb_inventory_data")
.select("*", { count: "exact" })
.order(sortBy, { ascending: sortOrder === "asc" })
.range(offset, offset + limit - 1);
if (searchTerm && searchTerm.length > 4) {
query = query.ilike("item_name", `%${searchTerm}%`);
}
if (searchedVillaId) {
query = query.eq("villa_id", searchedVillaId);
}
if (year) {
query = query.or(`year.eq.${year},year.eq.0`);
}
const { data, error, count } = await query;
if (error) {
console.error("Error fetching inventory:", error);
return [];
}
inventoryItems = data as Inventory[];
totalItems = count || 0;
return { items: inventoryItems, total: totalItems };
}
async function updateQtyMonth(
itemId: string,
month: number,
year: number,
qty: number,
) {
// Update the quantity for a specific month di vb_inventory_qty_month jika tidak ada, insert baru
const { data, error: fetchError } = await supabase
.from("vb_inventory_qty_month")
.select("*")
.eq("id_inventory", itemId)
.eq("month", month)
.eq("year", year)
.single();
if (fetchError && fetchError.code !== "PGRST116") {
console.error("Error fetching quantity for month:", fetchError);
alert("Failed to fetch quantity for month. Please try again.");
return false;
}
console.log(
`Fetched data for item ${itemId} month ${month} year ${year}:`,
data,
);
if (data) {
// If data exists, update it
const { error: updateError } = await supabase
.from("vb_inventory_qty_month")
.update({ qty: qty })
.eq("id_inventory", itemId)
.eq("month", month)
.eq("year", year);
if (updateError) {
console.error(
"Error updating quantity for month:",
updateError,
);
alert("Failed to update quantity for month. Please try again.");
return false;
} else {
console.log(
`Quantity for item ${itemId} month ${month} year ${year} updated successfully.`,
);
return true;
}
} else {
// If no data exists, insert a new record
const { error: insertError } = await supabase
.from("vb_inventory_qty_month")
.insert({
id_inventory: itemId,
month: month,
year: year,
qty: qty,
});
if (insertError) {
console.error(
"Error inserting quantity for month:",
insertError,
);
alert("Failed to insert quantity for month. Please try again.");
return false;
} else {
console.log(
`Quantity for item ${itemId} month ${month} year ${year} inserted successfully.`,
);
return true;
}
}
}
onMount(async () => {
selectedYear = new Date().getFullYear();
await fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
// Fetch villa names for dropdown
const { data: villaData, error: villaError } = await supabase
.from("vb_villas")
.select("id, villa_name");
if (villaError) {
console.error("Error fetching villas:", villaError);
} else {
villaItems = villaData as Villa[];
}
// Check if user is logged in
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
goto("/login");
}
});
async function openModal() {
showModal = true;
newItem = {
item_name: "",
quantity: 0,
unit_price: 0,
description: "",
};
}
async function submitForm() {
if (isEditing) {
// Update existing item
const { error } = await supabase
.from("vb_inventory")
.update({
item_name: form.item_name,
villa_id: form.villa_id,
item_location: form.item_location,
brand_color_material: form.brand_color_material,
condition: form.condition,
remarks: form.remarks,
updated_at: new Date().toISOString(),
})
.eq("id", currentEditingId);
if (error) {
console.error("Error updating item:", error);
} else {
alert("Item updated successfully!");
showModal = false;
await fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}
} else {
// Insert new item
const { error } = await supabase.from("vb_inventory").insert({
...form,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
if (error) {
console.error("Error inserting item:", error);
} else {
alert("New item added successfully!");
showModal = false;
await fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages) return;
currentPage = page;
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
(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;
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}
</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>
Inventory Management
</h2>
<p class="text-sm text-gray-600 mb-2">
Manage your inventory items, track stock levels, and ensure
everything is in order.
</p>
<p class="text-xs text-gray-500 italic mb-2">
Note : Tekan tombol Enter untuk menyimpan. <span
class="text-red-500">⚠️</span
>
</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();
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}}
/>
<!-- dropdown villa -->
<select
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-2 py-2 rounded-xl text-sm w-48 transition"
bind:value={selectedVillaId}
on:change={() => {
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}}
>
<option value="" selected>All Villa</option>
{#each villaItems as villa}
<option value={villa.id}>{villa.villa_name}</option>
{/each}
</select>
<select
id="year"
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-2 py-2 rounded-xl text-sm w-20 transition"
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}
</select>
<button
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
on:click={() =>
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
)}
>
🔄 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
</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 === "item_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="bg-white divide-y divide-gray-200">
{#each inventoryItems as item}
<tr class="hover:bg-gray-100 transition duration-150">
{#each columns as col}
{#if +col.key >= 1 && +col.key <= 12}
<td class="px-4 py-3 whitespace-nowrap">
<input
type="number"
min="0"
class="border border-gray-300 rounded px-2 py-1 w-16"
bind:value={
item[col.key as keyof Inventory]
}
on:change={(e) => {
const target =
e.target as HTMLInputElement | null;
if (!target) return;
const value = Number(target.value);
updateQtyMonth(
item.id,
+col.key,
selectedYear ||
new Date().getFullYear(),
value,
).then((success) => {
if (success) {
alert(
`Quantity for ${item.item_name} in month ${col.key} updated successfully.`,
);
} else {
alert(
`Failed to update quantity for ${item.item_name} in month ${col.key}.`,
);
}
});
}}
/>
</td>
{:else if col.key === "item_name"}
<td
class="px-4 py-2 sticky left-0 whitespace-nowrap bg-gray-100 z-10"
>
<span
class="font-medium text-gray-800 hover:text-blue-600"
>
{item.item_name}
</span>
</td>
{:else if col.key === "created_at" || col.key === "updated_at"}
<td class="px-4 py-2">
{item[col.key] !== undefined
? new Date(
item[col.key] as
| string
| number
| Date,
).toLocaleString()
: "N/A"}
</td>
{:else if col.key === "actions"}
<td class="px-4 py-2">
<div class="flex space-x-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={() => {
isEditing = true;
currentEditingId = item.id;
form = {
item_name: item.item_name,
villa_id: item.villa_id,
item_location:
item.item_location,
brand_color_material:
item.brand_color_material,
condition: item.condition,
remarks: item.remarks,
created_at: item.created_at,
updated_at: item.updated_at,
};
showModal = true;
}}
>
✏️ 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={async () => {
const { error } = await supabase
.from("vb_inventory")
.delete()
.eq("id", item.id);
if (error) {
console.error(
"Error deleting item:",
error,
);
} else {
alert(
"Item deleted successfully!",
);
await fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
offset,
limit,
);
}
}}
>
🗑️ Delete
</button>
</div>
</td>
{:else}
<td class="px-4 py-2">
{item[col.key as keyof Inventory]}
</td>
{/if}
{/each}
</tr>
{:else}
<tr>
<td colspan={columns.length} class="text-center py-4">
No items found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<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 flex">
<div class="flex items-center space-x-2">
<label for="rowsPerPage" class="text-gray-700"
>Rows per page:</label
>
<select
id="rowsPerPage"
class="border border-gray-300 rounded px-2 py-1"
bind:value={rowsPerPage}
on:change={() => {
currentPage = 1; // Reset to first page
fetchInventory(
searchTerm,
selectedVillaId,
selectedYear,
sortBy,
sortOrder,
(currentPage - 1) * rowsPerPage,
rowsPerPage,
);
}}
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
<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 border-blue-600'
: 'bg-white border-gray-300 hover:bg-gray-100'}"
>
{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>
<!-- 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 Item" : "New Item"}
</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"
>Item Name</label
>
<input
type="text"
id="t_eb"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.item_name}
placeholder="Enter item name"
required
/>
</div>
<div>
<label for="tvn" class="block text-sm font-medium mb-1"
>Villa Name</label
>
<select
id="tvn"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.villa_id}
>
<option value="" disabled selected>Select Villa</option>
{#each villaItems as villa}
<option value={villa.id}>
{villa.villa_name}
</option>
{/each}
</select>
</div>
<div>
<label for="til" class="block text-sm font-medium mb-1"
>Item Location</label
>
<input
type="text"
id="til"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.item_location}
placeholder="Enter item location"
required
/>
</div>
<div>
<label for="tbcm" class="block text-sm font-medium mb-1"
>Brand/Color/Material</label
>
<input
type="text"
id="tbcm"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.brand_color_material}
placeholder="Enter brand, color, or material"
/>
</div>
<div>
<label for="tcond" class="block text-sm font-medium mb-1"
>Condition</label
>
<select
id="tcond"
class="w-full border border-gray-300 p-2 rounded"
bind:value={form.condition}
>
<option value="NEW">New</option>
<option value="GOOD">Good</option>
<option value="AVERAGE">Average</option>
<option value="POOR">Poor</option>
<option value="SUBSTANDARD">Substandard</option>
<option value="BROKEN">Broken</option>
</select>
</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 Inventory" : "New Entry"}
</button>
</form>
</div>
{/if}