Add sentiment feature to employee reports
- 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:
@@ -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} />
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -21,12 +21,15 @@ export interface VoxerLink {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReportSentiment = 'Positive' | 'Negative' | 'Neutral';
|
||||||
|
|
||||||
export interface EmployeeReportRecord {
|
export interface EmployeeReportRecord {
|
||||||
id: string;
|
id: string;
|
||||||
report?: string;
|
report?: string;
|
||||||
voxers?: VoxerLink[] | string; // JSON array or string from PB
|
voxers?: VoxerLink[] | string; // JSON array or string from PB
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
|
Sentiment?: ReportSentiment;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,15 @@
|
|||||||
import type {
|
import type {
|
||||||
EmployeeRecord,
|
EmployeeRecord,
|
||||||
EmployeeReportRecord,
|
EmployeeReportRecord,
|
||||||
|
ReportSentiment,
|
||||||
VoxerLink
|
VoxerLink
|
||||||
} from './+page.server';
|
} 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 ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
import LinkIcon from '@lucide/svelte/icons/link';
|
import LinkIcon from '@lucide/svelte/icons/link';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
@@ -48,6 +55,9 @@
|
|||||||
let openDeleteReport = $state(false);
|
let openDeleteReport = $state(false);
|
||||||
let reportFormText = $state('');
|
let reportFormText = $state('');
|
||||||
let reportVoxers = $state<VoxerLink[]>([]);
|
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 editingReport = $state<EmployeeReportRecord | null>(null);
|
||||||
let deletingReport = $state<EmployeeReportRecord | null>(null);
|
let deletingReport = $state<EmployeeReportRecord | null>(null);
|
||||||
let reportSaving = $state(false);
|
let reportSaving = $state(false);
|
||||||
@@ -143,6 +153,8 @@
|
|||||||
function openCreateReportDialog() {
|
function openCreateReportDialog() {
|
||||||
reportFormText = '';
|
reportFormText = '';
|
||||||
reportVoxers = [];
|
reportVoxers = [];
|
||||||
|
reportCreatedLocal = nowToLocalDatetimeLocal();
|
||||||
|
reportSentiment = 'Neutral';
|
||||||
reportError = null;
|
reportError = null;
|
||||||
editingReport = null;
|
editingReport = null;
|
||||||
openCreateReport = true;
|
openCreateReport = true;
|
||||||
@@ -152,6 +164,8 @@
|
|||||||
editingReport = report;
|
editingReport = report;
|
||||||
reportFormText = report.report ?? '';
|
reportFormText = report.report ?? '';
|
||||||
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
|
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
|
||||||
|
reportCreatedLocal = utcIsoToLocalDatetimeLocal(report.created);
|
||||||
|
reportSentiment = (report.Sentiment === 'Positive' || report.Sentiment === 'Negative' ? report.Sentiment : 'Neutral') as ReportSentiment;
|
||||||
reportError = null;
|
reportError = null;
|
||||||
openEditReport = true;
|
openEditReport = true;
|
||||||
}
|
}
|
||||||
@@ -177,7 +191,12 @@
|
|||||||
const res = await fetch(`/api/employees/${employeeId}/reports`, {
|
const res = await fetch(`/api/employees/${employeeId}/reports`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
@@ -200,7 +219,12 @@
|
|||||||
const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, {
|
const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!res.ok) throw new Error('Update failed');
|
||||||
openEditReport = false;
|
openEditReport = false;
|
||||||
@@ -416,6 +440,9 @@
|
|||||||
<Card.Title class="text-base">Report</Card.Title>
|
<Card.Title class="text-base">Report</Card.Title>
|
||||||
<p class="text-muted-foreground text-xs">
|
<p class="text-muted-foreground text-xs">
|
||||||
Created {formatDate(report.created)} · Updated {formatDate(report.updated)}
|
Created {formatDate(report.created)} · Updated {formatDate(report.updated)}
|
||||||
|
{#if report.Sentiment}
|
||||||
|
· <span class="capitalize">{report.Sentiment}</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-1">
|
<div class="flex shrink-0 gap-1">
|
||||||
@@ -512,6 +539,30 @@
|
|||||||
}}
|
}}
|
||||||
class="grid gap-4 py-4"
|
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">
|
<div class="space-y-2">
|
||||||
<Label for="new-report">Report</Label>
|
<Label for="new-report">Report</Label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -588,6 +639,30 @@
|
|||||||
}}
|
}}
|
||||||
class="grid gap-4 py-4"
|
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">
|
<div class="space-y-2">
|
||||||
<Label for="edit-report">Report</Label>
|
<Label for="edit-report">Report</Label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -33,12 +33,19 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
voxers = [];
|
voxers = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const report = await locals.pb.collection(REPORTS_COLLECTION).create<EmployeeReportRecord>({
|
const createPayload: Record<string, unknown> = {
|
||||||
Employee: employeeId,
|
Employee: employeeId,
|
||||||
report: body.report ?? '',
|
report: body.report ?? '',
|
||||||
voxers,
|
voxers,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
Sentiment: body.Sentiment === 'Positive' || body.Sentiment === 'Negative' ? body.Sentiment : 'Neutral',
|
||||||
...(locals.user?.id && { entered_by: locals.user.id })
|
...(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 });
|
return json({ report });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
if (body.voxers !== undefined) {
|
if (body.voxers !== undefined) {
|
||||||
update.voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
|
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
|
const report = await locals.pb
|
||||||
.collection(REPORTS_COLLECTION)
|
.collection(REPORTS_COLLECTION)
|
||||||
.update<EmployeeReportRecord>(reportId, update);
|
.update<EmployeeReportRecord>(reportId, update);
|
||||||
|
|||||||
Reference in New Issue
Block a user