chore(style): codebase em-dash sweep + minor layout polish
Replaces every em-dash and en-dash with regular ASCII hyphens across comments, JSX strings, and dev-facing logs. Mostly cosmetic but stops the inconsistent mix that crept in over the last few months (some files used em-dashes in comments, others didn't, some used both). Bundles two small dashboard-layout tweaks that touch a couple of already-modified files: - (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6 pb-6 so page content sits closer to the topbar. - Sidebar now receives the ports list it needs for the footer port switcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
label: 'Default signature (HTML)',
|
label: 'Default signature (HTML)',
|
||||||
description: 'Appended to the bottom of system-generated emails.',
|
description: 'Appended to the bottom of system-generated emails.',
|
||||||
type: 'html',
|
type: 'html',
|
||||||
placeholder: '<p>—<br>The Port Nimara team</p>',
|
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
{
|
{
|
||||||
key: 'smtp_pass_override',
|
key: 'smtp_pass_override',
|
||||||
label: 'SMTP password override',
|
label: 'SMTP password override',
|
||||||
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
|
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
|||||||
/**
|
/**
|
||||||
* Route-level loading UI for the client detail page. Renders while the
|
* Route-level loading UI for the client detail page. Renders while the
|
||||||
* server component resolves the session and the client component bootstraps
|
* server component resolves the session and the client component bootstraps
|
||||||
* its initial query — replaces the previous empty-header flash on direct
|
* its initial query - replaces the previous empty-header flash on direct
|
||||||
* URL visits.
|
* URL visits.
|
||||||
*/
|
*/
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header strip — title, badges, action buttons */}
|
{/* Header strip - title, badges, action buttons */}
|
||||||
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-7 w-56" />
|
<Skeleton className="h-7 w-56" />
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
|
|||||||
}, [setChrome]);
|
}, [setChrome]);
|
||||||
|
|
||||||
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
||||||
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
|
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
|
||||||
// the review step. Doubles as the source of truth for the billing entity prefill.
|
// the review step. Doubles as the source of truth for the billing entity prefill.
|
||||||
const { data: prefilledInterest } = useQuery<{
|
const { data: prefilledInterest } = useQuery<{
|
||||||
data: {
|
data: {
|
||||||
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header — desktop only; mobile gets the title from the topbar */}
|
{/* Header - desktop only; mobile gets the title from the topbar */}
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
|
|||||||
{prefilledInterest?.data
|
{prefilledInterest?.data
|
||||||
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||||
prefilledInterest.data.berthMooringNumber
|
prefilledInterest.data.berthMooringNumber
|
||||||
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
|
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
|
||||||
: ''
|
: ''
|
||||||
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
||||||
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<RealtimeToasts />
|
<RealtimeToasts />
|
||||||
{/* Desktop shell — hidden by CSS on mobile */}
|
{/* Desktop shell - hidden by CSS on mobile */}
|
||||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
}}
|
}}
|
||||||
|
ports={ports}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile shell — hidden by CSS on desktop */}
|
{/* Mobile shell - hidden by CSS on desktop */}
|
||||||
<MobileLayout>{children}</MobileLayout>
|
<MobileLayout>{children}</MobileLayout>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function PortalLayout({
|
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
// This layout wraps all portal routes including login/verify
|
// This layout wraps all portal routes including login/verify
|
||||||
// We can't easily check pathname in a server layout, so we attempt
|
// We can't easily check pathname in a server layout, so we attempt
|
||||||
// to get the session and pass it down — login/verify pages handle their own
|
// to get the session and pass it down - login/verify pages handle their own
|
||||||
// redirect logic independently.
|
// redirect logic independently.
|
||||||
const session = await getPortalSession().catch(() => null);
|
const session = await getPortalSession().catch(() => null);
|
||||||
|
|
||||||
@@ -42,17 +38,11 @@ export default async function PortalLayout({
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{session && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<PortalHeader
|
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||||
portName={portName}
|
|
||||||
portLogoUrl={portLogoUrl}
|
|
||||||
clientName={clientName}
|
|
||||||
/>
|
|
||||||
<PortalNav />
|
<PortalNav />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
|
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
|
|||||||
<PasswordSetForm
|
<PasswordSetForm
|
||||||
endpoint="/api/portal/auth/activate"
|
endpoint="/api/portal/auth/activate"
|
||||||
title="Activate your account"
|
title="Activate your account"
|
||||||
description="Welcome — choose a password to finish setting up your client portal account."
|
description="Welcome - choose a password to finish setting up your client portal account."
|
||||||
successTitle="Account activated"
|
successTitle="Account activated"
|
||||||
successDescription="You can now sign in with your new password."
|
successDescription="You can now sign in with your new password."
|
||||||
submitLabel="Activate account"
|
submitLabel="Activate account"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Always returns 200 — caller never sees whether email exists.
|
// Always returns 200 - caller never sees whether email exists.
|
||||||
await fetch('/api/portal/auth/forgot-password', {
|
await fetch('/api/portal/auth/forgot-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
|
|||||||
<span className="font-medium text-gray-900">General Interest</span>
|
<span className="font-medium text-gray-900">General Interest</span>
|
||||||
)}
|
)}
|
||||||
{interest.berthArea && (
|
{interest.berthArea && (
|
||||||
<span className="text-sm text-gray-400">— {interest.berthArea}</span>
|
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{interest.leadCategory && (
|
{interest.leadCategory && (
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||||
{r.berthMooringNumber && (
|
{r.berthMooringNumber && (
|
||||||
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
<span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
|
|||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
name: `${portName} — Scanner`,
|
name: `${portName} - Scanner`,
|
||||||
short_name: 'Scanner',
|
short_name: 'Scanner',
|
||||||
description: `Capture and submit expense receipts for ${portName}.`,
|
description: `Capture and submit expense receipts for ${portName}.`,
|
||||||
start_url: `/${portSlug}/scan`,
|
start_url: `/${portSlug}/scan`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
|||||||
import { ScanShell } from '@/components/scan/scan-shell';
|
import { ScanShell } from '@/components/scan/scan-shell';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Scan receipt — Port Nimara',
|
title: 'Scan receipt - Port Nimara',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liveness probe — confirms the Next.js process is responding.
|
* Liveness probe - confirms the Next.js process is responding.
|
||||||
*
|
*
|
||||||
* Returns 200 unconditionally; if the process is wedged or has crashed
|
* Returns 200 unconditionally; if the process is wedged or has crashed
|
||||||
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
||||||
* checks in this endpoint — a transient downstream blip should drop the
|
* checks in this endpoint - a transient downstream blip should drop the
|
||||||
* pod from the load balancer (readiness), not restart the pod (liveness).
|
* pod from the load balancer (readiness), not restart the pod (liveness).
|
||||||
*
|
*
|
||||||
* For deep dependency checks, hit `/api/ready` instead.
|
* For deep dependency checks, hit `/api/ready` instead.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
|||||||
// Keep the helper aligned with that.
|
// Keep the helper aligned with that.
|
||||||
type Tx = typeof db;
|
type Tx = typeof db;
|
||||||
|
|
||||||
// POST /api/public/interests — unauthenticated public interest registration.
|
// POST /api/public/interests - unauthenticated public interest registration.
|
||||||
// Creates the trio (client + yacht + interest) plus an optional company +
|
// Creates the trio (client + yacht + interest) plus an optional company +
|
||||||
// membership, all inside a single transaction.
|
// membership, all inside a single transaction.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||||
|
|
||||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
// Resolve berth by mooring number (if provided). Read-only lookup - safe
|
||||||
// to do outside the transaction.
|
// to do outside the transaction.
|
||||||
let berthId: string | null = null;
|
let berthId: string | null = null;
|
||||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
* POST /api/public/residential-inquiries - unauthenticated entry point for
|
||||||
* the public website's residential interest form. Creates a
|
* the public website's residential interest form. Creates a
|
||||||
* `residential_clients` row and an opening `residential_interests` row in a
|
* `residential_clients` row and an opening `residential_interests` row in a
|
||||||
* single transaction.
|
* single transaction.
|
||||||
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
|
|||||||
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
||||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
||||||
|
|
||||||
// Send notification emails (non-blocking — failures shouldn't 500 the
|
// Send notification emails (non-blocking - failures shouldn't 500 the
|
||||||
// public form).
|
// public form).
|
||||||
void sendResidentialNotifications({
|
void sendResidentialNotifications({
|
||||||
portId,
|
portId,
|
||||||
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
|
|||||||
});
|
});
|
||||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||||
|
|
||||||
// Sales-team alert — pull recipients from system_settings if configured;
|
// Sales-team alert - pull recipients from system_settings if configured;
|
||||||
// fall back to the inquiry_contact_email if available.
|
// fall back to the inquiry_contact_email if available.
|
||||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface ReadyResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe — verifies that every backing service this process
|
* Readiness probe - verifies that every backing service this process
|
||||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||||
* load balancer until the next probe succeeds; it should not trigger a
|
* load balancer until the next probe succeeds; it should not trigger a
|
||||||
* pod restart (that's what `/api/health` is for).
|
* pod restart (that's what `/api/health` is for).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
|||||||
* exercised by the realapi socket fanout test.
|
* exercised by the realapi socket fanout test.
|
||||||
*
|
*
|
||||||
* Requires super_admin or per-port admin permissions; the engine itself
|
* Requires super_admin or per-port admin permissions; the engine itself
|
||||||
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
|
* is idempotent - duplicate runs only re-evaluate, never duplicate rows.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(async (_req, ctx) => {
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin probe — calls Documenso /api/v1/health using the port's effective
|
* Admin probe - calls Documenso /api/v1/health using the port's effective
|
||||||
* config. Used by the "Test connection" button on /admin/documenso.
|
* config. Used by the "Test connection" button on /admin/documenso.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
|
|||||||
.map((p) => {
|
.map((p) => {
|
||||||
const a = clientById.get(p.clientAId);
|
const a = clientById.get(p.clientAId);
|
||||||
const b = clientById.get(p.clientBId);
|
const b = clientById.get(p.clientBId);
|
||||||
if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive
|
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
|
||||||
// Skip pairs where one side has already been merged or archived.
|
// Skip pairs where one side has already been merged or archived.
|
||||||
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
|
|||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
// crm_user_invites is a global table (no per-port column) — invites
|
// crm_user_invites is a global table (no per-port column) - invites
|
||||||
// mint better-auth users that may later be assigned roles in any
|
// mint better-auth users that may later be assigned roles in any
|
||||||
// port. Listing it cross-tenant would let a port-A director
|
// port. Listing it cross-tenant would let a port-A director
|
||||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const schema = z.object({
|
|||||||
apiKey: z.string().min(1),
|
apiKey: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// `manage_settings`-gated for parity with the parent OCR settings route —
|
// `manage_settings`-gated for parity with the parent OCR settings route -
|
||||||
// triggers outbound AI provider auth requests using a caller-supplied key.
|
// triggers outbound AI provider auth requests using a caller-supplied key.
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req) => {
|
withPermission('admin', 'manage_settings', async (req) => {
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
|
|||||||
* POST /api/v1/admin/templates/preview
|
* POST /api/v1/admin/templates/preview
|
||||||
*
|
*
|
||||||
* Generates a preview PDF from a TipTap JSON content block.
|
* Generates a preview PDF from a TipTap JSON content block.
|
||||||
* Returns { data: { pdfBase64: string } } — the client can render this
|
* Returns { data: { pdfBase64: string } } - the client can render this
|
||||||
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
||||||
*
|
*
|
||||||
* Body:
|
* Body:
|
||||||
* content: TipTap JSON document
|
* content: TipTap JSON document
|
||||||
* sampleData?: Record<string, string> — variable substitutions
|
* sampleData?: Record<string, string> - variable substitutions
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('documents', 'manage', async (req, _ctx) => {
|
withPermission('documents', 'manage', async (req, _ctx) => {
|
||||||
@@ -60,10 +60,7 @@ export const POST = withAuth(
|
|||||||
/**
|
/**
|
||||||
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
||||||
*/
|
*/
|
||||||
function substituteInDoc(
|
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||||
node: TipTapNode,
|
|
||||||
data: Record<string, string>,
|
|
||||||
): TipTapNode {
|
|
||||||
if (node.type === 'text' && node.text) {
|
if (node.type === 'text' && node.text) {
|
||||||
return { ...node, text: substituteVariables(node.text, data) };
|
return { ...node, text: substituteVariables(node.text, data) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||||
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
// PATCH cannot use `withPermission` wrapper - the required permission depends
|
||||||
// on the `action` field in the body. `requirePermission` is called inside the
|
// on the `action` field in the body. `requirePermission` is called inside the
|
||||||
// handler after the body is parsed.
|
// handler after the body is parsed.
|
||||||
export const PATCH = withAuth(patchHandler);
|
export const PATCH = withAuth(patchHandler);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const PUT = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// PATCH /api/v1/berths/[id]/waiting-list — reorder a single entry
|
// PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
|
// GET /api/v1/berths/options - lightweight list for selects/comboboxes
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('berths', 'view', async (req, ctx) => {
|
withPermission('berths', 'view', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const inviteSchema = z.object({
|
|||||||
*
|
*
|
||||||
* Admin creates a portal account for a client and triggers the activation
|
* Admin creates a portal account for a client and triggers the activation
|
||||||
* email. Idempotent in spirit: if a portal user already exists for the
|
* email. Idempotent in spirit: if a portal user already exists for the
|
||||||
* email, returns 409 — the admin can resend the activation via
|
* email, returns 409 - the admin can resend the activation via
|
||||||
* ?action=resend.
|
* ?action=resend.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
|
|||||||
const nameResult = rawName ? normalizeName(rawName) : null;
|
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||||
|
|
||||||
// If the caller didn't give us anything useful to match on, return empty
|
// If the caller didn't give us anything useful to match on, return empty
|
||||||
// — short-circuit rather than scan every client for nothing.
|
// - short-circuit rather than scan every client for nothing.
|
||||||
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
||||||
return NextResponse.json({ data: [] });
|
return NextResponse.json({ data: [] });
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
|
|||||||
mediumScore: 50,
|
mediumScore: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only return medium+ — low-confidence noise isn't useful at the
|
// Only return medium+ - low-confidence noise isn't useful at the
|
||||||
// create-form layer (background scoring queue picks those up).
|
// create-form layer (background scoring queue picks those up).
|
||||||
const useful = matches.filter((m) => m.confidence !== 'low');
|
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||||
if (useful.length === 0) {
|
if (useful.length === 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
const mergeSchema = z.object({
|
const mergeSchema = z.object({
|
||||||
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
|
/** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-port budget gate — refuse the call before we spend tokens
|
// Per-port budget gate - refuse the call before we spend tokens
|
||||||
// when the port has already hit its hard cap, or when the request
|
// when the port has already hit its hard cap, or when the request
|
||||||
// would push it past the cap. Soft-cap warnings ride along on the
|
// would push it past the cap. Soft-cap warnings ride along on the
|
||||||
// success response so the UI can show a banner without blocking.
|
// success response so the UI can show a banner without blocking.
|
||||||
@@ -99,7 +99,7 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||||
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
// Provider hiccup - degrade to manual entry rather than 500-ing.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
parsed: EMPTY,
|
parsed: EMPTY,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
const body = await parseBody(req, createFolderSchema);
|
const body = await parseBody(req, createFolderSchema);
|
||||||
|
|
||||||
// Sanitize path — no null bytes, no path traversal
|
// Sanitize path - no null bytes, no path traversal
|
||||||
const safePath = body.path
|
const safePath = body.path
|
||||||
.replace(/\x00/g, '')
|
.replace(/\x00/g, '')
|
||||||
.replace(/\.\.\//g, '')
|
.replace(/\.\.\//g, '')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/v1/interests/[id]/recommendations — add manual recommendation
|
// POST /api/v1/interests/[id]/recommendations - add manual recommendation
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('interests', 'edit', async (req, ctx, params) => {
|
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
|
|||||||
|
|
||||||
const OUTCOME_LABELS: Record<string, string> = {
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
won: 'Won',
|
won: 'Won',
|
||||||
lost_other_marina: 'Lost — went to another marina',
|
lost_other_marina: 'Lost - went to another marina',
|
||||||
lost_unqualified: 'Lost — unqualified',
|
lost_unqualified: 'Lost - unqualified',
|
||||||
lost_no_response: 'Lost — no response',
|
lost_no_response: 'Lost - no response',
|
||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ function buildAuditDescription(
|
|||||||
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||||
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||||
const reason = (newValue?.reason as string | undefined) ?? '';
|
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||||
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'outcome_cleared') {
|
if (type === 'outcome_cleared') {
|
||||||
@@ -200,9 +200,9 @@ function buildAuditDescription(
|
|||||||
const reason = (newValue.reason as string | undefined) ?? '';
|
const reason = (newValue.reason as string | undefined) ?? '';
|
||||||
const auto = userId === 'system';
|
const auto = userId === 'system';
|
||||||
if (auto) {
|
if (auto) {
|
||||||
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
|
||||||
}
|
}
|
||||||
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'update' && newValue?.pipelineStage) {
|
if (action === 'update' && newValue?.pipelineStage) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
|||||||
|
|
||||||
const results = await search(ctx.portId, q);
|
const results = await search(ctx.portId, q);
|
||||||
|
|
||||||
// Fire-and-forget — do not await
|
// Fire-and-forget - do not await
|
||||||
saveRecentSearch(ctx.userId, ctx.portId, q);
|
saveRecentSearch(ctx.userId, ctx.portId, q);
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping');
|
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
|
||||||
return NextResponse.json({ ok: true }, { status: 200 });
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
@apply bg-background text-foreground font-sans antialiased;
|
@apply bg-background text-foreground font-sans antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wave watermark — subtle background texture for auth pages */
|
/* Wave watermark - subtle background texture for auth pages */
|
||||||
.wave-watermark {
|
.wave-watermark {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||||
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||||||
*
|
*
|
||||||
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
|
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
|
||||||
* display, because the desktop shell uses Tailwind's `flex` class which would
|
* display, because the desktop shell uses Tailwind's `flex` class which would
|
||||||
* be overridden by `display: block` (same specificity, later cascade).
|
* be overridden by `display: block` (same specificity, later cascade).
|
||||||
*/
|
*/
|
||||||
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recharts focus-ring suppression.
|
||||||
|
*
|
||||||
|
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
|
||||||
|
* them (the library adds tabindex on chart sectors / paths). The global
|
||||||
|
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
|
||||||
|
* which on a chart surface reads as a stray rectangle around the plot
|
||||||
|
* area. Hover/tooltip already handles chart interactivity, so suppress
|
||||||
|
* the ring entirely here.
|
||||||
|
*
|
||||||
|
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
|
||||||
|
* it during purge (an earlier copy inside `@layer base` was being
|
||||||
|
* silently removed at build time, leaving the ring intact).
|
||||||
|
*/
|
||||||
|
div.recharts-wrapper:focus,
|
||||||
|
div.recharts-wrapper:focus-visible,
|
||||||
|
svg.recharts-surface:focus,
|
||||||
|
svg.recharts-surface:focus-visible,
|
||||||
|
div.recharts-responsive-container:focus,
|
||||||
|
div.recharts-responsive-container:focus-visible,
|
||||||
|
.recharts-wrapper *:focus,
|
||||||
|
.recharts-wrapper *:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-color: transparent !important;
|
||||||
|
--tw-ring-offset-color: transparent !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function AuditLogList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
// Filter state — debounce text inputs.
|
// Filter state - debounce text inputs.
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [entityType, setEntityType] = useState<string>('all');
|
const [entityType, setEntityType] = useState<string>('all');
|
||||||
const [action, setAction] = useState<string>('all');
|
const [action, setAction] = useState<string>('all');
|
||||||
@@ -215,7 +215,7 @@ export function AuditLogList() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-xs text-muted-foreground">—</span>;
|
return <span className="text-xs text-muted-foreground">-</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +245,7 @@ export function AuditLogList() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Audit Log"
|
title="Audit Log"
|
||||||
eyebrow="Admin"
|
eyebrow="Admin"
|
||||||
description="Every state change in this port — fully searchable."
|
description="Every state change in this port - fully searchable."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CustomFieldForm({
|
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
field,
|
|
||||||
onSuccess,
|
|
||||||
}: CustomFieldFormProps) {
|
|
||||||
const isEdit = !!field;
|
const isEdit = !!field;
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
@@ -72,9 +67,7 @@ export function CustomFieldForm({
|
|||||||
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
||||||
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
||||||
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
||||||
const [selectOptions, setSelectOptions] = useState<string[]>(
|
const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
|
||||||
field?.selectOptions ?? [],
|
|
||||||
);
|
|
||||||
const [newOption, setNewOption] = useState('');
|
const [newOption, setNewOption] = useState('');
|
||||||
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
||||||
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
||||||
@@ -169,13 +162,11 @@ export function CustomFieldForm({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
|
||||||
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||||
{/* Entity Type — create only */}
|
{/* Entity Type - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
@@ -198,7 +189,7 @@ export function CustomFieldForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Field Name — create only */}
|
{/* Field Name - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-field-name">
|
<Label htmlFor="cf-field-name">
|
||||||
Field Name
|
Field Name
|
||||||
@@ -232,7 +223,7 @@ export function CustomFieldForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Field Type — create only */}
|
{/* Field Type - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-field-type">Field Type</Label>
|
<Label htmlFor="cf-field-type">Field Type</Label>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
@@ -260,7 +251,7 @@ export function CustomFieldForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select Options — visible when fieldType = 'select' */}
|
{/* Select Options - visible when fieldType = 'select' */}
|
||||||
{fieldType === 'select' && (
|
{fieldType === 'select' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Options</Label>
|
<Label>Options</Label>
|
||||||
@@ -302,11 +293,7 @@ export function CustomFieldForm({
|
|||||||
{/* Is Required */}
|
{/* Is Required */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="cf-is-required">Required field</Label>
|
<Label htmlFor="cf-is-required">Required field</Label>
|
||||||
<Switch
|
<Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
|
||||||
id="cf-is-required"
|
|
||||||
checked={isRequired}
|
|
||||||
onCheckedChange={setIsRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Order */}
|
{/* Sort Order */}
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||||
|
|
||||||
@@ -61,20 +55,13 @@ interface TemplateFormProps {
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateForm({
|
export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
template,
|
|
||||||
onSuccess,
|
|
||||||
}: TemplateFormProps) {
|
|
||||||
const isEdit = !!template;
|
const isEdit = !!template;
|
||||||
|
|
||||||
const [name, setName] = useState(template?.name ?? '');
|
const [name, setName] = useState(template?.name ?? '');
|
||||||
const [type, setType] = useState(template?.templateType ?? 'other');
|
const [type, setType] = useState(template?.templateType ?? 'other');
|
||||||
const [contentJson, setContentJson] = useState(
|
const [contentJson, setContentJson] = useState(
|
||||||
template?.content
|
template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
|
||||||
? JSON.stringify(template.content, null, 2)
|
|
||||||
: EMPTY_DOC,
|
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -86,7 +73,7 @@ export function TemplateForm({
|
|||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setJsonError('Invalid JSON — check syntax.');
|
setJsonError('Invalid JSON - check syntax.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,8 +102,7 @@ export function TemplateForm({
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||||
err instanceof Error ? err.message : 'Something went wrong';
|
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -127,9 +113,7 @@ export function TemplateForm({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
|
||||||
{isEdit ? 'Edit Template' : 'New Document Template'}
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||||
@@ -145,7 +129,7 @@ export function TemplateForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type — only on create */}
|
{/* Type - only on create */}
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="template-type">Document Type</Label>
|
<Label htmlFor="template-type">Document Type</Label>
|
||||||
@@ -166,15 +150,11 @@ export function TemplateForm({
|
|||||||
|
|
||||||
{/* TipTap JSON Content */}
|
{/* TipTap JSON Content */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="template-content">
|
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
||||||
Document Content (TipTap JSON)
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Paste or edit TipTap JSON. Use{' '}
|
Paste or edit TipTap JSON. Use{' '}
|
||||||
<code className="rounded bg-muted px-1 text-xs">
|
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
|
||||||
{'{{variable.key}}'}
|
dynamic content.
|
||||||
</code>{' '}
|
|
||||||
tokens for dynamic content.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
id="template-content"
|
id="template-content"
|
||||||
@@ -187,9 +167,7 @@ export function TemplateForm({
|
|||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
{jsonError && (
|
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
|
||||||
<p className="text-xs text-destructive">{jsonError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Available Variables Reference */}
|
{/* Available Variables Reference */}
|
||||||
@@ -200,19 +178,15 @@ export function TemplateForm({
|
|||||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||||
{TEMPLATE_VARIABLES.map((v) => (
|
{TEMPLATE_VARIABLES.map((v) => (
|
||||||
<div key={v.key} className="text-xs">
|
<div key={v.key} className="text-xs">
|
||||||
<code className="rounded bg-muted px-1">
|
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
|
||||||
{`{{${v.key}}}`}
|
<span className="text-muted-foreground">- {v.label}</span>
|
||||||
</code>{' '}
|
|
||||||
<span className="text-muted-foreground">— {v.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
@@ -225,11 +199,7 @@ export function TemplateForm({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !!jsonError}>
|
<Button type="submit" disabled={loading || !!jsonError}>
|
||||||
{loading
|
{loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
|
||||||
? 'Saving…'
|
|
||||||
: isEdit
|
|
||||||
? 'Save Changes'
|
|
||||||
: 'Create Template'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TemplateForm } from './template-form';
|
import { TemplateForm } from './template-form';
|
||||||
import { TemplateVersionHistory } from './template-version-history';
|
import { TemplateVersionHistory } from './template-version-history';
|
||||||
@@ -57,9 +52,7 @@ export function TemplateList() {
|
|||||||
const fetchTemplates = useCallback(async () => {
|
const fetchTemplates = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ data: AdminTemplate[] }>(
|
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
|
||||||
'/api/v1/admin/templates',
|
|
||||||
);
|
|
||||||
setTemplates(res.data);
|
setTemplates(res.data);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -122,9 +115,7 @@ export function TemplateList() {
|
|||||||
accessorKey: 'version',
|
accessorKey: 'version',
|
||||||
header: 'Version',
|
header: 'Version',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">v{row.original.version}</span>
|
||||||
v{row.original.version}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -151,10 +142,7 @@ export function TemplateList() {
|
|||||||
header: '',
|
header: '',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<TemplatePreview
|
<TemplatePreview content={row.original.content} templateName={row.original.name} />
|
||||||
content={row.original.content}
|
|
||||||
templateName={row.original.name}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -177,9 +165,7 @@ export function TemplateList() {
|
|||||||
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
||||||
onClick={() => handleToggleActive(row.original)}
|
onClick={() => handleToggleActive(row.original)}
|
||||||
>
|
>
|
||||||
<span className="text-xs">
|
<span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
|
||||||
{row.original.isActive ? 'Off' : 'On'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
trigger={
|
trigger={
|
||||||
@@ -233,9 +219,7 @@ export function TemplateList() {
|
|||||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||||
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
|
||||||
Version History — {historyTemplate?.name}
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{historyTemplate && (
|
{historyTemplate && (
|
||||||
|
|||||||
@@ -3,12 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Eye, ExternalLink } from 'lucide-react';
|
import { Eye, ExternalLink } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||||
|
|
||||||
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Build sample data from TEMPLATE_VARIABLES examples
|
// Build sample data from TEMPLATE_VARIABLES examples
|
||||||
const sampleData = Object.fromEntries(
|
const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
|
||||||
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handlePreview() {
|
async function handlePreview() {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle>Preview — {templateName}</DialogTitle>
|
<DialogTitle>Preview - {templateName}</DialogTitle>
|
||||||
{pdfBase64 && (
|
{pdfBase64 && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleOpenInNewTab}
|
|
||||||
className="mr-6"
|
|
||||||
>
|
|
||||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Open in new tab
|
Open in new tab
|
||||||
</Button>
|
</Button>
|
||||||
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
|
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pdfBase64 && !loading && (
|
{pdfBase64 && !loading && (
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function InvitationsManager() {
|
|||||||
{invites.map((i) => (
|
{invites.map((i) => (
|
||||||
<tr key={i.id} className="border-t">
|
<tr key={i.id} className="border-t">
|
||||||
<td className="px-3 py-2 font-medium">{i.email}</td>
|
<td className="px-3 py-2 font-medium">{i.email}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '—'}</td>
|
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
||||||
</td>
|
</td>
|
||||||
@@ -163,7 +163,7 @@ export function InvitationsManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
|||||||
Enable AI receipt parsing for this port
|
Enable AI receipt parsing for this port
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Off by default. Receipts are read on-device using Tesseract.js — accurate enough for
|
Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
|
||||||
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
||||||
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
||||||
</p>
|
</p>
|
||||||
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
|||||||
id={`apiKey-${scope}`}
|
id={`apiKey-${scope}`}
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setApiKey(e.target.value);
|
setApiKey(e.target.value);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
|
|||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(ts: number | undefined): string {
|
function formatDate(ts: number | undefined): string {
|
||||||
if (!ts) return '—';
|
if (!ts) return '-';
|
||||||
return new Date(ts).toLocaleString();
|
return new Date(ts).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function truncateReason(reason: string | undefined): string {
|
function truncateReason(reason: string | undefined): string {
|
||||||
if (!reason) return '—';
|
if (!reason) return '-';
|
||||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{total} total jobs — page {page} of {totalPages}
|
{total} total jobs - page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function RoleList() {
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: 'Description',
|
header: 'Description',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
|
<span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ export function SettingsManager() {
|
|||||||
);
|
);
|
||||||
void saveSetting(setting.key, parsed);
|
void saveSetting(setting.key, parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// invalid JSON — do nothing
|
// invalid JSON - do nothing
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
|
|||||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email subtitle — only when display name is shown as title */}
|
{/* Email subtitle - only when display name is shown as title */}
|
||||||
{user.displayName && user.displayName !== user.email ? (
|
{user.displayName && user.displayName !== user.email ? (
|
||||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
|||||||
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
<TableRow key={d.id}>
|
<TableRow key={d.id}>
|
||||||
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
|
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
|
||||||
{d.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
{d.responseStatus ?? '—'}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
|
||||||
<TableCell className="text-sm">{d.attempt}</TableCell>
|
<TableCell className="text-sm">{d.attempt}</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{d.deliveredAt
|
{d.deliveredAt
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
|||||||
export function AlertBell() {
|
export function AlertBell() {
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
|
// Count is cheap (one aggregate query) - fire on every page so the badge stays live.
|
||||||
// List is heavier — only fetch when the popover is actually open.
|
// List is heavier - only fetch when the popover is actually open.
|
||||||
const { data: count } = useAlertCount();
|
const { data: count } = useAlertCount();
|
||||||
const { data: list, isLoading } = useAlertList('open', open);
|
const { data: list, isLoading } = useAlertList('open', open);
|
||||||
useAlertRealtime();
|
useAlertRealtime();
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ export function AlertRail() {
|
|||||||
<section
|
<section
|
||||||
data-testid="alert-rail"
|
data-testid="alert-rail"
|
||||||
aria-label="Active alerts"
|
aria-label="Active alerts"
|
||||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
// Natural height - the parent aside no longer forces 100% of the
|
||||||
// gives this rail a sibling column whose height it should match. On
|
// dashboard grid row, so the rail can sit compactly under Reminders
|
||||||
// mobile (single-column stack) there's no fixed-height context, so
|
// without bleeding down into the Recent Activity panel below.
|
||||||
// forcing 100% height makes the section overflow / look stretched.
|
className="flex flex-col gap-3"
|
||||||
className="flex flex-col gap-3 xl:h-full"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||||
@@ -57,7 +56,7 @@ export function AlertRail() {
|
|||||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||||
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
+{overflow} more — view all
|
+{overflow} more - view all
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'mooringNumber',
|
accessorKey: 'mooringNumber',
|
||||||
header: 'Mooring #',
|
header: 'Mooring #',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||||
<span className="font-medium">{row.original.mooringNumber}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'area',
|
accessorKey: 'area',
|
||||||
header: 'Area',
|
header: 'Area',
|
||||||
cell: ({ row }) => row.original.area ?? '—',
|
cell: ({ row }) => row.original.area ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { lengthM, widthM } = row.original;
|
const { lengthM, widthM } = row.original;
|
||||||
if (!lengthM && !widthM) return '—';
|
if (!lengthM && !widthM) return '-';
|
||||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
header: 'Price',
|
header: 'Price',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { price, priceCurrency } = row.original;
|
const { price, priceCurrency } = row.original;
|
||||||
if (!price) return '—';
|
if (!price) return '-';
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: priceCurrency || 'USD',
|
currency: priceCurrency || 'USD',
|
||||||
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'tenureType',
|
accessorKey: 'tenureType',
|
||||||
header: 'Tenure',
|
header: 'Tenure',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||||||
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function SelectOrEmpty({
|
|||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NONE}>—</SelectItem>
|
<SelectItem value={NONE}>-</SelectItem>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<SelectItem key={opt} value={opt}>
|
<SelectItem key={opt} value={opt}>
|
||||||
{opt}
|
{opt}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
href={`/${portSlug}/interests/${i.id}` as never}
|
href={`/${portSlug}/interests/${i.id}` as never}
|
||||||
className="hover:text-brand"
|
className="hover:text-brand"
|
||||||
>
|
>
|
||||||
{i.clientName ?? '—'}
|
{i.clientName ?? '-'}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
|
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
|
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
{new Date(i.createdAt).toLocaleDateString()}
|
{new Date(i.createdAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function BerthList() {
|
|||||||
title="Berths"
|
title="Berths"
|
||||||
description="View and manage berth allocations"
|
description="View and manage berth allocations"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
// No "New" button — berths are import-only
|
// No "New" button - berths are import-only
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
|
||||||
who's interested + how warm without clicking into the Interests tab. */}
|
who's interested + how warm without clicking into the Interests tab. */}
|
||||||
<BerthInterestPulse berthId={berth.id} />
|
<BerthInterestPulse berthId={berth.id} />
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function getClientColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
||||||
if (!primary) return <span className="text-muted-foreground">—</span>;
|
if (!primary) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||||
@@ -86,7 +86,7 @@ export function getClientColumns({
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const iso = getValue() as string | null;
|
const iso = getValue() as string | null;
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
|
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@ export function getClientColumns({
|
|||||||
header: 'Source',
|
header: 'Source',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const source = getValue() as string | null;
|
const source = getValue() as string | null;
|
||||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="capitalize text-xs">
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
{SOURCE_LABELS[source] ?? source}
|
{SOURCE_LABELS[source] ?? source}
|
||||||
@@ -111,7 +111,7 @@ export function getClientColumns({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const c = row.original.yachtCount ?? 0;
|
const c = row.original.yachtCount ?? 0;
|
||||||
return c === 0 ? (
|
return c === 0 ? (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{c}
|
{c}
|
||||||
@@ -126,7 +126,7 @@ export function getClientColumns({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const c = row.original.companyCount ?? 0;
|
const c = row.original.companyCount ?? 0;
|
||||||
return c === 0 ? (
|
return c === 0 ? (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{c}
|
{c}
|
||||||
@@ -140,7 +140,7 @@ export function getClientColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const clientTags = row.original.tags ?? [];
|
const clientTags = row.original.tags ?? [];
|
||||||
if (clientTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{clientTags.slice(0, 3).map((tag) => (
|
{clientTags.slice(0, 3).map((tag) => (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
|
|||||||
|
|
||||||
function formatSince(startDate: string | Date): string {
|
function formatSince(startDate: string | Date): string {
|
||||||
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
||||||
if (Number.isNaN(d.getTime())) return '—';
|
if (Number.isNaN(d.getTime())) return '-';
|
||||||
return format(d, 'MMM d, yyyy');
|
return format(d, 'MMM d, yyyy');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
|||||||
Primary
|
Primary
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right: archive/restore as a small icon button — destructive
|
{/* Top-right: archive/restore as a small icon button - destructive
|
||||||
action sits out of the primary action flow. */}
|
action sits out of the primary action flow. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} 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
|
||||||
attach the new interest to that client instead of
|
attach the new interest to that client instead of
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
|
|||||||
}) {
|
}) {
|
||||||
// Pin the most recently selected interest so the drawer stays populated
|
// Pin the most recently selected interest so the drawer stays populated
|
||||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||||
// after `open=false`). Conditional setState is safe here — the guard
|
// after `open=false`). Conditional setState is safe here - the guard
|
||||||
// ensures it only fires when the prop actually changes to a new row.
|
// ensures it only fires when the prop actually changes to a new row.
|
||||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||||
if (interest && interest !== pinned) setPinned(interest);
|
if (interest && interest !== pinned) setPinned(interest);
|
||||||
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
{/* Pipeline-stepper segmented bar - the same primitive used on the
|
||||||
row card, so the at-a-glance progress hint is consistent
|
row card, so the at-a-glance progress hint is consistent
|
||||||
across surfaces. */}
|
across surfaces. */}
|
||||||
{stage ? (
|
{stage ? (
|
||||||
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Milestones — three sections matching the full interest detail
|
{/* Milestones - three sections matching the full interest detail
|
||||||
page (EOI / Deposit / Contract). Done-state is derived from
|
page (EOI / Deposit / Contract). Done-state is derived from
|
||||||
the pipeline stage so seed data without per-step dates still
|
the pipeline stage so seed data without per-step dates still
|
||||||
renders correctly. The full milestone columns + per-step
|
renders correctly. The full milestone columns + per-step
|
||||||
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Compact key/value pairs — lead category, source, last contact,
|
{/* Compact key/value pairs - lead category, source, last contact,
|
||||||
activity. Each row collapses cleanly when its value is
|
activity. Each row collapses cleanly when its value is
|
||||||
missing so the drawer scales from sparse seed data to full
|
missing so the drawer scales from sparse seed data to full
|
||||||
records without empty placeholders. */}
|
records without empty placeholders. */}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
|||||||
interface PipelineSummaryProps {
|
interface PipelineSummaryProps {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
/**
|
/**
|
||||||
* `hero` — single-line pulse for the detail header (highest active stage only).
|
* `hero` - single-line pulse for the detail header (highest active stage only).
|
||||||
* `panel` — compact list of every active interest, for the Overview tab.
|
* `panel` - compact list of every active interest, for the Overview tab.
|
||||||
*/
|
*/
|
||||||
variant?: 'hero' | 'panel';
|
variant?: 'hero' | 'panel';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
|
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{y.hullNumber ?? '—'}</TableCell>
|
<TableCell>{y.hullNumber ?? '-'}</TableCell>
|
||||||
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -225,10 +225,10 @@ function ContactRow({
|
|||||||
|
|
||||||
{/* Bottom / right: tag + actions.
|
{/* Bottom / right: tag + actions.
|
||||||
Two layers of hiding compose here:
|
Two layers of hiding compose here:
|
||||||
(a) phoneEditing — when the phone editor is open, hide the entire
|
(a) phoneEditing - when the phone editor is open, hide the entire
|
||||||
action cluster (tag + star + trash) so the user can focus on
|
action cluster (tag + star + trash) so the user can focus on
|
||||||
the form without chips fighting for space.
|
the form without chips fighting for space.
|
||||||
(b) contact.value — when the value is empty (stale import row,
|
(b) contact.value - when the value is empty (stale import row,
|
||||||
aborted edit), hide just the tag + Make-primary star;
|
aborted edit), hide just the tag + Make-primary star;
|
||||||
neither makes sense without a value. The trash icon stays
|
neither makes sense without a value. The trash icon stays
|
||||||
so the user can clean up the empty entry.
|
so the user can clean up the empty entry.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
||||||
// Clear the dismissed flag when inputs change — the user typed
|
// Clear the dismissed flag when inputs change - the user typed
|
||||||
// something new, so the prior dismissal no longer applies.
|
// something new, so the prior dismissal no longer applies.
|
||||||
setDismissed(false);
|
setDismissed(false);
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
|
|||||||
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||||
},
|
},
|
||||||
enabled: hasSomething && !dismissed,
|
enabled: hasSomething && !dismissed,
|
||||||
// Same query is fine to cache for a minute — moves are slow at this layer.
|
// Same query is fine to cache for a minute - moves are slow at this layer.
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
|
|||||||
<p className="text-sm font-semibold leading-tight">
|
<p className="text-sm font-semibold leading-tight">
|
||||||
{isHigh
|
{isHigh
|
||||||
? 'This looks like an existing client'
|
? 'This looks like an existing client'
|
||||||
: 'Possible match — check before creating'}
|
: 'Possible match - check before creating'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Export queued — refresh in ~30 seconds');
|
toast.success('Export queued - refresh in ~30 seconds');
|
||||||
qc.invalidateQueries({ queryKey });
|
qc.invalidateQueries({ queryKey });
|
||||||
setEmailOverride('');
|
setEmailOverride('');
|
||||||
},
|
},
|
||||||
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
Email the bundle when ready
|
Email the bundle when ready
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Sends a 7-day signed download link to the client's primary email — or to the
|
Sends a 7-day signed download link to the client's primary email - or to the
|
||||||
override below.
|
override below.
|
||||||
</p>
|
</p>
|
||||||
{emailToClient ? (
|
{emailToClient ? (
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
|
|||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
||||||
// Detect 409 — service returns a "membership already exists" message
|
// Detect 409 - service returns a "membership already exists" message
|
||||||
if (/already exists/i.test(msg)) {
|
if (/already exists/i.test(msg)) {
|
||||||
msg = 'This membership already exists (same client + role + start date).';
|
msg = 'This membership already exists (same client + role + start date).';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function getCompanyColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const value = getValue() as string | null;
|
const value = getValue() as string | null;
|
||||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
return <span className="text-sm">{value}</span>;
|
return <span className="text-sm">{value}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ export function getCompanyColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const value = getValue() as string | null;
|
const value = getValue() as string | null;
|
||||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
return <span className="text-sm">{value}</span>;
|
return <span className="text-sm">{value}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,7 +98,7 @@ export function getCompanyColumns({
|
|||||||
size: 88,
|
size: 88,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const n = row.original.memberCount ?? 0;
|
const n = row.original.memberCount ?? 0;
|
||||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return <Badge variant="secondary">{n}</Badge>;
|
return <Badge variant="secondary">{n}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +109,7 @@ export function getCompanyColumns({
|
|||||||
size: 88,
|
size: 88,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const n = row.original.yachtCount ?? 0;
|
const n = row.original.yachtCount ?? 0;
|
||||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return <Badge variant="secondary">{n}</Badge>;
|
return <Badge variant="secondary">{n}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CreateCompanyInput) => {
|
mutationFn: async (data: CreateCompanyInput) => {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
// updateCompanySchema omits tagIds — strip them from PATCH body.
|
// updateCompanySchema omits tagIds - strip them from PATCH body.
|
||||||
const { tagIds: _tIds, ...rest } = data;
|
const { tagIds: _tIds, ...rest } = data;
|
||||||
void _tIds;
|
void _tIds;
|
||||||
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
||||||
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
|||||||
value={watch('incorporationCountryIso')}
|
value={watch('incorporationCountryIso')}
|
||||||
onChange={(iso) => {
|
onChange={(iso) => {
|
||||||
setValue('incorporationCountryIso', iso ?? undefined);
|
setValue('incorporationCountryIso', iso ?? undefined);
|
||||||
// Wipe subdivision when country flips — codes are country-scoped.
|
// Wipe subdivision when country flips - codes are country-scoped.
|
||||||
setValue('incorporationSubdivisionIso', undefined);
|
setValue('incorporationSubdivisionIso', undefined);
|
||||||
}}
|
}}
|
||||||
data-testid="company-incorp-country"
|
data-testid="company-incorp-country"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) return '—';
|
if (!value) return '-';
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||||
{m.roleDetail ?? '—'}
|
{m.roleDetail ?? '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDate(m.startDate)}</TableCell>
|
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{m.endDate ? (
|
{m.endDate ? (
|
||||||
formatDate(m.endDate)
|
formatDate(m.endDate)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
|||||||
Primary
|
Primary
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
function formatDimensions(y: OwnedYachtRow): string | null {
|
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||||
if (y.lengthFt || y.widthFt) {
|
if (y.lengthFt || y.widthFt) {
|
||||||
const length = y.lengthFt ?? '—';
|
const length = y.lengthFt ?? '-';
|
||||||
const width = y.widthFt ?? '—';
|
const width = y.widthFt ?? '-';
|
||||||
return `${length} × ${width} ft`;
|
return `${length} × ${width} ft`;
|
||||||
}
|
}
|
||||||
if (y.lengthM || y.widthM) {
|
if (y.lengthM || y.widthM) {
|
||||||
const length = y.lengthM ?? '—';
|
const length = y.lengthM ?? '-';
|
||||||
const width = y.widthM ?? '—';
|
const width = y.widthM ?? '-';
|
||||||
return `${length} × ${width} m`;
|
return `${length} × ${width} m`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
|
|||||||
{dims ? (
|
{dims ? (
|
||||||
<span className="text-sm">{dims}</span>
|
<span className="text-sm">{dims}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{y.hullNumber ? (
|
{y.hullNumber ? (
|
||||||
<span className="text-sm">{y.hullNumber}</span>
|
<span className="text-sm">{y.hullNumber}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
|||||||
<InlineCountryField
|
<InlineCountryField
|
||||||
value={company.incorporationCountryIso}
|
value={company.incorporationCountryIso}
|
||||||
onSave={async (iso) => {
|
onSave={async (iso) => {
|
||||||
// Wipe subdivision when country flips — codes are country-scoped.
|
// Wipe subdivision when country flips - codes are country-scoped.
|
||||||
await mutation.mutateAsync({
|
await mutation.mutateAsync({
|
||||||
incorporationCountryIso: iso,
|
incorporationCountryIso: iso,
|
||||||
incorporationSubdivisionIso: null,
|
incorporationSubdivisionIso: null,
|
||||||
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
|||||||
variant="textarea"
|
variant="textarea"
|
||||||
value={company.notes}
|
value={company.notes}
|
||||||
onSave={save('notes')}
|
onSave={save('notes')}
|
||||||
emptyText="No notes — click to add"
|
emptyText="No notes - click to add"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function ActivityFeedInner() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No recent activity yet — your team's actions (interests created, stages changed,
|
No recent activity yet - your team's actions (interests created, stages changed,
|
||||||
invoices sent) will appear here.
|
invoices sent) will appear here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
) : !slices.length ? (
|
) : !slices.length ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No interests in range"
|
title="No interests in range"
|
||||||
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Percentage radii + center-anchored chart so the pie scales with
|
// Percentage radii + center-anchored chart so the pie scales with
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
|||||||
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
||||||
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
||||||
toast.info(
|
toast.info(
|
||||||
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} — opens in PR8 wizard`,
|
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface DocumentListProps {
|
|||||||
interestId?: string;
|
interestId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
/** Override the default empty state ("No documents yet.") with a contextual
|
/** Override the default empty state ("No documents yet.") with a contextual
|
||||||
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
* CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||||
emptyState?: React.ReactNode;
|
emptyState?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSignerProgress = (doc: DocumentRow) => {
|
const getSignerProgress = (doc: DocumentRow) => {
|
||||||
if (!doc.signers) return '—';
|
if (!doc.signers) return '-';
|
||||||
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
||||||
return `${signed}/${doc.signers.length} signed`;
|
return `${signed}/${doc.signers.length} signed`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '—'}
|
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import {
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
/** Required for the EOI's top paragraph (Section 2) — without these the
|
/** Required for the EOI's top paragraph (Section 2) - without these the
|
||||||
* document is unsignable, so generation is blocked. Yacht and berth fields
|
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||||
* belong to Section 3 and may be left blank. */
|
* belong to Section 3 and may be left blank. */
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
hasEmail: boolean;
|
hasEmail: boolean;
|
||||||
hasAddress: boolean;
|
hasAddress: boolean;
|
||||||
/** Optional — info-only checks. Generation proceeds without them. */
|
/** Optional - info-only checks. Generation proceeds without them. */
|
||||||
hasYacht: boolean;
|
hasYacht: boolean;
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Optional (Section 3 — left blank if absent)
|
Optional (Section 3 - left blank if absent)
|
||||||
</p>
|
</p>
|
||||||
{OPTIONAL_LABELS.map(({ key, label }) => (
|
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||||
<div key={key} className="flex items-center gap-3 text-sm">
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Amount — prominent */}
|
{/* Amount - prominent */}
|
||||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||||
{amountFormatted}
|
{amountFormatted}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function getExpenseColumns({
|
|||||||
className="font-medium text-primary hover:underline"
|
className="font-medium text-primary hover:underline"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{row.original.establishmentName ?? '—'}
|
{row.original.establishmentName ?? '-'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -113,7 +113,7 @@ export function getExpenseColumns({
|
|||||||
header: 'Category',
|
header: 'Category',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const cat = getValue() as string | null;
|
const cat = getValue() as string | null;
|
||||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
if (!cat) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="capitalize text-xs">
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
{cat.replace(/_/g, ' ')}
|
{cat.replace(/_/g, ' ')}
|
||||||
|
|||||||
@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Category</span>
|
<span className="text-muted-foreground">Category</span>
|
||||||
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
|
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Method</span>
|
<span className="text-muted-foreground">Payment Method</span>
|
||||||
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
|
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payer</span>
|
<span className="text-muted-foreground">Payer</span>
|
||||||
<p className="mt-0.5">{expense.payer ?? '—'}</p>
|
<p className="mt-0.5">{expense.payer ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Description</span>
|
<span className="text-muted-foreground">Description</span>
|
||||||
<p className="mt-0.5">{expense.description ?? '—'}</p>
|
<p className="mt-0.5">{expense.description ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||||
* for inline editing — user clicks the chip, picks a new stage from the
|
* for inline editing - user clicks the chip, picks a new stage from the
|
||||||
* popover (with optional reason), commits in one click. The popover stays
|
* popover (with optional reason), commits in one click. The popover stays
|
||||||
* compact: a small reason field above the stage list, and clicking any stage
|
* compact: a small reason field above the stage list, and clicking any stage
|
||||||
* fires the mutation immediately.
|
* fires the mutation immediately.
|
||||||
@@ -140,7 +140,7 @@ export function InlineStagePicker({
|
|||||||
isCurrent && 'font-medium',
|
isCurrent && 'font-medium',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
{/* Colored chip (mirrors the inline stage badge) - turns
|
||||||
the picker into a visual scan rather than just a list. */}
|
the picker into a visual scan rather than just a list. */}
|
||||||
<span
|
<span
|
||||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function getInterestColumns({
|
|||||||
className="truncate font-medium text-primary hover:underline"
|
className="truncate font-medium text-primary hover:underline"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{row.original.clientName ?? '—'}
|
{row.original.clientName ?? '-'}
|
||||||
</Link>
|
</Link>
|
||||||
{notesCount > 0 ? (
|
{notesCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
@@ -99,7 +99,7 @@ export function getInterestColumns({
|
|||||||
header: 'Berth',
|
header: 'Berth',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
||||||
return <span className="text-muted-foreground">—</span>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -150,7 +150,7 @@ export function getInterestColumns({
|
|||||||
header: 'Category',
|
header: 'Category',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const cat = getValue() as string | null;
|
const cat = getValue() as string | null;
|
||||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
if (!cat) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-xs capitalize">
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
{CATEGORY_LABELS[cat] ?? cat}
|
{CATEGORY_LABELS[cat] ?? cat}
|
||||||
@@ -164,7 +164,7 @@ export function getInterestColumns({
|
|||||||
header: 'Source',
|
header: 'Source',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const source = getValue() as string | null;
|
const source = getValue() as string | null;
|
||||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{SOURCE_LABELS[source] ?? source}
|
{SOURCE_LABELS[source] ?? source}
|
||||||
@@ -178,7 +178,7 @@ export function getInterestColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const rowTags = row.original.tags ?? [];
|
const rowTags = row.original.tags ?? [];
|
||||||
if (rowTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
if (rowTags.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{rowTags.slice(0, 3).map((tag) => (
|
{rowTags.slice(0, 3).map((tag) => (
|
||||||
@@ -203,7 +203,7 @@ export function getInterestColumns({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||||
if (!lastIso) {
|
if (!lastIso) {
|
||||||
return <span className="text-muted-foreground text-sm">—</span>;
|
return <span className="text-muted-foreground text-sm">-</span>;
|
||||||
}
|
}
|
||||||
const d = new Date(lastIso);
|
const d = new Date(lastIso);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||||
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
|
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_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_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_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
|
||||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ interface InterestDetailHeaderProps {
|
|||||||
clientPrimaryPhone?: string | null;
|
clientPrimaryPhone?: string | null;
|
||||||
clientPrimaryPhoneE164?: string | null;
|
clientPrimaryPhoneE164?: string | null;
|
||||||
/** Pending/snoozed reminders attached to this interest. Drives the
|
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||||
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
* alarm-bell badge on the header - surfaces follow-ups so the rep
|
||||||
* doesn't have to remember to check /reminders. */
|
* doesn't have to remember to check /reminders. */
|
||||||
activeReminderCount?: number;
|
activeReminderCount?: number;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
@@ -107,7 +107,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
const isClosed = !!interest.outcome;
|
const isClosed = !!interest.outcome;
|
||||||
|
|
||||||
// Contact deep-links — resolved from the linked client's primary channels.
|
// 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
|
// 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
|
// stripping non-digits from the display value when the canonical form is
|
||||||
// missing.
|
// missing.
|
||||||
@@ -258,7 +258,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
||||||
client without leaving the interest workspace. Resolved from
|
client without leaving the interest workspace. Resolved from
|
||||||
the linked client's primary contact channels (server-side
|
the linked client's primary contact channels (server-side
|
||||||
fetch in getInterestById). */}
|
fetch in getInterestById). */}
|
||||||
@@ -343,7 +343,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
the won/lost meaning (green vs rose). Adding a "Won" /
|
the won/lost meaning (green vs rose). Adding a "Won" /
|
||||||
"Lost" text label inline blew out the cluster width and
|
"Lost" text label inline blew out the cluster width and
|
||||||
forced the Email/Call/WhatsApp action-chip row above to
|
forced the Email/Call/WhatsApp action-chip row above to
|
||||||
stack vertically — bad trade. From sm up, the full
|
stack vertically - bad trade. From sm up, the full
|
||||||
"Mark won" / "Close as lost" labels read clearly. */}
|
"Mark won" / "Close as lost" labels read clearly. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prerequisites = {
|
const prerequisites = {
|
||||||
// Required (EOI Section 2 — top paragraph): name, address, email.
|
// Required (EOI Section 2 - top paragraph): name, address, email.
|
||||||
hasName: Boolean(interest?.clientName),
|
hasName: Boolean(interest?.clientName),
|
||||||
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||||
hasAddress: Boolean(interest?.clientHasAddress),
|
hasAddress: Boolean(interest?.clientHasAddress),
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Required before the interest can leave the "Open" stage.
|
Required before the interest can leave the "Open" stage.
|
||||||
</p>
|
</p>
|
||||||
{/* TODO: also include company-owned yachts where client is a member — requires autocomplete owner=any|company filter */}
|
{/* TODO: also include company-owned yachts where client is a member - requires autocomplete owner=any|company filter */}
|
||||||
{/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
|
{/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function InterestList() {
|
|||||||
|
|
||||||
const bulkArchiveMutation = useMutation({
|
const bulkArchiveMutation = useMutation({
|
||||||
mutationFn: async (ids: string[]) => {
|
mutationFn: async (ids: string[]) => {
|
||||||
// Concurrent fan-out — small batches in practice (page size cap = 100).
|
// Concurrent fan-out - small batches in practice (page size cap = 100).
|
||||||
// If a single delete fails the others still run; the rejected one
|
// If a single delete fails the others still run; the rejected one
|
||||||
// surfaces a toast via the standard apiFetch error path.
|
// surfaces a toast via the standard apiFetch error path.
|
||||||
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
|
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
|
||||||
@@ -194,7 +194,7 @@ export function InterestList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile FAB — primary "New interest" affordance for the bottom-tab UX.
|
{/* Mobile FAB - primary "New interest" affordance for the bottom-tab UX.
|
||||||
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
|
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
|
||||||
gap). Hidden on lg+ where the header button already does the job. */}
|
gap). Hidden on lg+ where the header button already does the job. */}
|
||||||
<PermissionGate resource="interests" action="create">
|
<PermissionGate resource="interests" action="create">
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import { type InterestOutcome } from '@/lib/validators/interests';
|
|||||||
|
|
||||||
const OUTCOME_LABELS: Record<InterestOutcome, string> = {
|
const OUTCOME_LABELS: Record<InterestOutcome, string> = {
|
||||||
won: 'Won',
|
won: 'Won',
|
||||||
lost_other_marina: 'Lost — went to another marina',
|
lost_other_marina: 'Lost - went to another marina',
|
||||||
lost_unqualified: 'Lost — unqualified',
|
lost_unqualified: 'Lost - unqualified',
|
||||||
lost_no_response: 'Lost — no response',
|
lost_no_response: 'Lost - no response',
|
||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import type { InterestScore } from '@/lib/services/interest-scoring.service';
|
import type { InterestScore } from '@/lib/services/interest-scoring.service';
|
||||||
@@ -15,9 +10,12 @@ import type { InterestScore } from '@/lib/services/interest-scoring.service';
|
|||||||
// ─── Score tier helpers ───────────────────────────────────────────────────────
|
// ─── Score tier helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getScoreTier(score: number): { label: string; className: string } {
|
function getScoreTier(score: number): { label: string; className: string } {
|
||||||
if (score >= 80) return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' };
|
if (score >= 80)
|
||||||
if (score >= 60) return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' };
|
return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' };
|
||||||
if (score >= 40) return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' };
|
if (score >= 60)
|
||||||
|
return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' };
|
||||||
|
if (score >= 40)
|
||||||
|
return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' };
|
||||||
return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' };
|
return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ export function InterestScoreBadge({ interestId }: InterestScoreBadgeProps) {
|
|||||||
queryKey: ['interest-score', interestId],
|
queryKey: ['interest-score', interestId],
|
||||||
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
|
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
|
||||||
enabled: featureEnabled,
|
enabled: featureEnabled,
|
||||||
staleTime: 60 * 60 * 1000, // 1 hour — mirrors server-side cache TTL
|
staleTime: 60 * 60 * 1000, // 1 hour - mirrors server-side cache TTL
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!featureEnabled) return null;
|
if (!featureEnabled) return null;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ interface InterestTabsOptions {
|
|||||||
reminderLastFired: string | null;
|
reminderLastFired: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
/** Surfaced by getInterestById for the Overview "most recent note"
|
/** Surfaced by getInterestById for the Overview "most recent note"
|
||||||
* teaser — saves a click into the Notes tab to peek at the latest. */
|
* teaser - saves a click into the Notes tab to peek at the latest. */
|
||||||
notesCount?: number;
|
notesCount?: number;
|
||||||
recentNote?: {
|
recentNote?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -145,7 +145,7 @@ interface MilestoneSectionProps {
|
|||||||
onAdvance: (stage: string) => void;
|
onAdvance: (stage: string) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
/** Current pipelineStage. Used to mark steps as done when the pipeline has
|
/** Current pipelineStage. Used to mark steps as done when the pipeline has
|
||||||
* moved past their advanceStage even if the date stamp is missing — e.g.
|
* moved past their advanceStage even if the date stamp is missing - e.g.
|
||||||
* a seed-data interest that started already at eoi_signed will show both
|
* a seed-data interest that started already at eoi_signed will show both
|
||||||
* EOI sub-steps as done. Stage truth > date truth. */
|
* EOI sub-steps as done. Stage truth > date truth. */
|
||||||
currentStage: string;
|
currentStage: string;
|
||||||
@@ -158,7 +158,7 @@ interface MilestoneSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One milestone section (EOI / Deposit / Contract) — shows a vertical lifecycle
|
* One milestone section (EOI / Deposit / Contract) - shows a vertical lifecycle
|
||||||
* with completed steps checked, the next step exposing a quick "mark as…"
|
* with completed steps checked, the next step exposing a quick "mark as…"
|
||||||
* button that bumps the pipeline stage. Each stage flip auto-stamps its date
|
* button that bumps the pipeline stage. Each stage flip auto-stamps its date
|
||||||
* via the service layer (interests.service.ts). When external systems wire in
|
* via the service layer (interests.service.ts). When external systems wire in
|
||||||
@@ -308,7 +308,7 @@ function OverviewTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Sales-process milestones — the heart of the system. Each section is a
|
{/* Sales-process milestones - the heart of the system. Each section is a
|
||||||
mini lifecycle that auto-completes as actions happen on the platform
|
mini lifecycle that auto-completes as actions happen on the platform
|
||||||
(Documenso webhook, paid deposit invoice, signed contract). Until the
|
(Documenso webhook, paid deposit invoice, signed contract). Until the
|
||||||
automation lands, salespeople nudge stages forward via the inline
|
automation lands, salespeople nudge stages forward via the inline
|
||||||
@@ -420,7 +420,7 @@ function OverviewTab({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact dates (read-only — kept compact next to Lead) */}
|
{/* Contact dates (read-only - kept compact next to Lead) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -484,7 +484,7 @@ function OverviewTab({
|
|||||||
variant="textarea"
|
variant="textarea"
|
||||||
value={interest.notes}
|
value={interest.notes}
|
||||||
onSave={save('notes')}
|
onSave={save('notes')}
|
||||||
emptyText="No notes — click to add"
|
emptyText="No notes - click to add"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Sales-triage urgency badges for interest list rows + cards.
|
* Sales-triage urgency badges for interest list rows + cards.
|
||||||
*
|
*
|
||||||
* Derived purely from the dates we already return on the row, so this is a
|
* Derived purely from the dates we already return on the row, so this is a
|
||||||
* pure function — no DB hits, no extra fetch. Mirrors the logic the
|
* pure function - no DB hits, no extra fetch. Mirrors the logic the
|
||||||
* server-side alert-rules engine uses, but for at-a-glance rendering on
|
* server-side alert-rules engine uses, but for at-a-glance rendering on
|
||||||
* the list itself.
|
* the list itself.
|
||||||
*/
|
*/
|
||||||
@@ -47,7 +47,7 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[]
|
|||||||
|
|
||||||
const badges: UrgencyBadge[] = [];
|
const badges: UrgencyBadge[] = [];
|
||||||
|
|
||||||
// Silent in mid-funnel stages — most actionable.
|
// Silent in mid-funnel stages - most actionable.
|
||||||
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
|
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
|
||||||
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
|
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
|
||||||
const days = daysSince(lastTouchIso);
|
const days = daysSince(lastTouchIso);
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function InvoiceCard({
|
|||||||
<span className="truncate">{invoice.clientName}</span>
|
<span className="truncate">{invoice.clientName}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Amount — prominent */}
|
{/* Amount - prominent */}
|
||||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||||
{amountFormatted}
|
{amountFormatted}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function formatPaymentMethod(method: string | null | undefined): string {
|
function formatPaymentMethod(method: string | null | undefined): string {
|
||||||
if (!method) return '—';
|
if (!method) return '-';
|
||||||
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
|
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateOnly(value: string | null | undefined): string {
|
function formatDateOnly(value: string | null | undefined): string {
|
||||||
if (!value) return '—';
|
if (!value) return '-';
|
||||||
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift.
|
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift.
|
||||||
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
|
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
|
||||||
const d = new Date(isoDate);
|
const d = new Date(isoDate);
|
||||||
@@ -299,7 +299,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
|
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{exp.category ?? '—'} · {exp.expenseDate}
|
{exp.category ?? '-'} · {exp.expenseDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums">
|
||||||
@@ -341,7 +341,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Reference</span>
|
<span className="text-muted-foreground">Reference</span>
|
||||||
<p className="mt-0.5">{invoice.paymentReference ?? '—'}</p>
|
<p className="mt-0.5">{invoice.paymentReference ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
|||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
|
// UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
|
||||||
// from the breadcrumbs since the page H1 already shows the entity name.
|
// from the breadcrumbs since the page H1 already shows the entity name.
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function Breadcrumbs() {
|
|||||||
// Split pathname and filter empty segments
|
// Split pathname and filter empty segments
|
||||||
const rawSegments = pathname.split('/').filter(Boolean);
|
const rawSegments = pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
// Remove the portSlug segment and any UUID-ish entity-id segments — the
|
// Remove the portSlug segment and any UUID-ish entity-id segments - the
|
||||||
// page H1 already shows the entity name, no need to leak the raw id.
|
// page H1 already shows the entity name, no need to leak the raw id.
|
||||||
const segments = (
|
const segments = (
|
||||||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ type TabSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bottom nav ordering, left → right:
|
// Bottom nav ordering, left → right:
|
||||||
// Dashboard — daily overview
|
// Dashboard - daily overview
|
||||||
// Berths — marina inventory grid (touches sales + ops both)
|
// Berths - marina inventory grid (touches sales + ops both)
|
||||||
// Clients — the address book / dedup surface (centered: it's the
|
// Clients - the address book / dedup surface (centered: it's the
|
||||||
// primary mental anchor for "find this person", with
|
// primary mental anchor for "find this person", with
|
||||||
// interests living as a tab on the client detail rather
|
// interests living as a tab on the client detail rather
|
||||||
// than a peer in the bottom nav)
|
// than a peer in the bottom nav)
|
||||||
// Documents — signature tracking (chase signers, EOI queue)
|
// Documents - signature tracking (chase signers, EOI queue)
|
||||||
// More — overflow drawer (Interests, Yachts, Companies, …)
|
// More - overflow drawer (Interests, Yachts, Companies, …)
|
||||||
//
|
//
|
||||||
// Interests is intentionally NOT in the bottom row — having both Clients
|
// Interests is intentionally NOT in the bottom row - having both Clients
|
||||||
// and Interests as peer tabs created a Clients-vs-Interests confusion
|
// and Interests as peer tabs created a Clients-vs-Interests confusion
|
||||||
// for sales reps, and the per-client interests tab + the new bottom-sheet
|
// for sales reps, and the per-client interests tab + the new bottom-sheet
|
||||||
// drawer cover the day-to-day deal review without needing a dedicated tab.
|
// drawer cover the day-to-day deal review without needing a dedicated tab.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { MoreSheet } from './more-sheet';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||||||
* bar. Renders only when CSS reveals it (data-shell="mobile") — both shells
|
* bar. Renders only when CSS reveals it (data-shell="mobile") - both shells
|
||||||
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
|
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
|
||||||
* derive the active port slug from the URL themselves, so this layout takes
|
* derive the active port slug from the URL themselves, so this layout takes
|
||||||
* no portSlug prop.
|
* no portSlug prop.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useMobileChrome } from './mobile-layout-provider';
|
|||||||
* left when there's no back affordance, and a soft glow shadow underneath
|
* left when there's no back affordance, and a soft glow shadow underneath
|
||||||
* for depth instead of a hard divider line.
|
* for depth instead of a hard divider line.
|
||||||
*
|
*
|
||||||
* Slots: title (auto-truncating), back arrow, primary action — all driven by
|
* Slots: title (auto-truncating), back arrow, primary action - all driven by
|
||||||
* `useMobileChrome()` from the active page. When no page has set a title the
|
* `useMobileChrome()` from the active page. When no page has set a title the
|
||||||
* URL's last segment is title-cased as a fallback.
|
* URL's last segment is title-cased as a fallback.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function PortSwitcher({ ports }: PortSwitcherProps) {
|
|||||||
|
|
||||||
setPort(port.id, port.slug);
|
setPort(port.id, port.slug);
|
||||||
|
|
||||||
// Invalidate all cached queries — they are port-scoped
|
// Invalidate all cached queries - they are port-scoped
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
// Navigate to the selected port's dashboard
|
// Navigate to the selected port's dashboard
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function ReminderForm({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground text-xs">
|
<Label className="text-muted-foreground text-xs">
|
||||||
Link to Entity (optional — paste UUIDs, or leave blank)
|
Link to Entity (optional - paste UUIDs, or leave blank)
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export function ReportsList() {
|
|||||||
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
|
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const rows = query.state.data?.data ?? [];
|
const rows = query.state.data?.data ?? [];
|
||||||
const hasPending = rows.some(
|
const hasPending = rows.some((r) => r.status === 'queued' || r.status === 'processing');
|
||||||
(r) => r.status === 'queued' || r.status === 'processing',
|
|
||||||
);
|
|
||||||
return hasPending ? 5000 : false;
|
return hasPending ? 5000 : false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -65,9 +63,7 @@ export function ReportsList() {
|
|||||||
const handleDownload = async (reportId: string) => {
|
const handleDownload = async (reportId: string) => {
|
||||||
setDownloadingId(reportId);
|
setDownloadingId(reportId);
|
||||||
try {
|
try {
|
||||||
const result = await apiFetch<{ url: string }>(
|
const result = await apiFetch<{ url: string }>(`/api/v1/reports/${reportId}/download`);
|
||||||
`/api/v1/reports/${reportId}/download`,
|
|
||||||
);
|
|
||||||
window.open(result.url, '_blank');
|
window.open(result.url, '_blank');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download failed', err);
|
console.error('Download failed', err);
|
||||||
@@ -91,9 +87,7 @@ export function ReportsList() {
|
|||||||
) : !data?.data.length ? (
|
) : !data?.data.length ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
||||||
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
|
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">No reports generated yet</p>
|
||||||
No reports generated yet
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use the form above to generate your first report.
|
Use the form above to generate your first report.
|
||||||
</p>
|
</p>
|
||||||
@@ -127,18 +121,18 @@ export function ReportsList() {
|
|||||||
})}
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{report.completedAt
|
{report.completedAt ? (
|
||||||
? new Date(report.completedAt).toLocaleString('en-GB', {
|
new Date(report.completedAt).toLocaleString('en-GB', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})
|
})
|
||||||
: report.status === 'failed' && report.errorMessage
|
) : report.status === 'failed' && report.errorMessage ? (
|
||||||
? (
|
|
||||||
<span className="text-destructive text-xs" title={report.errorMessage}>
|
<span className="text-destructive text-xs" title={report.errorMessage}>
|
||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
)
|
) : (
|
||||||
: '—'}
|
'-'
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{report.status === 'ready' && report.fileId && (
|
{report.status === 'ready' && report.fileId && (
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserve
|
|||||||
const msg = err instanceof Error ? err.message : 'Failed to activate';
|
const msg = err instanceof Error ? err.message : 'Failed to activate';
|
||||||
if (/active reservation|conflict|409/i.test(msg)) {
|
if (/active reservation|conflict|409/i.test(msg)) {
|
||||||
setFormError(
|
setFormError(
|
||||||
'This berth already has an active reservation. The pending record was created — activate it manually once the other reservation ends.',
|
'This berth already has an active reservation. The pending record was created - activate it manually once the other reservation ends.',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setFormError(msg);
|
setFormError(msg);
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function ReservationList({
|
|||||||
View contract
|
View contract
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'-'
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ const STAGE_LABELS: Record<string, string> = {
|
|||||||
viewing_scheduled: 'Viewing scheduled',
|
viewing_scheduled: 'Viewing scheduled',
|
||||||
offer_made: 'Offer made',
|
offer_made: 'Offer made',
|
||||||
offer_accepted: 'Offer accepted',
|
offer_accepted: 'Offer accepted',
|
||||||
closed_won: 'Closed — won',
|
closed_won: 'Closed - won',
|
||||||
closed_lost: 'Closed — lost',
|
closed_lost: 'Closed - lost',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
||||||
@@ -188,7 +188,7 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
|||||||
<InlineCountryField
|
<InlineCountryField
|
||||||
value={client.placeOfResidenceCountryIso}
|
value={client.placeOfResidenceCountryIso}
|
||||||
onSave={async (iso) => {
|
onSave={async (iso) => {
|
||||||
// When country flips, clear the subdivision — codes are country-scoped.
|
// When country flips, clear the subdivision - codes are country-scoped.
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
placeOfResidenceCountryIso: iso,
|
placeOfResidenceCountryIso: iso,
|
||||||
subdivisionIso: null,
|
subdivisionIso: null,
|
||||||
@@ -249,7 +249,7 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
|||||||
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
|
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
|
||||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '—'}</span>
|
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '-'}</span>
|
||||||
<Link
|
<Link
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user