fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s

- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 17:36:35 +02:00
parent 4d018be800
commit 7f04c765f4
5 changed files with 75 additions and 8 deletions

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName);
// Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return (
<Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -49,6 +49,13 @@ export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
dismissed: 'bg-slate-100 text-slate-600',
};
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
open: 'Open',
assigned: 'Assigned',
converted: 'Converted',
dismissed: 'Dismissed',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' },
@@ -114,7 +121,7 @@ export function getInquiryColumns({
const state = row.original.triageState;
return (
<div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{state}</Badge>
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? (
<Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}

View File

@@ -8,8 +8,10 @@ import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout'
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import {
KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE,
type InquiryKind,
type InquiryTriageState,
@@ -49,6 +51,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id],
@@ -74,6 +77,10 @@ export function InquiryDetail({ id }: { id: string }) {
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
const tabs: DetailTab[] = [
{
@@ -88,7 +95,9 @@ export function InquiryDetail({ id }: { id: string }) {
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' ? <Row label="Comments" value={str('comments')} /> : null}
{comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} />
@@ -107,7 +116,9 @@ export function InquiryDetail({ id }: { id: string }) {
label="Status"
value={
data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : (
''
)
@@ -155,7 +166,7 @@ export function InquiryDetail({ id }: { id: string }) {
</pre>
),
},
];
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return (
<DetailLayout
@@ -166,7 +177,9 @@ export function InquiryDetail({ id }: { id: string }) {
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">

View File

@@ -848,7 +848,18 @@ function OverviewTab({
deposit_paid: 'deposit',
contract: 'contract',
};
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
const stageOwnedMilestoneRaw =
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
// use completion ordering, so the signed milestone shows as done/past and the
// next incomplete one (Reservation) becomes current. Display-only; the
// pipeline_stage column is unchanged.
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
? milestoneCompletion[stageOwnedMilestoneRaw]
: false;
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed.