From a5d3ac3a841d6c88f5bd2cd0c5dbf006aceb6ecb Mon Sep 17 00:00:00 2001 From: eewing Date: Fri, 20 Feb 2026 09:28:08 -0600 Subject: [PATCH] feat(employee-management): add scope filtering and management for employees - 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. --- src/routes/+page.svelte | 80 ++++++++++++++++++++++-- src/routes/[id]/+page.server.ts | 1 + src/routes/[id]/+page.svelte | 75 +++++++++++++++++++++- src/routes/api/employees/+server.ts | 25 +++++++- src/routes/api/employees/[id]/+server.ts | 27 +++++++- 5 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cd13c08..99002c6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -24,8 +24,23 @@ let loading = $state(true); let loadingMore = $state(false); let error = $state(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('all'); + let scopeFilter = $state('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 + { + goto(listUrl(), { replaceState: true }); + loading = true; + if (searchQuery.trim()) { + searchEmployees(searchQuery); + } else { + loadEmployees(); + } + }} + > + + {scopeFilter === 'all' ? 'All scopes' : scopeFilter} + + + All scopes + {#each SCOPE_OPTIONS as scope} + {scope} + {/each} + +
Last Name Type Status + Scopes Phone Email Voxer ID @@ -373,13 +430,13 @@ {#if loading} - + Loading… {:else if error} - + {error} @@ -402,6 +459,17 @@ {/if} {employee.Status} + + {#if (employee.Scopes ?? []).length > 0} +
+ {#each (Array.isArray(employee.Scopes) ? employee.Scopes : []) as scope} + {scope} + {/each} +
+ {:else} + + {/if} +
{employee.Phone_Number} {employee.Email_Address} {employee.Voxer_ID} @@ -441,7 +509,7 @@ {:else} - + {searchQuery.trim() ? 'No matching employees.' : 'No employee records found.'} diff --git a/src/routes/[id]/+page.server.ts b/src/routes/[id]/+page.server.ts index d5b4903..6c6a272 100644 --- a/src/routes/[id]/+page.server.ts +++ b/src/routes/[id]/+page.server.ts @@ -14,6 +14,7 @@ export interface EmployeeRecord { Voxer_ID: string; Status: string; Notes?: string; + Scopes?: string[]; [key: string]: unknown; } diff --git a/src/routes/[id]/+page.svelte b/src/routes/[id]/+page.svelte index 52f14b0..0d62a5d 100644 --- a/src/routes/[id]/+page.svelte +++ b/src/routes/[id]/+page.svelte @@ -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 @@
{employee.Voxer_ID || '—'}
{/if}
+
+ + {#if editMode} +
+ {#each SCOPE_OPTIONS as scope} + + {/each} +
+ {:else} +
+ {#if employeeScopes().length > 0} +
+ {#each employeeScopes() as scope} + {scope} + {/each} +
+ {:else} + — + {/if} +
+ {/if} +
diff --git a/src/routes/api/employees/+server.ts b/src/routes/api/employees/+server.ts index 9f9fb54..d808b12 100644 --- a/src/routes/api/employees/+server.ts +++ b/src/routes/api/employees/+server.ts @@ -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 }) }); diff --git a/src/routes/api/employees/[id]/+server.ts b/src/routes/api/employees/[id]/+server.ts index 89aca0a..3939604 100644 --- a/src/routes/api/employees/[id]/+server.ts +++ b/src/routes/api/employees/[id]/+server.ts @@ -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(id, { + + const payload: Record = { ...(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 }) - }); - return json({ employee: record }); + }; + + if (body.Scopes !== undefined) { + payload.Scopes = Array.isArray(body.Scopes) ? body.Scopes : []; + } + + try { + const record = await locals.pb.collection(COLLECTION).update(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 }) => {