feat(sales): admin-configurable EOI signers + richer timeline events

1. Per-port EOI signer config

     - New `eoi_signers` system_settings key (JSON: { developer, approver },
       each `{ name, email }`). Settings UI exposes it under Admin → Settings.
     - getPortEoiSigners(portId) reads the setting with a typed validator;
       falls back to the legacy David Mizrahi / Abbie May defaults if the
       row is missing or malformed (so older ports keep working until an
       admin saves a value).
     - Both EOI generation pathways now read from the helper instead of
       hardcoded constants:
         * documenso-template path (generateAndSignViaDocumensoTemplate)
         * in-app PDF-fill path (generateAndSignViaInApp)

  2. Timeline upgrades

     The interest detail Activity tab now distinguishes the new automation
     events that arrived with sessions 1+2:

     - Stage auto-advances (userId='system') get a small "Auto" pill and
       carry their reason into the description (e.g. "Stage advanced to
       EOI Signed (auto-advanced — EOI signed via Documenso)").
     - outcome_set events show "Marked as Won" / "Marked as Lost — went
       to another marina" with optional reason; trophy/X icons.
     - outcome_cleared events show "Reopened to {stage}" with a refresh
       icon.
     - Document events humanized: "Document 'X' fully signed" instead
       of "Document X: completed".
     - Stage labels run through stageLabel() so the timeline shows the
       human label, not the enum key.
     - Timestamps switched to relative-time with full-date tooltip.
     - "by system" is rendered plainly (no longer the literal user-id).

tsc clean. vitest 832/832 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 00:19:55 +02:00
parent ba5fb6db5e
commit dbbd03fd22
5 changed files with 205 additions and 38 deletions

View File

@@ -107,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
type: 'json',
defaultValue: [],
},
{
key: 'eoi_signers',
label: 'EOI Signers',
description:
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
type: 'json',
defaultValue: {
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
},
},
];
export function SettingsManager() {

View File

@@ -1,8 +1,19 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Pencil, FileText, Clock, PlusCircle, Archive, RotateCcw } from 'lucide-react';
import { format, formatDistanceToNowStrict } from 'date-fns';
import {
Pencil,
FileText,
Clock,
PlusCircle,
Archive,
RotateCcw,
Trophy,
XCircle,
RefreshCcw,
Bot,
} from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
@@ -20,15 +31,37 @@ interface InterestTimelineProps {
interestId: string;
}
const LOST_OUTCOMES = new Set([
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'cancelled',
]);
function eventIcon(event: TimelineEvent) {
if (event.type === 'document_event') return <FileText className="h-4 w-4" />;
const type = event.metadata?.type as string | undefined;
if (type === 'outcome_set') {
const outcome = (event.metadata as Record<string, unknown>).outcome as string | undefined;
if (outcome === 'won') return <Trophy className="h-4 w-4 text-emerald-600" />;
if (outcome && LOST_OUTCOMES.has(outcome)) return <XCircle className="h-4 w-4 text-rose-600" />;
return <XCircle className="h-4 w-4 text-rose-600" />;
}
if (type === 'outcome_cleared') return <RefreshCcw className="h-4 w-4 text-blue-500" />;
if (event.type === 'document_event') return <FileText className="h-4 w-4 text-sky-600" />;
if (event.action === 'create') return <PlusCircle className="h-4 w-4 text-green-500" />;
if (event.action === 'archive') return <Archive className="h-4 w-4 text-orange-500" />;
if (event.action === 'restore') return <RotateCcw className="h-4 w-4 text-blue-500" />;
if (event.metadata?.type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
if (type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
return <Pencil className="h-4 w-4 text-muted-foreground" />;
}
function actorLabel(userId: string | null): string | null {
if (!userId) return null;
if (userId === 'system') return 'system';
return userId;
}
export function InterestTimeline({ interestId }: InterestTimelineProps) {
const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({
queryKey: ['interest-timeline', interestId],
@@ -66,22 +99,36 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
{/* Vertical line */}
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event, _idx) => (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
{eventIcon(event)}
</div>
{events.map((event) => {
const actor = actorLabel(event.userId);
const isAuto = event.userId === 'system';
return (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
{eventIcon(event)}
</div>
<div className="flex-1 pt-1">
<p className="text-sm">{event.description}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{format(new Date(event.createdAt), 'MMM d, yyyy HH:mm')}
{event.userId && ` · by ${event.userId}`}
</p>
<div className="flex-1 pt-1">
<p className="text-sm">
{event.description}
{isAuto ? (
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3 w-3" />
Auto
</span>
) : null}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<time dateTime={event.createdAt} title={format(new Date(event.createdAt), 'PPpp')}>
{formatDistanceToNowStrict(new Date(event.createdAt), { addSuffix: true })}
</time>
{actor ? <span> · by {actor}</span> : null}
</p>
</div>
</div>
</div>
))}
);
})}
</div>
);
}