Compare commits
2 Commits
3b3ac287e0
...
98211066a5
| Author | SHA1 | Date | |
|---|---|---|---|
| 98211066a5 | |||
| 0d9208a052 |
@@ -17,18 +17,24 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
const folderIdRaw = formData.get('folderId') as string | undefined;
|
// A16: FormData.get returns null when the field is absent, not
|
||||||
|
// undefined. Zod's .optional() accepts undefined but rejects null,
|
||||||
|
// so the previous `as string | undefined` cast lied and uploads at
|
||||||
|
// the hub root (no entity selected) 400'd. Coerce absent / empty
|
||||||
|
// values to undefined before parse.
|
||||||
|
const formStr = (key: string): string | undefined => {
|
||||||
|
const v = formData.get(key);
|
||||||
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||||
|
};
|
||||||
const metadata = uploadFileSchema.parse({
|
const metadata = uploadFileSchema.parse({
|
||||||
filename: (formData.get('filename') as string | null) ?? file.name,
|
filename: formStr('filename') ?? file.name,
|
||||||
clientId: formData.get('clientId') as string | undefined,
|
clientId: formStr('clientId'),
|
||||||
yachtId: formData.get('yachtId') as string | undefined,
|
yachtId: formStr('yachtId'),
|
||||||
companyId: formData.get('companyId') as string | undefined,
|
companyId: formStr('companyId'),
|
||||||
category: formData.get('category') as string | undefined,
|
category: formStr('category'),
|
||||||
entityType: formData.get('entityType') as string | undefined,
|
entityType: formStr('entityType'),
|
||||||
entityId: formData.get('entityId') as string | undefined,
|
entityId: formStr('entityId'),
|
||||||
// Hub uploads pass the current folderId so the file lands inside
|
folderId: formStr('folderId'),
|
||||||
// the user's currently-selected folder. Empty string ⇒ root (null).
|
|
||||||
folderId: folderIdRaw && folderIdRaw.length > 0 ? folderIdRaw : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await uploadFile(
|
const result = await uploadFile(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
import { changeInterestStage } from '@/lib/services/interests.service';
|
import { changeInterestStage, STAGE_NOOP } from '@/lib/services/interests.service';
|
||||||
import { changeStageSchema } from '@/lib/validators/interests';
|
import { changeStageSchema } from '@/lib/validators/interests';
|
||||||
|
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
@@ -26,6 +26,10 @@ export const PATCH = withAuth(
|
|||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
});
|
});
|
||||||
|
// A19 / F27: same-stage write returns the sentinel — emit 204.
|
||||||
|
if (interest === STAGE_NOOP) {
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
return NextResponse.json({ data: interest });
|
return NextResponse.json({ data: interest });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
|
|||||||
38
src/app/api/v1/me/ports/route.ts
Normal file
38
src/app/api/v1/me/ports/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
|
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
|
// A17: bootstrap-friendly ports list for the calling user — sales-reps
|
||||||
|
// and viewers can hit this without the super-admin gate that blocks
|
||||||
|
// `/api/v1/admin/ports`. Returns only the ports the user actually has
|
||||||
|
// access to (super-admin sees every active port).
|
||||||
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
|
try {
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, ctx.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profile?.isSuperAdmin) {
|
||||||
|
const all = await db.query.ports.findMany({
|
||||||
|
where: eq(portsTable.isActive, true),
|
||||||
|
orderBy: portsTable.name,
|
||||||
|
columns: { id: true, slug: true, name: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: all });
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await db.query.userPortRoles.findMany({
|
||||||
|
where: eq(userPortRoles.userId, ctx.userId),
|
||||||
|
with: { port: { columns: { id: true, slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
const data = memberships.map((m) => m.port);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -78,7 +78,11 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
|
|||||||
const [newClientEmail, setNewClientEmail] = useState('');
|
const [newClientEmail, setNewClientEmail] = useState('');
|
||||||
const [newClientPhone, setNewClientPhone] = useState('');
|
const [newClientPhone, setNewClientPhone] = useState('');
|
||||||
const [yachtId, setYachtId] = useState<string | null>(null);
|
const [yachtId, setYachtId] = useState<string | null>(null);
|
||||||
const [pipelineStage, setPipelineStage] = useState<string>('enquiry');
|
// A9: stageOverride is the user's explicit choice. When null, the
|
||||||
|
// effective stage derives from the loaded berth's status (under_offer
|
||||||
|
// → eoi, sold → contract). Pre-fix this was a useState seeded to
|
||||||
|
// 'enquiry' which never updated when the berth loaded.
|
||||||
|
const [stageOverride, setStageOverride] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch the berth so the wizard can scope the stage options to what
|
// Fetch the berth so the wizard can scope the stage options to what
|
||||||
// makes sense for the current manual status. Disabled until open so
|
// makes sense for the current manual status. Disabled until open so
|
||||||
@@ -95,11 +99,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
|
|||||||
// under_offer defaults to eoi since that's the most common pre-deal
|
// under_offer defaults to eoi since that's the most common pre-deal
|
||||||
// status that reps mark manually.
|
// status that reps mark manually.
|
||||||
const defaultStage = berth?.data.status === 'sold' ? 'contract' : 'eoi';
|
const defaultStage = berth?.data.status === 'sold' ? 'contract' : 'eoi';
|
||||||
|
const pipelineStage = stageOverride ?? defaultStage;
|
||||||
// Keep selected stage in sync with the loaded berth's allowed set.
|
|
||||||
if (berth && pipelineStage !== defaultStage && !allowedStages.includes(pipelineStage)) {
|
|
||||||
setPipelineStage(defaultStage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = useMutation({
|
const submit = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -143,7 +143,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
|
|||||||
setNewClientEmail('');
|
setNewClientEmail('');
|
||||||
setNewClientPhone('');
|
setNewClientPhone('');
|
||||||
setYachtId(null);
|
setYachtId(null);
|
||||||
setPipelineStage('enquiry');
|
setStageOverride(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -235,7 +235,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Pipeline stage</Label>
|
<Label>Pipeline stage</Label>
|
||||||
<Select value={pipelineStage} onValueChange={setPipelineStage}>
|
<Select value={pipelineStage} onValueChange={setStageOverride}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function ClientForm({
|
|||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
getValues,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<z.input<typeof createClientSchema>, unknown, CreateClientInput>({
|
} = useForm<z.input<typeof createClientSchema>, unknown, CreateClientInput>({
|
||||||
@@ -224,7 +225,24 @@ export function ClientForm({
|
|||||||
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
// A4: prune empty contact rows BEFORE handleSubmit/zod runs.
|
||||||
|
// The schema requires `value: z.string().min(1)`, so an empty
|
||||||
|
// row (the form pre-adds one for convenience) silently fails
|
||||||
|
// form validation with no visible error. Strip them first so
|
||||||
|
// the rest of the validation sees only real rows.
|
||||||
|
const current = getValues('contacts') ?? [];
|
||||||
|
const cleaned = current.filter(
|
||||||
|
(c) => typeof c?.value === 'string' && c.value.trim().length > 0,
|
||||||
|
);
|
||||||
|
if (cleaned.length !== current.length) {
|
||||||
|
setValue('contacts', cleaned, { shouldValidate: false });
|
||||||
|
}
|
||||||
|
return handleSubmit((data) => mutation.mutate(data))(e);
|
||||||
|
}}
|
||||||
|
className="space-y-6 py-6"
|
||||||
|
>
|
||||||
{/* Dedup suggestion - only on the create path. Watches the
|
{/* Dedup suggestion - only on the create path. Watches the
|
||||||
live form values for email / phone / name and surfaces
|
live form values for email / phone / name and surfaces
|
||||||
an existing client when one matches. The user can
|
an existing client when one matches. The user can
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageLabelFor } from '@/lib/constants';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
interface DossierBerth {
|
interface DossierBerth {
|
||||||
@@ -339,7 +340,7 @@ function SmartArchiveDialogBody({
|
|||||||
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
|
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{i.pipelineStage}
|
{stageLabelFor(i.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
|
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
|
||||||
</span>
|
</span>
|
||||||
@@ -411,7 +412,9 @@ function SmartArchiveDialogBody({
|
|||||||
Releasing will notify the sales rep. Other interests on this berth:{' '}
|
Releasing will notify the sales rep. Other interests on this berth:{' '}
|
||||||
{b.otherInterests
|
{b.otherInterests
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((o) => `${o.clientName ?? '?'} (${o.pipelineStage})`)
|
.map(
|
||||||
|
(o) => `${o.clientName ?? '?'} (${stageLabelFor(o.pipelineStage)})`,
|
||||||
|
)
|
||||||
.join(', ')}
|
.join(', ')}
|
||||||
{b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}
|
{b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
|
import {
|
||||||
|
STAGE_LABELS,
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
LEGACY_STAGE_REMAP,
|
||||||
|
formatSource,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/lib/constants';
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,7 +49,14 @@ function normalizeEnumValue(field: string, value: unknown): unknown {
|
|||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
const f = field.replace(/_/g, '').toLowerCase();
|
const f = field.replace(/_/g, '').toLowerCase();
|
||||||
if (f === 'pipelinestage' || f === 'stage') {
|
if (f === 'pipelinestage' || f === 'stage') {
|
||||||
return STAGE_LABELS[value as PipelineStage] ?? humanizeFieldName(value);
|
// A2: map legacy 9-stage enum values to their 7-stage equivalents so
|
||||||
|
// historical audit-log rows ("deposit_10pct", "contract_sent", ...)
|
||||||
|
// render as the modern label rather than a humanized raw enum.
|
||||||
|
const modern = (PIPELINE_STAGES as readonly string[]).includes(value)
|
||||||
|
? (value as PipelineStage)
|
||||||
|
: LEGACY_STAGE_REMAP[value];
|
||||||
|
if (modern) return STAGE_LABELS[modern];
|
||||||
|
return humanizeFieldName(value);
|
||||||
}
|
}
|
||||||
if (f === 'source') {
|
if (f === 'source') {
|
||||||
return formatSource(value) ?? value;
|
return formatSource(value) ?? value;
|
||||||
@@ -169,7 +182,11 @@ function ActivityFeedInner() {
|
|||||||
return <CardSkeleton />;
|
return <CardSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = data ?? [];
|
// A1: permission_denied rows on the activity feed render as a bare
|
||||||
|
// action badge with no entity name (they target `admin.X` with empty
|
||||||
|
// entityId). They're noise for the rep — keep them in the audit log
|
||||||
|
// page but hide them from the dashboard feed.
|
||||||
|
const items = (data ?? []).filter((i) => i.action !== 'permission_denied');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import dynamic from 'next/dynamic';
|
|||||||
import { ExternalLink, ZoomIn } from 'lucide-react';
|
import { ExternalLink, ZoomIn } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
||||||
@@ -71,6 +77,12 @@ export function FilePreviewDialog({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
{/* A6: screen-reader description; visually hidden because the
|
||||||
|
* title + preview surface tells sighted users what the dialog
|
||||||
|
* contains. Skips the Radix "missing aria-describedby" warning. */}
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Inline preview of {fileName ?? 'the selected file'}.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden rounded-lg border bg-muted/20">
|
<div className="flex-1 overflow-hidden rounded-lg border bg-muted/20">
|
||||||
|
|||||||
@@ -113,13 +113,21 @@ export function OwnerPicker({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate flex items-center gap-2">
|
||||||
{value && (
|
{/* A20: surface the dual-mode (Client/Company) hint even when
|
||||||
<span className="mr-2 text-xs opacity-60">
|
* no value is picked yet, so users know the trigger opens a
|
||||||
|
* two-tab picker — pre-fix the toggle was hidden until the
|
||||||
|
* popover was open, making the form read as client-only. */}
|
||||||
|
{value ? (
|
||||||
|
<span className="text-xs opacity-60">
|
||||||
{value.type === 'client' ? 'Client:' : 'Company:'}
|
{value.type === 'client' ? 'Client:' : 'Company:'}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-sm border border-border bg-muted px-1.5 py-px text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Client / Company
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedLabel}
|
<span className="truncate">{selectedLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
|
|||||||
if (!inFlightPortsLookup) {
|
if (!inFlightPortsLookup) {
|
||||||
inFlightPortsLookup = (async () => {
|
inFlightPortsLookup = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/ports', { credentials: 'include' });
|
// A17: use /me/ports — works for every authenticated user.
|
||||||
|
// The prior code hit /admin/ports which is super-admin-gated, so
|
||||||
|
// sales-reps/viewers fired a wasteful 400 on every page load.
|
||||||
|
const res = await fetch('/api/v1/me/ports', { credentials: 'include' });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
|
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
|
||||||
return body.data ?? null;
|
return body.data ?? null;
|
||||||
|
|||||||
@@ -38,6 +38,54 @@ export const STAGE_LABELS: Record<PipelineStage, string> = {
|
|||||||
contract: 'Contract',
|
contract: 'Contract',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map legacy 9-stage enum values to their 7-stage equivalents. Audit logs
|
||||||
|
* and any pre-migration data still carry the legacy values; this lets the
|
||||||
|
* activity feed, audit diffs, and reporting render the modern label
|
||||||
|
* without having to back-fill the underlying rows.
|
||||||
|
*
|
||||||
|
* Mirrors the migration applied in `seed-synthetic-data.ts` (and
|
||||||
|
* documented in the 9→7 pipeline refactor):
|
||||||
|
* details_sent → enquiry
|
||||||
|
* in_communication → qualified
|
||||||
|
* eoi_sent, eoi_signed → eoi (doc-status carries sent/signed sub-state)
|
||||||
|
* deposit_10pct → deposit_paid
|
||||||
|
* contract_sent, contract_signed → contract
|
||||||
|
* completed → contract (with outcome=won)
|
||||||
|
* open → enquiry (legacy alias for the initial stage)
|
||||||
|
*/
|
||||||
|
export const LEGACY_STAGE_REMAP: Record<string, PipelineStage> = {
|
||||||
|
open: 'enquiry',
|
||||||
|
details_sent: 'enquiry',
|
||||||
|
in_communication: 'qualified',
|
||||||
|
eoi_sent: 'eoi',
|
||||||
|
eoi_signed: 'eoi',
|
||||||
|
deposit_10pct: 'deposit_paid',
|
||||||
|
contract_sent: 'contract',
|
||||||
|
contract_signed: 'contract',
|
||||||
|
completed: 'contract',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any stage-like string to a canonical 7-stage value. Returns
|
||||||
|
* the modern stage as-is, maps legacy values via LEGACY_STAGE_REMAP,
|
||||||
|
* and falls back to 'enquiry' for genuinely unknown values.
|
||||||
|
*/
|
||||||
|
export function canonicalizeStage(value: string | null | undefined): PipelineStage {
|
||||||
|
if (!value) return 'enquiry';
|
||||||
|
if (PIPELINE_STAGES.includes(value as PipelineStage)) return value as PipelineStage;
|
||||||
|
return LEGACY_STAGE_REMAP[value] ?? 'enquiry';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-friendly label for any stage-like string — modern or legacy. Use
|
||||||
|
* this in any read surface (activity feed, audit diff, notification copy,
|
||||||
|
* reports) that might be handed pre-migration data.
|
||||||
|
*/
|
||||||
|
export function stageLabelFor(value: string | null | undefined): string {
|
||||||
|
return STAGE_LABELS[canonicalizeStage(value)];
|
||||||
|
}
|
||||||
|
|
||||||
// Compact labels for cramped contexts (mobile chart axes, dense tables).
|
// Compact labels for cramped contexts (mobile chart axes, dense tables).
|
||||||
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
|
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
|
||||||
enquiry: 'Enquiry',
|
enquiry: 'Enquiry',
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A8: normalize legacy `statusOverrideMode = 'auto'` values to NULL.
|
||||||
|
--
|
||||||
|
-- The NocoDB importer historically wrote 'auto' to indicate "no override
|
||||||
|
-- in effect" for legacy data. The post-refactor code uses NULL for that
|
||||||
|
-- sentinel and 'manual' / 'automated' for the new states. Mixed values
|
||||||
|
-- pollute the reconcile-queue predicate and the Manual chip — neither
|
||||||
|
-- path treats 'auto' specially today, but normalizing closes the gap
|
||||||
|
-- once and for all and keeps the column to a 3-state enum.
|
||||||
|
UPDATE berths
|
||||||
|
SET status_override_mode = NULL
|
||||||
|
WHERE status_override_mode = 'auto';
|
||||||
@@ -40,7 +40,7 @@ export const clients = pgTable(
|
|||||||
/** Better-auth user id of the operator who archived this client. */
|
/** Better-auth user id of the operator who archived this client. */
|
||||||
archivedBy: text('archived_by'),
|
archivedBy: text('archived_by'),
|
||||||
/** Free-text reason captured at archive time. Required when archiving a
|
/** Free-text reason captured at archive time. Required when archiving a
|
||||||
* client at deposit_10pct or later (compliance trail). Optional when
|
* client at deposit_paid or later (compliance trail). Optional when
|
||||||
* archiving an early-stage lead. */
|
* archiving an early-stage lead. */
|
||||||
archiveReason: text('archive_reason'),
|
archiveReason: text('archive_reason'),
|
||||||
/** Per-decision metadata captured during smart-archive flow. Used by
|
/** Per-decision metadata captured during smart-archive flow. Used by
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const invoices = pgTable(
|
|||||||
pdfFileId: text('pdf_file_id').references(() => files.id),
|
pdfFileId: text('pdf_file_id').references(() => files.id),
|
||||||
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
||||||
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
|
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
|
||||||
* to deposit_10pct (no-op if already further along). */
|
* to deposit_paid (no-op if already further along). */
|
||||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||||
/** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks
|
/** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks
|
||||||
* the 10% berth-purchase deposit and is what triggers the stage advance. */
|
* the 10% berth-purchase deposit and is what triggers the stage advance. */
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export type RolePermissions = {
|
|||||||
edit: boolean;
|
edit: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
change_stage: boolean;
|
change_stage: boolean;
|
||||||
/** Bypass the canTransitionStage table (e.g. mark a contract_signed
|
/** Bypass the canTransitionStage table (e.g. jump a deal straight to
|
||||||
* deal as completed without going through deposit_10pct first when
|
* Contract without going through Deposit Paid first when the data
|
||||||
* the data was entered out of order). Audit-logged with the reason
|
* was entered out of order). Audit-logged with the reason the rep
|
||||||
* the rep gives. Sales-team-restricted. */
|
* gives. Sales-team-restricted. */
|
||||||
override_stage: boolean;
|
override_stage: boolean;
|
||||||
generate_eoi: boolean;
|
generate_eoi: boolean;
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
|||||||
@@ -184,20 +184,32 @@ export async function loadRecommenderSettings(portId: string): Promise<Recommend
|
|||||||
|
|
||||||
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// L-001: modern 7-stage ranks own the canonical positions; legacy 9-stage
|
||||||
|
// keys map to their post-refactor equivalents so historical interests in
|
||||||
|
// the recommender stage-aware queries continue to rank correctly.
|
||||||
const STAGE_ORDER: Record<string, number> = {
|
const STAGE_ORDER: Record<string, number> = {
|
||||||
|
// modern
|
||||||
|
enquiry: 1,
|
||||||
|
qualified: 2,
|
||||||
|
nurturing: 2,
|
||||||
|
eoi: 3,
|
||||||
|
reservation: 4,
|
||||||
|
deposit_paid: 5,
|
||||||
|
contract: 6,
|
||||||
|
// legacy aliases
|
||||||
open: 1,
|
open: 1,
|
||||||
details_sent: 2,
|
details_sent: 1,
|
||||||
in_communication: 3,
|
in_communication: 2,
|
||||||
eoi_sent: 4,
|
eoi_sent: 3,
|
||||||
eoi_signed: 5,
|
eoi_signed: 3,
|
||||||
deposit_10pct: 6,
|
deposit_10pct: 5,
|
||||||
contract_sent: 7,
|
contract_sent: 6,
|
||||||
contract_signed: 8,
|
contract_signed: 6,
|
||||||
completed: 9,
|
completed: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stage at which a berth is "in late stage" (Tier D when active). */
|
/** Stage at which a berth is "in late stage" (Tier D when active). */
|
||||||
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6
|
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5
|
||||||
|
|
||||||
export type Tier = 'A' | 'B' | 'C' | 'D';
|
export type Tier = 'A' | 'B' | 'C' | 'D';
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export interface ClientArchiveDossier {
|
|||||||
* confirmation + reason required). */
|
* confirmation + reason required). */
|
||||||
stakeLevel: ArchiveStakeLevel;
|
stakeLevel: ArchiveStakeLevel;
|
||||||
/** The interest stage that earned the high-stakes classification (so
|
/** The interest stage that earned the high-stakes classification (so
|
||||||
* the UI can explain "this client is in deposit_10pct, please confirm").
|
* the UI can explain "this client is in Deposit Paid, please confirm").
|
||||||
* Null when low-stakes. */
|
* Null when low-stakes. */
|
||||||
highStakesStage: PipelineStage | null;
|
highStakesStage: PipelineStage | null;
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function archiveClientWithDecisions(args: {
|
|||||||
|
|
||||||
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
|
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'A reason is required when archiving a client at deposit_10pct or later.',
|
'A reason is required when archiving a client at Deposit Paid or later.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,21 +252,36 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
// Aggregate berths per client, sorted so the most-action-worthy
|
// Aggregate berths per client, sorted so the most-action-worthy
|
||||||
// interest floats to the top of the chip row. Priority:
|
// interest floats to the top of the chip row. Priority:
|
||||||
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
||||||
// 2. within open: most progressed stage first (contract_signed > … > open)
|
// 2. within open: most progressed stage first (contract > … > enquiry)
|
||||||
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
||||||
// The list-view UI shows the top 2 with full labels; the rest fall
|
// The list-view UI shows the top 2 with full labels; the rest fall
|
||||||
// through into a "+N more" popover.
|
// through into a "+N more" popover.
|
||||||
|
//
|
||||||
|
// L-001 fix: pre-refactor this map used the 9-stage legacy names
|
||||||
|
// (contract_signed, deposit_10pct, …) and every modern 7-stage value
|
||||||
|
// fell through to rank 0, making the sort effectively random for any
|
||||||
|
// post-refactor interest. Modern values now own the canonical ranks
|
||||||
|
// and legacy keys map to their 7-stage equivalents so historical data
|
||||||
|
// continues to sort correctly.
|
||||||
const stageRank: Record<string, number> = {
|
const stageRank: Record<string, number> = {
|
||||||
|
// modern (post 9→7 refactor)
|
||||||
|
contract: 1,
|
||||||
|
deposit_paid: 2,
|
||||||
|
reservation: 3,
|
||||||
|
eoi: 4,
|
||||||
|
nurturing: 5,
|
||||||
|
qualified: 6,
|
||||||
|
enquiry: 7,
|
||||||
|
// legacy aliases — kept so audit-log + soft-archive data sorts the same
|
||||||
contract_signed: 1,
|
contract_signed: 1,
|
||||||
|
contract_sent: 1,
|
||||||
|
completed: 1,
|
||||||
deposit_10pct: 2,
|
deposit_10pct: 2,
|
||||||
contract_sent: 3,
|
|
||||||
eoi_signed: 4,
|
eoi_signed: 4,
|
||||||
eoi_sent: 5,
|
eoi_sent: 4,
|
||||||
in_communication: 6,
|
in_communication: 6,
|
||||||
details_sent: 7,
|
details_sent: 7,
|
||||||
qualified: 8,
|
open: 7,
|
||||||
open: 9,
|
|
||||||
completed: 10,
|
|
||||||
};
|
};
|
||||||
type LinkedBerth = {
|
type LinkedBerth = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -818,6 +818,13 @@ export async function updateInterest(
|
|||||||
|
|
||||||
// ─── Change Stage ─────────────────────────────────────────────────────────────
|
// ─── Change Stage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel returned by changeInterestStage when the requested target
|
||||||
|
* matches the current stage (no audit row, no socket emit, no DB update).
|
||||||
|
* The route handler translates this to a 204 No Content response.
|
||||||
|
*/
|
||||||
|
export const STAGE_NOOP = Symbol('stage-noop');
|
||||||
|
|
||||||
export async function changeInterestStage(
|
export async function changeInterestStage(
|
||||||
id: string,
|
id: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
@@ -832,12 +839,13 @@ export async function changeInterestStage(
|
|||||||
throw new NotFoundError('Interest');
|
throw new NotFoundError('Interest');
|
||||||
}
|
}
|
||||||
|
|
||||||
// F27: same-stage write is a no-op. Return the existing row without
|
// F27 / A19: same-stage write is a no-op. The service signals this via
|
||||||
// bumping updatedAt or emitting an audit log entry — pre-fix every
|
// the sentinel `STAGE_NOOP` so the route handler can return 204 No
|
||||||
// re-submit (e.g. accidental double-click) wrote a "Same → Same"
|
// Content instead of 200 + full body. Pre-fix every re-submit (e.g.
|
||||||
// audit entry and triggered downstream invalidations.
|
// accidental double-click) wrote a "Same → Same" audit entry and
|
||||||
|
// triggered downstream invalidations.
|
||||||
if (existing.pipelineStage === data.pipelineStage) {
|
if (existing.pipelineStage === data.pipelineStage) {
|
||||||
return existing;
|
return STAGE_NOOP;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan: yachtId required to leave the initial enquiry stage
|
// Plan: yachtId required to leave the initial enquiry stage
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export const setOutcomeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const clearOutcomeSchema = z.object({
|
export const clearOutcomeSchema = z.object({
|
||||||
// Stage to revert to when reopening (defaults to in_communication).
|
// Stage to revert to when reopening. When omitted the service picks the
|
||||||
|
// stage immediately before the outcome was set; falls back to qualified.
|
||||||
reopenStage: z.enum(PIPELINE_STAGES).optional(),
|
reopenStage: z.enum(PIPELINE_STAGES).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ describe('interests.service — yacht ownership validation', () => {
|
|||||||
{ pipelineStage: 'qualified' },
|
{ pipelineStage: 'qualified' },
|
||||||
makeAuditMeta({ portId: port.id }),
|
makeAuditMeta({ portId: port.id }),
|
||||||
);
|
);
|
||||||
|
// After A19: changeInterestStage returns the sentinel STAGE_NOOP only
|
||||||
|
// when target === current. Here the stage actually changes, so the
|
||||||
|
// result is the updated row.
|
||||||
|
if (typeof updated === 'symbol') throw new Error('unexpected no-op');
|
||||||
expect(updated.pipelineStage).toBe('qualified');
|
expect(updated.pipelineStage).toBe('qualified');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,21 @@ describe('classifyTier', () => {
|
|||||||
it('"B" when only lost interests exist (no active)', () => {
|
it('"B" when only lost interests exist (no active)', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B');
|
expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B');
|
||||||
});
|
});
|
||||||
it('"C" when an active interest is in an early stage', () => {
|
// L-001 renumber: 7-stage ranks are 1=enquiry, 2=qualified/nurturing,
|
||||||
|
// 3=eoi, 4=reservation, 5=deposit_paid, 6=contract. Tier D fires at
|
||||||
|
// deposit_paid (5) or later.
|
||||||
|
it('"C" when an active interest is in an early stage (eoi)', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C');
|
||||||
});
|
});
|
||||||
it('"C" even when a prior interest was lost, if there is an active one', () => {
|
it('"C" even when a prior interest was lost, if there is an active one', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C');
|
||||||
});
|
});
|
||||||
it('"D" when an active interest is at deposit or beyond', () => {
|
it('"D" when an active interest is at deposit or beyond', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('D');
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D');
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 8 })).toBe('D');
|
|
||||||
});
|
});
|
||||||
it('still "C" at eoi_signed (stage 5) - tier D only kicks in at deposit', () => {
|
it('still "C" at reservation (stage 4) - tier D only kicks in at deposit', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 4 })).toBe('C');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ describe('computeHeat', () => {
|
|||||||
latestFallthroughAt: null,
|
latestFallthroughAt: null,
|
||||||
totalInterestCount: 0,
|
totalInterestCount: 0,
|
||||||
eoiSignedCount: 0,
|
eoiSignedCount: 0,
|
||||||
fallthroughMaxStage: 6, // deposit_10pct
|
fallthroughMaxStage: 5, // deposit_paid (was deposit_10pct=6 pre-refactor)
|
||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
NOW,
|
NOW,
|
||||||
|
|||||||
Reference in New Issue
Block a user