Add svelte-sonner for notifications and enhance employee search functionality
- Integrated svelte-sonner for toast notifications in the layout. - Implemented server-side search for employees with pagination in the API. - Updated employee loading logic to support search queries and loading more results. - Enhanced employee detail view with navigation and improved UI interactions. - Adjusted cookie handling in hooks for PocketBase authentication.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
"svelte-sonner": "^1.0.7",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -332,6 +333,8 @@
|
|||||||
|
|
||||||
"svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
|
"svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
|
||||||
|
|
||||||
|
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
|
||||||
|
|
||||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
@@ -373,5 +376,7 @@
|
|||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
"svelte-sonner": "^1.0.7",
|
||||||
"tailwind-merge": "^3.4.1"
|
"tailwind-merge": "^3.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -49,7 +49,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
|
|
||||||
const response = await resolve(event);
|
const response = await resolve(event);
|
||||||
|
|
||||||
response.headers.append('set-cookie', event.locals.pb.authStore.exportToCookie());
|
// httpOnly: false so client PocketBase can read auth for realtime subscriptions
|
||||||
|
response.headers.append(
|
||||||
|
'set-cookie',
|
||||||
|
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
|
||||||
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow-xs hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground border-input",
|
||||||
|
link: "border-transparent bg-primary/10 text-primary hover:bg-primary/20",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
export type BadgeProps = WithElementRef<HTMLAttributes<HTMLSpanElement>> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: BadgeProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-slot="badge"
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Root, {
|
||||||
|
type BadgeProps,
|
||||||
|
type BadgeVariant,
|
||||||
|
badgeVariants,
|
||||||
|
} from "./badge.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type BadgeProps as Props,
|
||||||
|
//
|
||||||
|
Root as Badge,
|
||||||
|
badgeVariants,
|
||||||
|
type BadgeProps,
|
||||||
|
type BadgeVariant,
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
const EMPLOYEE_COLLECTION = 'Employee_Records';
|
||||||
|
const REPORT_COLLECTION = 'Employee_Reports';
|
||||||
|
|
||||||
|
type RecordSubscription<T = Record<string, unknown>> = {
|
||||||
|
action: 'create' | 'update' | 'delete';
|
||||||
|
record: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRecordLabel(record: Record<string, unknown>, collection: string): string {
|
||||||
|
if (collection === EMPLOYEE_COLLECTION) {
|
||||||
|
const first = (record.First_Name as string) ?? '';
|
||||||
|
const last = (record.Last_Name as string) ?? '';
|
||||||
|
if (first || last) return `${first} ${last}`.trim();
|
||||||
|
}
|
||||||
|
if (collection === REPORT_COLLECTION) {
|
||||||
|
const report = (record.report as string) ?? '';
|
||||||
|
if (report) return report.slice(0, 50) + (report.length > 50 ? '…' : '');
|
||||||
|
}
|
||||||
|
return 'Record';
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(collection: string, action: string, record: Record<string, unknown>) {
|
||||||
|
const label = getRecordLabel(record, collection);
|
||||||
|
const collectionLabel =
|
||||||
|
collection === EMPLOYEE_COLLECTION ? 'Employee' : 'Report';
|
||||||
|
const title =
|
||||||
|
action === 'create'
|
||||||
|
? `${collectionLabel} added`
|
||||||
|
: action === 'update'
|
||||||
|
? `${collectionLabel} updated`
|
||||||
|
: `${collectionLabel} removed`;
|
||||||
|
const description = action === 'delete' ? undefined : label;
|
||||||
|
toast(title, {
|
||||||
|
description: description || undefined,
|
||||||
|
...(action === 'create' && { duration: 4000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initRealtime(): Promise<() => void> {
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
pb.authStore.loadFromCookie(document.cookie);
|
||||||
|
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubs: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const u1 = await pb.collection(EMPLOYEE_COLLECTION).subscribe(
|
||||||
|
'*',
|
||||||
|
(e: RecordSubscription) => {
|
||||||
|
notify(EMPLOYEE_COLLECTION, e.action, e.record as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
unsubs.push(u1);
|
||||||
|
const u2 = await pb.collection(REPORT_COLLECTION).subscribe(
|
||||||
|
'*',
|
||||||
|
(e: RecordSubscription) => {
|
||||||
|
notify(REPORT_COLLECTION, e.action, e.record as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
unsubs.push(u2);
|
||||||
|
} catch {
|
||||||
|
// Realtime not available or auth failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubs.forEach((u) => u());
|
||||||
|
void pb.realtime.unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Toaster } from 'svelte-sonner';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { initRealtime } from '$lib/realtime';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let cleanupRealtime: (() => void) | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
initRealtime().then((cleanup) => {
|
||||||
|
if (!cancelled) cleanupRealtime = cleanup;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanupRealtime?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{
|
||||||
|
unstyled: false,
|
||||||
|
class: 'border border-border bg-card text-card-foreground shadow-lg'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
+121
-22
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
@@ -10,13 +11,19 @@
|
|||||||
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';
|
||||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import SearchIcon from '@lucide/svelte/icons/search';
|
import SearchIcon from '@lucide/svelte/icons/search';
|
||||||
|
|
||||||
let employees = $state<EmployeeRecord[]>([]);
|
let employees = $state<EmployeeRecord[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let searchQuery = $state('');
|
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;
|
||||||
|
|
||||||
let openCreate = $state(false);
|
let openCreate = $state(false);
|
||||||
let openEdit = $state(false);
|
let openEdit = $state(false);
|
||||||
@@ -36,11 +43,14 @@
|
|||||||
let deleteError = $state<string | null>(null);
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadEmployees() {
|
async function loadEmployees() {
|
||||||
|
searchMode = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/employees');
|
const res = await fetch('/api/employees?page=1&perPage=200');
|
||||||
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 ?? [];
|
||||||
|
total = json.total ?? 0;
|
||||||
|
nextPage = 2;
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load employees';
|
error = e instanceof Error ? e.message : 'Failed to load employees';
|
||||||
@@ -49,6 +59,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchEmployees(query: string) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
await loadEmployees();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchMode = true;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
const json = await res.json();
|
||||||
|
employees = json.employees ?? [];
|
||||||
|
total = json.total ?? 0;
|
||||||
|
nextPage = 2;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Search failed';
|
||||||
|
employees = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
if (searchDebounceId) clearTimeout(searchDebounceId);
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (!q) {
|
||||||
|
searchDebounceId = null;
|
||||||
|
// Clear search: reload first page of full list
|
||||||
|
loading = true;
|
||||||
|
loadEmployees();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchDebounceId = setTimeout(() => {
|
||||||
|
searchDebounceId = null;
|
||||||
|
searchEmployees(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMore || employees.length >= total) return;
|
||||||
|
loadingMore = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100`);
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
const json = await res.json();
|
||||||
|
const list = json.employees ?? [];
|
||||||
|
employees = [...employees, ...list];
|
||||||
|
nextPage += 1;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load more';
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
formFirst = '';
|
formFirst = '';
|
||||||
formLast = '';
|
formLast = '';
|
||||||
@@ -160,21 +228,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEmployees = $derived(
|
// When using server-side search, employees are already filtered; otherwise show loaded list
|
||||||
!searchQuery.trim()
|
const filteredEmployees = $derived(employees);
|
||||||
? employees
|
|
||||||
: employees.filter((emp) => {
|
const hasMore = $derived(
|
||||||
const q = searchQuery.toLowerCase();
|
!searchMode && employees.length < total && !loading
|
||||||
return (
|
|
||||||
(emp.First_Name ?? '').toLowerCase().includes(q) ||
|
|
||||||
(emp.Last_Name ?? '').toLowerCase().includes(q) ||
|
|
||||||
(emp.Email_Address ?? '').toLowerCase().includes(q) ||
|
|
||||||
(emp.Phone_Number ?? '').toLowerCase().includes(q) ||
|
|
||||||
(emp.Voxer_ID ?? '').toLowerCase().includes(q) ||
|
|
||||||
(emp.Status ?? '').toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
function intersectionObserverAction(node: HTMLDivElement, onVisible: () => void) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onVisible();
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px', threshold: 0 }
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
onMount(loadEmployees);
|
onMount(loadEmployees);
|
||||||
</script>
|
</script>
|
||||||
@@ -189,6 +262,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search by name, email, phone, Voxer ID, status…"
|
placeholder="Search by name, email, phone, Voxer ID, status…"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
|
oninput={onSearchInput}
|
||||||
class="pl-9"
|
class="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,11 +278,11 @@
|
|||||||
<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>Status</Table.Head>
|
||||||
<Table.Head>Phone</Table.Head>
|
<Table.Head>Phone</Table.Head>
|
||||||
<Table.Head>Email</Table.Head>
|
<Table.Head>Email</Table.Head>
|
||||||
<Table.Head>Voxer ID</Table.Head>
|
<Table.Head>Voxer ID</Table.Head>
|
||||||
<Table.Head>Status</Table.Head>
|
<Table.Head class="w-[70px]"></Table.Head>
|
||||||
<Table.Head class="w-[70px]">Actions</Table.Head>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -226,24 +300,38 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredEmployees as employee (employee.id)}
|
{#each filteredEmployees as employee (employee.id)}
|
||||||
<Table.Row>
|
<Table.Row
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="cursor-pointer hover:bg-muted/50 focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => goto(`/${employee.id}`)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && goto(`/${employee.id}`)}
|
||||||
|
>
|
||||||
<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>{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>
|
||||||
<Table.Cell>{employee.Voxer_ID}</Table.Cell>
|
<Table.Cell>{employee.Voxer_ID}</Table.Cell>
|
||||||
<Table.Cell>{employee.Status}</Table.Cell>
|
<Table.Cell
|
||||||
<Table.Cell>
|
class="cursor-default"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
class="flex size-8 items-center justify-center rounded-md hover:bg-accent"
|
class="flex size-8 items-center justify-center rounded-md hover:bg-accent"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" class="size-8">
|
<Button variant="ghost" size="icon" class="size-8" type="button">
|
||||||
<EllipsisVerticalIcon class="size-4" />
|
<EllipsisVerticalIcon class="size-4" aria-label="Actions" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onclick={() => goto(`/${employee.id}`)}>
|
||||||
|
<EyeIcon class="size-4" />
|
||||||
|
View
|
||||||
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onclick={() => openEditDialog(employee)}>
|
<DropdownMenu.Item onclick={() => openEditDialog(employee)}>
|
||||||
<PencilIcon class="size-4" />
|
<PencilIcon class="size-4" />
|
||||||
Edit
|
Edit
|
||||||
@@ -271,6 +359,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
{#if !loading && !error && hasMore}
|
||||||
|
<div
|
||||||
|
use:intersectionObserverAction={loadMore}
|
||||||
|
class="flex min-h-12 items-center justify-center py-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#if loadingMore}
|
||||||
|
<span class="text-muted-foreground text-sm">Loading more…</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const EMPLOYEES_COLLECTION = 'Employee_Records';
|
||||||
|
const REPORTS_COLLECTION = 'Employee_Reports';
|
||||||
|
|
||||||
|
export interface EmployeeRecord {
|
||||||
|
id: string;
|
||||||
|
First_Name: string;
|
||||||
|
Last_Name: string;
|
||||||
|
Phone_Number: string;
|
||||||
|
Email_Address: string;
|
||||||
|
Voxer_ID: string;
|
||||||
|
Status: string;
|
||||||
|
Notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoxerLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeReportRecord {
|
||||||
|
id: string;
|
||||||
|
report?: string;
|
||||||
|
voxers?: VoxerLink[] | string; // JSON array or string from PB
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const id = params.id;
|
||||||
|
try {
|
||||||
|
const [employee, reportsResult] = await Promise.all([
|
||||||
|
locals.pb.collection(EMPLOYEES_COLLECTION).getOne<EmployeeRecord>(id),
|
||||||
|
locals.pb
|
||||||
|
.collection(REPORTS_COLLECTION)
|
||||||
|
.getList<EmployeeReportRecord>(1, 100, {
|
||||||
|
filter: `Employee = "${id}" && delete != true`,
|
||||||
|
sort: '-created'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
if ((employee as { delete?: boolean }).delete === true) {
|
||||||
|
throw error(404, 'Employee not found');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
employee,
|
||||||
|
reports: reportsResult.items ?? []
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const is404 =
|
||||||
|
e &&
|
||||||
|
typeof e === 'object' &&
|
||||||
|
'status' in e &&
|
||||||
|
(e as { status: number }).status === 404;
|
||||||
|
if (is404) throw error(404, 'Employee not found');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
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 * 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 type {
|
||||||
|
EmployeeRecord,
|
||||||
|
EmployeeReportRecord,
|
||||||
|
VoxerLink
|
||||||
|
} from './+page.server';
|
||||||
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import LinkIcon from '@lucide/svelte/icons/link';
|
||||||
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const employeeId = $derived($page.params.id);
|
||||||
|
|
||||||
|
function formatDate(val: string | undefined): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(val).toLocaleString(undefined, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let employee = $state<EmployeeRecord | null>(data?.employee ?? null);
|
||||||
|
let reports = $state<EmployeeReportRecord[]>((data?.reports ?? []) as EmployeeReportRecord[]);
|
||||||
|
let editMode = $state(false);
|
||||||
|
let patchSaving = $state(false);
|
||||||
|
let patchError = $state<string | null>(null);
|
||||||
|
let openDelete = $state(false);
|
||||||
|
let deleteSaving = $state(false);
|
||||||
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Report CRUD state
|
||||||
|
let openCreateReport = $state(false);
|
||||||
|
let openEditReport = $state(false);
|
||||||
|
let openDeleteReport = $state(false);
|
||||||
|
let reportFormText = $state('');
|
||||||
|
let reportVoxers = $state<VoxerLink[]>([]);
|
||||||
|
let editingReport = $state<EmployeeReportRecord | null>(null);
|
||||||
|
let deletingReport = $state<EmployeeReportRecord | null>(null);
|
||||||
|
let reportSaving = $state(false);
|
||||||
|
let reportError = $state<string | null>(null);
|
||||||
|
let reportDeleteSaving = $state(false);
|
||||||
|
let reportDeleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function parseVoxers(report: EmployeeReportRecord): VoxerLink[] {
|
||||||
|
const raw = report.voxers;
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw as VoxerLink[];
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as VoxerLink[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVoxLabel(voxers: VoxerLink[]): string {
|
||||||
|
const n = voxers.length + 1;
|
||||||
|
return `Vox ${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (data?.employee) employee = data.employee as EmployeeRecord;
|
||||||
|
if (data?.reports) reports = (data.reports as EmployeeReportRecord[]).slice();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadReports() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}/reports`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load reports');
|
||||||
|
const json = await res.json();
|
||||||
|
reports = json.reports ?? [];
|
||||||
|
} catch {
|
||||||
|
reports = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmployee() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { error?: string }).error ?? 'Failed to load');
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
employee = (json.employee ?? null) as EmployeeRecord | null;
|
||||||
|
} catch (e) {
|
||||||
|
employee = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchField(field: keyof EmployeeRecord, value: string) {
|
||||||
|
if (!employee?.id) return;
|
||||||
|
patchSaving = true;
|
||||||
|
patchError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [field]: value })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Update failed');
|
||||||
|
const json = await res.json();
|
||||||
|
employee = (json.employee ?? employee) as EmployeeRecord;
|
||||||
|
} catch (e) {
|
||||||
|
patchError = e instanceof Error ? e.message : 'Update failed';
|
||||||
|
} finally {
|
||||||
|
patchSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleteSaving = true;
|
||||||
|
deleteError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
openDelete = false;
|
||||||
|
goto('/');
|
||||||
|
} catch (e) {
|
||||||
|
deleteError = e instanceof Error ? e.message : 'Failed to delete';
|
||||||
|
} finally {
|
||||||
|
deleteSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateReportDialog() {
|
||||||
|
reportFormText = '';
|
||||||
|
reportVoxers = [];
|
||||||
|
reportError = null;
|
||||||
|
editingReport = null;
|
||||||
|
openCreateReport = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditReportDialog(report: EmployeeReportRecord) {
|
||||||
|
editingReport = report;
|
||||||
|
reportFormText = report.report ?? '';
|
||||||
|
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
|
||||||
|
reportError = null;
|
||||||
|
openEditReport = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVoxer() {
|
||||||
|
reportVoxers = [...reportVoxers, { label: nextVoxLabel(reportVoxers), url: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVoxer(i: number) {
|
||||||
|
reportVoxers = reportVoxers.filter((_, idx) => idx !== i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteReportDialog(report: EmployeeReportRecord) {
|
||||||
|
deletingReport = report;
|
||||||
|
reportDeleteError = null;
|
||||||
|
openDeleteReport = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateReport() {
|
||||||
|
reportSaving = true;
|
||||||
|
reportError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { error?: string }).error ?? 'Failed to create');
|
||||||
|
}
|
||||||
|
openCreateReport = false;
|
||||||
|
await loadReports();
|
||||||
|
} catch (e) {
|
||||||
|
reportError = e instanceof Error ? e.message : 'Failed to create report';
|
||||||
|
} finally {
|
||||||
|
reportSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditReport() {
|
||||||
|
if (!editingReport) return;
|
||||||
|
reportSaving = true;
|
||||||
|
reportError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Update failed');
|
||||||
|
openEditReport = false;
|
||||||
|
editingReport = null;
|
||||||
|
await loadReports();
|
||||||
|
} catch (e) {
|
||||||
|
reportError = e instanceof Error ? e.message : 'Failed to update report';
|
||||||
|
} finally {
|
||||||
|
reportSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteReport() {
|
||||||
|
if (!deletingReport) return;
|
||||||
|
reportDeleteSaving = true;
|
||||||
|
reportDeleteError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/employees/${employeeId}/reports/${deletingReport.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
openDeleteReport = false;
|
||||||
|
deletingReport = null;
|
||||||
|
await loadReports();
|
||||||
|
} catch (e) {
|
||||||
|
reportDeleteError = e instanceof Error ? e.message : 'Failed to delete';
|
||||||
|
} finally {
|
||||||
|
reportDeleteSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{employee
|
||||||
|
? `${employee.First_Name} ${employee.Last_Name} | HRM`
|
||||||
|
: 'Employee | HRM'}
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="flex flex-col gap-6 p-6 md:p-8">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" href="/" class="shrink-0">
|
||||||
|
<ArrowLeftIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight shrink-0">
|
||||||
|
{employee?.First_Name} {employee?.Last_Name}
|
||||||
|
</h1>
|
||||||
|
{#if employee}
|
||||||
|
<div class="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant={editMode ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (editMode = !editMode)}
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4" />
|
||||||
|
{editMode ? 'Done' : 'Edit'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (openDelete = true)}>
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if employee}
|
||||||
|
{#if patchError}
|
||||||
|
<p class="text-destructive text-sm">{patchError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 md:gap-8">
|
||||||
|
<dl class="grid gap-4 sm:grid-cols-2 content-start">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-first" class="text-muted-foreground text-sm">First name</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-first"
|
||||||
|
value={employee.First_Name ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, First_Name: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('First_Name', employee?.First_Name ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>{employee.First_Name || '—'}</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-last" class="text-muted-foreground text-sm">Last name</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-last"
|
||||||
|
value={employee.Last_Name ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, Last_Name: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('Last_Name', employee?.Last_Name ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>{employee.Last_Name || '—'}</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-status" class="text-muted-foreground text-sm">Status</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-status"
|
||||||
|
value={employee.Status ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, Status: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('Status', employee?.Status ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>{employee.Status || '—'}</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-phone" class="text-muted-foreground text-sm">Phone</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-phone"
|
||||||
|
value={employee.Phone_Number ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, Phone_Number: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('Phone_Number', employee?.Phone_Number ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>{employee.Phone_Number || '—'}</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-email" class="text-muted-foreground text-sm">Email</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-email"
|
||||||
|
type="email"
|
||||||
|
value={employee.Email_Address ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, Email_Address: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('Email_Address', employee?.Email_Address ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>
|
||||||
|
{#if employee.Email_Address}
|
||||||
|
<a href={`mailto:${employee.Email_Address}`} class="text-primary underline underline-offset-4">{employee.Email_Address}</a>
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label for="emp-voxer" class="text-muted-foreground text-sm">Voxer ID</Label>
|
||||||
|
{#if editMode}
|
||||||
|
<Input
|
||||||
|
id="emp-voxer"
|
||||||
|
value={employee.Voxer_ID ?? ''}
|
||||||
|
oninput={(e) => (employee = { ...employee!, Voxer_ID: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
onblur={() => patchField('Voxer_ID', employee?.Voxer_ID ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<dd>{employee.Voxer_ID || '—'}</dd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Notes section -->
|
||||||
|
<Card.Root class="h-fit">
|
||||||
|
<Card.Content>
|
||||||
|
{#if editMode}
|
||||||
|
<textarea
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground min-h-[140px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||||
|
placeholder="Rich text notes…"
|
||||||
|
value={employee?.Notes ?? ''}
|
||||||
|
oninput={(e) =>
|
||||||
|
(employee = { ...employee!, Notes: (e.currentTarget as HTMLTextAreaElement).value })}
|
||||||
|
onblur={() => patchField('Notes', employee?.Notes ?? '')}
|
||||||
|
disabled={patchSaving}
|
||||||
|
rows="6"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{#if employee.Notes}
|
||||||
|
<div
|
||||||
|
class="prose prose-sm dark:prose-invert max-w-none text-foreground [&_a]:text-primary [&_a]:underline [&_ul]:list-disc [&_ol]:list-decimal"
|
||||||
|
>
|
||||||
|
{@html employee.Notes}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground text-sm">No notes.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
{#if patchSaving}
|
||||||
|
<p class="text-muted-foreground text-sm">Saving…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Employee reports -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">Reports</h2>
|
||||||
|
<Button size="sm" onclick={openCreateReportDialog}>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if reports.length > 0}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each reports as report (report.id)}
|
||||||
|
<Card.Root class="flex flex-col">
|
||||||
|
<Card.Header class="flex flex-row items-start justify-between gap-2 space-y-0 pb-2">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Card.Title class="text-base">Report</Card.Title>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Created {formatDate(report.created)} · Updated {formatDate(report.updated)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onclick={() => openEditReportDialog(report)}
|
||||||
|
aria-label="Edit report"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8 text-destructive hover:text-destructive"
|
||||||
|
onclick={() => openDeleteReportDialog(report)}
|
||||||
|
aria-label="Delete report"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex-1 pt-0 space-y-3">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{report.report || '—'}</p>
|
||||||
|
{@const voxersList = parseVoxers(report)}
|
||||||
|
{#if voxersList.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each voxersList as vox (vox.url + vox.label)}
|
||||||
|
<a
|
||||||
|
href={vox.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={badgeVariants({ variant: 'link' }) + ' cursor-pointer no-underline'}
|
||||||
|
>
|
||||||
|
<LinkIcon class="mr-1 inline size-3" />
|
||||||
|
{vox.label || 'Link'}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground text-sm">No reports yet. Add one to get started.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground">Employee not found.</p>
|
||||||
|
<Button variant="outline" onclick={loadEmployee}>Retry</Button>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<Dialog.Root bind:open={openDelete}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete employee</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Are you sure you want to delete
|
||||||
|
{#if employee}
|
||||||
|
<strong>{employee.First_Name} {employee.Last_Name}</strong>?
|
||||||
|
{/if}
|
||||||
|
This cannot be undone.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="text-destructive text-sm">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button type="button" variant="outline">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button variant="destructive" disabled={deleteSaving} onclick={confirmDelete}>
|
||||||
|
{deleteSaving ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Create report -->
|
||||||
|
<Dialog.Root bind:open={openCreateReport}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Add report</Dialog.Title>
|
||||||
|
<Dialog.Description>Add a new report for this employee.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitCreateReport();
|
||||||
|
}}
|
||||||
|
class="grid gap-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="new-report">Report</Label>
|
||||||
|
<textarea
|
||||||
|
id="new-report"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground flex min-h-[120px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Enter report content…"
|
||||||
|
bind:value={reportFormText}
|
||||||
|
disabled={reportSaving}
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>Voxer links</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onclick={addVoxer} disabled={reportSaving}>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#each reportVoxers as vox, i}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Label (e.g. Vox 1)"
|
||||||
|
value={vox.label}
|
||||||
|
oninput={(e) => {
|
||||||
|
reportVoxers = reportVoxers.map((v, idx) =>
|
||||||
|
idx === i ? { ...v, label: (e.currentTarget as HTMLInputElement).value } : v
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="https://…"
|
||||||
|
value={vox.url}
|
||||||
|
oninput={(e) => {
|
||||||
|
reportVoxers = reportVoxers.map((v, idx) =>
|
||||||
|
idx === i ? { ...v, url: (e.currentTarget as HTMLInputElement).value } : v
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
class="flex-[2]"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onclick={() => removeVoxer(i)} aria-label="Remove link">
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if reportError}
|
||||||
|
<p class="text-destructive text-sm">{reportError}</p>
|
||||||
|
{/if}
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button type="button" variant="outline">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit" disabled={reportSaving}>
|
||||||
|
{reportSaving ? 'Saving…' : 'Add report'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Edit report -->
|
||||||
|
<Dialog.Root bind:open={openEditReport}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Edit report</Dialog.Title>
|
||||||
|
<Dialog.Description>Update this report.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitEditReport();
|
||||||
|
}}
|
||||||
|
class="grid gap-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="edit-report">Report</Label>
|
||||||
|
<textarea
|
||||||
|
id="edit-report"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground flex min-h-[120px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Enter report content…"
|
||||||
|
bind:value={reportFormText}
|
||||||
|
disabled={reportSaving}
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>Voxer links</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onclick={addVoxer} disabled={reportSaving}>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#each reportVoxers as vox, i}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Label (e.g. Vox 1)"
|
||||||
|
value={vox.label}
|
||||||
|
oninput={(e) => {
|
||||||
|
reportVoxers = reportVoxers.map((v, idx) =>
|
||||||
|
idx === i ? { ...v, label: (e.currentTarget as HTMLInputElement).value } : v
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="https://…"
|
||||||
|
value={vox.url}
|
||||||
|
oninput={(e) => {
|
||||||
|
reportVoxers = reportVoxers.map((v, idx) =>
|
||||||
|
idx === i ? { ...v, url: (e.currentTarget as HTMLInputElement).value } : v
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
class="flex-[2]"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onclick={() => removeVoxer(i)} aria-label="Remove link">
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if reportError}
|
||||||
|
<p class="text-destructive text-sm">{reportError}</p>
|
||||||
|
{/if}
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button type="button" variant="outline">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit" disabled={reportSaving}>
|
||||||
|
{reportSaving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Delete report confirmation -->
|
||||||
|
<Dialog.Root bind:open={openDeleteReport}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete report</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Are you sure you want to delete this report? This cannot be undone.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
{#if reportDeleteError}
|
||||||
|
<p class="text-destructive text-sm">{reportDeleteError}</p>
|
||||||
|
{/if}
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button type="button" variant="outline">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button variant="destructive" disabled={reportDeleteSaving} onclick={confirmDeleteReport}>
|
||||||
|
{reportDeleteSaving ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -14,12 +14,38 @@ export interface EmployeeRecord {
|
|||||||
|
|
||||||
const COLLECTION = 'Employee_Records';
|
const COLLECTION = 'Employee_Records';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
/** Escape a string for use inside PocketBase filter double-quotes. */
|
||||||
const { items } = await locals.pb
|
function escapeFilterValue(s: string): string {
|
||||||
.collection(COLLECTION)
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
.getList<EmployeeRecord>(1, 500, { sort: 'Last_Name,First_Name' });
|
}
|
||||||
|
|
||||||
return json({ employees: items });
|
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||||
|
const search = url.searchParams.get('search')?.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';
|
||||||
|
if (search.length > 0) {
|
||||||
|
const escaped = escapeFilterValue(search);
|
||||||
|
// Search across main text fields (PocketBase ~ is "contains")
|
||||||
|
filter = `(${[
|
||||||
|
`First_Name ~ "${escaped}"`,
|
||||||
|
`Last_Name ~ "${escaped}"`,
|
||||||
|
`Email_Address ~ "${escaped}"`,
|
||||||
|
`Phone_Number ~ "${escaped}"`,
|
||||||
|
`Voxer_ID ~ "${escaped}"`,
|
||||||
|
`Status ~ "${escaped}"`
|
||||||
|
].join(' || ')}) && delete != true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, totalItems } = await locals.pb
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.getList<EmployeeRecord>(page, perPage, {
|
||||||
|
filter,
|
||||||
|
sort: 'Last_Name,First_Name'
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ employees: items, total: totalItems });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
@@ -30,7 +56,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
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 ?? '',
|
||||||
Status: body.Status ?? ''
|
Status: body.Status ?? '',
|
||||||
|
delete: false,
|
||||||
|
...(locals.user?.id && { entered_by: locals.user.id })
|
||||||
});
|
});
|
||||||
return json(record);
|
return json(record);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ import type { EmployeeRecord } from '../+server';
|
|||||||
|
|
||||||
const COLLECTION = 'Employee_Records';
|
const COLLECTION = 'Employee_Records';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const id = params.id;
|
||||||
|
try {
|
||||||
|
const employee = await locals.pb
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.getOne<EmployeeRecord>(id);
|
||||||
|
if ((employee as { delete?: boolean }).delete === true) {
|
||||||
|
return json({ error: 'Employee not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return json({ employee });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const is404 =
|
||||||
|
e &&
|
||||||
|
typeof e === 'object' &&
|
||||||
|
'status' in e &&
|
||||||
|
(e as { status: number }).status === 404;
|
||||||
|
return json({ error: 'Employee not found' }, { status: is404 ? 404 : 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -13,12 +33,17 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
...(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 }),
|
||||||
...(body.Status !== undefined && { Status: body.Status })
|
...(body.Status !== undefined && { Status: body.Status }),
|
||||||
|
...(body.Notes !== undefined && { Notes: body.Notes }),
|
||||||
|
...(locals.user?.id && { updated_by: locals.user.id })
|
||||||
});
|
});
|
||||||
return json(record);
|
return json({ employee: record });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
await locals.pb.collection(COLLECTION).delete(params.id);
|
await locals.pb.collection(COLLECTION).update(params.id, {
|
||||||
|
delete: true,
|
||||||
|
...(locals.user?.id && { updated_by: locals.user.id })
|
||||||
|
});
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
const REPORTS_COLLECTION = 'Employee_Reports';
|
||||||
|
|
||||||
|
export interface EmployeeReportRecord {
|
||||||
|
id: string;
|
||||||
|
report?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const employeeId = params.id;
|
||||||
|
const { items } = await locals.pb
|
||||||
|
.collection(REPORTS_COLLECTION)
|
||||||
|
.getList<EmployeeReportRecord>(1, 100, {
|
||||||
|
filter: `Employee = "${employeeId}" && delete != true`,
|
||||||
|
sort: '-created'
|
||||||
|
});
|
||||||
|
return json({ reports: items });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const employeeId = params.id;
|
||||||
|
const body = await request.json();
|
||||||
|
let voxers: Array<{ label: string; url: string }> = [];
|
||||||
|
if (body.voxers != null) {
|
||||||
|
try {
|
||||||
|
voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
|
||||||
|
} catch {
|
||||||
|
voxers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const report = await locals.pb.collection(REPORTS_COLLECTION).create<EmployeeReportRecord>({
|
||||||
|
Employee: employeeId,
|
||||||
|
report: body.report ?? '',
|
||||||
|
voxers,
|
||||||
|
delete: false,
|
||||||
|
...(locals.user?.id && { entered_by: locals.user.id })
|
||||||
|
});
|
||||||
|
return json({ report });
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
const REPORTS_COLLECTION = 'Employee_Reports';
|
||||||
|
|
||||||
|
export interface EmployeeReportRecord {
|
||||||
|
id: string;
|
||||||
|
report?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const { reportId } = params;
|
||||||
|
const body = await request.json();
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
...(body.report !== undefined && { report: body.report }),
|
||||||
|
...(locals.user?.id && { updated_by: locals.user.id })
|
||||||
|
};
|
||||||
|
if (body.voxers !== undefined) {
|
||||||
|
update.voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
|
||||||
|
}
|
||||||
|
const report = await locals.pb
|
||||||
|
.collection(REPORTS_COLLECTION)
|
||||||
|
.update<EmployeeReportRecord>(reportId, update);
|
||||||
|
return json({ report });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await locals.pb.collection(REPORTS_COLLECTION).update(params.reportId, {
|
||||||
|
delete: true,
|
||||||
|
...(locals.user?.id && { updated_by: locals.user.id })
|
||||||
|
});
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user