Add employee type management and filtering functionality
CI / build (push) Has been skipped
CI / deploy (push) Successful in 52s

- 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:
eewing
2026-02-18 13:41:43 -06:00
parent debc6559c9
commit bbf185df50
7 changed files with 152 additions and 13 deletions
+1
View File
@@ -64,6 +64,7 @@
</script>
<svelte:head>
<title>HRM</title>
<link rel="icon" href={favicon} />
</svelte:head>
+1
View File
@@ -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
View File
@@ -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} />
+1
View File
@@ -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;
+37 -2
View File
@@ -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}
+12 -5
View File
@@ -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 ?? '',
+1
View File
@@ -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 }),