feat(employee-management): add scope filtering and management for employees
CI / build (push) Has been skipped
CI / deploy (push) Successful in 53s

- Introduced a new `Scopes` field in the `EmployeeRecord` interface to categorize employee scopes.
- Implemented scope filtering in the employee listing, allowing users to filter by various scopes.
- Enhanced employee detail and edit views to manage employee scopes with checkboxes.
- Updated API endpoints to support scope data during employee creation and updates.
- Adjusted UI components to display employee scopes in lists and detail views.
This commit is contained in:
eewing
2026-02-20 09:28:08 -06:00
parent 7c1d089035
commit a5d3ac3a84
5 changed files with 194 additions and 14 deletions
+74 -6
View File
@@ -24,8 +24,23 @@
let loading = $state(true);
let loadingMore = $state(false);
let error = $state<string | null>(null);
const SCOPE_OPTIONS = [
'General',
'OFFICE',
'IT',
'Framing',
'Insulation',
'Hanging',
'Scarp & Paper',
'Taping',
'Sand & Mask',
'WT & Cleanup',
'Grid & Tile'
];
type TypeFilterValue = 'all' | 'Employee' | 'Sub-Contractor';
let typeFilter = $state<TypeFilterValue>('all');
let scopeFilter = $state<string>('all');
let searchQuery = $state('');
let nextPage = $state(2);
let searchMode = $state(false); // true when showing server-side search results
@@ -35,9 +50,14 @@
return typeFilter === 'all' ? '' : `&type=${encodeURIComponent(typeFilter)}`;
}
function scopeParam(): string {
return scopeFilter === 'all' || !scopeFilter ? '' : `&scope=${encodeURIComponent(scopeFilter)}`;
}
function listUrl(): string {
const params = new URLSearchParams();
if (typeFilter !== 'all') params.set('type', typeFilter);
if (scopeFilter !== 'all' && scopeFilter) params.set('scope', scopeFilter);
const q = searchQuery.trim();
if (q) params.set('search', q);
const s = params.toString();
@@ -48,6 +68,8 @@
const params = get(page).url.searchParams;
const type = params.get('type');
typeFilter = type === 'Sub-Contractor' ? 'Sub-Contractor' : type === 'Employee' ? 'Employee' : 'all';
const scope = params.get('scope') ?? 'all';
scopeFilter = SCOPE_OPTIONS.includes(scope) ? scope : 'all';
searchQuery = params.get('search') ?? '';
}
@@ -62,6 +84,17 @@
}
}
function setScopeFilter(value: string) {
scopeFilter = value;
goto(listUrl(), { replaceState: true });
loading = true;
if (searchQuery.trim()) {
searchEmployees(searchQuery);
} else {
loadEmployees();
}
}
let openCreate = $state(false);
let openEdit = $state(false);
let openDelete = $state(false);
@@ -84,7 +117,7 @@
async function loadEmployees() {
searchMode = false;
try {
const res = await fetch(`/api/employees?page=1&perPage=200${typeParam()}`);
const res = await fetch(`/api/employees?page=1&perPage=200${typeParam()}${scopeParam()}`);
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
employees = json.employees ?? [];
@@ -108,7 +141,7 @@
error = null;
try {
const res = await fetch(
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}${typeParam()}`
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}${typeParam()}${scopeParam()}`
);
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
@@ -144,7 +177,7 @@
if (loadingMore || employees.length >= total) return;
loadingMore = true;
try {
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100${typeParam()}`);
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100${typeParam()}${scopeParam()}`);
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
const list = json.employees ?? [];
@@ -338,6 +371,29 @@
Sub-Contractor
</Button>
</div>
<Select.Root
type="single"
bind:value={scopeFilter}
onValueChange={() => {
goto(listUrl(), { replaceState: true });
loading = true;
if (searchQuery.trim()) {
searchEmployees(searchQuery);
} else {
loadEmployees();
}
}}
>
<Select.Trigger class="w-[180px]" aria-label="Filter by scope">
{scopeFilter === 'all' ? 'All scopes' : scopeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all" label="All scopes">All scopes</Select.Item>
{#each SCOPE_OPTIONS as scope}
<Select.Item value={scope} label={scope}>{scope}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<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"
@@ -364,6 +420,7 @@
<Table.Head>Last Name</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Scopes</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Voxer ID</Table.Head>
@@ -373,13 +430,13 @@
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={8} class="text-muted-foreground py-8 text-center">
<Table.Cell colspan={9} class="text-muted-foreground py-8 text-center">
Loading…
</Table.Cell>
</Table.Row>
{:else if error}
<Table.Row>
<Table.Cell colspan={8} class="text-destructive py-8 text-center">
<Table.Cell colspan={9} class="text-destructive py-8 text-center">
{error}
</Table.Cell>
</Table.Row>
@@ -402,6 +459,17 @@
{/if}
</Table.Cell>
<Table.Cell>{employee.Status}</Table.Cell>
<Table.Cell>
{#if (employee.Scopes ?? []).length > 0}
<div class="flex flex-wrap gap-1">
{#each (Array.isArray(employee.Scopes) ? employee.Scopes : []) as scope}
<Badge variant="secondary" class="text-xs">{scope}</Badge>
{/each}
</div>
{:else}
<span class="text-muted-foreground">—</span>
{/if}
</Table.Cell>
<Table.Cell>{employee.Phone_Number}</Table.Cell>
<Table.Cell>{employee.Email_Address}</Table.Cell>
<Table.Cell>{employee.Voxer_ID}</Table.Cell>
@@ -441,7 +509,7 @@
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={8} class="text-muted-foreground text-center">
<Table.Cell colspan={9} class="text-muted-foreground text-center">
{searchQuery.trim()
? 'No matching employees.'
: 'No employee records found.'}
+1
View File
@@ -14,6 +14,7 @@ export interface EmployeeRecord {
Voxer_ID: string;
Status: string;
Notes?: string;
Scopes?: string[];
[key: string]: unknown;
}
+72 -3
View File
@@ -115,7 +115,7 @@
}
}
async function patchField(field: keyof EmployeeRecord, value: string) {
async function patchField(field: keyof EmployeeRecord, value: string | string[]) {
if (!employee?.id) return;
patchSaving = true;
patchError = null;
@@ -125,8 +125,15 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (!res.ok) throw new Error('Update failed');
const json = await res.json();
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = (json as { error?: string }).error ?? 'Update failed';
const data = (json as { data?: unknown }).data;
patchError = data && typeof data === 'object' && data !== null
? `${msg}: ${JSON.stringify(data)}`
: msg;
return;
}
employee = (json.employee ?? employee) as EmployeeRecord;
} catch (e) {
patchError = e instanceof Error ? e.message : 'Update failed';
@@ -135,6 +142,37 @@
}
}
const SCOPE_OPTIONS = [
'General',
'OFFICE',
'IT',
'Framing',
'Insulation',
'Hanging',
'Scarp & Paper',
'Taping',
'Sand & Mask',
'WT & Cleanup',
'Grid & Tile'
];
function employeeScopes(): string[] {
const raw = employee?.Scopes;
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') return raw ? [raw] : [];
return [];
}
function toggleScope(scope: string) {
if (!employee) return;
const current = employeeScopes();
const next = current.includes(scope)
? current.filter((s) => s !== scope)
: [...current, scope];
employee = { ...employee, Scopes: next };
patchField('Scopes', next);
}
async function confirmDelete() {
deleteSaving = true;
deleteError = null;
@@ -434,6 +472,37 @@
<dd>{employee.Voxer_ID || '—'}</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label class="text-muted-foreground text-sm">Scopes</Label>
{#if editMode}
<div class="border-input bg-background flex min-h-[80px] flex-wrap gap-2 rounded-md border px-3 py-2">
{#each SCOPE_OPTIONS as scope}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
class="border-input size-4 rounded"
checked={employeeScopes().includes(scope)}
onchange={() => toggleScope(scope)}
disabled={patchSaving}
/>
{scope}
</label>
{/each}
</div>
{:else}
<dd>
{#if employeeScopes().length > 0}
<div class="flex flex-wrap gap-1">
{#each employeeScopes() as scope}
<Badge variant="secondary">{scope}</Badge>
{/each}
</div>
{:else}
{/if}
</dd>
{/if}
</div>
</dl>
<!-- Notes section -->
+23 -2
View File
@@ -10,6 +10,7 @@ export interface EmployeeRecord {
Email_Address: string;
Voxer_ID: string;
Status: string;
Scopes?: string[];
[key: string]: unknown;
}
@@ -20,9 +21,24 @@ function escapeFilterValue(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
const SCOPE_OPTIONS = [
'General',
'OFFICE',
'IT',
'Framing',
'Insulation',
'Hanging',
'Scarp & Paper',
'Taping',
'Sand & Mask',
'WT & Cleanup',
'Grid & Tile'
];
export const GET: RequestHandler = async ({ locals, url }) => {
const search = url.searchParams.get('search')?.trim() ?? '';
const typeFilter = url.searchParams.get('type')?.trim() ?? '';
const scopeFilter = url.searchParams.get('scope')?.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)));
@@ -30,7 +46,11 @@ export const GET: RequestHandler = async ({ locals, url }) => {
typeFilter === 'Employee' || typeFilter === 'Sub-Contractor'
? `(Type = "${escapeFilterValue(typeFilter)}") && `
: '';
let filter = `${typeClause}delete != true`;
const scopeClause =
scopeFilter.length > 0 && SCOPE_OPTIONS.includes(scopeFilter)
? `(Scopes ?~ "${escapeFilterValue(scopeFilter)}") && `
: '';
let filter = `${typeClause}${scopeClause}delete != true`;
if (search.length > 0) {
const escaped = escapeFilterValue(search);
const searchFilter = `(${[
@@ -41,7 +61,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
`Voxer_ID ~ "${escaped}"`,
`Status ~ "${escaped}"`
].join(' || ')})`;
filter = `${typeClause}${searchFilter} && delete != true`;
filter = `${typeClause}${scopeClause}${searchFilter} && delete != true`;
}
const { items, totalItems } = await locals.pb
@@ -64,6 +84,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
Email_Address: body.Email_Address ?? '',
Voxer_ID: body.Voxer_ID ?? '',
Status: body.Status ?? '',
Scopes: Array.isArray(body.Scopes) ? body.Scopes : undefined,
delete: false,
...(locals.user?.id && { entered_by: locals.user.id })
});
+23 -2
View File
@@ -27,7 +27,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const id = params.id;
const body = await request.json();
const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, {
const payload: Record<string, unknown> = {
...(body.First_Name !== undefined && { First_Name: body.First_Name }),
...(body.Last_Name !== undefined && { Last_Name: body.Last_Name }),
...(body.Type !== undefined && { Type: body.Type }),
@@ -37,8 +38,28 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
...(body.Status !== undefined && { Status: body.Status }),
...(body.Notes !== undefined && { Notes: body.Notes }),
...(locals.user?.id && { updated_by: locals.user.id })
});
};
if (body.Scopes !== undefined) {
payload.Scopes = Array.isArray(body.Scopes) ? body.Scopes : [];
}
try {
const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, payload);
return json({ employee: record });
} catch (e: unknown) {
const status = e && typeof e === 'object' && 'status' in e ? (e as { status: number }).status : 500;
const response = e && typeof e === 'object' && 'response' in e ? (e as { response: unknown }).response : null;
const message =
response && typeof response === 'object' && response !== null && 'message' in response
? (response as { message: string }).message
: 'Failed to update record';
const data =
response && typeof response === 'object' && response !== null && 'data' in response
? (response as { data: unknown }).data
: undefined;
return json({ error: message, data }, { status });
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {