Files
vberp/src/routes/backoffice/account/+page.svelte
2025-07-01 18:23:42 +08:00

674 lines
25 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts">
import { supabase } from "$lib/supabaseClient";
import { onMount } from "svelte";
import { writable } from "svelte/store";
const roleUser = [
{ label: "IT", value: "it" },
{ label: "Guest", value: "guest" },
,
{ label: "Accounting", value: "accounting" },
{ label: "GA", value: "ga" },
{ label: "HR", value: "hr" },
{ label: "S & M", value: "s&m" },
{ label: "Office", value: "office" },
{ label: "HM", value: "hm" },
{ label: "VM", value: "vm" },
];
type User = {
id: string;
email: string;
password: string; // Note: Passwords should not be stored in plain text
role: string;
full_name: string;
phone: string;
address: string;
profile_picture: string;
last_login: string;
is_active: boolean;
is_verified: boolean;
is_deleted: boolean;
last_updated: string;
last_updated_by: string;
created_by: string;
created_at: string;
};
let allRows: User[] = [];
let offset = 0;
let limit = 10;
let totalItems = 0;
export let formErrors = writable<{ [key: string]: string }>({});
type columns = {
key: string;
title: string;
};
let columns: columns[] = [
{ key: "no", title: "No. " },
{ key: "email", title: "Email" },
{ key: "password", title: "Password" },
{ key: "role", title: "Role" },
{ key: "full_name", title: "Full Name" },
{ key: "phone", title: "Phone" },
{ key: "address", title: "Address" },
{ key: "profile_picture", title: "Profile Picture" },
{ key: "last_login", title: "Last Login" },
{ key: "is_active", title: "Active" },
{ key: "is_verified", title: "Verified" },
{ key: "last_updated", title: "Last Updated" },
{ key: "last_updated_by", title: "Last Updated By" },
{ key: "created_by", title: "Created By" },
{ key: "created_at", title: "Created At" },
];
async function fetchData(
filter: string = "",
sortBy: string = "created_at",
sortOrder: "asc" | "desc" = "desc",
search: string = "",
offset: number = 0,
limit: number = 10,
) {
try {
const { data, error, count } = await supabase
.from("vb_users")
.select("*", { count: "exact" })
.ilike("email", `%${search}%`)
.order(sortBy, { ascending: sortOrder === "asc" })
.range(offset, offset + limit - 1);
if (error) {
throw error;
}
allRows = data as User[];
totalItems = count || 0;
} catch (error) {
console.error("Error fetching data:", error);
}
}
onMount(() => {
fetchData();
});
let currentPage = offset + 1;
let rowsPerPage = 10;
$: totalPages = Math.ceil(totalItems / rowsPerPage);
function nextPage() {
if (currentPage < totalPages) {
currentPage += 1;
offset = (currentPage - 1) * rowsPerPage;
fetchData("", "created_at", "desc", "", offset, rowsPerPage);
}
}
function prevPage() {
if (currentPage > 1) {
currentPage -= 1;
offset = (currentPage - 1) * rowsPerPage;
fetchData("", "created_at", "desc", "", offset, rowsPerPage);
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
offset = (currentPage - 1) * rowsPerPage;
fetchData("", "created_at", "desc", "", offset, rowsPerPage);
}
}
let showModal = false;
let isEditing = false;
let currentEditingId: string | null = null;
let newUser: Record<string, any> = {};
const excludedKeys = [
"id",
"last_updated",
"last_updated_by",
"created_by",
"created_at",
"last_login",
"deleted",
"no",
];
const formColumns = columns.filter(
(col) => !excludedKeys.includes(col.key),
);
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 get public URL for image supabase
async function getPublicUrl(path: string): Promise<string> {
const { data } = supabase.storage.from("villabugis").getPublicUrl(path);
return data.publicUrl;
}
function openModal(user?: Record<string, any>) {
if (user) {
isEditing = true;
currentEditingId = user.id;
newUser = { ...user };
} else {
isEditing = false;
currentEditingId = null;
newUser = {};
}
showModal = true;
}
function validateForm(formData: FormData): boolean {
const errors: { [key: string]: string } = {};
const requiredFields = [
"email",
"role",
"full_name",
];
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";
}
async function saveUser(event: Event) {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
if (!validateForm(formData)) {
console.error("Form validation failed:", $formErrors);
return;
}
if (selectedFile) {
// Upload file to Supabase Storage
const oldFilePath = newUser.profile_picture
? newUser.profile_picture.split("/").pop()
: null;
if (oldFilePath) {
await supabase.storage
.from("villabugis")
.remove([`profile_pictures/${oldFilePath}`]);
}
const fileName = selectedFile.name;
const fileExtension = fileName.split(".").pop();
const randomFileName = `${crypto.randomUUID()}.${fileExtension}`;
const { data, error } = await supabase.storage
.from("villabugis")
.upload(`profile_pictures/${randomFileName}`, selectedFile);
if (error) {
console.error("Error uploading file:", error);
return;
}
newUser.profile_picture = data.path; // Store the path in newUser
}
try {
if (isEditing && currentEditingId) {
// Update user in 'users' table
const { error } = await supabase
.from("vb_users")
.update(newUser)
.eq("id", currentEditingId);
if (error) throw error;
} else {
// valid password tidak boleh kosong
if (!newUser.password || newUser.password.trim() === "") {
alert("Password is required for new users.");
return;
}
// 1. Create user in Supabase Auth
const { data: authUser, error: authError } =
await supabase.auth.signUp({
email: newUser.email,
password: newUser.password, // Use NIP as password or fallback
});
if (authError) throw authError;
// 2. Insert user in 'users' table with auth user id
const { error } = await supabase
.from("vb_users")
.insert([{ ...newUser, id: authUser.user?.id }]);
if (error) throw error;
}
showModal = false;
fetchData();
} catch (error) {
console.error("Error saving user:", error);
}
}
async function deleteUser(id: string) {
if (confirm("Are you sure you want to delete this user?")) {
try {
const { error } = await supabase
.from("vb_users")
.delete()
.eq("id", id);
if (error) throw error;
fetchData();
} catch (error) {
console.error("Error deleting user:", error);
}
}
}
function closeModal() {
showModal = false;
isEditing = false;
currentEditingId = null;
newUser = {};
formErrors.set({});
}
$: if (showModal) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
$: if (isEditing && currentEditingId) {
const user = allRows.find((u) => u.id === currentEditingId);
if (user) {
newUser = { ...user };
}
}
$: if (!isEditing) {
newUser = {
email: "",
role: "user",
full_name: "",
phone: "",
address: "",
profile_picture: "",
is_active: true,
is_verified: false,
is_deleted: false,
};
}
</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">👤 User List</h2>
<p class="text-sm text-gray-500">
Manage and view all users in the system.
</p>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<input
type="text"
placeholder="🔍 Search by email, name, or NIP..."
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;
fetchData(
"",
"created_at",
"desc",
searchTerm,
0,
rowsPerPage,
);
}}
/>
<button
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-xl hover:bg-gray-300 text-sm transition"
on:click={() =>
fetchData("", "created_at", "desc", "", 0, rowsPerPage)}
>
🔄 Reset
</button>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
on:click={() => openModal()}
>
Add User
</button>
</div>
</div>
<div class="overflow-x-auto rounded-lg shadow mb-4">
<table class="min-w-[1000px] divide-y divide-gray-200 text-sm w-max">
<thead class="bg-gray-100">
<tr>
{#each columns as col}
<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 text-left font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap"
>
Actions
</th>
</tr>
</thead>
{#if allRows.length === 0}
<tbody>
<tr>
<td
colspan={columns.length + 1}
class="px-4 py-12 text-center text-gray-400 bg-gray-50"
>
<div
class="flex flex-col items-center justify-center space-y-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-gray-300 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span class="text-lg font-semibold"
>No users found</span
>
<span class="text-sm text-gray-400"
>Try adjusting your search or add a new
user.</span
>
</div>
</td>
</tr>
</tbody>
{:else}
<tbody class="divide-y divide-gray-200 bg-white">
{#each allRows as row, i}
<tr class="hover:bg-gray-50 transition">
{#each columns as col}
<td class="px-4 py-2 text-gray-700">
{#if col.key === "no"}
{offset + i + 1}
{:else if col.key === "is_active" || col.key === "is_verified" || col.key === "is_deleted"}
{(row as Record<string, any>)[col.key]
? "✅"
: "❌"}
{:else if col.key === "profile_picture"}
{#if row.profile_picture}
{#if typeof row[col.key] === "string" && row[col.key]}
{#await getPublicUrl(row[col.key] 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}
{/if}
{:else}
{(row as Record<string, any>)[col.key]}
{/if}
</td>
{/each}
<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={() => deleteUser(row.id)}
>
🗑️ Delete
</button>
</td>
</tr>
{/each}
</tbody>
{/if}
</table>
</div>
<!-- Pagination controls -->
<div class="flex justify-between items-center text-sm">
<div>
Showing {offset + 1}{Math.min(offset + rowsPerPage, totalItems)} of
{totalItems}
</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={prevPage}
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={nextPage}
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={saveUser}
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 User" : "Add New User"}
</h3>
{#each formColumns as col}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700">
{col.title}
</label>
{#if col.key === "role"}
<select
name="role"
class="w-full border px-3 py-2 rounded {errorClass(
'role',
)}"
bind:value={newUser.role}
>
<option value="" disabled>Select Role</option>
{#each roleUser as role}
{#if role}
<option value={role.value}
>{role.label}</option
>
{/if}
{/each}
</select>
{#if $formErrors.role}
<p class="text-red-500 text-xs">
{$formErrors.role}
</p>
{/if}
{:else if col.key === "email"}
<input
name="email"
class="w-full border px-3 py-2 rounded {errorClass(
'email',
)}"
type="email"
bind:value={newUser.email}
placeholder="Email"
/>
{#if $formErrors.email}
<p class="text-red-500 text-xs">
{$formErrors.email}
</p>
{/if}
{:else if col.key === "password"}
<input
name="password"
class="w-full border px-3 py-2 rounded {errorClass(
'password',
)}"
type="password"
bind:value={newUser.password}
placeholder="Password"
/>
{#if $formErrors.password}
<p class="text-red-500 text-xs">
{$formErrors.password}
</p>
{/if}
{:else if col.key === "phone"}
<input
name="phone"
class="w-full border px-3 py-2 rounded {errorClass(
'phone',
)}"
type="tel"
bind:value={newUser.phone}
placeholder="Phone Number"
/>
{#if $formErrors.phone}
<p class="text-red-500 text-xs">
{$formErrors.phone}
</p>
{/if}
{:else if col.key === "profile_picture"}
<div class="space-y-1">
<input
type="file"
accept="image/*"
class="w-full border px-3 py-2 rounded"
on:change={handleFileChange}
/>
{#if imagePreviewUrl}
<img
src={imagePreviewUrl}
alt="Preview"
class="mt-2 max-h-48 rounded border"
/>
<p class="text-xs text-gray-500">
Preview of selected image
</p>
{:else if newUser.profile_picture}
{#await getPublicUrl(newUser.profile_picture) then publicUrl}
<img
src={publicUrl}
alt="Profile Picture"
class="mt-2 max-h-48 rounded border"
/>
{:catch}
<span class="text-red-500"
>Error loading image</span
>
{/await}
{:else}
<span class="text-gray-400">No Image</span>
{/if}
</div>
{:else if col.key === "is_active" || col.key === "is_verified" || col.key === "is_deleted"}
<select
name={col.key}
class="w-full border px-3 py-2 rounded"
bind:value={newUser[col.key]}
>
<option value={true}>Yes</option>
<option value={false}>No</option>
</select>
{:else}
<input
name={col.key}
class="w-full border px-3 py-2 rounded {errorClass(
col.key,
)}"
type="text"
bind:value={newUser[col.key]}
placeholder={col.title}
/>
{#if $formErrors[col.key]}
<p class="text-red-500 text-xs">
{$formErrors[col.key]}
</p>
{/if}
{/if}
</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"
type="button"
on:click={closeModal}
>
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}