Add sentiment feature to employee reports
CI / build (push) Has been skipped
CI / deploy (push) Successful in 49s

- Introduced a new `Sentiment` field in the `EmployeeReportRecord` interface to capture report sentiment as 'Positive', 'Negative', or 'Neutral'.
- Updated the report creation and editing forms in Svelte to include a sentiment selection dropdown.
- Enhanced API endpoints to handle sentiment data during report creation and updates.
- Adjusted the display logic to show sentiment information in the report details.
This commit is contained in:
eewing
2026-02-17 10:59:31 -06:00
parent 069f38a313
commit debc6559c9
19 changed files with 442 additions and 4 deletions
+37
View File
@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
+48
View File
@@ -0,0 +1,48 @@
/**
* Utilities for converting between local date/time and UTC ISO strings for PocketBase.
* PocketBase stores dates in UTC (ISO 8601). We display and edit in the user's local timezone.
*/
/** Format expected by <input type="datetime-local">: "YYYY-MM-DDTHH:mm" in local time */
export const DATETIME_LOCAL_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
/**
* Convert a UTC ISO string (from PocketBase) to local datetime for datetime-local input.
* Handles both "2024-01-15T12:00:00.000Z" and "2024-01-15 12:00:00.000Z" styles.
*/
export function utcIsoToLocalDatetimeLocal(isoUtc: string | undefined): string {
if (!isoUtc || typeof isoUtc !== 'string') return '';
const d = new Date(isoUtc);
if (Number.isNaN(d.getTime())) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day}T${h}:${min}`;
}
/**
* Convert current local date/time to datetime-local string (for default "now" when creating).
*/
export function nowToLocalDatetimeLocal(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day}T${h}:${min}`;
}
/**
* Parse a datetime-local value (local time) and return an ISO UTC string for PocketBase.
*/
export function localDatetimeLocalToUtcIso(localStr: string | undefined): string {
if (!localStr || typeof localStr !== 'string' || !DATETIME_LOCAL_FORMAT.test(localStr.trim())) {
return new Date().toISOString();
}
const d = new Date(localStr);
if (Number.isNaN(d.getTime())) return new Date().toISOString();
return d.toISOString();
}
+3
View File
@@ -21,12 +21,15 @@ export interface VoxerLink {
url: string;
}
export type ReportSentiment = 'Positive' | 'Negative' | 'Neutral';
export interface EmployeeReportRecord {
id: string;
report?: string;
voxers?: VoxerLink[] | string; // JSON array or string from PB
created: string;
updated: string;
Sentiment?: ReportSentiment;
[key: string]: unknown;
}
+77 -2
View File
@@ -10,8 +10,15 @@
import type {
EmployeeRecord,
EmployeeReportRecord,
ReportSentiment,
VoxerLink
} from './+page.server';
import * as Select from '$lib/components/ui/select/index.js';
import {
utcIsoToLocalDatetimeLocal,
nowToLocalDatetimeLocal,
localDatetimeLocalToUtcIso
} from '$lib/date-utils';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import LinkIcon from '@lucide/svelte/icons/link';
import PencilIcon from '@lucide/svelte/icons/pencil';
@@ -48,6 +55,9 @@
let openDeleteReport = $state(false);
let reportFormText = $state('');
let reportVoxers = $state<VoxerLink[]>([]);
const SENTIMENT_OPTIONS: ReportSentiment[] = ['Positive', 'Negative', 'Neutral'];
let reportCreatedLocal = $state('');
let reportSentiment = $state<ReportSentiment>('Neutral');
let editingReport = $state<EmployeeReportRecord | null>(null);
let deletingReport = $state<EmployeeReportRecord | null>(null);
let reportSaving = $state(false);
@@ -143,6 +153,8 @@
function openCreateReportDialog() {
reportFormText = '';
reportVoxers = [];
reportCreatedLocal = nowToLocalDatetimeLocal();
reportSentiment = 'Neutral';
reportError = null;
editingReport = null;
openCreateReport = true;
@@ -152,6 +164,8 @@
editingReport = report;
reportFormText = report.report ?? '';
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
reportCreatedLocal = utcIsoToLocalDatetimeLocal(report.created);
reportSentiment = (report.Sentiment === 'Positive' || report.Sentiment === 'Negative' ? report.Sentiment : 'Neutral') as ReportSentiment;
reportError = null;
openEditReport = true;
}
@@ -177,7 +191,12 @@
const res = await fetch(`/api/employees/${employeeId}/reports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
body: JSON.stringify({
report: reportFormText,
voxers: reportVoxers,
created: localDatetimeLocalToUtcIso(reportCreatedLocal),
Sentiment: reportSentiment
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -200,7 +219,12 @@
const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
body: JSON.stringify({
report: reportFormText,
voxers: reportVoxers,
created: localDatetimeLocalToUtcIso(reportCreatedLocal),
Sentiment: reportSentiment
})
});
if (!res.ok) throw new Error('Update failed');
openEditReport = false;
@@ -416,6 +440,9 @@
<Card.Title class="text-base">Report</Card.Title>
<p class="text-muted-foreground text-xs">
Created {formatDate(report.created)} · Updated {formatDate(report.updated)}
{#if report.Sentiment}
· <span class="capitalize">{report.Sentiment}</span>
{/if}
</p>
</div>
<div class="flex shrink-0 gap-1">
@@ -512,6 +539,30 @@
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-end">
<div class="space-y-2">
<Label for="new-created">Created (local time)</Label>
<Input
id="new-created"
type="datetime-local"
bind:value={reportCreatedLocal}
disabled={reportSaving}
/>
</div>
<div class="space-y-2">
<Label for="new-sentiment">Sentiment</Label>
<Select.Root bind:value={reportSentiment} disabled={reportSaving}>
<Select.Trigger class="w-full" id="new-sentiment">
{reportSentiment}
</Select.Trigger>
<Select.Content>
{#each SENTIMENT_OPTIONS as opt}
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-2">
<Label for="new-report">Report</Label>
<textarea
@@ -588,6 +639,30 @@
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-end">
<div class="space-y-2">
<Label for="edit-created">Created (local time)</Label>
<Input
id="edit-created"
type="datetime-local"
bind:value={reportCreatedLocal}
disabled={reportSaving}
/>
</div>
<div class="space-y-2">
<Label for="edit-sentiment">Sentiment</Label>
<Select.Root bind:value={reportSentiment} disabled={reportSaving}>
<Select.Trigger class="w-full" id="edit-sentiment">
{reportSentiment}
</Select.Trigger>
<Select.Content>
{#each SENTIMENT_OPTIONS as opt}
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-2">
<Label for="edit-report">Report</Label>
<textarea
@@ -33,12 +33,19 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
voxers = [];
}
}
const report = await locals.pb.collection(REPORTS_COLLECTION).create<EmployeeReportRecord>({
const createPayload: Record<string, unknown> = {
Employee: employeeId,
report: body.report ?? '',
voxers,
delete: false,
Sentiment: body.Sentiment === 'Positive' || body.Sentiment === 'Negative' ? body.Sentiment : 'Neutral',
...(locals.user?.id && { entered_by: locals.user.id })
});
};
if (typeof body.created === 'string' && body.created) {
createPayload.created = body.created;
}
const report = await locals.pb
.collection(REPORTS_COLLECTION)
.create<EmployeeReportRecord>(createPayload);
return json({ report });
};
@@ -21,6 +21,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (body.voxers !== undefined) {
update.voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
}
if (typeof body.created === 'string' && body.created) update.created = body.created;
if (body.Sentiment !== undefined && (body.Sentiment === 'Positive' || body.Sentiment === 'Negative' || body.Sentiment === 'Neutral')) {
update.Sentiment = body.Sentiment;
}
const report = await locals.pb
.collection(REPORTS_COLLECTION)
.update<EmployeeReportRecord>(reportId, update);