feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work
Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
canonical address form (line1/line2/city/state/postal + ISO
subdivision + CountryCombobox). Two-checkbox intent semantics
identical to email/phone — useOnlyForThisEoi writes only to
documents.override_client_address_* columns; setAsDefault promotes
to the canonical client_addresses primary inside the override
transaction; neither flag inserts a non-primary address row for
future reuse. eoi-context route now returns available.addresses so
the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
BEFORE generateAndSign creates the document row, so source_document_id
stayed NULL. Mirrored the bounded-recent backfill pattern from
contacts into persistDocumentOverrides for both client_addresses and
yachts (every row inserted in the last 60s with NULL source_document_id
and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
dropdown + get human labels in the card view.
Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
open reminders for an entity. Mounted on Overview tab of yacht /
client / interest detail. Empty state hints at the header button
rather than duplicating it.
Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
residential-inquiry — voice + sign-off match the 4 shipped earlier
("Dear X", "With warm regards, The {portName} Team", sentence-case
subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
set up to catch port-name leaks; templates are correct in review.
Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
badge), ResizeObserver-driven responsive PDF width, required-tokens-
unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
runs the in-app pdf-lib fill against the supplied interest, uploads
to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
takes multipart FormData, magic-byte verifies %PDF-, parses page
count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
warns when the new page count truncates the prior set.
Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
src/components/reminders/reminders-inline.tsx
Normal file
134
src/components/reminders/reminders-inline.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bell, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Phase 4 — inline reminders list rendered inside an entity's
|
||||
* Overview tab. Shows the most recent open (pending/snoozed) reminders
|
||||
* for the linked entity so reps can spot follow-ups without leaving the
|
||||
* detail page.
|
||||
*
|
||||
* Filter is exactly one of clientId / interestId / berthId / yachtId.
|
||||
* Caller responsibility — the listReminders service AND's whichever
|
||||
* filters are present, so multiple would intersect rather than union.
|
||||
*
|
||||
* No "+ Reminder" button here on purpose: the detail-page header
|
||||
* already carries one, threading the same default-entity-id prop.
|
||||
* Empty state hints at the header button instead of duplicating it.
|
||||
*/
|
||||
interface InlineReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
dueAt: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
|
||||
assignedTo: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: InlineReminder[];
|
||||
pagination?: { total?: number };
|
||||
}
|
||||
|
||||
const PRIORITY_DOT: Record<InlineReminder['priority'], string> = {
|
||||
urgent: 'bg-red-500',
|
||||
high: 'bg-orange-500',
|
||||
medium: 'bg-blue-500',
|
||||
low: 'bg-gray-400',
|
||||
};
|
||||
|
||||
const STATUS_ICON: Record<InlineReminder['status'], React.ReactNode> = {
|
||||
pending: <Bell className="h-3 w-3 text-amber-600" aria-hidden />,
|
||||
snoozed: <Clock className="h-3 w-3 text-slate-500" aria-hidden />,
|
||||
completed: <CheckCircle2 className="h-3 w-3 text-emerald-600" aria-hidden />,
|
||||
dismissed: <CheckCircle2 className="h-3 w-3 text-slate-400" aria-hidden />,
|
||||
};
|
||||
|
||||
interface RemindersInlineProps {
|
||||
/** Exactly one should be set — the entity to filter by. */
|
||||
clientId?: string;
|
||||
interestId?: string;
|
||||
berthId?: string;
|
||||
yachtId?: string;
|
||||
/** Soft cap on the rendered list; defaults to 5. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function RemindersInline(props: RemindersInlineProps) {
|
||||
const { clientId, interestId, berthId, yachtId, limit = 5 } = props;
|
||||
|
||||
const filterKey = clientId ?? interestId ?? berthId ?? yachtId ?? null;
|
||||
const filterParam = clientId
|
||||
? `clientId=${clientId}`
|
||||
: interestId
|
||||
? `interestId=${interestId}`
|
||||
: berthId
|
||||
? `berthId=${berthId}`
|
||||
: yachtId
|
||||
? `yachtId=${yachtId}`
|
||||
: '';
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['reminders', 'inline', filterKey],
|
||||
queryFn: () =>
|
||||
apiFetch<ListResponse>(
|
||||
`/api/v1/reminders?${filterParam}&status=pending&limit=${limit}&sort=dueAt&order=asc`,
|
||||
),
|
||||
enabled: !!filterKey,
|
||||
});
|
||||
|
||||
if (!filterKey) return null;
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Reminders
|
||||
</h3>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-muted-foreground italic">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No open reminders for this record. Use the bell in the header to add one.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map((r) => {
|
||||
const isPastDue = new Date(r.dueAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-start gap-2 rounded-md border bg-card px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', PRIORITY_DOT[r.priority])}
|
||||
/>
|
||||
{STATUS_ICON[r.status]}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">{r.title}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px]',
|
||||
isPastDue ? 'text-rose-700 font-medium' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Due {formatDistanceToNow(new Date(r.dueAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user