penambahan menu inventory

This commit is contained in:
Aji Setiaji
2025-07-11 10:43:16 +07:00
parent 9f998f8720
commit 8657680261

View File

@@ -1,7 +1,778 @@
<div
style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh;"
>
<div style="font-size: 4rem; margin-bottom: 1rem;">🔒🔨</div>
<h2>Halaman sedang dalam tahap development</h2>
<p>Mohon maaf, halaman ini belum tersedia.</p>
<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 = new Date().getFullYear(); // Current year
export let data;
async function fetchInventory(
searchTerm: string | null = "",
searchedVillaId: string | 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);
}
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 () => {
await fetchInventory();
// 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();
}
} 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();
}
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages) return;
currentPage = page;
fetchInventory(
searchTerm,
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,
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">
Manage your inventory items, track stock levels, and ensure
everything is in 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();
fetchInventory(null, "created_at", "desc", 0, 10);
}}
/>
<!-- 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,
"created_at",
"desc",
offset,
limit,
);
}}
>
<option value="" disabled selected>Select 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(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 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="bg-white divide-y divide-gray-200">
{#each inventoryItems as item}
<tr>
{#each columns as col}
<td class="px-4 py-3 whitespace-nowrap">
{#if +col.key >= 1 && +col.key <= 12}
<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}.`,
);
}
});
}}
/>
{: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"}
<div class="flex space-x-2">
<button
class="text-blue-600 hover:underline"
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="text-red-600 hover:underline"
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();
}
}}
>
🗑️ Delete
</button>
</div>
{:else}
{item[col.key as keyof Inventory]}
{/if}
</td>
{/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,
sortBy,
sortOrder,
0,
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>
<p class="text-xs text-gray-500 italic mb-2">
Tekan tombol Enter untuk menyimpan.
</p>
<!-- 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 Timesheet" : "New Entry"}
</button>
</form>
</div>
{/if}