chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
border-r/rounded-l-/rounded-r-) inside JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ export function AssignedToChip({
|
||||
type="button"
|
||||
aria-label={`Change deal owner (currently ${label})`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
currentAssignedTo
|
||||
? 'border-sky-200 bg-sky-50 text-sky-800 hover:bg-sky-100'
|
||||
: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
|
||||
|
||||
@@ -45,7 +45,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer',
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer',
|
||||
tint,
|
||||
)}
|
||||
aria-label={`Deal pulse: ${label}, score ${health.score}/100. Click for breakdown.`}
|
||||
@@ -65,7 +65,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
What pushed the score
|
||||
</p>
|
||||
{health.signals.length === 0 ? (
|
||||
@@ -79,7 +79,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
<li key={s.id} className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-xs font-semibold tabular-nums',
|
||||
s.delta > 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800',
|
||||
)}
|
||||
>
|
||||
@@ -92,7 +92,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
<div className="rounded-md bg-muted/40 p-2.5 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground/80">How this is calculated</p>
|
||||
<p className="mt-0.5">
|
||||
Every signal above traces to a specific date or pipeline stage on this deal. Recent
|
||||
|
||||
@@ -237,7 +237,7 @@ export function ExternalEoiUploadDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="sm:max-w-2xl lg:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -161,7 +161,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
b.className,
|
||||
)}
|
||||
>
|
||||
@@ -185,7 +185,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
) : null}
|
||||
|
||||
{lastActivity ? (
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
<p className="mt-1.5 text-xs text-muted-foreground tabular-nums">
|
||||
Last activity {lastActivity}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -227,7 +227,7 @@ export function getInterestColumns({
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
aria-label={b.detail}
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${b.className}`}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
|
||||
@@ -230,7 +230,7 @@ function ContactLogRow({
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground">{channelMeta.label}</span>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -511,7 +511,7 @@ function ComposeDialogBody({
|
||||
}
|
||||
onClick={() => (voice.isListening ? voice.stop() : voice.start())}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
voice.isListening
|
||||
? 'border-rose-300 bg-rose-50 text-rose-800 animate-pulse'
|
||||
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted',
|
||||
@@ -532,7 +532,7 @@ function ComposeDialogBody({
|
||||
) : (
|
||||
<span
|
||||
title="Voice transcription isn't supported in this browser."
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<MicOff className="size-3" aria-hidden />
|
||||
Voice unavailable
|
||||
@@ -547,10 +547,10 @@ function ComposeDialogBody({
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
/>
|
||||
{voice.isListening && voice.interim ? (
|
||||
<p className="text-[11px] italic text-muted-foreground">{voice.interim}…</p>
|
||||
<p className="text-xs italic text-muted-foreground">{voice.interim}…</p>
|
||||
) : null}
|
||||
{voice.error ? (
|
||||
<p className="text-[11px] text-rose-700">Voice error: {voice.error}</p>
|
||||
<p className="text-xs text-rose-700">Voice error: {voice.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -589,7 +589,7 @@ function ComposeDialogBody({
|
||||
onChange={setFollowUpAt}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A reminder is created on this interest for the time above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -414,7 +414,7 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
|
||||
'border-transparent text-xs font-semibold uppercase tracking-wide',
|
||||
STATUS_TONES[status],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -276,7 +276,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
)}
|
||||
{(interest.activeReminderCount ?? 0) > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
title={`${interest.activeReminderCount} pending reminder${
|
||||
interest.activeReminderCount === 1 ? '' : 's'
|
||||
}`}
|
||||
|
||||
@@ -402,7 +402,7 @@ function ActiveEoiCard({
|
||||
local preference here so the UI matches what was sent. */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide',
|
||||
signingOrder === 'SEQUENTIAL'
|
||||
? 'border-indigo-200 bg-indigo-50 text-indigo-800'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-800',
|
||||
@@ -665,7 +665,7 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
|
||||
'border-transparent text-xs font-semibold uppercase tracking-wide',
|
||||
STATUS_TONES[status],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -355,7 +355,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
requestClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetContent className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
@@ -533,7 +533,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
/>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{isPrimary && (
|
||||
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide text-primary">
|
||||
primary
|
||||
</span>
|
||||
)}
|
||||
@@ -935,7 +935,7 @@ function DimensionInput({
|
||||
}}
|
||||
/>
|
||||
{altValue ? (
|
||||
<p className="text-[11px] leading-tight text-muted-foreground">≈ {altValue}</p>
|
||||
<p className="text-xs leading-tight text-muted-foreground">≈ {altValue}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -410,7 +410,7 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
|
||||
'border-transparent text-xs font-semibold uppercase tracking-wide',
|
||||
STATUS_TONES[status],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -363,7 +363,7 @@ function MilestoneAdvanceButton({
|
||||
onChange={setDate}
|
||||
placeholder="Pick a date"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to today - back-date if the event happened earlier.
|
||||
</p>
|
||||
</div>
|
||||
@@ -429,7 +429,7 @@ function MilestoneBackfillButton({
|
||||
onChange={setDate}
|
||||
placeholder="Pick a date"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Records the date the milestone happened. Does not change the deal's pipeline stage.
|
||||
</p>
|
||||
</div>
|
||||
@@ -491,14 +491,14 @@ function MilestoneSection({
|
||||
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-xs font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
|
||||
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
|
||||
Next step
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{status ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{humanizeStatus(status)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -1004,7 +1004,7 @@ function OverviewTab({
|
||||
disabled={stageMutation.isPending}
|
||||
onConfirm={(date) => advance('deposit_paid', date)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Or record a payment in the Payments section.
|
||||
</span>
|
||||
</div>
|
||||
@@ -1119,7 +1119,7 @@ function OverviewTab({
|
||||
gates the actual stage move). */}
|
||||
{pastMilestones.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/20">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<span>Past</span>
|
||||
</div>
|
||||
<Accordion type="multiple" className="px-4">
|
||||
@@ -1129,7 +1129,7 @@ function OverviewTab({
|
||||
<div className="flex flex-1 items-center gap-2 text-left text-muted-foreground">
|
||||
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
|
||||
<span className="font-medium text-foreground">{m.title}</span>
|
||||
<span className="text-[10px]">·</span>
|
||||
<span className="text-xs">·</span>
|
||||
<span className="truncate text-xs">{m.pastSummary}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
@@ -1406,7 +1406,7 @@ function OverviewTab({
|
||||
a context hint. */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
|
||||
'bg-muted text-muted-foreground',
|
||||
)}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
<p className="text-sm">
|
||||
{event.description}
|
||||
{isAuto ? (
|
||||
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Bot className="h-3 w-3" aria-hidden />
|
||||
Auto
|
||||
</span>
|
||||
|
||||
@@ -378,7 +378,7 @@ function LinkedBerthRowItem({
|
||||
<HelpCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
|
||||
<TooltipContent side="top" className="max-w-xs text-xs leading-snug">
|
||||
Mark this berth as one your client is actively considering. When on, the berth
|
||||
appears as <strong>Under Offer</strong> on the public map and counts toward the
|
||||
recommender's "heat" score. Turn off if the link is legal/EOI-only.
|
||||
@@ -413,7 +413,7 @@ function LinkedBerthRowItem({
|
||||
<HelpCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
|
||||
<TooltipContent side="top" className="max-w-xs text-xs leading-snug">
|
||||
Include this berth in the EOI's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's{' '}
|
||||
<strong>Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
@@ -786,7 +786,7 @@ function BerthSection({
|
||||
) : null}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{hint}</p>
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
{count === 0 && emptyText ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function MultiEoiChip({ interestId }: { interestId: string }) {
|
||||
return (
|
||||
<span
|
||||
title={`This interest has ${inflight.length} in-flight EOI documents - review on the EOI tab.`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
>
|
||||
<FileSignature className="size-3" aria-hidden />
|
||||
{inflight.length} EOIs
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Receipt } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, Receipt } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@@ -77,6 +77,40 @@ function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-interest collapse state. Persisted to localStorage so a rep
|
||||
* who collapses the section on one deal sees it stay collapsed when
|
||||
* they navigate away and back. Default collapsed (deposits are a
|
||||
* reference history once the rep stops actively recording them); the
|
||||
* "add a deposit" flow auto-expands so the new row is visible.
|
||||
*/
|
||||
function usePaymentsCollapsed(interestId: string): readonly [boolean, (v: boolean) => void] {
|
||||
const storageKey = `pn-crm.payments-collapsed.v1.${interestId}`;
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(true);
|
||||
// Canonical client-only hydration pattern: server renders the default
|
||||
// (true), then on mount we read localStorage and reconcile. Initializing
|
||||
// useState() lazily from localStorage would risk a hydration mismatch
|
||||
// since the server has no access to the user's browser storage.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (raw !== null) setCollapsedState(raw === '1');
|
||||
} catch {
|
||||
// ignore — localStorage may be unavailable in private mode
|
||||
}
|
||||
}, [storageKey]);
|
||||
function setCollapsed(v: boolean): void {
|
||||
setCollapsedState(v);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, v ? '1' : '0');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return [collapsed, setCollapsed] as const;
|
||||
}
|
||||
|
||||
export function PaymentsSection({
|
||||
interestId,
|
||||
depositExpectedAmount,
|
||||
@@ -88,6 +122,7 @@ export function PaymentsSection({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [recordOpen, setRecordOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = usePaymentsCollapsed(interestId);
|
||||
|
||||
const { data, isLoading } = useQuery<PaymentsResponse>({
|
||||
queryKey: ['interest-payments', interestId],
|
||||
@@ -122,12 +157,65 @@ export function PaymentsSection({
|
||||
? Math.max(0, expectedAmount - runningTotal)
|
||||
: null;
|
||||
|
||||
// Collapsed summary bar — drops the full section to one line so the
|
||||
// milestone strip above gets the rep's primary visual focus. Click
|
||||
// anywhere on the bar (or the chevron) to expand inline.
|
||||
if (collapsed) {
|
||||
const summaryText =
|
||||
payments.length === 0
|
||||
? 'Not received yet'
|
||||
: `${formatMoney(total?.total ?? '0', expectedCurrency)} received · ${payments.length} payment${payments.length === 1 ? '' : 's'}`;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-lg border bg-card/40 px-4 py-3 text-left transition-colors hover:bg-card/60"
|
||||
aria-expanded={false}
|
||||
aria-controls={`payments-section-${interestId}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ChevronRight
|
||||
className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-semibold">Payments</span>
|
||||
<span className="text-xs text-muted-foreground">· {summaryText}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Expand</span>
|
||||
</button>
|
||||
<RecordPaymentSheet
|
||||
open={recordOpen}
|
||||
onOpenChange={setRecordOpen}
|
||||
interestId={interestId}
|
||||
defaultCurrency={expectedCurrency}
|
||||
onRecorded={() => setCollapsed(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
|
||||
<section
|
||||
id={`payments-section-${interestId}`}
|
||||
className="rounded-lg border bg-card/40 p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Payments</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="group inline-flex items-center gap-2 text-left"
|
||||
aria-expanded={true}
|
||||
aria-controls={`payments-section-${interestId}`}
|
||||
>
|
||||
<ChevronDown
|
||||
className="size-4 text-muted-foreground transition-transform group-hover:translate-y-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="text-sm font-semibold">Payments</h3>
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Records that money was received or refunded. No invoices are issued - the bank handles
|
||||
that.
|
||||
</p>
|
||||
@@ -169,7 +257,7 @@ export function PaymentsSection({
|
||||
<li key={p.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
TYPE_TINT[p.paymentType] ?? TYPE_TINT.other
|
||||
}`}
|
||||
>
|
||||
@@ -213,6 +301,7 @@ export function PaymentsSection({
|
||||
onOpenChange={setRecordOpen}
|
||||
interestId={interestId}
|
||||
defaultCurrency={expectedCurrency}
|
||||
onRecorded={() => setCollapsed(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -223,11 +312,13 @@ function RecordPaymentSheet({
|
||||
onOpenChange,
|
||||
interestId,
|
||||
defaultCurrency,
|
||||
onRecorded,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
interestId: string;
|
||||
defaultCurrency: string;
|
||||
onRecorded?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [paymentType, setPaymentType] = useState<string>('deposit');
|
||||
@@ -257,7 +348,7 @@ function RecordPaymentSheet({
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
onOpenChange(false);
|
||||
// Reset form for next use
|
||||
onRecorded?.();
|
||||
setAmount('');
|
||||
setNotes('');
|
||||
setAcknowledgedNoReceipt(false);
|
||||
|
||||
@@ -188,7 +188,7 @@ export function QualificationChecklist({
|
||||
</span>
|
||||
{c.autoSatisfied && (
|
||||
<span
|
||||
className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||
className="rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||
title="System-derived from data on this interest"
|
||||
>
|
||||
Auto
|
||||
|
||||
@@ -174,7 +174,7 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
submitted. Hidden when the list is empty. */}
|
||||
{tokens.length > 0 ? (
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Issuance history
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
|
||||
Reference in New Issue
Block a user