Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
530 lines
21 KiB
TypeScript
530 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Pencil,
|
|
Archive,
|
|
RotateCcw,
|
|
Trophy,
|
|
XCircle,
|
|
RefreshCcw,
|
|
Mail,
|
|
Phone,
|
|
AlarmClock,
|
|
User,
|
|
} from 'lucide-react';
|
|
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
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 { AssignedToChip } from '@/components/interests/assigned-to-chip';
|
|
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
|
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
|
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;
|
|
yachtId: 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;
|
|
dateFirstContact?: string | null;
|
|
dateEoiSent?: string | null;
|
|
dateEoiSigned?: string | null;
|
|
dateReservationSigned?: string | null;
|
|
dateContractSent?: string | null;
|
|
dateContractSigned?: string | null;
|
|
dateDepositReceived?: string | null;
|
|
eoiDocStatus?: string | null;
|
|
reservationDocStatus?: string | null;
|
|
contractDocStatus?: string | null;
|
|
/** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */
|
|
recentActivityCount?: number | null;
|
|
/** Sales rep who owns this deal — populated by the AssignedToChip. */
|
|
assignedTo?: string | null;
|
|
assignedToName?: 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'] });
|
|
// F26: confirm to the user that the action ran — pre-fix the
|
|
// button gave no feedback and reps weren't sure if it took.
|
|
toast.success('Outcome cleared — interest is open again.');
|
|
},
|
|
});
|
|
|
|
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}
|
|
currentYachtId={interest.yachtId}
|
|
clientId={interest.clientId}
|
|
/>
|
|
</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" aria-hidden />
|
|
{interest.activeReminderCount}
|
|
</span>
|
|
) : null}
|
|
<PermissionGate resource="interests" action="edit">
|
|
<AssignedToChip
|
|
interestId={interest.id}
|
|
currentAssignedTo={interest.assignedTo ?? null}
|
|
currentAssignedToName={interest.assignedToName ?? null}
|
|
/>
|
|
</PermissionGate>
|
|
<MultiEoiChip interestId={interest.id} />
|
|
<DealPulseChip
|
|
interest={{
|
|
pipelineStage: interest.pipelineStage,
|
|
outcome: interest.outcome,
|
|
archivedAt: interest.archivedAt,
|
|
dateFirstContact: interest.dateFirstContact,
|
|
dateLastContact: interest.dateLastContact,
|
|
dateEoiSent: interest.dateEoiSent,
|
|
dateEoiSigned: interest.dateEoiSigned,
|
|
dateReservationSigned: interest.dateReservationSigned,
|
|
dateContractSent: interest.dateContractSent,
|
|
dateContractSigned: interest.dateContractSigned,
|
|
dateDepositReceived: interest.dateDepositReceived,
|
|
eoiDocStatus: interest.eoiDocStatus,
|
|
reservationDocStatus: interest.reservationDocStatus,
|
|
contractDocStatus: interest.contractDocStatus,
|
|
recentActivityCount: interest.recentActivityCount,
|
|
}}
|
|
/>
|
|
</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 ||
|
|
interest.clientId ? (
|
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
|
{interest.clientId ? (
|
|
<Button
|
|
asChild
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
>
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
|
aria-label="Open client page"
|
|
>
|
|
<User />
|
|
Client page
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
{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`}
|
|
>
|
|
<WhatsAppIcon className="h-4 w-4" />
|
|
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" aria-hidden />
|
|
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" aria-hidden />
|
|
<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" aria-hidden />
|
|
<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" aria-hidden />
|
|
</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" aria-hidden />
|
|
) : (
|
|
<Archive className="size-4" aria-hidden />
|
|
)}
|
|
</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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|