662 lines
25 KiB
Svelte
662 lines
25 KiB
Svelte
<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;
|
||
nip: 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: "nip", title: "NIP" },
|
||
{ 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("users")
|
||
.select("*", { count: "exact" })
|
||
.ilike("email", `%${search}%`)
|
||
.or(`full_name.ilike.%${search}%,nip.ilike.%${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",
|
||
"nip",
|
||
"full_name",
|
||
"phone",
|
||
"address",
|
||
];
|
||
|
||
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 { data, error } = await supabase.storage
|
||
.from("villabugis")
|
||
.upload(`profile_pictures/${selectedFile.name}`, 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("users")
|
||
.update(newUser)
|
||
.eq("id", currentEditingId);
|
||
|
||
if (error) throw error;
|
||
} else {
|
||
// 1. Create user in Supabase Auth
|
||
const { data: authUser, error: authError } =
|
||
await supabase.auth.signUp({
|
||
email: newUser.email,
|
||
password: newUser.nip || "defaultPassword123", // 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("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("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",
|
||
nip: "",
|
||
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}
|