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.
This commit is contained in:
+74
-6
@@ -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.'}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface EmployeeRecord {
|
||||
Voxer_ID: string;
|
||||
Status: string;
|
||||
Notes?: string;
|
||||
Scopes?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user