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:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user