wulf-pulse/components/mobile/EngagementProfileBreakdown.tsx
lorentz 0be0c1f7f8 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
2026-05-07 20:50:56 -04:00

96 lines
3.7 KiB
TypeScript

'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>
);
}