Postgres returns NUMERIC columns as strings via pg, so calling .toFixed(1) on time_entries.hours_worked from /api/engagement/user/[userId] threw at runtime. The period-level hours in the same response are already parseFloat'd; the recentEntries and matchedEntries arrays pass rows through verbatim, so wrap with Number() at render.
103 lines
4.5 KiB
TypeScript
103 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
/* EngagementRecentEntries — phase 08 (D-18..D-21).
|
|
* Purpose: Collapsible list of up to 10 recent time entries. Tap-to-expand
|
|
* reveals full notes, title, company, and start timestamp. Local Set<string>
|
|
* state tracks expanded IDs — period changes do not reset expansion (D-20). */
|
|
|
|
import { useState } from 'react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
export interface RecentTimeEntry {
|
|
entry_date: string;
|
|
hours_worked: number;
|
|
billable: boolean | null;
|
|
notes: string | null;
|
|
title: string | null;
|
|
start_date_time: string | null;
|
|
end_date_time: string | null;
|
|
company_name: string | null;
|
|
}
|
|
|
|
export interface EngagementRecentEntriesProps {
|
|
entries: RecentTimeEntry[]; // caller passes recentEntries.slice(0, 10)
|
|
}
|
|
|
|
export function EngagementRecentEntries({ entries }: EngagementRecentEntriesProps) {
|
|
const tz = useUserTimezone();
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
|
|
const toggle = (id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// ID derivation: caller doesn't pass an explicit id, so derive a stable string per row.
|
|
const idFor = (e: RecentTimeEntry, i: number) =>
|
|
`${e.entry_date}|${e.start_date_time ?? ''}|${i}`;
|
|
|
|
return (
|
|
<Card className="py-0 shadow-none">
|
|
<CardContent className="px-4 py-4">
|
|
<h3 className="text-sm font-semibold text-foreground mb-3">Recent time entries</h3>
|
|
{entries.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">No time entries in the last 30 days</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{entries.slice(0, 10).map((entry, i) => {
|
|
const id = idFor(entry, i);
|
|
const open = expandedIds.has(id);
|
|
const dateLabel = formatInUserTimezone(entry.entry_date, tz, { month: 'short', day: 'numeric' });
|
|
const isBillable = entry.billable !== false; // null defaults true
|
|
const oneLine = entry.notes
|
|
? entry.notes.split('\n')[0]?.slice(0, 80) ?? ''
|
|
: (entry.title ?? '');
|
|
|
|
return (
|
|
<li key={id}>
|
|
<Collapsible open={open} onOpenChange={() => toggle(id)}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="w-full text-left flex items-center justify-between gap-2 min-h-[44px] py-2"
|
|
>
|
|
<span className="flex-1 min-w-0 flex items-baseline gap-2">
|
|
<span className="text-sm font-medium tabular-nums shrink-0">{dateLabel}</span>
|
|
<span className="text-sm text-muted-foreground tabular-nums shrink-0">
|
|
{Number(entry.hours_worked).toFixed(1)}h
|
|
</span>
|
|
{isBillable && (
|
|
<Badge variant="secondary" className="shrink-0">Billable</Badge>
|
|
)}
|
|
<span className="text-sm text-muted-foreground truncate">{oneLine}</span>
|
|
</span>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-1">
|
|
{entry.title && <p><span className="font-medium text-foreground">Title:</span> {entry.title}</p>}
|
|
{entry.company_name && <p><span className="font-medium text-foreground">Company:</span> {entry.company_name}</p>}
|
|
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
|
|
{entry.start_date_time && (
|
|
<p>
|
|
<span className="font-medium text-foreground">Started:</span>{' '}
|
|
{formatInUserTimezone(entry.start_date_time, tz, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
|
</p>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|