first commit

This commit is contained in:
Aji Setiaji
2025-05-27 21:43:01 +07:00
commit 8d984635af
30 changed files with 7314 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<script>
import Sidebar from "../../components/Sidebar.svelte";
export let data;
let notifications = 3; // Contoh jumlah notifikasi
let user = {
name: "John Doe",
avatar: "https://i.pravatar.cc/40", // Avatar placeholder
};
</script>
<div class="flex h-screen">
<Sidebar />
<!-- Main Content -->
<div class="flex-1 flex flex-col bg-gray-100 overflow-hidden">
<!-- Navbar -->
<div
class="flex items-center justify-between bg-white shadow px-6 py-3 border-b"
>
<div class="text-lg font-semibold text-gray-700">Dashboard</div>
<div class="flex items-center gap-6">
<!-- Notifications -->
<div class="relative">
<button class="text-gray-600 hover:text-gray-800 text-xl">
🔔
</button>
{#if notifications > 0}
<span
class="absolute -top-1 -right-1 bg-red-500 text-white text-xs w-5 h-5 flex items-center justify-center rounded-full"
>{notifications}</span
>
{/if}
</div>
<!-- Profile -->
<div class="flex items-center gap-2">
<img
src={user.avatar}
alt="User Avatar"
class="w-8 h-8 rounded-full"
/>
<span class="text-sm font-medium text-gray-700"
>{user.name}</span
>
</div>
</div>
</div>
<!-- Page Content -->
<div class="flex-1 p-6 overflow-y-auto">
<slot />
</div>
</div>
</div>

View File

View File

@@ -0,0 +1,986 @@
<script lang="ts">
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
import { writable } from "svelte/store";
const priority = [
{ label: "Low", value: "Low" },
{ label: "Medium", value: "Medium" },
{ label: "High", value: "High" },
{ label: "Critical", value: "Critical" },
];
const issueSource = [
{ label: "Email", value: "Email" },
{ label: "Phone Call", value: "Phone Call" },
{ label: "In-Person", value: "In-Person" },
{ label: "Online Form", value: "Online Form" },
{ label: "Other", value: "Other" },
];
const issueTypes = [
{ label: "Facilities - Light", value: "Facilities - Light" },
{ label: "Facilities - Linen", value: "Facilities - Linen" },
{ label: "Facilities - Other", value: "Facilities - Other" },
{ label: "Facilities - Towel", value: "Facilities - Towel" },
{ label: "Facilities - Fan", value: "Facilities - Fan" },
{ label: "Cleanliness - Other", value: "Cleanliness - Other" },
{ label: "Cleanliness - Floor", value: "Cleanliness - Floor" },
{ label: "Cleanliness - Kitchen", value: "Cleanliness - Kitchen" },
{ label: "Cleanliness - Bathroom", value: "Cleanliness - Bathroom" },
{
label: "Maintenance - Electrical",
value: "Maintenance - Electrical",
},
{ label: "Maintenance - Plumbing", value: "Maintenance - Plumbing" },
{ label: "Maintenance - HVAC", value: "Maintenance - HVAC" },
{
label: "Maintenance - Structural",
value: "Maintenance - Structural",
},
{ label: "Safety Issue", value: "Safety Issue" },
{ label: "Security Concern", value: "Security Concern" },
{ label: "Other", value: "Other" },
{ label: "General Inquiry", value: "General Inquiry" },
{ label: "Feedback", value: "Feedback" },
{ label: "Complaint", value: "Complaint" },
{ label: "Request", value: "Request" },
{ label: "Suggestion", value: "Suggestion" },
{ label: "Booking Issue", value: "Booking Issue" },
{ label: "Payment Issue", value: "Payment Issue" },
{ label: "Cancellation Request", value: "Cancellation Request" },
{ label: "Refund Request", value: "Refund Request" },
{ label: "Reservation Change", value: "Reservation Change" },
{ label: "Check-in Issue", value: "Check-in Issue" },
{ label: "Check-out Issue", value: "Check-out Issue" },
];
const areaOfVilla = [
{ label: "All Bathrooms", value: "All Bathrooms" },
{ label: "All Guest Houses", value: "All Guest Houses" },
{ label: "All Rooms", value: "All Rooms" },
{ label: "All Villa Areas", value: "All Villa Areas" },
{ label: "Balcony", value: "Balcony" },
{ label: "Bathroom (Guest)", value: "Bathroom (Guest)" },
{ label: "Bathroom (Master)", value: "Bathroom (Master)" },
{ label: "Bathroom 1", value: "Bathroom 1" },
{ label: "Bathroom 2", value: "Bathroom 2" },
{ label: "Bathroom 3", value: "Bathroom 3" },
{ label: "Bedroom (Guest)", value: "Bedroom (Guest)" },
{ label: "Bedroom (Master)", value: "Bedroom (Master)" },
{ label: "Bedroom 1", value: "Bedroom 1" },
{ label: "Bedroom 2", value: "Bedroom 2" },
{ label: "Bedroom 3", value: "Bedroom 3" },
{ label: "Ceiling", value: "Ceiling" },
{ label: "Dining Area", value: "Dining Area" },
{ label: "Door", value: "Door" },
{ label: "Entrance", value: "Entrance" },
{ label: "Garden", value: "Garden" },
{ label: "General", value: "General" },
{ label: "Glass", value: "Glass" },
{ label: "Hallway", value: "Hallway" },
{ label: "Kitchen", value: "Kitchen" },
{ label: "Laundry Area", value: "Laundry Area" },
{ label: "Living Room", value: "Living Room" },
{ label: "Outdoor Area", value: "Outdoor Area" },
{ label: "Parking Area", value: "Parking Area" },
{ label: "Pool Area", value: "Pool Area" },
{ label: "Roof", value: "Roof" },
{ label: "Stairs", value: "Stairs" },
{ label: "Storage", value: "Storage" },
{ label: "Terrace", value: "Terrace" },
{ label: "Toilet", value: "Toilet" },
{ label: "Wall", value: "Wall" },
{ label: "Window", value: "Window" },
{ label: "Others", value: "Others" },
];
const inputBy = [
{ label: "Admin", value: "Admin" },
{ label: "Staff", value: "Staff" },
{ label: "Manager", value: "Manager" },
{ label: "Guest", value: "Guest" },
];
const reportedBy = [
{ label: "Admin", value: "Admin" },
{ label: "Staff", value: "Staff" },
{ label: "Manager", value: "Manager" },
{ label: "Guest", value: "Guest" },
];
type Villa = {
id: string;
name: string;
};
let dataVilla: Villa[] = [];
onMount(async () => {
const { data, error } = await supabase
.from("villas")
.select("id, name");
if (error) {
console.error("Error fetching villas:", error);
} else if (data) {
dataVilla = data;
}
});
type Issue = {
id: number;
name: string;
villa_name: string;
area_of_villa: string;
priority: string;
issue_type: string;
issue_number: string;
move_issue: string;
description_of_the_issue: string;
reported_date: string;
issue_related_image: string;
issue_source: string;
reported_by: string;
input_by: string;
guest_communication: string;
resolution: string;
guest_has_aggreed_issue_has_been_resolved: boolean;
follow_up: boolean;
need_approval: boolean;
created_at: string;
};
type issueInsert = {
name: string;
villa_name: string;
area_of_villa: string;
priority: string;
issue_type: string;
description_of_the_issue: string;
reported_date: string;
issue_related_image: string;
issue_source: string;
reported_by: string;
input_by: string;
guest_communication: string;
resolution: string;
guest_has_aggreed_issue_has_been_resolved: boolean;
follow_up: boolean;
need_approval: boolean;
};
let allRows: Issue[] = [];
type columns = {
key: string;
title: string;
};
const columns: columns[] = [
{ key: "name", title: "Name" },
{ key: "villa_name", title: "Villa Name" },
{ key: "area_of_villa", title: "Area Of Villa" },
{ key: "priority", title: "Priority" },
{ key: "issue_type", title: "Issue Type" },
{ key: "issue_number", title: "Issue Number" },
{ key: "move_issue", title: "Move Issue" },
{ key: "description_of_the_issue", title: "Description of The Issue" },
{ key: "reported_date", title: "Reported Date" },
{ key: "issue_related_image", title: "Issue Related Image" },
{ key: "issue_source", title: "Issue Source" },
{ key: "reported_by", title: "Reported By" },
{ key: "input_by", title: "Input By" },
{ key: "guest_communication", title: "Guest Communication" },
{ key: "resolution", title: "Resolution" },
{
key: "guest_has_aggreed_issue_has_been_resolved",
title: "Guest Has Aggred Issue Has Been Resolved",
},
{ key: "follow_up", title: "Follow Up" },
{ key: "need_approval", title: "Need Approval" },
{ key: "created_at", title: "Created At" },
{ key: "actions", title: "Actions" },
];
async function fetchIssues() {
const { data: issues, error: issueError } = await supabase
.from("issues")
.select("*")
.order("created_at", { ascending: false });
if (issueError) {
console.error("Error fetching issues:", issueError);
return;
}
// Ambil semua villa_id unik dari issues
const villaIds = [...new Set(issues.map((i: Issue) => i.villa_name))];
const { data: villas, error: villaError } = await supabase
.from("villas")
.select("*")
.in("id", villaIds);
if (villaError) {
console.error("Error fetching villas:", villaError);
return;
}
// Gabungkan data villa ke dalam setiap issue
allRows = issues.map((issue: Issue) => ({
...issue,
villa_name:
villas.find((v) => v.id === issue.villa_name).name || null,
}));
}
let currentPage = 1;
let rowsPerPage = 5;
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
$: paginatedRows = allRows.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage,
);
function editIssue(id: number) {
alert(`Edit issue with ID ${id}`);
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) currentPage = page;
}
onMount(() => {
fetchIssues();
});
// Initialize the first page
$: currentPage = 1;
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newIssue: Record<string, any> = {};
const excludedKeys = [
"id",
"created_at",
"move_issue",
"issue_number",
"actions",
];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
function openModal(issue?: Record<string, any>) {
if (issue) {
isEditing = true;
currentEditingId = issue.id;
newIssue = { ...issue };
} else {
isEditing = false;
currentEditingId = null;
newIssue = {};
}
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("issues")
.update(newIssue)
.eq("id", currentEditingId);
if (error) {
alert("Error updating issue: " + error.message);
console.error("Error updating issue:", error);
return;
}
} else {
const issueInsert: issueInsert = {
name: formData.get("name") as string,
villa_name: formData.get("villa_name") as string,
area_of_villa: formData.get("area_of_villa") as string,
priority: formData.get("priority") as string,
issue_type: formData.get("issue_type") as string,
description_of_the_issue: formData.get(
"description_of_the_issue",
) as string,
reported_date: formData.get("reported_date") as string,
issue_related_image: imagePreviewUrl || "",
issue_source: formData.get("issue_source") as string,
reported_by: formData.get("reported_by") as string,
input_by: formData.get("input_by") as string,
guest_communication: formData.get(
"guest_communication",
) as string,
resolution: formData.get("resolution") as string,
guest_has_aggreed_issue_has_been_resolved:
formData.get(
"guest_has_aggreed_issue_has_been_resolved",
) === "true"
? true
: false,
follow_up: formData.get("follow_up") === "true" ? true : false,
need_approval:
formData.get("need_approval") === "true" ? true : false,
};
const { error } = await supabase
.from("issues")
.insert([issueInsert]);
if (error) {
console.error("Error adding issue:", error);
return;
}
}
await fetchIssues();
showModal = false;
}
async function deleteIssue(id: number) {
if (confirm("Are you sure you want to delete this issue?")) {
const { error } = await supabase
.from("issues")
.delete()
.eq("id", id);
if (error) {
console.error("Error deleting issue:", error);
return;
}
await fetchIssues();
}
}
let selectedFile: File | null = null;
let imagePreviewUrl: string | null = null;
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
selectedFile = input.files[0];
imagePreviewUrl = URL.createObjectURL(selectedFile);
}
}
export let formErrors = writable<{ [key: string]: string }>({});
function validateForm(formData: FormData): boolean {
const errors: { [key: string]: string } = {};
const requiredFields = [
"name",
"description_of_the_issue",
"issue_source",
"villa_name",
"reported_date",
"reported_by",
"priority",
"issue_type",
"input_by",
"area_of_villa",
];
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";
}
// insert id issue to project
async function moveIssueToProject(issueId: number) {
// update move_issue field in the issue
const { error: updateError } = await supabase
.from("issues")
.update({ move_issue: "PROJECT" })
.eq("id", issueId);
if (updateError) {
console.error("Error updating issue move_issue:", updateError);
return;
}
const { error } = await supabase
.from("projects")
.insert({ issue_id: issueId });
if (error) {
console.error("Error moving issue to project:", error);
return;
}
alert(`Issue ${issueId} moved to project successfully.`);
await fetchIssues();
}
// insert id issue to purchase order
async function moveIssueToPurchaseOrder(issueId: number) {
// update move_issue field in the issue
const { error: updateError } = await supabase
.from("issues")
.update({ move_issue: "PURCHASE_ORDER" })
.eq("id", issueId);
if (updateError) {
console.error("Error updating issue move_issue:", updateError);
return;
}
const { error } = await supabase
.from("purchase_orders")
.insert({ issue_id: issueId });
if (error) {
console.error("Error moving issue to purchase order:", error);
return;
}
alert(`Issue ${issueId} moved to purchase order successfully.`);
await fetchIssues();
}
</script>
<div>
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-lg font-semibold text-gray-800">Issue List</h2>
<p class="text-sm text-gray-600">
Manage and view all issues reported in the system.
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => openModal()}
>
Add Issue
</button>
</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 === "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 === "name"}
<td
class="sticky left-0 px-4 py-2 font-medium text-blue-600"
style="background-color: #f0f8ff; cursor: pointer;"
>
{row[col.key]}
</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={() => deleteIssue(row.id)}
>
🗑️ Delete
</button>
</td>
{:else if col.key === "move_issue"}
{#if row[col.key as keyof Issue] === "PROJECT"}
<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 disabled:opacity-90"
disabled
>
➡️ PROJECT
</button>
</td>
{:else if row[col.key as keyof Issue] === "PURCHASE_ORDER"}
<td class="px-4 py-2">
<button
class="inline-flex items-center gap-1 rounded bg-yellow-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-yellow-700 disabled:opacity-90"
disabled
>
➡️ PURCHASE ORDER
</button>
</td>
{:else}
<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={() =>
moveIssueToProject(row.id)}
>
➡️ PROJECT
</button>
<button
class="inline-flex items-center gap-1 rounded bg-yellow-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-yellow-700"
on:click={() =>
moveIssueToPurchaseOrder(
row.id,
)}
>
➡️ PURCHASE ORDER
</button>
</td>
{/if}
{:else if col.key === "guest_has_aggreed_issue_has_been_resolved"}
<td class="px-4 py-2">
{#if row[col.key as keyof Issue]}
{:else}
{/if}
</td>
{:else if col.key === "need_approval"}
<td class="px-4 py-2">
{#if row[col.key as keyof Issue]}
{:else}
{/if}
</td>
{:else if col.key === "follow_up"}
<td class="px-4 py-2">
{#if row[col.key as keyof Issue]}
{:else}
{/if}
</td>
{:else}
<td class="px-4 py-2 text-gray-700"
>{row[col.key as keyof Issue]}</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>
<!-- Modal -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<form
on:submit|preventDefault={saveIssue}
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold">
{isEditing ? "Edit Issue" : "Add New Issue"}
</h3>
{#each formColumns as col}
{#if col.key === "name"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Customer / Guest Name</label
>
<input
class="w-full border px-3 py-2 rounded {errorClass(
'name',
)}"
name="name"
type="text"
bind:value={newIssue[col.key as keyof Issue]}
placeholder={col.title}
required
/>
{#if $formErrors.name}
<p class="text-red-500 text-xs">
{$formErrors.name}
</p>
{/if}
</div>
{:else if col.key === "guest_has_aggreed_issue_has_been_resolved"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Guest Has Aggred Issue Has Been Resolved</label
>
<select
name="guest_has_aggreed_issue_has_been_resolved"
class="w-full border px-3 py-2 rounded"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
{:else if col.key === "follow_up"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Follow Up</label
>
<select
name="follow_up"
class="w-full border px-3 py-2 rounded"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
{:else if col.key === "issue_related_image"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700">
Issue Related Image
</label>
<input
name="issue_related_image"
class="w-full border px-3 py-2 rounded"
type="file"
accept="image/*"
on:change={handleFileChange}
/>
<p class="text-xs text-gray-500">
Upload an image related to the issue.
</p>
{#if imagePreviewUrl}
<img
src={imagePreviewUrl}
alt="Preview"
class="mt-2 max-h-48 rounded border"
/>
{/if}
</div>
{:else if col.key === "reported_date"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Reported Date</label
>
<input
name="reported_date"
class="w-full border px-3 py-2 rounded {errorClass(
'reported_date',
)}"
type="date"
bind:value={newIssue[col.key as keyof Issue]}
/>
{#if $formErrors.reported_date}
<p class="text-red-500 text-xs">
{$formErrors.reported_date}
</p>
{/if}
</div>
{:else if col.key === "need_approval"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Need Approval</label
>
<select
name="need_approval"
class="w-full border px-3 py-2 rounded"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
{:else if col.key === "issue_source"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Issue Source</label
>
<select
name="issue_source"
class="w-full border px-3 py-2 rounded {errorClass(
'issue_source',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Source</option
>
{#each issueSource as source}
<option value={source.value}
>{source.label}</option
>
{/each}
</select>
{#if $formErrors.issue_source}
<p class="text-red-500 text-xs">
{$formErrors.issue_source}
</p>
{/if}
</div>
{:else if col.key === "reported_by"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Reported By</label
>
<select
name="reported_by"
class="w-full border px-3 py-2 rounded {errorClass(
'reported_by',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Reporter</option
>
{#each reportedBy as reporter}
<option value={reporter.value}
>{reporter.label}</option
>
{/each}
</select>
{#if $formErrors.reported_by}
<p class="text-red-500 text-xs">
{$formErrors.reported_by}
</p>
{/if}
</div>
{:else if col.key === "input_by"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Input By</label
>
<select
name="input_by"
class="w-full border px-3 py-2 rounded {errorClass(
'input_by',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Input By</option
>
{#each inputBy as input}
<option value={input.value}
>{input.label}</option
>
{/each}
</select>
{#if $formErrors.input_by}
<p class="text-red-500 text-xs">
{$formErrors.input_by}
</p>
{/if}
</div>
{:else if col.key === "area_of_villa"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Area of Villa</label
>
<select
name="area_of_villa"
class="w-full border px-3 py-2 rounded {errorClass(
'area_of_villa',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Area</option
>
{#each areaOfVilla as area}
<option value={area.value}>{area.label}</option>
{/each}
</select>
{#if $formErrors.area_of_villa}
<p class="text-red-500 text-xs">
{$formErrors.area_of_villa}
</p>
{/if}
</div>
{:else if col.key === "issue_type"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Issue Type</label
>
<select
name="issue_type"
class="w-full border px-3 py-2 rounded {errorClass(
'issue_type',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Issue Type</option
>
{#each issueTypes as type}
<option value={type.value}>{type.label}</option>
{/each}
</select>
{#if $formErrors.issue_type}
<p class="text-red-500 text-xs">
{$formErrors.issue_type}
</p>
{/if}
</div>
{:else if col.key === "priority"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Priority</label
>
<select
name="priority"
class="w-full border px-3 py-2 rounded {errorClass(
'priority',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Priority</option
>
{#each priority as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
{#if $formErrors.priority}
<p class="text-red-500 text-xs">
{$formErrors.priority}
</p>
{/if}
</div>
{:else if col.key === "villa_name"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Villa Name</label
>
<select
name="villa_name"
class="w-full border px-3 py-2 rounded {errorClass(
'villa_name',
)}"
bind:value={newIssue[col.key as keyof Issue]}
>
<option value="" disabled selected
>Select Villa</option
>
{#each dataVilla as villa}
<option value={villa.id}>{villa.name}</option>
{/each}
</select>
{#if $formErrors.villa_name}
<p class="text-red-500 text-xs">
{$formErrors.villa_name}
</p>
{/if}
</div>
{:else if col.key === "description_of_the_issue"}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>Description of The Issue</label
>
<textarea
name="description_of_the_issue"
class="w-full border px-3 py-2 rounded {errorClass(
'description_of_the_issue',
)}"
bind:value={newIssue[col.key as keyof Issue]}
placeholder={col.title}
rows="4"
></textarea>
{#if $formErrors.description_of_the_issue}
<p class="text-red-500 text-xs">
{$formErrors.description_of_the_issue}
</p>
{/if}
</div>
{:else}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>{col.title}</label
>
<input
name={col.key}
class="w-full border px-3 py-2 rounded"
type="text"
bind:value={newIssue[col.key as keyof Issue]}
placeholder={col.title}
/>
</div>
{/if}
{/each}
<div class="flex justify-end gap-2 mt-4">
<button
class="px-4 py-2 text-sm rounded bg-gray-200 hover:bg-gray-300"
on:click={() => (showModal = false)}
>
Cancel
</button>
<button
class="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
type="submit"
>
Save
</button>
</div>
</form>
</div>
{/if}

View File

@@ -0,0 +1,607 @@
<script lang="ts">
// This is a placeholder for any script you might want to add
// For example, you could handle form submission here
import { onMount } from "svelte";
import { supabase } from "$lib/supabaseClient";
import { writable } from "svelte/store";
const priority = [
{ label: "Low", value: "Low" },
{ label: "Medium", value: "Medium" },
{ label: "High", value: "High" },
{ label: "Critical", value: "Critical" },
];
const issueSource = [
{ label: "Email", value: "Email" },
{ label: "Phone Call", value: "Phone Call" },
{ label: "In-Person", value: "In-Person" },
{ label: "Online Form", value: "Online Form" },
{ label: "Other", value: "Other" },
];
const issueTypes = [
{ label: "Facilities - Light", value: "Facilities - Light" },
{ label: "Facilities - Linen", value: "Facilities - Linen" },
{ label: "Facilities - Other", value: "Facilities - Other" },
{ label: "Facilities - Towel", value: "Facilities - Towel" },
{ label: "Facilities - Fan", value: "Facilities - Fan" },
{ label: "Cleanliness - Other", value: "Cleanliness - Other" },
{ label: "Cleanliness - Floor", value: "Cleanliness - Floor" },
{ label: "Cleanliness - Kitchen", value: "Cleanliness - Kitchen" },
{ label: "Cleanliness - Bathroom", value: "Cleanliness - Bathroom" },
{
label: "Maintenance - Electrical",
value: "Maintenance - Electrical",
},
{ label: "Maintenance - Plumbing", value: "Maintenance - Plumbing" },
{ label: "Maintenance - HVAC", value: "Maintenance - HVAC" },
{
label: "Maintenance - Structural",
value: "Maintenance - Structural",
},
{ label: "Safety Issue", value: "Safety Issue" },
{ label: "Security Concern", value: "Security Concern" },
{ label: "Other", value: "Other" },
{ label: "General Inquiry", value: "General Inquiry" },
{ label: "Feedback", value: "Feedback" },
{ label: "Complaint", value: "Complaint" },
{ label: "Request", value: "Request" },
{ label: "Suggestion", value: "Suggestion" },
{ label: "Booking Issue", value: "Booking Issue" },
{ label: "Payment Issue", value: "Payment Issue" },
{ label: "Cancellation Request", value: "Cancellation Request" },
{ label: "Refund Request", value: "Refund Request" },
{ label: "Reservation Change", value: "Reservation Change" },
{ label: "Check-in Issue", value: "Check-in Issue" },
{ label: "Check-out Issue", value: "Check-out Issue" },
];
const areaOfVilla = [
{ label: "All Bathrooms", value: "All Bathrooms" },
{ label: "All Guest Houses", value: "All Guest Houses" },
{ label: "All Rooms", value: "All Rooms" },
{ label: "All Villa Areas", value: "All Villa Areas" },
{ label: "Balcony", value: "Balcony" },
{ label: "Bathroom (Guest)", value: "Bathroom (Guest)" },
{ label: "Bathroom (Master)", value: "Bathroom (Master)" },
{ label: "Bathroom 1", value: "Bathroom 1" },
{ label: "Bathroom 2", value: "Bathroom 2" },
{ label: "Bathroom 3", value: "Bathroom 3" },
{ label: "Bedroom (Guest)", value: "Bedroom (Guest)" },
{ label: "Bedroom (Master)", value: "Bedroom (Master)" },
{ label: "Bedroom 1", value: "Bedroom 1" },
{ label: "Bedroom 2", value: "Bedroom 2" },
{ label: "Bedroom 3", value: "Bedroom 3" },
{ label: "Ceiling", value: "Ceiling" },
{ label: "Dining Area", value: "Dining Area" },
{ label: "Door", value: "Door" },
{ label: "Entrance", value: "Entrance" },
{ label: "Garden", value: "Garden" },
{ label: "General", value: "General" },
{ label: "Glass", value: "Glass" },
{ label: "Hallway", value: "Hallway" },
{ label: "Kitchen", value: "Kitchen" },
{ label: "Laundry Area", value: "Laundry Area" },
{ label: "Living Room", value: "Living Room" },
{ label: "Outdoor Area", value: "Outdoor Area" },
{ label: "Parking Area", value: "Parking Area" },
{ label: "Pool Area", value: "Pool Area" },
{ label: "Roof", value: "Roof" },
{ label: "Stairs", value: "Stairs" },
{ label: "Storage", value: "Storage" },
{ label: "Terrace", value: "Terrace" },
{ label: "Toilet", value: "Toilet" },
{ label: "Wall", value: "Wall" },
{ label: "Window", value: "Window" },
{ label: "Others", value: "Others" },
];
const inputBy = [
{ label: "Admin", value: "Admin" },
{ label: "Staff", value: "Staff" },
{ label: "Manager", value: "Manager" },
{ label: "Guest", value: "Guest" },
];
const followUp = [
{ label: "Yes", value: "true" },
{ label: "No", value: "false" },
];
const reportedBy = [
{ label: "Admin", value: "Admin" },
{ label: "Staff", value: "Staff" },
{ label: "Manager", value: "Manager" },
{ label: "Guest", value: "Guest" },
];
let issueImageFile: File | null = null;
let issueImageUrl: string = "";
function handleFileChange(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
issueImageFile = file;
issueImageUrl = URL.createObjectURL(file);
}
}
type Villa = {
id: string;
name: string;
};
let dataVilla: Villa[] = [];
onMount(async () => {
const { data, error } = await supabase
.from("villas")
.select("id, name");
if (error) {
console.error("Error fetching villas:", error);
} else if (data) {
dataVilla = data;
}
});
type Issue = {
name: string;
villa_name: string;
area_of_villa: string;
priority: string;
issue_type: string;
issue_number: string;
move_issue: boolean;
description_of_the_issue: string;
reported_date: string;
issue_related_image: string;
issue_source: string;
reported_by: string;
input_by: string;
guest_communication: string;
resolution: string;
guest_has_aggreed_issue_has_been_resolved: boolean;
follow_up: boolean;
need_approval: boolean;
created_at: string;
};
async function handleSubmit(event: Event): Promise<void> {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
// Validate form data
if (!validateForm(formData)) {
console.error("Form validation failed");
return;
}
const issue: Issue = {
name: formData.get("name") as string,
villa_name: formData.get("villa_name") as string,
area_of_villa: formData.get("area_of_villa") as string,
priority: formData.get("priority") as string,
issue_type: formData.get("issue_type") as string,
issue_number: formData.get("issue_number") as string,
move_issue: formData.get("move_issue") === "false",
description_of_the_issue: formData.get(
"description_of_the_issue",
) as string,
reported_date: formData.get("reported_date") as string,
issue_related_image: issueImageUrl,
issue_source: formData.get("issue_source") as string,
reported_by: formData.get("reported_by") as string,
input_by: formData.get("input_by") as string,
guest_communication: formData.get("guest_communication") as string,
resolution: formData.get("resolution") as string,
guest_has_aggreed_issue_has_been_resolved:
formData.get("guest_has_aggreed_issue_has_been_resolved") ===
"false",
follow_up: formData.get("follow_up") === "true" ? true : false,
need_approval: false, // Set this based on your logic
created_at: new Date().toISOString(),
};
const { data, error } = await supabase.from("issues").insert([issue]);
if (error) {
console.error("Error submitting issue:", error);
} else {
console.log("Issue submitted successfully:", data);
alert("Issue submitted successfully!");
}
}
export let formErrors = writable<{ [key: string]: string }>({});
function validateForm(formData: FormData): boolean {
const errors: { [key: string]: string } = {};
const requiredFields = [
"description_of_the_issue",
"issue_source",
"villa_name",
"reported_date",
"reported_by",
"priority",
"issue_type",
"due_issue_date",
"input_by",
"area_of_villa",
];
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>
<form
class="max-w-6xl mx-auto bg-white p-8 rounded-2xl shadow-xl space-y-8 text-gray-800"
on:submit|preventDefault={handleSubmit}
>
<!-- Title -->
<h2 class="text-2xl font-semibold">Submit New Issue</h2>
<!-- 2 Column Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left Column -->
<div class="space-y-5">
<div>
<label class="block text-sm font-medium mb-1"
>Description of Issues<span class="text-red-500">*</span
></label
>
<input
name="description_of_the_issue"
type="text"
placeholder="Tell detail of the issue"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
'description_of_the_issue',
)}"
/>
{#if $formErrors.description_of_the_issue}
<p class="text-sm text-red-500 mt-1">
{$formErrors.description_of_the_issue}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Issue Source<span class="text-red-500">*</span></label
>
<select
name="issue_source"
class={`w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 ${errorClass("issue_source")}`}
>
<option value="" disabled selected
>Select option...</option
>
{#each issueSource as source}
<option value={source.value}>{source.label}</option>
{/each}
</select>
{#if $formErrors.issue_source}
<p class="text-sm text-red-500 mt-1">
{$formErrors.issue_source}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Villa Name<span class="text-red-500">*</span></label
>
<select
name="villa_name"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 {errorClass(
'villa_name',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each dataVilla as villa}
<option value={villa.id}>{villa.name}</option>
{/each}
</select>
{#if $formErrors.villa_name}
<p class="text-sm text-red-500 mt-1">
{$formErrors.villa_name}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Issue related image</label
>
<div
class="w-full border-2 border-dashed rounded-xl px-4 py-10 text-center text-gray-400 cursor-pointer hover:bg-gray-50 transition relative"
>
<input
name="issue_related_image"
type="file"
accept="image/*"
class="hidden"
id="issue_image"
on:change={handleFileChange}
/>
<label for="issue_image" class="cursor-pointer">
<span class="block mb-2">Click to upload</span>
<span class="text-xs">or drag and drop</span>
</label>
<p class="mt-2 text-xs">
Supported formats: JPG, PNG, GIF
</p>
</div>
{#if issueImageUrl}
<div class="mt-4">
<p class="text-sm text-gray-600 mb-2">Preview:</p>
<img
src={issueImageUrl}
alt="Issue preview"
class="w-full h-48 object-cover rounded-xl shadow-sm"
/>
</div>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Date Reported<span class="text-red-500">*</span></label
>
<input
name="reported_date"
type="date"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
'reported_date',
)}"
/>
{#if $formErrors.reported_date}
<p class="text-sm text-red-500 mt-1">
{$formErrors.reported_date}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Reported By<span class="text-red-500">*</span></label
>
<select
name="reported_by"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 text-gray-600 {errorClass(
'reported_by',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each reportedBy as reporter}
<option value={reporter.value}>
{reporter.label}
</option>
{/each}
</select>
{#if $formErrors.reported_by}
<p class="text-sm text-red-500 mt-1">
{$formErrors.reported_by}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>URLDrive</label
>
<input
name="url_drive"
type="url"
placeholder="Enter URL"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
</div>
</div>
<!-- Right Column -->
<div class="space-y-5">
<div>
<label class="block text-sm font-medium mb-1"
>Priority<span class="text-red-500">*</span></label
>
<select
name="priority"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 {errorClass(
'priority',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each priority as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
{#if $formErrors.priority}
<p class="text-sm text-red-500 mt-1">
{$formErrors.priority}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Issue Type<span class="text-red-500">*</span></label
>
<select
name="issue_type"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 {errorClass(
'issue_type',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each issueTypes as type}
<option value={type.value}>{type.label}</option>
{/each}
</select>
{#if $formErrors.issue_type}
<p class="text-sm text-red-500 mt-1">
{$formErrors.issue_type}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Area of Villa<span class="text-red-500">*</span></label
>
<select
name="area_of_villa"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 {errorClass(
'area_of_villa',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each areaOfVilla as area}
<option value={area.value}>{area.label}</option>
{/each}
</select>
{#if $formErrors.area_of_villa}
<p class="text-sm text-red-500 mt-1">
{$formErrors.area_of_villa}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Customer / Guest Name</label
>
<input
name="name"
type="text"
placeholder="Enter text"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Due Issue Date<span class="text-red-500">*</span
></label
>
<input
name="due_issue_date"
type="date"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 {errorClass(
'due_issue_date',
)}"
/>
{#if $formErrors.due_issue_date}
<p class="text-sm text-red-500 mt-1">
{$formErrors.due_issue_date}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Input By<span class="text-red-500">*</span></label
>
<select
name="input_by"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600 {errorClass(
'input_by',
)}"
>
<option value="" disabled selected
>Select option...</option
>
{#each inputBy as input}
<option value={input.value}>{input.label}</option>
{/each}
</select>
{#if $formErrors.input_by}
<p class="text-sm text-red-500 mt-1">
{$formErrors.input_by}
</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Follow Up</label
>
<select
name="follow_up"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400 text-gray-600"
>
<option value="" disabled selected
>Select option...</option
>
{#each followUp as follow}
<option value={follow.value}>{follow.label}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Full Width Fields -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium mb-1"
>Resolved How?</label
>
<textarea
name="resolution"
rows="3"
placeholder="How you resolve? e.g. 'copy to project'"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400"
></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1"
>Guest Communication</label
>
<textarea
name="guest_communication"
rows="3"
placeholder="Communication with guest while still in the villa"
class="w-full border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-400"
></textarea>
</div>
<div class="flex items-center space-x-2">
<input
name="guest_has_aggreed_issue_has_been_resolved"
value="false"
type="checkbox"
id="guest_agreed"
class="h-4 w-4 rounded border-gray-300"
/>
<label for="guest_agreed" class="text-sm"
>Guest has agreed issue has been resolved</label
>
</div>
</div>
<!-- Submit Button -->
<div class="text-center pt-4">
<button
type="submit"
class="bg-purple-600 text-white px-8 py-3 rounded-xl hover:bg-purple-700 transition-all font-medium shadow-md"
>
Submit
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,618 @@
<script lang="ts">
import { onMount } from "svelte";
import { supabase } from "$lib/supabaseClient";
type Project = {
id: string;
issue_id: string;
project_number: string;
add_to_po: boolean;
input_by: string;
project_due_date: string;
picture_link: string;
};
type insetProject = {
issue_id: string;
input_by: string;
project_due_date: string;
picture_link: string;
};
type Projects = {
id: string;
name: string;
priority: string;
add_to_po: boolean;
description_of_the_issue: string;
picture_link: string;
need_approval: boolean;
area_of_villa: string;
input_by: string;
issue_number: string;
issue_id: string;
villa_name: string;
report_date: string;
project_due_date: string;
};
let allRows: Projects[] = [];
type columns = {
key: string;
title: string;
};
const columns: columns[] = [
{ key: "name", title: "Project Name" },
{ key: "priority", title: "Priority" },
{ key: "add_to_po", title: "Add to PO" },
{ key: "description_of_the_issue", title: "Description" },
{ key: "picture_link", title: "Picture Link" },
{ key: "need_approval", title: "Need Approval" },
{ key: "area_of_villa", title: "Area of Villa" },
{ key: "input_by", title: "Input By" },
{ key: "issue_number", title: "Issue Number" },
{ key: "villa_name", title: "Villa Name" },
{ key: "report_date", title: "Report Date" },
{ key: "project_due_date", title: "Project Due Date" },
{ key: "actions", title: "Actions" },
];
let selectedFile: File | null = null;
let imagePreviewUrl: string | null = null;
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
selectedFile = input.files[0];
imagePreviewUrl = URL.createObjectURL(selectedFile);
}
}
let currentPage = 1;
let rowsPerPage = 10;
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
$: paginatedRows = allRows.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage,
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) currentPage = page;
}
async function fetchProjects() {
// Fetch all projects
const { data, error } = await supabase
.from("projects")
.select("*")
.order("id", { ascending: false });
// ambil issue_id dari projects kemudian ambil data issue yang sesuai
const issueIds = data?.map((project: Project) => project.issue_id);
const { data: issueData, error: issueError } = await supabase
.from("issues")
.select("*")
.in("id", issueIds || [])
.order("id", { ascending: false });
// gabungkan data projects dengan data issues
if (!data || !issueData) {
console.error("Error fetching projects or issues:", error);
return;
}
// Set allRows to the combined data
allRows = data.map((project: Project) => {
const issue = issueData.find(
(issue) => issue.id === project.issue_id,
);
return {
...project,
id: project.id,
name: issue ? issue.name : "Unknown",
priority: issue ? issue.priority : "Unknown",
add_to_po: project.add_to_po,
description_of_the_issue: issue
? issue.description_of_the_issue
: "No description",
picture_link: project.picture_link,
need_approval: issue ? issue.need_approval : false,
area_of_villa: issue ? issue.area_of_villa : "Unknown",
input_by: project.input_by,
issue_number: issue ? issue.issue_number : "Unknown",
villa_name: issue ? issue.villa_name : "Unknown",
report_date: issue ? issue.reported_date : "Unknown",
project_due_date: project.project_due_date,
};
});
// hanya valid uuid
if (issueError) {
console.error("Error fetching available issues:", issueError);
return;
}
}
onMount(() => {
fetchProjects();
});
$: currentPage = 1; // Reset to first page when allRows changes
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newProjects: Record<string, any> = {};
let projectForUpdate: Record<string, any> | null = null;
let dataIssueIds: Record<string, any>[] = [];
const excludedKeys = [
"id",
"issue_id",
"number_project",
"input_by",
"name",
"priority",
"description_of_the_issue",
"need_approval",
"area_of_villa",
"villa_name",
"report_date",
"actions",
"add_to_po",
"issue_number",
];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
function openModal(project: Projects | null = null) {
if (project) {
isEditing = true;
currentEditingId = project.id;
newProjects = { ...project };
} else {
isEditing = false;
currentEditingId = null;
newProjects = {};
}
showModal = true;
fetchIssueIds(); // Fetch issue IDs when opening the modal
}
//validation project make
function validateProjectCheckBox(project: insetProject): boolean {
if (!project.project_due_date) {
console.error("Project due date is required");
alert("Project due date is required");
return false;
}
if (!project.picture_link) {
console.error("Picture link is required");
alert("Picture link is required");
return false;
}
if (
project.project_due_date &&
isNaN(Date.parse(project.project_due_date))
) {
console.error("Project due date must be a valid date");
alert("Project due date must be a valid date");
return false;
}
// if (
// project.picture_link &&
// !/^https?:\/\/.+\.(jpg|jpeg|png|gif)$/.test(project.picture_link)
// ) {
// console.error("Picture link must be a valid image URL");
// alert("Picture link must be a valid image URL");
// return false;
// }
return true;
}
//get all id dan name from issues
async function fetchIssueIds() {
const { data, error } = await supabase
.from("issues")
.select("id, name")
.order("id", { ascending: false });
if (error) {
console.error("Error fetching issues:", error);
return;
}
dataIssueIds = data.map((issue) => ({
id: issue.id,
name: issue.name,
}));
}
async function addToPo(project: Project) {
if (!project.add_to_po) {
console.error("Project must be added to PO");
alert("Project must be added to PO");
return;
}
// Validate project before saving
if (!validateProjectCheckBox(project)) {
return;
}
projectForUpdate = {
add_to_po: project?.add_to_po,
input_by: project?.input_by,
project_due_date: project?.project_due_date,
picture_link: project?.picture_link,
};
const { data, error } = await supabase
.from("projects")
.update(projectForUpdate)
.eq("id", currentEditingId);
if (error) {
console.error("Error updating project:", error);
return;
}
alert("Add To PO Successfully");
await fetchProjects();
// tambah ke Purchase Order
const { data: poData, error: poError } = await supabase
.from("purchase_orders")
.insert({
issue_id: project.issue_id,
po_status: "REQUESTED",
});
if (poError) {
console.error("Error adding to Purchase Order:", poError);
return;
}
}
async function saveProject(event: Event) {
const formData = new FormData(event.target as HTMLFormElement);
const projectUpdate: insetProject = {
issue_id: formData.get("issue_id") as string,
input_by: formData.get("input_by") as string,
project_due_date: formData.get("project_due_date") as string,
picture_link:
imagePreviewUrl || (formData.get("picture_link") as string),
};
// Validate project before saving
if (!validateProjectCheckBox(projectUpdate)) {
return;
}
console.log("current editing", currentEditingId);
if (isEditing && currentEditingId) {
const { data, error } = await supabase
.from("projects")
.update(projectUpdate)
.eq("id", currentEditingId);
if (error) {
console.error("Error updating project:", error);
return;
} else {
alert("Project updated successfully");
}
} else {
const { data, error } = await supabase
.from("projects")
.insert(projectUpdate);
if (error) {
console.error("Error inserting project:", error);
return;
}
}
await fetchProjects();
showModal = false;
}
async function deleteProject(id: string) {
const { error } = await supabase.from("projects").delete().eq("id", id);
if (error) {
console.error("Error deleting project:", error);
return;
}
await fetchProjects();
}
</script>
<div>
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-lg font-semibold text-gray-800">Project List</h2>
<p class="text-sm text-gray-600">
Manage your projects here. You can add, edit, or delete
projects.
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => openModal()}
>
Add Projects
</button>
</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 === "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 === "name"}
<td
class="sticky left-0 px-4 py-2 font-medium text-blue-600"
style="background-color: #f0f8ff; cursor: pointer;"
>
{row[col.key]}
</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={() => deleteProject(row.id)}
>
🗑️ Delete
</button>
</td>
{:else if col.key === "add_to_po"}
<td class="px-4 py-2 text-center">
<input
type="checkbox"
checked={row.add_to_po}
on:change={async (e) => {
const isChecked = (
e.target as HTMLInputElement
).checked;
row.add_to_po = isChecked;
if (isChecked) {
// map to project
const project: Project = {
id: row.id,
issue_id: row.issue_id,
project_number:
row.issue_number,
add_to_po: isChecked,
input_by: row.input_by,
project_due_date:
row.project_due_date,
picture_link:
row.picture_link,
};
currentEditingId = row.id;
await addToPo(project);
}
}}
/>
</td>
{:else if col.key === "need_approval"}
<td class="px-4 py-2">
{#if row[col.key as keyof Projects]}
{:else}
{/if}
</td>
{:else if col.key === "project_picture_link"}
<td class="px-4 py-2">
{#if row.picture_link}
<a
href={row.picture_link}
target="_blank"
class="text-blue-600 hover:underline"
>View Picture</a
>
{:else}
No Picture
{/if}
</td>
{:else}
<td class="px-4 py-2 text-gray-700"
>{row[col.key as keyof Projects]}</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 flex items-center justify-center bg-gray-800 bg-opacity-50 z-50"
>
<div
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold mb-4">
{isEditing ? "Edit Project" : "Add Project"}
</h3>
<form on:submit|preventDefault={saveProject}>
{#each formColumns as col}
{#if col.key === "project_due_date"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
name={col.key}
type="date"
id={col.key}
bind:value={newProjects[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{:else if col.key === "picture_link"}
<!-- image upload -->
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
name="picture_link"
class="w-full border px-3 py-2 rounded"
type="file"
accept="image/*"
on:change={handleFileChange}
/>
<p class="text-xs text-gray-500">
Upload an image related to the issue.
</p>
{#if imagePreviewUrl}
<img
src={imagePreviewUrl}
alt="Preview"
class="mt-2 max-h-48 rounded border"
/>
{/if}
</div>
{:else}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
name={col.key}
type="text"
id={col.key}
bind:value={newProjects[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{/if}
{/each}
<!-- list issue dan bisa mencari -->
<div class="mb-4">
<label
for="issue_id"
class="block text-sm font-medium text-gray-700 mb-1"
>
Issue ID
</label>
<select
id="issue_id"
name="issue_id"
required
bind:value={newProjects.issue_id}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
>
<option value="" disabled selected>Select Issue</option>
{#each dataIssueIds as issueId}
<option value={issueId.id}>{issueId.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-2">
<button
type="button"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
on:click={() => (showModal = false)}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,946 @@
<script lang="ts">
import { onMount } from "svelte";
import Select from "svelte-select";
import { supabase } from "$lib/supabaseClient";
type PurchaseOrderInsert = {
issue_id: string;
prepared_date: string;
po_type: string;
po_quantity: number;
po_status: string;
approved_vendor: string;
acknowledge_by: string;
approved_by: string;
approved_price: number;
completed_status: string;
};
let purchaseOrderInsert: PurchaseOrderInsert = {
issue_id: "",
prepared_date: "",
po_type: "",
po_quantity: 0,
po_status: "REQUESTED",
approved_vendor: "",
acknowledge_by: "",
approved_by: "",
approved_price: 0,
completed_status: "",
};
type PurchaseOrders = {
id: string;
purchase_order_number: string;
prepared_date: string;
po_type: string;
po_quantity: number;
po_status: string;
approved_vendor: string;
acknowledged: boolean;
approved_vendor_id: string;
acknowledge_by: string;
approved_price: number;
approved_quantity: number;
total_approved_order_amount: number;
approval: string;
completed_status: string;
received: boolean;
received_by: string;
input_by: string;
issue_id: string;
approved_by: string;
created_at: string;
};
type PurchaseOrderDisplay = {
id: string;
name: string;
purchase_order_number: string;
villa_name: string;
priority: string;
prepared_date: string;
po_type: string;
po_quantity: number;
po_status: string;
approved_vendor: string;
acknowledged: boolean;
acknowledge_by: string;
approved_by: string;
approved_price: number;
approved_quantity: number;
total_approved_order_amount: number;
approval: string;
completed_status: string;
received: boolean;
received_by: string;
};
let allRows: PurchaseOrderDisplay[] = [];
type columns = {
key: string;
title: string;
};
const columns: columns[] = [
{ key: "name", title: "Name" },
{ key: "purchase_order_number", title: "Purchase Order Number" },
{ key: "villa_name", title: "Villa Name" },
{ key: "priority", title: "Priority" },
{ key: "prepared_date", title: "Prepared Date" },
{ key: "po_type", title: "PO Type" },
{ key: "po_quantity", title: "PO Quantity" },
{ key: "po_status", title: "PO Status" },
{ key: "approved_vendor", title: "Approved Vendor" },
{ key: "acknowledged", title: "Acknowledged" },
{ key: "acknowledge_by", title: "Acknowledged By" },
{ key: "approved_by", title: "Approved By" },
{ key: "approved_price", title: "Approved Price" },
{ key: "approved_quantity", title: "Approved Quantity" },
{
key: "total_approved_order_amount",
title: "Total Approved Order Amount",
},
{ key: "approval", title: "Approval" },
{ key: "completed_status", title: "Completed Status" },
{ key: "received", title: "Received" },
{ key: "received_by", title: "Received By" },
{ key: "created_at", title: "Created At" },
{ key: "actions", title: "Actions" }, // For edit/delete buttons
];
let currentPage = 1;
let rowsPerPage = 10;
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
$: paginatedRows = allRows.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage,
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) currentPage = page;
}
async function fetchPurchaseOrder() {
const { data, error } = await supabase
.from("purchase_orders")
.select("*")
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching purchase orders:", error);
return;
}
// fetch issue and villa names
const issueIds = data.map((row) => row.issue_id);
const { data: issues, error: issueError } = await supabase
.from("issues")
.select("*")
.in("id", issueIds);
if (issueError) {
console.error("Error fetching issues:", issueError);
return;
}
const villaIds = issues.map((row) => row.villa_name).filter(Boolean);
const { data: villas, error: villaError } = await supabase
.from("villas")
.select("id, name")
.in("id", villaIds);
if (villaError) {
console.error("Error fetching villas:", villaError);
return;
}
// masukkan villa name dan issue name ke dalam data
allRows = data.map((row) => {
const issue = issues.find((issue) => issue.id === row.issue_id);
const villa = villas.find((villa) => villa.id === issue.villa_name);
const vendor = vendors.find(
(vendor) => vendor.id === row.approved_vendor,
);
return {
...row,
name: issue ? issue.name : "Unknown Issue",
villa_name: villa ? villa.name : "Unknown Villa",
priority: issue ? issue.priority : "Unknown Priority",
approved_vendor: vendor
? vendor.name
: "Unknown Approved Vendor",
approval: row.approval || "",
completed_status: row.completed_status || "",
} as PurchaseOrderDisplay;
});
}
//fetch all issues
async function fetchIssues() {
const { data, error } = await supabase
.from("issues")
.select("id, name");
if (error) {
console.error("Error fetching issues:", error);
return [];
}
issues = data.map((issue) => ({
id: issue.id,
name: issue.name,
}));
}
async function fetchVendors() {
const { data, error } = await supabase
.from("vendor")
.select("id, name");
if (error) {
console.error("Error fetching vendors:", error);
return [];
}
vendors = data.map((vendor) => ({
id: vendor.id,
name: vendor.name,
}));
}
onMount(() => {
fetchPurchaseOrder();
fetchVendors();
});
$: currentPage = 1; // Reset to first page when allRows changes
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newPurchaseOrders: Record<string, any> = {};
let vendors: { id: string; name: string }[] = [];
let issues: { id: string; name: string }[] = [];
const excludedKeys = [
"id",
"priority",
"villa_name",
"purchase_order_number",
"issue_id",
"number_project",
"input_by",
"created_at",
"actions",
"acknowledged",
"acknowledge_by",
"approval",
"completed_status",
"received",
"received_by",
"approved_quantity",
"total_approved_order_amount",
"approved_by",
"name",
"po_status",
];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
async function openModal(purchase: PurchaseOrderDisplay | null = null) {
await fetchIssues();
if (purchase) {
isEditing = true;
currentEditingId = purchase.id;
newPurchaseOrders = { ...purchase };
} else {
isEditing = false;
currentEditingId = null;
newPurchaseOrders = {};
}
showModal = true;
}
async function saveProject() {
purchaseOrderInsert = {
issue_id: newPurchaseOrders.issue_id || "",
prepared_date: newPurchaseOrders.prepared_date || "",
po_type: newPurchaseOrders.po_type || "",
po_quantity: newPurchaseOrders.po_quantity || 0,
po_status: newPurchaseOrders.po_status || "REQUESTED",
approved_vendor: newPurchaseOrders.approved_vendor || "",
acknowledge_by: newPurchaseOrders.acknowledge_by || "",
approved_price: newPurchaseOrders.approved_price || "",
approved_by: newPurchaseOrders.approved_by || "",
completed_status: newPurchaseOrders.completed_status || "",
};
if (isEditing && currentEditingId) {
const { data, error } = await supabase
.from("purchase_orders")
.update(purchaseOrderInsert)
.eq("id", currentEditingId);
if (error) {
console.error("Error updating purchase order:", error);
return;
}
} else {
const { data, error } = await supabase
.from("purchase_orders")
.insert(purchaseOrderInsert);
if (error) {
console.error("Error inserting purchase order:", error);
return;
}
}
await fetchPurchaseOrder();
showModal = false;
}
async function deleteProject(id: string) {
const { error } = await supabase
.from("purchase_orders")
.delete()
.eq("id", id);
if (error) {
console.error("Error deleting project:", error);
return;
}
await fetchPurchaseOrder();
}
const statusOptions = [
{ label: "Requested", value: "REQUESTED", color: "#e5e7eb" },
{ label: "Prepared", value: "PREPARED", color: "#93c5fd" },
{ label: "Approved", value: "APPROVED", color: "#34d399" },
{ label: "Acknowledged", value: "ACKNOWLEDGE", color: "#60a5fa" },
{
label: "Received - Incomplete",
value: "RECEIVE - INCOMPLETE",
color: "#fb923c",
},
{
label: "Received - Completed",
value: "RECEIVE COMPLETED",
color: "#10b981",
},
{ label: "Canceled", value: "CANCELED", color: "#f87171" },
];
function getStatusOption(value: string) {
return statusOptions.find((option) => option.value === value) ?? null;
}
//validate input fields purchase order
function validateInput() {
const requiredFields = [
"prepared_date",
"po_type",
"po_quantity",
"approved_vendor",
];
for (const field of requiredFields) {
if (!newPurchaseOrders[field]) {
alert(`Please fill in the ${field} field.`);
return false;
}
}
return true;
}
function validateInputApproval() {
const requiredFields = ["approved_price"];
for (const field of requiredFields) {
if (!newPurchaseOrders[field]) {
alert(`Please fill in the ${field} field.`);
return false;
}
}
return true;
}
async function updatePurchaseOrderStatus(
e: Event,
id: string,
row: PurchaseOrderDisplay,
) {
const selectedOption = (e.target as HTMLSelectElement)?.value ?? "";
const option = getStatusOption(selectedOption);
newPurchaseOrders = {
...row,
po_status: option?.value || row.po_status,
};
if (option?.value === "APPROVED") {
if (!validateInput()) {
e.preventDefault();
return;
}
}
const { data, error } = await supabase
.from("purchase_orders")
.update({ po_status: newPurchaseOrders.po_status })
.eq("id", id);
if (error) {
console.error("Error updating purchase order status:", error);
return;
}
await fetchPurchaseOrder();
}
async function acknowledgedOk(id: string, status: boolean) {
const { data, error } = await supabase
.from("purchase_orders")
.update({ acknowledged: status })
.eq("id", id);
if (error) {
console.error("Error acknowledging purchase order:", error);
return;
}
await fetchPurchaseOrder();
}
async function receivedOk(id: string, status: boolean) {
const { data, error } = await supabase
.from("purchase_orders")
.update({ receivedOk: status })
.eq("id", id);
if (error) {
console.error("Error acknowledging purchase order:", error);
return;
}
await fetchPurchaseOrder();
}
async function updatePurchaseOrderApprovalStatus(
e: Event,
id: string,
row: PurchaseOrderDisplay,
) {
const selectedOption = (e.target as HTMLSelectElement)?.value ?? "";
const option = getStatusOption(selectedOption);
newPurchaseOrders = {
...row,
approval: option?.value || row.approval,
};
if (option?.value === "APPROVED") {
if (!validateInputApproval()) {
e.preventDefault();
return;
}
}
const { data, error } = await supabase
.from("purchase_orders")
.update({ approval: newPurchaseOrders.approval })
.eq("id", id);
if (error) {
console.error("Error updating purchase order status:", error);
return;
}
await fetchPurchaseOrder();
}
async function completedStatusOk(id: string, status: string) {
const { data, error } = await supabase
.from("purchase_orders")
.update({ completed_status: status })
.eq("id", id);
if (error) {
console.error("Error acknowledging purchase order:", error);
return;
}
await fetchPurchaseOrder();
}
</script>
<div>
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-lg font-semibold text-gray-800">
Purchase Order List
</h2>
<p class="text-sm text-gray-600">
Manage your purchase orders efficiently. You can add, edit, or
delete purchase orders as needed.
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => openModal()}
>
Add Purchase Order
</button>
</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 === "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 === "name"}
<td
class="sticky left-0 px-4 py-2 font-medium text-blue-600"
style="background-color: #f0f8ff; cursor: pointer;"
>
{row[col.key as keyof PurchaseOrderDisplay]}
</td>
{:else if col.key === "po_status"}
<td class="px-4 py-2">
<select
bind:value={
row[
col.key as keyof PurchaseOrderDisplay
]
}
class="w-full p-3 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
on:change={(e: Event) => {
updatePurchaseOrderStatus(
e,
row.id,
row,
);
}}
>
{#each statusOptions as option}
<option
value={option.value}
style="background-color: {option.color};"
>
{option.label}
</option>
{/each}
</select>
</td>
{:else if col.key === "approval"}
<td class="px-4 py-2">
<select
bind:value={
row[
col.key as keyof PurchaseOrderDisplay
]
}
class="w-full p-3 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500 text-gray-900"
on:change={(e: Event) => {
updatePurchaseOrderApprovalStatus(
e,
row.id,
row,
);
}}
>
<option value="" disabled selected
>SELECT APPROVAL</option
>
<option value="APPROVED"
>APPROVED</option
>
<option value="REJECTED"
>REJECTED</option
>
</select>
</td>
{:else if col.key === "acknowledged"}
<td class="px-4 py-2 text-center">
<input
type="checkbox"
checked={row.acknowledged}
on:change={async (e) => {
const isChecked = (
e.target as HTMLInputElement
).checked;
row.acknowledged = isChecked;
if (isChecked) {
// map to project
await acknowledgedOk(
row.id,
isChecked,
);
}
}}
/>
</td>
{:else if col.key === "received"}
<td class="px-4 py-2 text-center">
<input
type="checkbox"
checked={row.received}
on:change={async (e) => {
const isChecked = (
e.target as HTMLInputElement
).checked;
row.received = isChecked;
if (isChecked) {
// map to project
await receivedOk(
row.id,
isChecked,
);
}
}}
/>
</td>
{:else if col.key === "completed_status"}
<td class="px-4 py-2">
<select
bind:value={
row[
col.key as keyof PurchaseOrderDisplay
]
}
class="w-full p-3 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500 text-gray-900"
on:change={async (e) => {
const isValue = (
e.target as HTMLInputElement
).value;
if (isValue) {
// map to project
await completedStatusOk(
row.id,
isValue,
);
}
}}
>
<option value="" disabled selected
>SELECT COMPLETE</option
>
<option value="APPROVED"
>RECEIVED COMPLETE</option
>
<option value="REJECTED"
>COMPLETE INCOMPLETE</option
>
</select>
</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={() => deleteProject(row.id)}
>
🗑️ Delete
</button>
</td>
{:else if col.key === "move_issue"}
<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={() =>
alert(
`Move issue ${row.id} to project`,
)}
>
➡️ PROJECT
</button>
<button
class="inline-flex items-center gap-1 rounded bg-yellow-600 px-3 py-1.5 text-white text-xs font-medium hover:bg-yellow-700"
on:click={() =>
alert(
`Move issue ${row.id} to another area`,
)}
>
➡️ PURCHASE ORDER
</button>
</td>
{:else}
<td class="px-4 py-2 text-gray-700"
>{row[
col.key as keyof PurchaseOrderDisplay
]}</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 flex items-center justify-center bg-gray-800 bg-opacity-50 z-50"
>
<div
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold mb-4">
{isEditing ? "Edit Project" : "Add Project"}
</h3>
<form on:submit|preventDefault={saveProject}>
<!-- choose issuess -->
<div class="mb-4">
<label
for="issue_id"
class="block text-sm font-medium text-gray-700 mb-1"
>
Choose Issue
</label>
<select
id="issue_id"
bind:value={newPurchaseOrders.issue_id}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
on:change={(e: Event) => {
const selectedIssue =
(e.target as HTMLSelectElement)?.value ?? "";
newPurchaseOrders.issue_id = selectedIssue;
}}
>
<option value="">Select Issue</option>
{#each issues as issue}
<option value={issue.id}>{issue.name}</option>
{/each}
</select>
</div>
{#each formColumns as col}
{#if col.key === "po_status"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<select
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
on:change={(e: Event) => {
const selectedOption =
(e.target as HTMLSelectElement)
?.value ?? "";
const option =
getStatusOption(selectedOption);
if (
option?.value === "APPROVED" &&
!validateInput()
) {
e.preventDefault();
return;
}
}}
>
{#each statusOptions as option}
<option
value={option.value}
style="background-color: {option.color};"
>
{option.label}
</option>
{/each}
</select>
</div>
{:else if col.key === "po_type"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<select
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
>
<option value="">Select PO Type</option>
<option value="Regular">Regular</option>
<option value="Urgent">Urgent</option>
</select>
</div>
{:else if col.key === "prepared_date"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
type="date"
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{:else if col.key === "po_quantity"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
type="number"
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{:else if col.key === "approved_price"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
type="number"
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{:else if col.key === "approved_vendor"}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<select
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
on:change={(e: Event) => {
const selectedVendor =
(e.target as HTMLSelectElement)
?.value ?? "";
newPurchaseOrders[col.key] = selectedVendor;
}}
>
<option value="">Select Vendor</option>
{#each vendors as vendor}
<option value={vendor.id}>
{vendor.name}
</option>
{/each}
</select>
</div>
{:else}
<div class="mb-4">
<label
for={col.key}
class="block text-sm font-medium text-gray-700 mb-1"
>
{col.title}
</label>
<input
type="text"
id={col.key}
bind:value={newPurchaseOrders[col.key]}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
{/if}
{/each}
<div class="flex justify-end space-x-2">
<button
type="button"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
on:click={() => (showModal = false)}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,597 @@
<script lang="ts">
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
type Vendor = {
id: string;
name: string;
contact_type: string;
vendor_status: string;
vendor_subtype: string;
address: string;
contact_comment: string;
vendor_unik: string;
created_by: string;
created_at: string;
updated_at: string;
};
let allRowsVendor: Vendor[] = [];
let allRowsContactVendor: ContactVendor[] = [];
type columns = {
key: string;
title: string;
};
let columns: columns[] = [
{ key: "name", title: "Name" },
{ key: "vendor_type", title: "Vendor Type" },
{ key: "vendor_status", title: "Vendor Status" },
{ key: "vendor_subtype", title: "Vendor Subtype" },
{ key: "address", title: "Address" },
{ key: "vendor_unik", title: "Unique Vendor ID" },
{ key: "created_by", title: "Created By" },
{ key: "created_at", title: "Created At" },
];
onMount(async () => {
const { data: vendorData, error: vendorError } = await supabase
.from("vendor")
.select("*")
.order("created_at", { ascending: false });
if (vendorError) {
console.error("Error fetching vendors:", vendorError);
} else {
allRowsVendor = vendorData as Vendor[];
}
});
let currentPage = 1;
let itemsPerPage = 10;
$: totalPages = Math.ceil(allRowsVendor.length / itemsPerPage);
$: paginatedRows = allRowsVendor.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage,
);
function nextPage() {
if (currentPage < totalPages) {
currentPage += 1;
}
}
function previousPage() {
if (currentPage > 1) {
currentPage -= 1;
}
}
function resetPagination() {
currentPage = 1;
}
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newVendor: Record<string, any> = {};
const excludedKeys = ["id", "created_by", "created_at", "updated_at"];
$: formColumns = columns.filter((col) => !excludedKeys.includes(col.key));
function openModal(vendor?: Vendor) {
showModal = true;
isEditing = !!vendor;
currentEditingId = vendor ? vendor.id : null;
newVendor = {};
for (const col of formColumns) {
newVendor[col.key] = vendor ? vendor[col.key as keyof Vendor] : "";
}
}
async function addVendor() {
const { data, error } = await supabase
.from("vendor")
.insert([newVendor])
.select();
if (error) {
console.error("Error adding vendor:", error);
} else {
allRowsVendor.push(data[0]);
resetPagination();
showModal = false;
}
}
async function updateVendor() {
const { data, error } = await supabase
.from("vendor")
.update(newVendor)
.eq("id", currentEditingId)
.select();
if (error) {
console.error("Error updating vendor:", error);
} else {
const index = allRowsVendor.findIndex(
(v) => v.id === currentEditingId,
);
if (index !== -1) {
allRowsVendor[index] = data[0];
resetPagination();
showModal = false;
}
}
}
async function deleteVendor(vendorId: string) {
const { error } = await supabase
.from("vendor")
.delete()
.eq("id", vendorId);
if (error) {
console.error("Error deleting vendor:", error);
} else {
allRowsVendor = allRowsVendor.filter((v) => v.id !== vendorId);
resetPagination();
}
}
type ContactVendor = {
id: string;
contact_name: string;
contact_type: string;
contact_status: string;
contact_position: string;
contact_email: string;
contact_phone: string;
contact_phone_mobile: string;
urutan: number;
contact_address: string;
contact_comment: string;
vendor_id: string;
created_by: string;
created_at: string;
updated_at: string;
};
let columnsContact: columns[] = [
{ key: "contact_name", title: "Contact Name" },
{ key: "contact_type", title: "Contact Type" },
{ key: "contact_status", title: "Contact Status" },
{ key: "contact_position", title: "Position" },
{ key: "contact_email", title: "Email" },
{ key: "contact_phone", title: "Phone" },
{ key: "contact_phone_mobile", title: "Mobile" },
{ key: "urutan", title: "Order" },
];
async function fetchContactVendor(vendorId: string) {
const { data: contactData, error: contactError } = await supabase
.from("contact_vendor")
.select("*")
.eq("vendor_id", vendorId)
.order("urutan", { ascending: true });
if (contactError) {
console.error("Error fetching contact vendors:", contactError);
} else {
allRowsContactVendor = contactData as ContactVendor[];
}
}
function handleVendorClick(vendorId: string) {
fetchContactVendor(vendorId);
}
let showModalContact = false;
let showModalAddEditContact = false;
let selectedVendorId: string | null = null;
let isEditingContact = false;
let newVendorContact: Record<string, any> = {};
let excludedKeysContact = [
"id",
"created_by",
"created_at",
"updated_at",
"vendor_id",
];
$: formColumnsContact = columnsContact.filter(
(col) => !excludedKeysContact.includes(col.key),
);
function openContactModal(vendorId: string) {
selectedVendorId = vendorId;
showModalContact = true;
showModalAddEditContact = true;
}
function closeContactModal() {
showModalContact = false;
showModalAddEditContact = false;
selectedVendorId = null;
}
function openModalAddContact() {
showModalAddEditContact = true;
showModalContact = false;
}
async function addContactVendor(contact: ContactVendor) {
(contact.vendor_id as string) == selectedVendorId;
const { data, error } = await supabase
.from("contact_vendor")
.insert([contact])
.select();
if (error) {
console.error("Error adding contact vendor:", error);
} else {
allRowsContactVendor.push(data[0]);
closeContactModal();
}
}
async function updateContactVendor(contact: ContactVendor) {
const { data, error } = await supabase
.from("contact_vendor")
.update(contact)
.eq("id", contact.id)
.select();
if (error) {
console.error("Error updating contact vendor:", error);
} else {
const index = allRowsContactVendor.findIndex(
(c) => c.id === contact.id,
);
if (index !== -1) {
allRowsContactVendor[index] = data[0];
closeContactModal();
}
}
}
async function deleteContactVendor(contactId: string) {
const { error } = await supabase
.from("contact_vendor")
.delete()
.eq("id", contactId);
if (error) {
console.error("Error deleting contact vendor:", error);
} else {
allRowsContactVendor = allRowsContactVendor.filter(
(c) => c.id !== contactId,
);
closeContactModal();
}
}
// Initialize the modal state
$: showModal = false;
$: showModalContact = false;
</script>
<!-- Table untuk daftar Vendor -->
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-lg font-semibold text-gray-800">Vendor List</h2>
<p class="text-sm text-gray-600">Manage your vendor and contact data</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => {
showModal = true;
isEditing = false;
newVendor = {};
currentEditingId = null;
}}
>
Add Vendor
</button>
</div>
<!-- Vendor Table -->
<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 (col.key)}
<th
class="px-4 py-3 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
>{col.title}</th
>
{/each}
<th class="px-4 py-3">Contacts</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each paginatedRows as vendor}
<tr
class="hover:bg-gray-50 transition"
on:click={() => handleVendorClick(vendor.id)}
>
{#each columns as col}
<td class="px-4 py-2 text-gray-700"
>{vendor[col.key as keyof Vendor]}</td
>
{/each}
<!-- Contact Button -->
<td class="px-4 py-2">
<button
class="bg-green-600 text-white px-2 py-1 rounded text-xs hover:bg-green-700"
on:click|stopPropagation={() => {
fetchContactVendor(vendor.id);
showModalContact = true;
}}>📞 Contacts</button
>
</td>
<td class="px-4 py-2 space-x-2">
<button
class="bg-blue-600 text-white px-2 py-1 rounded text-xs hover:bg-blue-700"
on:click|stopPropagation={() => {
openModal(vendor);
}}>✏️ Edit</button
>
<button
class="bg-red-600 text-white px-2 py-1 rounded text-xs hover:bg-red-700"
on:click|stopPropagation={() =>
deleteVendor(vendor.id)}>🗑️ Delete</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center text-sm mt-2">
<div>
Showing {(currentPage - 1) * itemsPerPage + 1}{Math.min(
currentPage * itemsPerPage,
allRowsVendor.length,
)} of {allRowsVendor.length}
</div>
<div class="space-x-2">
<button
on:click={previousPage}
disabled={currentPage === 1}
class="px-3 py-1 rounded border bg-white hover:bg-gray-100 disabled:opacity-50"
>Previous</button
>
<button
on:click={nextPage}
disabled={currentPage === totalPages}
class="px-3 py-1 rounded border bg-white hover:bg-gray-100 disabled:opacity-50"
>Next</button
>
</div>
</div>
<!-- Contact Vendor Table -->
<!-- {#if allRowsContactVendor.length > 0}
<div class="mt-6">
<h3 class="text-md font-semibold mb-2">Contact Vendors</h3>
<table class="min-w-full border divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2">Name</th>
<th class="px-3 py-2">Position</th>
<th class="px-3 py-2">Email</th>
<th class="px-3 py-2">Phone</th>
<th class="px-3 py-2">Mobile</th>
</tr>
</thead>
<tbody class="divide-y">
{#each allRowsContactVendor as contact}
<tr class="hover:bg-gray-50">
<td class="px-3 py-2">{contact.contact_name}</td>
<td class="px-3 py-2">{contact.contact_position}</td>
<td class="px-3 py-2">{contact.contact_email}</td>
<td class="px-3 py-2">{contact.contact_phone}</td>
<td class="px-3 py-2">{contact.contact_phone_mobile}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if} -->
<!-- Modal for Add/Edit Vendor -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold">
{isEditing ? "Edit Vendor" : "Add New Vendor"}
</h3>
{#each formColumns as col}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>{col.title}</label
>
<input
class="w-full border px-3 py-2 rounded"
type="text"
bind:value={newVendor[col.key]}
placeholder={col.title}
/>
</div>
{/each}
<div class="flex justify-end gap-2 mt-4">
<button
class="px-4 py-2 text-sm rounded bg-gray-200 hover:bg-gray-300"
on:click={() => (showModal = false)}>Cancel</button
>
<button
class="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
on:click={isEditing ? updateVendor : addVendor}
>
Save
</button>
</div>
</div>
</div>
{/if}
<!-- Modal Contact ADD and Edit -->
{#if showModalAddEditContact}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold">
{isEditingContact
? "Edit Contact Vendor"
: "Add New Contact Vendor"}
</h3>
{#each formColumnsContact as col}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>{col.title}</label
>
<input
class="w-full border px-3 py-2 rounded"
type="text"
bind:value={newVendorContact[col.key]}
placeholder={col.title}
/>
</div>
{/each}
<div class="flex justify-end gap-2 mt-4">
<button
class="px-4 py-2 text-sm rounded bg-gray-200 hover:bg-gray-300"
on:click={() => (showModalAddEditContact = false)}
>Cancel</button
>
<button
class="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
on:click={() =>
isEditingContact
? updateContactVendor(
newVendorContact as ContactVendor,
)
: addContactVendor(
newVendorContact as ContactVendor,
)}
>
Save
</button>
</div>
</div>
</div>
{/if}
{#if showModalContact}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div
class="bg-white p-6 rounded-2xl shadow-2xl w-[90vw] max-w-5xl max-h-[90vh] overflow-y-auto space-y-6 transition-all duration-300"
>
<!-- Header Modal -->
<div class="flex justify-between items-center border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800">
📇 Contact List
</h3>
<div class="flex gap-2 items-center">
<button
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded bg-blue-600 text-white hover:bg-blue-700 shadow transition"
on:click={() => openModalAddContact()}
>
Add Contact
</button>
<button
class="text-gray-500 hover:text-red-600 text-2xl leading-none transition"
on:click={() => (showModalContact = false)}
>
×
</button>
</div>
</div>
<!-- Body Modal -->
{#if allRowsContactVendor.length > 0}
<div class="overflow-x-auto">
<table
class="min-w-full text-sm text-left border rounded-md overflow-hidden"
>
<thead
class="bg-gray-100 text-gray-700 uppercase text-xs font-semibold"
>
<tr>
{#each columnsContact as col (col.key)}
<th class="px-4 py-3 whitespace-nowrap"
>{col.title}</th
>
{/each}
<th class="px-4 py-3 whitespace-nowrap"
>Actions</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each allRowsContactVendor as contact}
<tr class="hover:bg-gray-50 transition">
{#each columnsContact as col}
<td
class="px-4 py-2 whitespace-nowrap text-gray-800"
>
{contact[
col.key as keyof ContactVendor
]}
</td>
{/each}
<td class="px-4 py-2 whitespace-nowrap">
<div class="flex gap-2">
<button
class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded text-xs transition"
on:click|stopPropagation={() => {
isEditingContact = true;
newVendorContact = {
...contact,
};
}}
>
✏️ Edit
</button>
<button
class="flex items-center gap-1 bg-red-500 hover:bg-red-600 text-white px-2.5 py-1 rounded text-xs transition"
on:click|stopPropagation={() =>
deleteContactVendor(
contact.id,
)}
>
🗑️ Delete
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="text-center space-y-2 pt-4">
<p class="text-base font-semibold text-gray-700">
No Contacts Available
</p>
<p class="text-sm text-gray-500">
There are no contacts available for this vendor.
</p>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,315 @@
<script lang="ts">
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
type Villa = {
id: string;
name: string;
villa_manager: string;
villa_location: string;
villa_status: string;
no_of_bedrooms: number;
closeable_living_room: boolean;
villa_condition: string;
monthly_rental_pre_approved_status: boolean;
long_term_rental_pre_approval: boolean;
pet_allowed_pre_approval_status: boolean;
session_1_rate: number;
session_2_rate: number;
session_3_rate: number;
session_4_rate: number;
session_5_rate: number;
session_6_rate: number;
session_7_rate: number;
villa_email_address: string;
villa_recovery_email_adress: string;
owner_portal_username: string;
owner_portal_password: string;
created_by: string;
created_at: string;
};
let allRows: Villa[] = [];
type columns = {
key: string;
title: string;
};
const columns: columns[] = [
{ key: "name", title: "Name" },
{ key: "villa_manager", title: "Villa Manager" },
{ key: "villa_location", title: "Villa Location" },
{ key: "villa_status", title: "Villa Status" },
{ key: "no_of_bedrooms", title: "No Of Bedrooms" },
{ key: "closeable_living_room", title: "Closeable Living Room" },
{ key: "villa_condition", title: "Villa Condition" },
{
key: "monthly_rental_pre_approved_status",
title: "Monthly Rental Pre Approved Status",
},
{
key: "long_term_rental_pre_approval",
title: "Long Term Rental Pre Approval",
},
{
key: "pet_allowed_pre_approval_status",
title: "Pet Allowed Pre Approval Status",
},
{ key: "session_1_rate", title: "Session 1 Rate" },
{ key: "session_2_rate", title: "Session 2 Rate" },
{ key: "session_3_rate", title: "Session 3 Rate" },
{ key: "session_4_rate", title: "Session 4 Rate" },
{ key: "session_5_rate", title: "Session 5 Rate" },
{ key: "session_6_rate", title: "Session 6 Rate" },
{ key: "session_7_rate", title: "Session 7 Rate" },
{ key: "villa_email_address", title: "Villa Email Address" },
{
key: "villa_recovery_email_adress",
title: "Villa Recovery Email Address",
},
{ key: "owner_portal_username", title: "Owner Portal Username" },
{ key: "owner_portal_password", title: "Owner Portal Password" },
{ key: "created_by", title: "Created By" },
{ key: "created_at", title: "Created At" },
{ key: "actions", title: "Actions" },
];
async function fetchVillas() {
const { data, error } = await supabase
.from("villas")
.select("*")
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching data:", error);
} else {
allRows = data;
}
}
onMount(fetchVillas);
let currentPage = 1;
let rowsPerPage = 5;
$: totalPages = Math.ceil(allRows.length / rowsPerPage);
$: paginatedRows = allRows.slice(
(currentPage - 1) * rowsPerPage,
currentPage * rowsPerPage,
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) currentPage = page;
}
// Modal Logic
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newVilla: Record<string, any> = {};
const excludedKeys = ["id", "createdAt", "actions"];
$: formColumns = columns.filter((col) => !excludedKeys.includes(col.key));
function openModal(villa?: Villa) {
showModal = true;
isEditing = !!villa;
currentEditingId = villa?.id ?? null;
newVilla = {};
for (const col of formColumns) {
newVilla[col.key] = villa
? (villa[col.key as keyof Villa] ?? "")
: "";
}
}
async function saveVilla() {
if (isEditing && currentEditingId) {
const { error } = await supabase
.from("villas")
.update(newVilla)
.eq("id", currentEditingId);
if (error) {
alert("Error updating villa: " + error.message);
return;
}
} else {
const { error } = await supabase.from("villas").insert([newVilla]);
if (error) {
alert("Error adding villa: " + error.message);
return;
}
}
await fetchVillas();
showModal = false;
}
async function deleteVilla(id: string) {
if (confirm("Are you sure you want to delete this villa?")) {
const { error } = await supabase
.from("villas")
.delete()
.eq("id", id);
if (error) {
alert("Error deleting villa: " + error.message);
} else {
await fetchVillas();
}
}
}
</script>
<div>
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-lg font-semibold text-gray-800">Villa List</h2>
<p class="text-sm text-gray-600">
Manage and track villas efficiently
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => openModal()}
>
Add Villa
</button>
</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 === "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 === "name"}
<td
class="sticky left-0 px-4 py-2 font-medium text-blue-600"
style="background-color: #f0f8ff; cursor: pointer;"
>
{row[col.key]}
</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={() => deleteVilla(row.id)}
>
🗑️ Delete
</button>
</td>
{:else}
<td class="px-4 py-2 text-gray-700"
>{row[col.key as keyof Villa]}</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>
<!-- Modal -->
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div
class="bg-white p-6 rounded shadow-lg w-[600px] max-h-[90vh] overflow-y-auto space-y-4"
>
<h3 class="text-lg font-semibold">
{isEditing ? "Edit Villa" : "Add New Villa"}
</h3>
{#each formColumns as col}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700"
>{col.title}</label
>
<input
class="w-full border px-3 py-2 rounded"
type="text"
bind:value={newVilla[col.key]}
placeholder={col.title}
/>
</div>
{/each}
<div class="flex justify-end gap-2 mt-4">
<button
class="px-4 py-2 text-sm rounded bg-gray-200 hover:bg-gray-300"
on:click={() => (showModal = false)}
>
Cancel
</button>
<button
class="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
on:click={saveVilla}
>
Save
</button>
</div>
</div>
</div>
{/if}