Add employee type management and filtering functionality
- Introduced a new `Type` field in the `EmployeeRecord` interface to categorize employees as 'Employee', 'Sub-Contractor', etc. - Updated forms and tables in Svelte to include type selection and display. - Enhanced API endpoints to support filtering employees by type in search queries. - Implemented type filtering buttons and a dropdown in the employee management interface for improved user experience.
This commit is contained in:
@@ -64,6 +64,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>HRM</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface EmployeeRecord {
|
||||
id: string;
|
||||
First_Name: string;
|
||||
Last_Name: string;
|
||||
Type?: string;
|
||||
Phone_Number: string;
|
||||
Email_Address: string;
|
||||
Voxer_ID: string;
|
||||
|
||||
+99
-6
@@ -7,6 +7,8 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import type { EmployeeRecord } from './+page.server';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
@@ -20,19 +22,37 @@
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
type TypeFilterValue = 'all' | 'Employee' | 'Sub-Contractor';
|
||||
let typeFilter = $state<TypeFilterValue>('all');
|
||||
let searchQuery = $state('');
|
||||
let nextPage = $state(2);
|
||||
let searchMode = $state(false); // true when showing server-side search results
|
||||
let searchDebounceId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function typeParam(): string {
|
||||
return typeFilter === 'all' ? '' : `&type=${encodeURIComponent(typeFilter)}`;
|
||||
}
|
||||
|
||||
function setTypeFilter(value: TypeFilterValue) {
|
||||
typeFilter = value;
|
||||
loading = true;
|
||||
if (searchQuery.trim()) {
|
||||
searchEmployees(searchQuery);
|
||||
} else {
|
||||
loadEmployees();
|
||||
}
|
||||
}
|
||||
|
||||
let openCreate = $state(false);
|
||||
let openEdit = $state(false);
|
||||
let openDelete = $state(false);
|
||||
let editingEmployee = $state<EmployeeRecord | null>(null);
|
||||
let deletingEmployee = $state<EmployeeRecord | null>(null);
|
||||
|
||||
const TYPE_OPTIONS = ['Employee', 'Sub-Contractor', 'Former-Employee', 'Applicant', 'Blacklisted'];
|
||||
let formFirst = $state('');
|
||||
let formLast = $state('');
|
||||
let formType = $state('');
|
||||
let formPhone = $state('');
|
||||
let formEmail = $state('');
|
||||
let formVoxer = $state('');
|
||||
@@ -45,7 +65,7 @@
|
||||
async function loadEmployees() {
|
||||
searchMode = false;
|
||||
try {
|
||||
const res = await fetch('/api/employees?page=1&perPage=200');
|
||||
const res = await fetch(`/api/employees?page=1&perPage=200${typeParam()}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
employees = json.employees ?? [];
|
||||
@@ -69,7 +89,7 @@
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}`
|
||||
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}${typeParam()}`
|
||||
);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
@@ -104,7 +124,7 @@
|
||||
if (loadingMore || employees.length >= total) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100`);
|
||||
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100${typeParam()}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const list = json.employees ?? [];
|
||||
@@ -120,6 +140,7 @@
|
||||
function resetForm() {
|
||||
formFirst = '';
|
||||
formLast = '';
|
||||
formType = '';
|
||||
formPhone = '';
|
||||
formEmail = '';
|
||||
formVoxer = '';
|
||||
@@ -137,6 +158,7 @@
|
||||
editingEmployee = emp;
|
||||
formFirst = emp.First_Name ?? '';
|
||||
formLast = emp.Last_Name ?? '';
|
||||
formType = emp.Type ?? '';
|
||||
formPhone = emp.Phone_Number ?? '';
|
||||
formEmail = emp.Email_Address ?? '';
|
||||
formVoxer = emp.Voxer_ID ?? '';
|
||||
@@ -161,6 +183,7 @@
|
||||
body: JSON.stringify({
|
||||
First_Name: formFirst,
|
||||
Last_Name: formLast,
|
||||
Type: formType || undefined,
|
||||
Phone_Number: formPhone,
|
||||
Email_Address: formEmail,
|
||||
Voxer_ID: formVoxer,
|
||||
@@ -191,6 +214,7 @@
|
||||
body: JSON.stringify({
|
||||
First_Name: formFirst,
|
||||
Last_Name: formLast,
|
||||
Type: formType || undefined,
|
||||
Phone_Number: formPhone,
|
||||
Email_Address: formEmail,
|
||||
Voxer_ID: formVoxer,
|
||||
@@ -254,6 +278,39 @@
|
||||
|
||||
<main class="flex flex-col gap-6 p-6 md:p-8">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div
|
||||
class="flex shrink-0 rounded-md border border-input bg-transparent p-0.5 shadow-xs"
|
||||
role="group"
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={typeFilter === 'all' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="rounded-[5px] px-3"
|
||||
onclick={() => setTypeFilter('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={typeFilter === 'Employee' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="rounded-[5px] px-3"
|
||||
onclick={() => setTypeFilter('Employee')}
|
||||
>
|
||||
Employee
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={typeFilter === 'Sub-Contractor' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="rounded-[5px] px-3"
|
||||
onclick={() => setTypeFilter('Sub-Contractor')}
|
||||
>
|
||||
Sub-Contractor
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative min-w-0 flex-1 sm:max-w-sm">
|
||||
<SearchIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||
@@ -278,6 +335,7 @@
|
||||
<Table.Row>
|
||||
<Table.Head>First Name</Table.Head>
|
||||
<Table.Head>Last Name</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Phone</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
@@ -288,13 +346,13 @@
|
||||
<Table.Body>
|
||||
{#if loading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
|
||||
<Table.Cell colspan={8} class="text-muted-foreground py-8 text-center">
|
||||
Loading…
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if error}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-destructive py-8 text-center">
|
||||
<Table.Cell colspan={8} class="text-destructive py-8 text-center">
|
||||
{error}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -309,6 +367,13 @@
|
||||
>
|
||||
<Table.Cell>{employee.First_Name}</Table.Cell>
|
||||
<Table.Cell>{employee.Last_Name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if employee.Type}
|
||||
<Badge variant="secondary">{employee.Type}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{employee.Status}</Table.Cell>
|
||||
<Table.Cell>{employee.Phone_Number}</Table.Cell>
|
||||
<Table.Cell>{employee.Email_Address}</Table.Cell>
|
||||
@@ -349,7 +414,7 @@
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground text-center">
|
||||
<Table.Cell colspan={8} class="text-muted-foreground text-center">
|
||||
{searchQuery.trim()
|
||||
? 'No matching employees.'
|
||||
: 'No employee records found.'}
|
||||
@@ -397,6 +462,20 @@
|
||||
<Input id="create-last" bind:value={formLast} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-type">Type</Label>
|
||||
<Select.Root type="single" bind:value={formType} disabled={formSaving}>
|
||||
<Select.Trigger class="w-full" id="create-type">
|
||||
{formType || 'Select type…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="(none)">(none)</Select.Item>
|
||||
{#each TYPE_OPTIONS as opt}
|
||||
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-phone">Phone</Label>
|
||||
<Input id="create-phone" bind:value={formPhone} />
|
||||
@@ -452,6 +531,20 @@
|
||||
<Input id="edit-last" bind:value={formLast} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-type">Type</Label>
|
||||
<Select.Root type="single" bind:value={formType} disabled={formSaving}>
|
||||
<Select.Trigger class="w-full" id="edit-type">
|
||||
{formType || 'Select type…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="(none)">(none)</Select.Item>
|
||||
{#each TYPE_OPTIONS as opt}
|
||||
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-phone">Phone</Label>
|
||||
<Input id="edit-phone" bind:value={formPhone} />
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface EmployeeRecord {
|
||||
id: string;
|
||||
First_Name: string;
|
||||
Last_Name: string;
|
||||
Type?: string;
|
||||
Phone_Number: string;
|
||||
Email_Address: string;
|
||||
Voxer_ID: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { badgeVariants } from '$lib/components/ui/badge/index.js';
|
||||
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
|
||||
import type {
|
||||
EmployeeRecord,
|
||||
EmployeeReportRecord,
|
||||
@@ -270,8 +270,11 @@
|
||||
<Button variant="ghost" size="icon" href="/" class="shrink-0">
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
<h1 class="text-2xl font-semibold tracking-tight shrink-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight shrink-0 flex items-center gap-2 flex-wrap">
|
||||
{employee?.First_Name} {employee?.Last_Name}
|
||||
{#if employee?.Type}
|
||||
<Badge variant="secondary">{employee.Type}</Badge>
|
||||
{/if}
|
||||
</h1>
|
||||
{#if employee}
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
@@ -325,6 +328,38 @@
|
||||
<dd>{employee.Last_Name || '—'}</dd>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="emp-type" class="text-muted-foreground text-sm">Type</Label>
|
||||
{#if editMode}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={employee.Type ?? ''}
|
||||
onValueChange={(v) => {
|
||||
employee = { ...employee!, Type: v ?? undefined };
|
||||
if (v !== undefined) patchField('Type', v ?? '');
|
||||
}}
|
||||
disabled={patchSaving}
|
||||
>
|
||||
<Select.Trigger class="w-full" id="emp-type">
|
||||
{employee.Type || 'Select type…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="(none)">(none)</Select.Item>
|
||||
{#each ['Employee', 'Sub-Contractor', 'Former-Employee', 'Applicant', 'Blacklisted'] as opt}
|
||||
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<dd>
|
||||
{#if employee.Type}
|
||||
<Badge variant="secondary">{employee.Type}</Badge>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="emp-status" class="text-muted-foreground text-sm">Status</Label>
|
||||
{#if editMode}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface EmployeeRecord {
|
||||
id: string;
|
||||
First_Name: string;
|
||||
Last_Name: string;
|
||||
Type?: string;
|
||||
Phone_Number: string;
|
||||
Email_Address: string;
|
||||
Voxer_ID: string;
|
||||
@@ -21,21 +22,26 @@ function escapeFilterValue(s: string): string {
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const search = url.searchParams.get('search')?.trim() ?? '';
|
||||
const typeFilter = url.searchParams.get('type')?.trim() ?? '';
|
||||
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
|
||||
const perPage = Math.min(500, Math.max(1, parseInt(url.searchParams.get('perPage') ?? '200', 10)));
|
||||
|
||||
let filter = 'delete != true';
|
||||
const typeClause =
|
||||
typeFilter === 'Employee' || typeFilter === 'Sub-Contractor'
|
||||
? `(Type = "${escapeFilterValue(typeFilter)}") && `
|
||||
: '';
|
||||
let filter = `${typeClause}delete != true`;
|
||||
if (search.length > 0) {
|
||||
const escaped = escapeFilterValue(search);
|
||||
// Search across main text fields (PocketBase ~ is "contains")
|
||||
filter = `(${[
|
||||
const searchFilter = `(${[
|
||||
`First_Name ~ "${escaped}"`,
|
||||
`Last_Name ~ "${escaped}"`,
|
||||
`Email_Address ~ "${escaped}"`,
|
||||
`Phone_Number ~ "${escaped}"`,
|
||||
`Voxer_ID ~ "${escaped}"`,
|
||||
`Status ~ "${escaped}"`
|
||||
].join(' || ')}) && delete != true`;
|
||||
].join(' || ')})`;
|
||||
filter = `${typeClause}${searchFilter} && delete != true`;
|
||||
}
|
||||
|
||||
const { items, totalItems } = await locals.pb
|
||||
@@ -50,9 +56,10 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json();
|
||||
const record = await locals.pb.collection(COLLECTION).create<EmployeeRecord>({
|
||||
const record = await locals.pb.collection(COLLECTION).create<EmployeeRecord>({
|
||||
First_Name: body.First_Name ?? '',
|
||||
Last_Name: body.Last_Name ?? '',
|
||||
Type: body.Type ?? undefined,
|
||||
Phone_Number: body.Phone_Number ?? '',
|
||||
Email_Address: body.Email_Address ?? '',
|
||||
Voxer_ID: body.Voxer_ID ?? '',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, {
|
||||
...(body.First_Name !== undefined && { First_Name: body.First_Name }),
|
||||
...(body.Last_Name !== undefined && { Last_Name: body.Last_Name }),
|
||||
...(body.Type !== undefined && { Type: body.Type }),
|
||||
...(body.Phone_Number !== undefined && { Phone_Number: body.Phone_Number }),
|
||||
...(body.Email_Address !== undefined && { Email_Address: body.Email_Address }),
|
||||
...(body.Voxer_ID !== undefined && { Voxer_ID: body.Voxer_ID }),
|
||||
|
||||
Reference in New Issue
Block a user