1182 lines
48 KiB
Svelte
1182 lines
48 KiB
Svelte
<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;
|
||
villa_name: string;
|
||
};
|
||
|
||
type User = {
|
||
id: string;
|
||
full_name: string;
|
||
};
|
||
|
||
let dataVilla: Villa[] = [];
|
||
let dataUser: User[] = [];
|
||
|
||
onMount(async () => {
|
||
const { data, error } = await supabase
|
||
.from("vb_villas")
|
||
.select("id, villa_name");
|
||
|
||
if (error) {
|
||
console.error("Error fetching villas:", error);
|
||
} else if (data) {
|
||
dataVilla = data;
|
||
}
|
||
|
||
const { data: userData, error: userError } = await supabase
|
||
.from("vb_users")
|
||
.select("id, full_name");
|
||
if (userError) {
|
||
console.error("Error fetching users:", userError);
|
||
} else if (userData) {
|
||
dataUser = userData;
|
||
}
|
||
});
|
||
|
||
type Issue = {
|
||
id: string;
|
||
name: string;
|
||
villa_id: 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;
|
||
updated_by?: string;
|
||
updated_at?: string;
|
||
};
|
||
|
||
type issueInsert = {
|
||
name: string;
|
||
villa_id: 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[] = [];
|
||
let offset = 0;
|
||
let limit = 10;
|
||
let totalItems = 0;
|
||
export let formErrors = writable<{ [key: string]: string }>({});
|
||
|
||
type columns = {
|
||
key: string;
|
||
title: string;
|
||
};
|
||
|
||
const columns: columns[] = [
|
||
{ key: "name", title: "Name" },
|
||
{ key: "villa_name", title: "Villa Name" },
|
||
{ key: "villa_id", title: "Villa ID" },
|
||
{ key: "input_by", title: "Input By" },
|
||
{ key: "reported_by", title: "Reported By" },
|
||
{ key: "reported_date", title: "Reported Date" },
|
||
{ 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: "issue_related_image", title: "Issue Related Image" },
|
||
{ key: "issue_source", title: "Issue Source" },
|
||
{ key: "reported_name", title: "Reported By" },
|
||
{ key: "inputed_name", 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: "updated_name", title: "Updated By" },
|
||
{ key: "updated_at", title: "Updated At" },
|
||
{ key: "actions", title: "Actions" },
|
||
];
|
||
|
||
async function fetchIssues(
|
||
filter: string | null = null,
|
||
search: string | null = null,
|
||
sort: string | null = null,
|
||
order: "asc" | "desc" = "desc",
|
||
offset: number = 0,
|
||
limit: number = 10,
|
||
) {
|
||
let query = supabase
|
||
.from("vb_issues_data")
|
||
.select("*", { count: "exact" })
|
||
.order(sort || "created_at", { ascending: order === "asc" })
|
||
.range(offset, offset + limit - 1);
|
||
|
||
if (filter) {
|
||
query = query.eq("move_issue", filter);
|
||
}
|
||
|
||
if (search) {
|
||
query = query.ilike("name", `%${search}%`);
|
||
}
|
||
|
||
const { data: issues, error, count } = await query;
|
||
if (error) {
|
||
console.error("Error fetching issues:", error);
|
||
return;
|
||
}
|
||
|
||
if (count !== undefined) {
|
||
totalItems = count ?? 0;
|
||
}
|
||
|
||
if (!issues || issues.length === 0) {
|
||
allRows = [];
|
||
return;
|
||
}
|
||
|
||
// Gabungkan data villa ke dalam setiap issue
|
||
allRows = issues;
|
||
}
|
||
|
||
let currentPage = offset + 1;
|
||
let rowsPerPage = limit;
|
||
$: totalPages = Math.ceil(totalItems / rowsPerPage);
|
||
|
||
function goToPage(page: number) {
|
||
if (page >= 1 && page <= totalPages) currentPage = page;
|
||
}
|
||
|
||
onMount(() => {
|
||
fetchIssues(null, null, "created_at", "desc", offset, limit);
|
||
});
|
||
|
||
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",
|
||
"villa_name",
|
||
"reported_name",
|
||
"inputed_name",
|
||
"updated_name",
|
||
"updated_at",
|
||
"updated_by",
|
||
];
|
||
|
||
const excludedKeysDisplay = [
|
||
"villa_id",
|
||
"reported_by",
|
||
"input_by",
|
||
"updated_by",
|
||
];
|
||
const formColumns = columns.filter(
|
||
(col) => !excludedKeys.includes(col.key),
|
||
);
|
||
|
||
const formColumnsDisplay = columns.filter(
|
||
(col) => !excludedKeysDisplay.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;
|
||
}
|
||
|
||
//upload image if selected
|
||
if (selectedFile) {
|
||
const { data, error } = await supabase.storage
|
||
.from("villabugis")
|
||
.upload(
|
||
`issues/${Date.now()}_${selectedFile.name}`,
|
||
selectedFile,
|
||
);
|
||
|
||
console.log("Image upload data:", data);
|
||
|
||
if (error) {
|
||
alert("Error uploading image: " + error.message);
|
||
console.error("Error uploading image:", error);
|
||
return;
|
||
}
|
||
newIssue.issue_related_image = data.path;
|
||
}
|
||
|
||
if (isEditing && currentEditingId) {
|
||
newIssue = formColumns.reduce(
|
||
(acc, col) => {
|
||
if (col.key in newIssue) {
|
||
acc[col.key] = newIssue[col.key];
|
||
}
|
||
return acc;
|
||
},
|
||
{} as Record<string, any>,
|
||
);
|
||
|
||
const { error } = await supabase
|
||
.from("vb_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_id: formData.get("villa_id") 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("vb_issues")
|
||
.insert([issueInsert]);
|
||
if (error) {
|
||
console.error("Error adding issue:", error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await fetchIssues();
|
||
showModal = false;
|
||
}
|
||
|
||
// function get public URL for image supabase
|
||
async function getPublicUrl(path: string): Promise<string> {
|
||
const { data } = supabase.storage.from("villabugis").getPublicUrl(path);
|
||
return data.publicUrl;
|
||
}
|
||
|
||
async function deleteIssue(id: string) {
|
||
if (confirm("Are you sure you want to delete this issue?")) {
|
||
const { error } = await supabase
|
||
.from("vb_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);
|
||
}
|
||
}
|
||
|
||
function validateForm(formData: FormData): boolean {
|
||
const errors: { [key: string]: string } = {};
|
||
const requiredFields = [
|
||
"name",
|
||
"description_of_the_issue",
|
||
"issue_source",
|
||
"villa_id",
|
||
"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: string) {
|
||
// get user id from session
|
||
const session = await supabase.auth.getSession();
|
||
if (!session) {
|
||
console.error("User not authenticated");
|
||
return;
|
||
}
|
||
|
||
const userId = session.data.session?.user.id;
|
||
|
||
// update move_issue field in the issue
|
||
const { error: updateError } = await supabase
|
||
.from("vb_issues")
|
||
.update({
|
||
move_issue: "PROJECT",
|
||
updated_by: userId,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", issueId);
|
||
|
||
if (updateError) {
|
||
console.error("Error updating issue move_issue:", updateError);
|
||
return;
|
||
}
|
||
|
||
const { error } = await supabase
|
||
.from("vb_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: string) {
|
||
// get user id from session
|
||
const session = await supabase.auth.getSession();
|
||
if (!session) {
|
||
console.error("User not authenticated");
|
||
return;
|
||
}
|
||
|
||
const userId = session.data.session?.user.id;
|
||
// update move_issue field in the issue
|
||
const { error: updateError } = await supabase
|
||
.from("vb_issues")
|
||
.update({
|
||
move_issue: "PURCHASE_ORDER",
|
||
updated_by: userId,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", issueId);
|
||
if (updateError) {
|
||
console.error("Error updating issue move_issue:", updateError);
|
||
return;
|
||
}
|
||
|
||
const { error } = await supabase
|
||
.from("vb_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-6 bg-white shadow-md rounded-2xl mb-4 flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4"
|
||
>
|
||
<div>
|
||
<h2 class="text-xl font-semibold text-gray-800">📝 Issue List</h2>
|
||
<p class="text-sm text-gray-500">
|
||
Manage and view all issues reported in the system.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Search by name..."
|
||
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-4 py-2 rounded-xl text-sm w-64 transition"
|
||
on:input={(e) => {
|
||
const searchTerm = (
|
||
e.target as HTMLInputElement
|
||
).value.toLowerCase();
|
||
fetchIssues(null, searchTerm, "created_at", "desc");
|
||
}}
|
||
/>
|
||
<select
|
||
class="border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:outline-none px-4 py-2 rounded-xl text-sm w-48 transition"
|
||
on:change={(e) => {
|
||
const filter = (e.target as HTMLSelectElement).value;
|
||
fetchIssues(filter, null, null, "desc");
|
||
}}
|
||
>
|
||
<option value="">All Issues</option>
|
||
<option value="PROJECT">Project Issues</option>
|
||
<option value="PURCHASE_ORDER">Purchase Order Issues</option>
|
||
</select>
|
||
<button
|
||
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
|
||
on:click={() =>
|
||
fetchIssues(null, null, "created_at", "desc", 0, 10)}
|
||
>
|
||
🔄 Reset
|
||
</button>
|
||
<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>
|
||
<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 formColumnsDisplay 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 allRows as row}
|
||
<tr class="hover:bg-gray-50 transition">
|
||
{#each formColumnsDisplay 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 === "issue_related_image"}
|
||
<td class="px-4 py-2">
|
||
{#if typeof row[col.key as keyof Issue] === "string" && row[col.key as keyof Issue]}
|
||
{#await getPublicUrl(row[col.key as keyof Issue] as string) then publicUrl}
|
||
<a
|
||
href={publicUrl}
|
||
target="_blank"
|
||
class="text-blue-600 hover:underline"
|
||
>View Picture</a
|
||
>
|
||
{:catch}
|
||
<span class="text-red-500"
|
||
>Error loading image</span
|
||
>
|
||
{/await}
|
||
{:else}
|
||
No Picture
|
||
{/if}
|
||
</td>
|
||
{:else if col.key === "reported_date"}
|
||
<td class="px-4 py-2">
|
||
{typeof row[col.key as keyof Issue] ===
|
||
"string" ||
|
||
typeof row[col.key as keyof Issue] ===
|
||
"number"
|
||
? new Date(
|
||
row[col.key as keyof Issue] as
|
||
| string
|
||
| number,
|
||
).toLocaleString("en-US")
|
||
: ""}
|
||
</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 if col.key === "created_at"}
|
||
<!-- beri jam jg -->
|
||
<td class="px-4 py-2">
|
||
{new Date(
|
||
row[col.key as keyof Issue] as string,
|
||
).toLocaleString("en-US")}
|
||
</td>
|
||
{:else if col.key === "updated_at"}
|
||
<td class="px-4 py-2">
|
||
{row[col.key as keyof Issue]
|
||
? new Date(
|
||
row[
|
||
col.key as keyof Issue
|
||
] as string,
|
||
).toLocaleString("en-US")
|
||
: ""}
|
||
</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"
|
||
/>
|
||
{:else if newIssue.issue_related_image}
|
||
{#await getPublicUrl(newIssue.issue_related_image) then url}
|
||
<img
|
||
src={url}
|
||
alt="Preview"
|
||
class="mt-2 max-h-48 rounded border"
|
||
/>
|
||
{/await}
|
||
{/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 dataUser as reporter}
|
||
<option value={reporter.id}
|
||
>{reporter.full_name}</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 dataUser as input}
|
||
<option value={input.id}
|
||
>{input.full_name}</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_id"}
|
||
<div class="space-y-1">
|
||
<label class="block text-sm font-medium text-gray-700"
|
||
>Villa Name</label
|
||
>
|
||
<select
|
||
name="villa_id"
|
||
class="w-full border px-3 py-2 rounded {errorClass(
|
||
'villa_id',
|
||
)}"
|
||
bind:value={newIssue[col.key as keyof Issue]}
|
||
>
|
||
<option value="" disabled selected
|
||
>Select Villa</option
|
||
>
|
||
{#each dataVilla as villa}
|
||
<option value={villa.id}
|
||
>{villa.villa_name}</option
|
||
>
|
||
{/each}
|
||
</select>
|
||
{#if $formErrors.villa_id}
|
||
<p class="text-red-500 text-xs">
|
||
{$formErrors.villa_id}
|
||
</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}
|