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.
142 lines
6 KiB
TypeScript
142 lines
6 KiB
TypeScript
'use client';
|
|
|
|
/* EngagementRecentMeetings — phase 08 (D-18..D-21).
|
|
* Purpose: Collapsible list of up to 10 recent Teams meetings. Tap-to-expand
|
|
* reveals matched time entries and attendee list. 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
export interface RecentMeeting {
|
|
subject: string | null;
|
|
startTime: string;
|
|
durationMinutes: number | null;
|
|
attendeeCount: number;
|
|
clientAttendeeCount: number;
|
|
hasClientAttendees: boolean;
|
|
clientCompanies: string[];
|
|
participantNames: string[];
|
|
matchedEntries: Array<{
|
|
hours_worked: number;
|
|
billable: boolean | null;
|
|
notes: string | null;
|
|
title: string | null;
|
|
company_name: string | null;
|
|
start_date_time: string | null;
|
|
end_date_time: string | null;
|
|
}>;
|
|
}
|
|
|
|
export interface EngagementRecentMeetingsProps {
|
|
meetings: RecentMeeting[];
|
|
}
|
|
|
|
export function EngagementRecentMeetings({ meetings }: EngagementRecentMeetingsProps) {
|
|
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 = (m: RecentMeeting, i: number) =>
|
|
`${m.startTime}|${m.subject ?? ''}|${i}`;
|
|
|
|
// Format a duration in minutes as "Hh Mm" / "Mm" — used in the collapsed summary
|
|
const fmtDuration = (mins: number | null): string => {
|
|
if (mins === null || mins === undefined || !Number.isFinite(mins) || mins <= 0) return '';
|
|
const h = Math.floor(mins / 60);
|
|
const m = Math.round(mins % 60);
|
|
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
|
if (h > 0) return `${h}h`;
|
|
return `${m}m`;
|
|
};
|
|
|
|
return (
|
|
<Card className="py-0 shadow-none">
|
|
<CardContent className="px-4 py-4">
|
|
<h3 className="text-sm font-semibold text-foreground mb-3">Recent meetings</h3>
|
|
{meetings.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">No meetings recorded</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{meetings.slice(0, 10).map((meeting, i) => {
|
|
const id = idFor(meeting, i);
|
|
const open = expandedIds.has(id);
|
|
const subjectLabel = meeting.subject ?? '(no subject)';
|
|
const startLabel = formatInUserTimezone(meeting.startTime, tz, {
|
|
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
});
|
|
const durationLabel = fmtDuration(meeting.durationMinutes);
|
|
const attendeeLabel = meeting.attendeeCount > 0
|
|
? `${meeting.attendeeCount} attendee${meeting.attendeeCount === 1 ? '' : 's'}`
|
|
: '';
|
|
|
|
const visibleParticipants = meeting.participantNames.slice(0, 5);
|
|
const moreCount = Math.max(0, meeting.participantNames.length - 5);
|
|
|
|
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 flex-col">
|
|
<span className="text-sm font-medium truncate">{subjectLabel}</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{startLabel}
|
|
{durationLabel ? ` · ${durationLabel}` : ''}
|
|
{attendeeLabel ? ` · ${attendeeLabel}` : ''}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-2">
|
|
{meeting.matchedEntries.length > 0 && (
|
|
<div>
|
|
<p className="font-medium text-foreground mb-1">Matched time entries:</p>
|
|
<ul className="space-y-1">
|
|
{meeting.matchedEntries.map((te, j) => (
|
|
<li key={j} className="flex flex-wrap gap-x-2">
|
|
<span className="text-foreground tabular-nums">{Number(te.hours_worked).toFixed(1)}h</span>
|
|
{te.company_name && <span>· {te.company_name}</span>}
|
|
{te.notes && <span className="truncate">· {te.notes.split('\n')[0]?.slice(0, 80) ?? ''}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{visibleParticipants.length > 0 && (
|
|
<p>
|
|
<span className="font-medium text-foreground">Attendees:</span>{' '}
|
|
{visibleParticipants.join(', ')}
|
|
{moreCount > 0 ? ` and ${moreCount} more` : ''}
|
|
</p>
|
|
)}
|
|
{durationLabel && (
|
|
<p>
|
|
<span className="font-medium text-foreground">Duration:</span> {durationLabel}
|
|
</p>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|