Files
pn-new-crm/src/components/interests/interest-detail-header.tsx
Matt 4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00

453 lines
17 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Pencil,
Archive,
RotateCcw,
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageCircle,
Phone,
AlarmClock,
} from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
import { formatOutcome } from '@/lib/constants';
import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
lost_other: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
};
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
// renders as a closed-state badge instead of falling back to the open-state
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
function resolveOutcomeBadge(outcome: string | null | undefined) {
if (!outcome) return null;
const known = OUTCOME_BADGE[outcome];
if (known) return known;
const isLoss = outcome.startsWith('lost');
return {
label: formatOutcome(outcome) ?? outcome,
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
};
}
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
interface InterestDetailHeaderProps {
portSlug: string;
interest: {
id: string;
clientId: string;
clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
/** Pending/snoozed reminders attached to this interest. Drives the
* alarm-bell badge on the header - surfaces follow-ups so the rep
* doesn't have to remember to check /reminders. */
activeReminderCount?: number;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
archivedAt: string | null;
outcome?: string | null;
outcomeReason?: string | null;
dateLastContact?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
function formatLastContactAge(iso: string): string {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome;
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
});
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const restoreMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const meta: Array<{ key: string; node: React.ReactNode }> = [];
if (interest.berthMooringNumber) {
meta.push({
key: 'berth',
node: (
<Link
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
Berth {interest.berthMooringNumber}
</Link>
),
});
}
if (interest.leadCategory) {
meta.push({
key: 'cat',
node: <span>{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}</span>,
});
}
if (interest.source) {
meta.push({
key: 'src',
node: <span className="capitalize">{interest.source}</span>,
});
}
if (interest.dateLastContact) {
meta.push({
key: 'last',
node: (
<span className="text-foreground/70">
Last contact {formatLastContactAge(interest.dateLastContact)}
</span>
),
});
}
return (
<>
<DetailHeaderStrip>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="truncate text-lg font-bold text-foreground sm:text-xl">
{interest.clientName ?? 'Unknown Client'}
</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
{outcomeBadge ? (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
outcomeBadge.className,
)}
title={interest.outcomeReason ?? undefined}
>
{outcomeBadge.label}
</span>
) : (
<PermissionGate
resource="interests"
action="change_stage"
fallback={
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
{interest.pipelineStage}
</span>
}
>
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
/>
</PermissionGate>
)}
{(interest.activeReminderCount ?? 0) > 0 ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
title={`${interest.activeReminderCount} pending reminder${
interest.activeReminderCount === 1 ? '' : 's'
}`}
>
<AlarmClock className="size-3" />
{interest.activeReminderCount}
</span>
) : null}
</div>
{meta.length > 0 ? (
<p className="text-xs text-muted-foreground sm:text-sm">
{meta.map((m, i) => (
<span key={m.key}>
{i > 0 ? (
<span aria-hidden className="mx-1.5">
·
</span>
) : null}
{m.node}
</span>
))}
</p>
) : null}
{interest.tags && interest.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{interest.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
{/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<MessageCircle />
WhatsApp
</a>
</Button>
) : null}
</div>
) : null}
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text
buttons on desktop; Edit/Archive stay icon-only. On mobile,
Won/Lost shrink to icon buttons to keep the cluster from
wrapping. */}
<div className="flex shrink-0 items-center gap-1">
<PermissionGate resource="interests" action="change_stage">
{isClosed ? (
<button
type="button"
onClick={() => reopenMutation.mutate()}
disabled={reopenMutation.isPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
'hover:bg-foreground/5 disabled:opacity-50',
)}
>
<RefreshCcw className="size-3.5" />
Reopen
</button>
) : (
<>
{/* Mobile: icon-only with title tooltip + colored fill carries
the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and
forced the Email/Call/WhatsApp action-chip row above to
stack vertically - bad trade. From sm up, the full
"Mark won" / "Close as lost" labels read clearly. */}
<button
type="button"
onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won"
title="Mark as won"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100',
)}
>
<Trophy className="size-3.5" />
<span className="hidden sm:inline">Mark won</span>
</button>
<button
type="button"
onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost"
title="Close as lost"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-rose-200 text-rose-700',
'hover:bg-rose-50',
)}
>
<XCircle className="size-3.5" />
<span className="hidden sm:inline">Close as lost</span>
</button>
</>
)}
</PermissionGate>
{/* The "Upload paper-signed EOI" button used to live here.
It's now on the dedicated EOI tab (in both the active-EOI
hero and the empty-state CTA row), where it sits next to
the document it relates to. The header was a shotgun of
actions that didn't all belong; collecting them per-tab
is the cleaner UX. */}
<PermissionGate resource="interests" action="edit">
<button
type="button"
onClick={() => setEditOpen(true)}
aria-label="Edit interest"
title="Edit interest"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Pencil className="size-4" />
</button>
</PermissionGate>
<PermissionGate resource="interests" action="delete">
<button
type="button"
onClick={() => setArchiveOpen(true)}
aria-label={isArchived ? 'Restore interest' : 'Archive interest'}
title={isArchived ? 'Restore interest' : 'Archive interest'}
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5',
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
)}
>
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
</button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
{outcomeDialog && (
<InterestOutcomeDialog
interestId={interest.id}
mode={outcomeDialog}
open={outcomeDialog !== null}
onOpenChange={(open) => !open && setOutcomeDialog(null)}
/>
)}
<InterestForm
open={editOpen}
onOpenChange={setEditOpen}
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={interest.clientName ?? 'Interest'}
entityType="Interest"
isArchived={isArchived}
onConfirm={() => {
if (isArchived) {
restoreMutation.mutate();
} else {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
</>
);
}