style(detail): apply gradient header strip to client/interest/yacht/company/berth/residential/invoice details
Adds shared <DetailHeaderStrip> wrapper (rounded-xl + gradient-brand-soft + shadow-xs)
and applies it to every legacy domain detail header. Residential client/interest and
invoice detail get an inline gradient strip with eyebrow ('Residential Client',
'Residential Interest', 'Invoice'). Residential bodies normalized to lg:grid-cols-[2fr_1fr]
per spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { BerthForm } from './berth-form';
|
import { BerthForm } from './berth-form';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -94,7 +95,7 @@ function StatusChangeDialog({
|
|||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm<UpdateBerthStatusInput>({
|
} = useForm<UpdateBerthStatusInput>({
|
||||||
resolver: zodResolver(updateBerthStatusSchema),
|
resolver: zodResolver(updateBerthStatusSchema),
|
||||||
defaultValues: { status: currentStatus as typeof BERTH_STATUSES[number], reason: '' },
|
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = watch('status');
|
const status = watch('status');
|
||||||
@@ -127,7 +128,7 @@ function StatusChangeDialog({
|
|||||||
<Label>New Status</Label>
|
<Label>New Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
onValueChange={(v) => setValue('status', v as typeof BERTH_STATUSES[number])}
|
onValueChange={(v) => setValue('status', v as (typeof BERTH_STATUSES)[number])}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -143,11 +144,7 @@ function StatusChangeDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Reason *</Label>
|
<Label>Reason *</Label>
|
||||||
<Textarea
|
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
|
||||||
{...register('reason')}
|
|
||||||
placeholder="Reason for status change..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
@@ -169,22 +166,18 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl font-bold text-foreground">Berth {berth.mooringNumber}</h1>
|
||||||
Berth {berth.mooringNumber}
|
|
||||||
</h1>
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[berth.status] ?? berth.status}
|
{STATUS_LABELS[berth.status] ?? berth.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{berth.area && (
|
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
||||||
<p className="text-muted-foreground mt-1">{berth.area}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
@@ -200,7 +193,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
|
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -140,7 +141,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
<ArchiveConfirmDialog
|
<ArchiveConfirmDialog
|
||||||
open={archiveOpen}
|
open={archiveOpen}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { toast } from 'sonner';
|
|||||||
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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { CompanyForm } from '@/components/companies/company-form';
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -74,7 +75,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -118,7 +119,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
<CompanyForm
|
<CompanyForm
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { InterestForm } from '@/components/interests/interest-form';
|
import { InterestForm } from '@/components/interests/interest-form';
|
||||||
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
|
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
|
||||||
@@ -70,8 +71,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const isArchived = !!interest.archivedAt;
|
const isArchived = !!interest.archivedAt;
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
@@ -80,8 +80,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
});
|
});
|
||||||
|
|
||||||
const restoreMutation = useMutation({
|
const restoreMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
|
||||||
apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
@@ -91,7 +90,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -99,7 +98,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
{interest.clientName ?? 'Unknown Client'}
|
{interest.clientName ?? 'Unknown Client'}
|
||||||
</h1>
|
</h1>
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<Badge variant="secondary" className="text-xs">Archived</Badge>
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Archived
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
|
||||||
@@ -130,8 +131,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
)}
|
)}
|
||||||
{interest.source && (
|
{interest.source && (
|
||||||
<span>
|
<span>
|
||||||
Source:{' '}
|
Source: <span className="text-foreground capitalize">{interest.source}</span>
|
||||||
<span className="text-foreground capitalize">{interest.source}</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +176,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
<InterestForm
|
<InterestForm
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -85,15 +85,18 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="text-xs font-semibold uppercase tracking-wide text-brand">Invoice</div>
|
||||||
<h2 className="text-xl font-semibold font-mono">{invoice.invoiceNumber}</h2>
|
<div className="mt-1 flex items-center gap-3 flex-wrap">
|
||||||
|
<h1 className="truncate text-2xl font-bold tracking-tight text-foreground font-mono">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
|
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
|
||||||
{invoice.status}
|
{invoice.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{invoice.clientName}</p>
|
<p className="text-sm text-muted-foreground mt-1">{invoice.clientName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{invoice.status === 'draft' && (
|
{invoice.status === 'draft' && (
|
||||||
|
|||||||
@@ -113,14 +113,17 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-6">
|
<div className="rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs">
|
||||||
<div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-brand">
|
||||||
<h1 className="text-2xl font-semibold">
|
Residential Client
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 truncate text-2xl font-bold tracking-tight text-foreground">
|
||||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="rounded-lg border bg-card p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-6">
|
||||||
<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>
|
||||||
<Row label="Email">
|
<Row label="Email">
|
||||||
|
|||||||
@@ -87,13 +87,12 @@ export function ResidentialInterestDetail({ interestId }: { interestId: string }
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-6">
|
<div className="rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs">
|
||||||
<div>
|
<p className="text-xs uppercase font-semibold tracking-wide text-brand">
|
||||||
<p className="text-xs uppercase text-muted-foreground tracking-wider mb-1">
|
Residential Interest
|
||||||
Residential interest
|
|
||||||
</p>
|
</p>
|
||||||
{interest.client && (
|
{interest.client && (
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="mt-1 truncate text-2xl font-bold tracking-tight text-foreground">
|
||||||
<Link
|
<Link
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
href={`/${portSlug}/residential/clients/${interest.client.id}` as any}
|
href={`/${portSlug}/residential/clients/${interest.client.id}` as any}
|
||||||
@@ -105,7 +104,8 @@ export function ResidentialInterestDetail({ interestId }: { interestId: string }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="rounded-lg border bg-card p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Pipeline</h3>
|
<h3 className="text-sm font-medium mb-2">Pipeline</h3>
|
||||||
<Row label="Stage">
|
<Row label="Stage">
|
||||||
|
|||||||
20
src/components/shared/detail-header-strip.tsx
Normal file
20
src/components/shared/detail-header-strip.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DetailHeaderStripProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailHeaderStrip({ children, className }: DetailHeaderStripProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { toast } from 'sonner';
|
|||||||
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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
||||||
@@ -140,7 +141,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -197,7 +198,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
<YachtForm
|
<YachtForm
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user