feat(08-02): activity breakdown + recent entries + meetings + page wiring (Task 2)
- New EngagementProfileBreakdown: Time/Communication/Meetings subsections, after-hours and Zoom conditional rows, py-2 per UI-SPEC override - New EngagementRecentEntries: collapsible list up to 10, Billable badge, Set<string> expand state, empty-state copy - New EngagementRecentMeetings: collapsible list up to 10, matched entries + attendees in expanded view, (no subject) fallback, Set<string> expand state - Page updated: 3 new component imports + breakdown/entries/meetings mounted in order - No dangerouslySetInnerHTML; D-01/D-22 guard rails untouched
This commit is contained in:
parent
df78ab8fa5
commit
0be0c1f7f8
4 changed files with 362 additions and 1 deletions
96
components/mobile/EngagementProfileBreakdown.tsx
Normal file
96
components/mobile/EngagementProfileBreakdown.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementProfileBreakdown — phase 08 (D-14..D-17).
|
||||
* Purpose: Single Card with three labeled subsections — Time / Communication / Meetings.
|
||||
* Rows use py-2 (UI-SPEC override of D-16's py-1.5). After-hours and Zoom rows are
|
||||
* conditional based on value presence (D-15, D-17). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export interface EngagementProfileBreakdownProps {
|
||||
// Time subsection
|
||||
hoursWorked: number;
|
||||
billableHours: number;
|
||||
daysWorked: number;
|
||||
// Communication subsection
|
||||
teamsMessages: number; // chat + private summed by caller
|
||||
emailsSent: number;
|
||||
afterHoursMessagesPct: number;
|
||||
afterHoursMeetingsPct: number;
|
||||
// Meetings subsection
|
||||
meetingsAttended: number;
|
||||
meetingsOrganized: number;
|
||||
meetingDurationSeconds: number;
|
||||
// Optional: only render Zoom row if non-null (D-17 presence rule)
|
||||
zoomCalls: number | null;
|
||||
}
|
||||
|
||||
const subsectionLabel = "text-sm font-semibold text-muted-foreground mb-2";
|
||||
const metricRow = "flex justify-between text-sm py-2";
|
||||
|
||||
function MetricRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={metricRow}>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EngagementProfileBreakdown(props: EngagementProfileBreakdownProps) {
|
||||
const utilizationPct = props.hoursWorked > 0
|
||||
? Math.round((props.billableHours / props.hoursWorked) * 100)
|
||||
: null;
|
||||
const meetingHours = props.meetingDurationSeconds / 3600;
|
||||
const showAfterHours = props.afterHoursMessagesPct > 0 || props.afterHoursMeetingsPct > 0;
|
||||
const showZoom = props.zoomCalls !== null && props.zoomCalls !== undefined;
|
||||
|
||||
return (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4 space-y-4">
|
||||
{/* Time */}
|
||||
<section>
|
||||
<h3 className={subsectionLabel}>Time</h3>
|
||||
<dl>
|
||||
<MetricRow label="Hours worked" value={`${props.hoursWorked.toFixed(1)}h`} />
|
||||
<MetricRow label="Billable hours" value={`${props.billableHours.toFixed(1)}h`} />
|
||||
<MetricRow label="Days worked" value={String(props.daysWorked)} />
|
||||
{utilizationPct !== null && (
|
||||
<MetricRow label={`Utilization · ${utilizationPct}%`} value={`${utilizationPct}%`} />
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Communication */}
|
||||
<section className="border-t border-border pt-3">
|
||||
<h3 className={subsectionLabel}>Communication</h3>
|
||||
<dl>
|
||||
<MetricRow label="Teams messages" value={String(props.teamsMessages)} />
|
||||
<MetricRow label="Emails sent" value={String(props.emailsSent)} />
|
||||
{showAfterHours && (
|
||||
<div className={metricRow}>
|
||||
<dt className="text-muted-foreground">
|
||||
After-hours · {props.afterHoursMessagesPct}% messages, {props.afterHoursMeetingsPct}% meetings
|
||||
</dt>
|
||||
<dd className="text-foreground tabular-nums" />
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Meetings */}
|
||||
<section className="border-t border-border pt-3">
|
||||
<h3 className={subsectionLabel}>Meetings</h3>
|
||||
<dl>
|
||||
<MetricRow label="Meetings attended" value={String(props.meetingsAttended)} />
|
||||
<MetricRow label="Meetings organized" value={String(props.meetingsOrganized)} />
|
||||
<MetricRow label="Meeting duration" value={`${meetingHours.toFixed(1)}h`} />
|
||||
{showZoom && (
|
||||
<MetricRow label="Zoom calls" value={String(props.zoomCalls)} />
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
components/mobile/EngagementRecentEntries.tsx
Normal file
103
components/mobile/EngagementRecentEntries.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
'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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
142
components/mobile/EngagementRecentMeetings.tsx
Normal file
142
components/mobile/EngagementRecentMeetings.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
'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">{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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue