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> </script>
<svelte:head> <svelte:head>
<title>HRM</title>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
+1
View File
@@ -4,6 +4,7 @@ export interface EmployeeRecord {
id: string; id: string;
First_Name: string; First_Name: string;
Last_Name: string; Last_Name: string;
Type?: string;
Phone_Number: string; Phone_Number: string;
Email_Address: string; Email_Address: string;
Voxer_ID: string; Voxer_ID: string;
+99 -6
View File
@@ -7,6 +7,8 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/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 type { EmployeeRecord } from './+page.server';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import PencilIcon from '@lucide/svelte/icons/pencil'; import PencilIcon from '@lucide/svelte/icons/pencil';
@@ -20,19 +22,37 @@
let loading = $state(true); let loading = $state(true);
let loadingMore = $state(false); let loadingMore = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
type TypeFilterValue = 'all' | 'Employee' | 'Sub-Contractor';
let typeFilter = $state<TypeFilterValue>('all');
let searchQuery = $state(''); let searchQuery = $state('');
let nextPage = $state(2); let nextPage = $state(2);
let searchMode = $state(false); // true when showing server-side search results let searchMode = $state(false); // true when showing server-side search results
let searchDebounceId: ReturnType<typeof setTimeout> | null = null; 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 openCreate = $state(false);
let openEdit = $state(false); let openEdit = $state(false);
let openDelete = $state(false); let openDelete = $state(false);
let editingEmployee = $state<EmployeeRecord | null>(null); let editingEmployee = $state<EmployeeRecord | null>(null);
let deletingEmployee = $state<EmployeeRecord | null>(null); let deletingEmployee = $state<EmployeeRecord | null>(null);
const TYPE_OPTIONS = ['Employee', 'Sub-Contractor', 'Former-Employee', 'Applicant', 'Blacklisted'];
let formFirst = $state(''); let formFirst = $state('');
let formLast = $state(''); let formLast = $state('');
let formType = $state('');
let formPhone = $state(''); let formPhone = $state('');
let formEmail = $state(''); let formEmail = $state('');
let formVoxer = $state(''); let formVoxer = $state('');
@@ -45,7 +65,7 @@
async function loadEmployees() { async function loadEmployees() {
searchMode = false; searchMode = false;
try { 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); if (!res.ok) throw new Error(res.statusText);
const json = await res.json(); const json = await res.json();
employees = json.employees ?? []; employees = json.employees ?? [];
@@ -69,7 +89,7 @@
error = null; error = null;
try { try {
const res = await fetch( 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); if (!res.ok) throw new Error(res.statusText);
const json = await res.json(); const json = await res.json();
@@ -104,7 +124,7 @@
if (loadingMore || employees.length >= total) return; if (loadingMore || employees.length >= total) return;
loadingMore = true; loadingMore = true;
try { 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); if (!res.ok) throw new Error(res.statusText);
const json = await res.json(); const json = await res.json();
const list = json.employees ?? []; const list = json.employees ?? [];
@@ -120,6 +140,7 @@
function resetForm() { function resetForm() {
formFirst = ''; formFirst = '';
formLast = ''; formLast = '';
formType = '';
formPhone = ''; formPhone = '';
formEmail = ''; formEmail = '';
formVoxer = ''; formVoxer = '';
@@ -137,6 +158,7 @@
editingEmployee = emp; editingEmployee = emp;
formFirst = emp.First_Name ?? ''; formFirst = emp.First_Name ?? '';
formLast = emp.Last_Name ?? ''; formLast = emp.Last_Name ?? '';
formType = emp.Type ?? '';
formPhone = emp.Phone_Number ?? ''; formPhone = emp.Phone_Number ?? '';
formEmail = emp.Email_Address ?? ''; formEmail = emp.Email_Address ?? '';
formVoxer = emp.Voxer_ID ?? ''; formVoxer = emp.Voxer_ID ?? '';
@@ -161,6 +183,7 @@
body: JSON.stringify({ body: JSON.stringify({
First_Name: formFirst, First_Name: formFirst,
Last_Name: formLast, Last_Name: formLast,
Type: formType || undefined,
Phone_Number: formPhone, Phone_Number: formPhone,
Email_Address: formEmail, Email_Address: formEmail,
Voxer_ID: formVoxer, Voxer_ID: formVoxer,
@@ -191,6 +214,7 @@
body: JSON.stringify({ body: JSON.stringify({
First_Name: formFirst, First_Name: formFirst,
Last_Name: formLast, Last_Name: formLast,
Type: formType || undefined,
Phone_Number: formPhone, Phone_Number: formPhone,
Email_Address: formEmail, Email_Address: formEmail,
Voxer_ID: formVoxer, Voxer_ID: formVoxer,
@@ -254,6 +278,39 @@
<main class="flex flex-col gap-6 p-6 md:p-8"> <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 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"> <div class="relative min-w-0 flex-1 sm:max-w-sm">
<SearchIcon <SearchIcon
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" 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.Row>
<Table.Head>First Name</Table.Head> <Table.Head>First Name</Table.Head>
<Table.Head>Last Name</Table.Head> <Table.Head>Last Name</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Status</Table.Head> <Table.Head>Status</Table.Head>
<Table.Head>Phone</Table.Head> <Table.Head>Phone</Table.Head>
<Table.Head>Email</Table.Head> <Table.Head>Email</Table.Head>
@@ -288,13 +346,13 @@
<Table.Body> <Table.Body>
{#if loading} {#if loading}
<Table.Row> <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… Loading…
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{:else if error} {:else if error}
<Table.Row> <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} {error}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@@ -309,6 +367,13 @@
> >
<Table.Cell>{employee.First_Name}</Table.Cell> <Table.Cell>{employee.First_Name}</Table.Cell>
<Table.Cell>{employee.Last_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.Status}</Table.Cell>
<Table.Cell>{employee.Phone_Number}</Table.Cell> <Table.Cell>{employee.Phone_Number}</Table.Cell>
<Table.Cell>{employee.Email_Address}</Table.Cell> <Table.Cell>{employee.Email_Address}</Table.Cell>
@@ -349,7 +414,7 @@
</Table.Row> </Table.Row>
{:else} {:else}
<Table.Row> <Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground text-center"> <Table.Cell colspan={8} class="text-muted-foreground text-center">
{searchQuery.trim() {searchQuery.trim()
? 'No matching employees.' ? 'No matching employees.'
: 'No employee records found.'} : 'No employee records found.'}
@@ -397,6 +462,20 @@
<Input id="create-last" bind:value={formLast} required /> <Input id="create-last" bind:value={formLast} required />
</div> </div>
</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"> <div class="space-y-2">
<Label for="create-phone">Phone</Label> <Label for="create-phone">Phone</Label>
<Input id="create-phone" bind:value={formPhone} /> <Input id="create-phone" bind:value={formPhone} />
@@ -452,6 +531,20 @@
<Input id="edit-last" bind:value={formLast} required /> <Input id="edit-last" bind:value={formLast} required />
</div> </div>
</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"> <div class="space-y-2">
<Label for="edit-phone">Phone</Label> <Label for="edit-phone">Phone</Label>
<Input id="edit-phone" bind:value={formPhone} /> <Input id="edit-phone" bind:value={formPhone} />
+1
View File
@@ -8,6 +8,7 @@ export interface EmployeeRecord {
id: string; id: string;
First_Name: string; First_Name: string;
Last_Name: string; Last_Name: string;
Type?: string;
Phone_Number: string; Phone_Number: string;
Email_Address: string; Email_Address: string;
Voxer_ID: string; Voxer_ID: string;
+37 -2
View File
@@ -6,7 +6,7 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Card from '$lib/components/ui/card/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 { import type {
EmployeeRecord, EmployeeRecord,
EmployeeReportRecord, EmployeeReportRecord,
@@ -270,8 +270,11 @@
<Button variant="ghost" size="icon" href="/" class="shrink-0"> <Button variant="ghost" size="icon" href="/" class="shrink-0">
<ArrowLeftIcon class="size-4" /> <ArrowLeftIcon class="size-4" />
</Button> </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} {employee?.First_Name} {employee?.Last_Name}
{#if employee?.Type}
<Badge variant="secondary">{employee.Type}</Badge>
{/if}
</h1> </h1>
{#if employee} {#if employee}
<div class="flex items-center gap-2 ml-auto"> <div class="flex items-center gap-2 ml-auto">
@@ -325,6 +328,38 @@
<dd>{employee.Last_Name || '—'}</dd> <dd>{employee.Last_Name || '—'}</dd>
{/if} {/if}
</div> </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"> <div class="flex flex-col gap-1">
<Label for="emp-status" class="text-muted-foreground text-sm">Status</Label> <Label for="emp-status" class="text-muted-foreground text-sm">Status</Label>
{#if editMode} {#if editMode}
+12 -5
View File
@@ -5,6 +5,7 @@ export interface EmployeeRecord {
id: string; id: string;
First_Name: string; First_Name: string;
Last_Name: string; Last_Name: string;
Type?: string;
Phone_Number: string; Phone_Number: string;
Email_Address: string; Email_Address: string;
Voxer_ID: string; Voxer_ID: string;
@@ -21,21 +22,26 @@ function escapeFilterValue(s: string): string {
export const GET: RequestHandler = async ({ locals, url }) => { export const GET: RequestHandler = async ({ locals, url }) => {
const search = url.searchParams.get('search')?.trim() ?? ''; 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 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))); 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) { if (search.length > 0) {
const escaped = escapeFilterValue(search); const escaped = escapeFilterValue(search);
// Search across main text fields (PocketBase ~ is "contains") const searchFilter = `(${[
filter = `(${[
`First_Name ~ "${escaped}"`, `First_Name ~ "${escaped}"`,
`Last_Name ~ "${escaped}"`, `Last_Name ~ "${escaped}"`,
`Email_Address ~ "${escaped}"`, `Email_Address ~ "${escaped}"`,
`Phone_Number ~ "${escaped}"`, `Phone_Number ~ "${escaped}"`,
`Voxer_ID ~ "${escaped}"`, `Voxer_ID ~ "${escaped}"`,
`Status ~ "${escaped}"` `Status ~ "${escaped}"`
].join(' || ')}) && delete != true`; ].join(' || ')})`;
filter = `${typeClause}${searchFilter} && delete != true`;
} }
const { items, totalItems } = await locals.pb const { items, totalItems } = await locals.pb
@@ -50,9 +56,10 @@ export const GET: RequestHandler = async ({ locals, url }) => {
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json(); 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 ?? '', First_Name: body.First_Name ?? '',
Last_Name: body.Last_Name ?? '', Last_Name: body.Last_Name ?? '',
Type: body.Type ?? undefined,
Phone_Number: body.Phone_Number ?? '', Phone_Number: body.Phone_Number ?? '',
Email_Address: body.Email_Address ?? '', Email_Address: body.Email_Address ?? '',
Voxer_ID: body.Voxer_ID ?? '', 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, { const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, {
...(body.First_Name !== undefined && { First_Name: body.First_Name }), ...(body.First_Name !== undefined && { First_Name: body.First_Name }),
...(body.Last_Name !== undefined && { Last_Name: body.Last_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.Phone_Number !== undefined && { Phone_Number: body.Phone_Number }),
...(body.Email_Address !== undefined && { Email_Address: body.Email_Address }), ...(body.Email_Address !== undefined && { Email_Address: body.Email_Address }),
...(body.Voxer_ID !== undefined && { Voxer_ID: body.Voxer_ID }), ...(body.Voxer_ID !== undefined && { Voxer_ID: body.Voxer_ID }),