Compare commits
65 Commits
refactor/d
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc | ||
|
|
868b1f40c0 | ||
|
|
dbbd03fd22 | ||
|
|
ba5fb6db5e | ||
|
|
886119cbde | ||
|
|
0d357731ad | ||
|
|
a75d4f5d69 | ||
|
|
0fb7920db5 | ||
|
|
16ad61ce15 | ||
|
|
d080bc52fa | ||
|
|
a653c8e039 | ||
|
|
7e8110b2ff | ||
|
|
9eadaf035e | ||
|
|
bcea28cd71 | ||
|
|
722491a9dd | ||
|
|
6009ccb7de | ||
|
|
71da6e8fdc | ||
|
|
c405124bc3 | ||
|
|
53cbee1d3d | ||
|
|
ac7f1db62c | ||
|
|
5d44f3cfa4 | ||
|
|
d0540dca55 | ||
|
|
0e9c24e222 | ||
|
|
3aba2181dc | ||
|
|
6237ad1567 | ||
|
|
34916d855e | ||
|
|
41ae8a328f | ||
|
|
1ff3160eac | ||
|
|
5698d742d3 | ||
|
|
e6ce265be0 | ||
|
|
19bc2f2a54 | ||
|
|
b0a11f1785 | ||
|
|
3cbf2444fe | ||
|
|
0330be1312 | ||
|
|
210360738d | ||
|
|
4df04e1a58 | ||
|
|
0c3baf04c5 | ||
|
|
79667b24da | ||
|
|
c4fdb29bbe | ||
|
|
38527d71fc | ||
|
|
3fbfba6598 | ||
|
|
e3a835675b | ||
|
|
1b085f81ed | ||
|
|
9f786fbcf3 | ||
|
|
906127a292 | ||
|
|
737b43589b | ||
|
|
fbb1f1f366 |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -20,10 +20,27 @@ tsconfig.tsbuildinfo
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
.remember/
|
.remember/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
eoi/
|
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||||
|
/eoi/
|
||||||
|
|
||||||
# Brainstorming companion mockup files
|
# Brainstorming companion mockup files
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||||
/*.png
|
/*.png
|
||||||
|
/*.jpg
|
||||||
|
|
||||||
|
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||||
|
/client-portal/
|
||||||
|
|
||||||
|
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||||
|
/website/
|
||||||
|
|
||||||
|
# Mobile audit screenshots — generated locally, regenerable
|
||||||
|
/.audit/
|
||||||
|
/.audit-screenshots/
|
||||||
|
|
||||||
|
# Tool caches / runtime state
|
||||||
|
/.claude/
|
||||||
|
/.serena/
|
||||||
|
/ruvector.db
|
||||||
|
|||||||
Submodule client-portal deleted from 84f89f9409
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Mobile Optimization Design
|
||||||
|
|
||||||
|
**Status**: Design approved 2026-04-29 — pending plan.
|
||||||
|
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
|
||||||
|
**Branch base**: stacks on `refactor/data-model`.
|
||||||
|
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Background
|
||||||
|
|
||||||
|
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
|
||||||
|
|
||||||
|
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
|
||||||
|
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
|
||||||
|
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
|
||||||
|
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
|
||||||
|
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
|
||||||
|
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
|
||||||
|
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
|
||||||
|
|
||||||
|
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
|
||||||
|
|
||||||
|
## 2. Approach
|
||||||
|
|
||||||
|
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
|
||||||
|
|
||||||
|
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
|
||||||
|
|
||||||
|
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
|
||||||
|
|
||||||
|
### 2.1 Target iPhone viewport range
|
||||||
|
|
||||||
|
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
|
||||||
|
|
||||||
|
| Tier | Models | Viewport |
|
||||||
|
| ------------------------------------------ | ----------------------------------------------- | -------- |
|
||||||
|
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
|
||||||
|
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
|
||||||
|
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
|
||||||
|
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
|
||||||
|
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
|
||||||
|
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
|
||||||
|
|
||||||
|
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
|
||||||
|
|
||||||
|
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
|
||||||
|
|
||||||
|
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
|
||||||
|
|
||||||
|
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
|
||||||
|
|
||||||
|
## 3. Foundation PR
|
||||||
|
|
||||||
|
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
|
||||||
|
|
||||||
|
### 3.1 Infrastructure
|
||||||
|
|
||||||
|
- `viewport` export in `src/app/layout.tsx` — `width=device-width, initial-scale=1, viewport-fit=cover`.
|
||||||
|
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
|
||||||
|
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
|
||||||
|
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
|
||||||
|
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
|
||||||
|
|
||||||
|
### 3.2 Mobile shell
|
||||||
|
|
||||||
|
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
|
||||||
|
|
||||||
|
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
|
||||||
|
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
|
||||||
|
|
||||||
|
- **`<MobileTopbar>`**
|
||||||
|
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
|
||||||
|
|
||||||
|
- **`<MobileBottomTabs>`**
|
||||||
|
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
|
||||||
|
|
||||||
|
- **`<MoreSheet>`**
|
||||||
|
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
|
||||||
|
|
||||||
|
- **`<MobileLayoutProvider>`**
|
||||||
|
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
|
||||||
|
|
||||||
|
### 3.3 Primitives
|
||||||
|
|
||||||
|
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
|
||||||
|
|
||||||
|
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
|
||||||
|
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
|
||||||
|
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
|
||||||
|
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
|
||||||
|
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
|
||||||
|
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
|
||||||
|
|
||||||
|
### 3.4 Default style adjustments
|
||||||
|
|
||||||
|
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
|
||||||
|
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
|
||||||
|
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
|
||||||
|
|
||||||
|
### 3.5 Bundle impact
|
||||||
|
|
||||||
|
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
|
||||||
|
|
||||||
|
### 3.6 PWA assets
|
||||||
|
|
||||||
|
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
|
||||||
|
|
||||||
|
## 4. Per-page playbook
|
||||||
|
|
||||||
|
Once foundation lands, each page follows the same workflow:
|
||||||
|
|
||||||
|
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
|
||||||
|
2. Replace any `<Dialog>` with `<Sheet>`.
|
||||||
|
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
|
||||||
|
4. Replace the ad-hoc page header with `<PageHeader>`.
|
||||||
|
5. Replace ad-hoc action button rows with `<ActionRow>`.
|
||||||
|
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
|
||||||
|
7. User reviews live in the headed browser, points out tweaks, iterate.
|
||||||
|
|
||||||
|
Most pages take 5–15 minutes in this loop. Heavy pages (email inbox, documents hub) may take 30–60 because the embedded widgets need their own mobile treatment beyond the primitives.
|
||||||
|
|
||||||
|
## 5. Migration sequence
|
||||||
|
|
||||||
|
After foundation PR:
|
||||||
|
|
||||||
|
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
|
||||||
|
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
|
||||||
|
2. **List pages** (~1–2 days) — convert via `<DataView>` + per-page `cardRender`:
|
||||||
|
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
|
||||||
|
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
|
||||||
|
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
|
||||||
|
4. **Detail pages** (~1–2 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
|
||||||
|
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
|
||||||
|
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
|
||||||
|
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
|
||||||
|
6. **Portal** — same patterns, smaller scope:
|
||||||
|
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
|
||||||
|
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
|
||||||
|
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
|
||||||
|
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
|
||||||
|
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
|
||||||
|
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
|
||||||
|
|
||||||
|
## 7. Open questions
|
||||||
|
|
||||||
|
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
|
||||||
|
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
|
||||||
|
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
|
||||||
|
|
||||||
|
## 8. Files to create
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/use-is-mobile.ts
|
||||||
|
src/components/layout/mobile/
|
||||||
|
mobile-layout.tsx
|
||||||
|
mobile-topbar.tsx
|
||||||
|
mobile-bottom-tabs.tsx
|
||||||
|
more-sheet.tsx
|
||||||
|
mobile-layout-provider.tsx
|
||||||
|
src/components/shared/
|
||||||
|
sheet.tsx (new — vaul wrapper)
|
||||||
|
data-view.tsx (new — table↔card)
|
||||||
|
page-header.tsx (new)
|
||||||
|
action-row.tsx (new)
|
||||||
|
detail-page-shell.tsx (new)
|
||||||
|
filter-chips.tsx (new)
|
||||||
|
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
|
||||||
|
public/icon-192.png (placeholder PWA asset)
|
||||||
|
public/icon-512.png (placeholder PWA asset)
|
||||||
|
public/icon-512-maskable.png (placeholder PWA asset)
|
||||||
|
public/apple-touch-icon.png (placeholder PWA asset)
|
||||||
|
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
|
||||||
|
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Files to modify per page
|
||||||
|
|
||||||
|
Per the playbook in §4, each page typically needs:
|
||||||
|
|
||||||
|
- One swap of header markup → `<PageHeader>`.
|
||||||
|
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
|
||||||
|
- For detail pages: wrap in `<DetailPageShell>`.
|
||||||
|
- Replace `<Dialog>` imports with `<Sheet>`.
|
||||||
|
- No service, validator, query, or schema changes anywhere.
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.24.0",
|
"zod": "^3.24.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
"lint-staged": "^15.2.0",
|
"lint-staged": "^15.2.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
"react-grab": "^0.1.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ export default defineConfig({
|
|||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||||
|
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||||
|
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||||
|
name: 'mobile-audit',
|
||||||
|
testMatch: /audit\/mobile\.spec\.ts/,
|
||||||
|
dependencies: ['setup'],
|
||||||
|
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
|
||||||
|
// 30 min headroom keeps us well under the wall-clock cost.
|
||||||
|
timeout: 1_800_000,
|
||||||
|
use: {
|
||||||
|
headless: false,
|
||||||
|
launchOptions: { slowMo: 200 },
|
||||||
|
screenshot: 'off',
|
||||||
|
video: 'off',
|
||||||
|
trace: 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Don't start the dev server — we expect it to already be running
|
// Don't start the dev server — we expect it to already be running
|
||||||
|
|||||||
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@@ -206,6 +206,9 @@ importers:
|
|||||||
tesseract.js:
|
tesseract.js:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
vaul:
|
||||||
|
specifier: ^1.1.2
|
||||||
|
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -276,6 +279,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
react-grab:
|
||||||
|
specifier: ^0.1.32
|
||||||
|
version: 0.1.32(react@19.2.4)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||||
@@ -339,6 +345,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
|
|
||||||
|
'@antfu/ni@0.23.2':
|
||||||
|
resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1':
|
'@babel/helper-string-parser@7.27.1':
|
||||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -2077,6 +2087,10 @@ packages:
|
|||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
react-dom: '>=16.9.0'
|
react-dom: '>=16.9.0'
|
||||||
|
|
||||||
|
'@react-grab/cli@0.1.32':
|
||||||
|
resolution: {integrity: sha512-TI4SHATLH2yM1DMRXgH3dt/8b3Rj51BplDOqOQiHQKAMOuKVAR9WE2WGWJRT3LwFpl8BXR9ytAM9vrGDrB7QGw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2':
|
'@reduxjs/toolkit@2.11.2':
|
||||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2848,6 +2862,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bippy@0.5.39:
|
||||||
|
resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0.1'
|
||||||
|
|
||||||
block-stream2@2.1.0:
|
block-stream2@2.1.0:
|
||||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||||
|
|
||||||
@@ -2953,6 +2972,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cli-spinners@2.9.2:
|
||||||
|
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
cli-truncate@4.0.0:
|
cli-truncate@4.0.0:
|
||||||
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3008,6 +3031,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
commander@14.0.3:
|
||||||
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
commander@4.1.1:
|
commander@4.1.1:
|
||||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3982,6 +4009,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-interactive@2.0.0:
|
||||||
|
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-map@2.0.3:
|
is-map@2.0.3:
|
||||||
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4030,6 +4061,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-unicode-supported@1.3.0:
|
||||||
|
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
is-unicode-supported@2.1.0:
|
||||||
|
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
is-url@1.2.4:
|
is-url@1.2.4:
|
||||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
@@ -4121,6 +4160,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1:
|
||||||
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -4128,6 +4170,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kleur@3.0.3:
|
||||||
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
kysely@0.28.11:
|
kysely@0.28.11:
|
||||||
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -4274,6 +4320,10 @@ packages:
|
|||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
|
log-symbols@6.0.0:
|
||||||
|
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
log-update@6.1.0:
|
log-update@6.1.0:
|
||||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4582,6 +4632,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
ora@8.2.0:
|
||||||
|
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4781,6 +4835,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
|
prompts@2.4.2:
|
||||||
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -5071,6 +5129,15 @@ packages:
|
|||||||
react-fast-compare@3.2.2:
|
react-fast-compare@3.2.2:
|
||||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||||
|
|
||||||
|
react-grab@0.1.32:
|
||||||
|
resolution: {integrity: sha512-ODZkzu4zjwX/5a1VxTdIkagPD6uPnp8IkSN2v5FDgFMZkH5r/YEMq43hIsdpHV5/R2ymqS9zLxp4H7SNSRx5ng==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-hook-form@7.71.2:
|
react-hook-form@7.71.2:
|
||||||
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -5357,6 +5424,9 @@ packages:
|
|||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
sisteransi@1.0.5:
|
||||||
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
slice-ansi@5.0.0:
|
slice-ansi@5.0.0:
|
||||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -5369,6 +5439,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
smol-toml@1.6.1:
|
||||||
|
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
socket.io-adapter@2.5.6:
|
||||||
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
||||||
|
|
||||||
@@ -5431,6 +5505,10 @@ packages:
|
|||||||
std-env@4.0.0:
|
std-env@4.0.0:
|
||||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||||
|
|
||||||
|
stdin-discarder@0.2.2:
|
||||||
|
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5763,6 +5841,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
vaul@1.1.2:
|
||||||
|
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
@@ -6065,6 +6149,8 @@ snapshots:
|
|||||||
resize-observer-polyfill: 1.5.1
|
resize-observer-polyfill: 1.5.1
|
||||||
throttle-debounce: 5.0.2
|
throttle-debounce: 5.0.2
|
||||||
|
|
||||||
|
'@antfu/ni@0.23.2': {}
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
@@ -7467,6 +7553,17 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@react-grab/cli@0.1.32':
|
||||||
|
dependencies:
|
||||||
|
'@antfu/ni': 0.23.2
|
||||||
|
commander: 14.0.3
|
||||||
|
ignore: 7.0.5
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
ora: 8.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
prompts: 2.4.2
|
||||||
|
smol-toml: 1.6.1
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -8233,6 +8330,10 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bippy@0.5.39(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
block-stream2@2.1.0:
|
block-stream2@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
@@ -8353,6 +8454,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
|
|
||||||
|
cli-spinners@2.9.2: {}
|
||||||
|
|
||||||
cli-truncate@4.0.0:
|
cli-truncate@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
slice-ansi: 5.0.0
|
slice-ansi: 5.0.0
|
||||||
@@ -8409,6 +8512,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@13.1.0: {}
|
commander@13.1.0: {}
|
||||||
|
|
||||||
|
commander@14.0.3: {}
|
||||||
|
|
||||||
commander@4.1.1: {}
|
commander@4.1.1: {}
|
||||||
|
|
||||||
component-classes@1.2.6:
|
component-classes@1.2.6:
|
||||||
@@ -9561,6 +9666,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-interactive@2.0.0: {}
|
||||||
|
|
||||||
is-map@2.0.3: {}
|
is-map@2.0.3: {}
|
||||||
|
|
||||||
is-negative-zero@2.0.3: {}
|
is-negative-zero@2.0.3: {}
|
||||||
@@ -9604,6 +9711,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
which-typed-array: 1.1.20
|
which-typed-array: 1.1.20
|
||||||
|
|
||||||
|
is-unicode-supported@1.3.0: {}
|
||||||
|
|
||||||
|
is-unicode-supported@2.1.0: {}
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
is-url@1.2.4: {}
|
||||||
|
|
||||||
is-weakmap@2.0.2: {}
|
is-weakmap@2.0.2: {}
|
||||||
@@ -9685,6 +9796,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1: {}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -9696,6 +9809,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
kysely@0.28.11: {}
|
kysely@0.28.11: {}
|
||||||
|
|
||||||
language-subtag-registry@0.3.23: {}
|
language-subtag-registry@0.3.23: {}
|
||||||
@@ -9823,6 +9938,11 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
|
log-symbols@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
chalk: 5.6.2
|
||||||
|
is-unicode-supported: 1.3.0
|
||||||
|
|
||||||
log-update@6.1.0:
|
log-update@6.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-escapes: 7.3.0
|
ansi-escapes: 7.3.0
|
||||||
@@ -10121,6 +10241,18 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
ora@8.2.0:
|
||||||
|
dependencies:
|
||||||
|
chalk: 5.6.2
|
||||||
|
cli-cursor: 5.0.0
|
||||||
|
cli-spinners: 2.9.2
|
||||||
|
is-interactive: 2.0.0
|
||||||
|
is-unicode-supported: 2.1.0
|
||||||
|
log-symbols: 6.0.0
|
||||||
|
stdin-discarder: 0.2.2
|
||||||
|
string-width: 7.2.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
@@ -10313,6 +10445,11 @@ snapshots:
|
|||||||
|
|
||||||
process@0.11.10: {}
|
process@0.11.10: {}
|
||||||
|
|
||||||
|
prompts@2.4.2:
|
||||||
|
dependencies:
|
||||||
|
kleur: 3.0.3
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -10728,6 +10865,13 @@ snapshots:
|
|||||||
|
|
||||||
react-fast-compare@3.2.2: {}
|
react-fast-compare@3.2.2: {}
|
||||||
|
|
||||||
|
react-grab@0.1.32(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@react-grab/cli': 0.1.32
|
||||||
|
bippy: 0.5.39(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react-hook-form@7.71.2(react@19.2.4):
|
react-hook-form@7.71.2(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -11075,6 +11219,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
slice-ansi@5.0.0:
|
slice-ansi@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
@@ -11087,6 +11233,8 @@ snapshots:
|
|||||||
|
|
||||||
smart-buffer@4.2.0: {}
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
|
smol-toml@1.6.1: {}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
socket.io-adapter@2.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -11167,6 +11315,8 @@ snapshots:
|
|||||||
|
|
||||||
std-env@4.0.0: {}
|
std-env@4.0.0: {}
|
||||||
|
|
||||||
|
stdin-discarder@0.2.2: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -11584,6 +11734,15 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.2.2
|
'@types/d3-array': 3.2.2
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
BIN
public/icon-512-maskable.png
Normal file
BIN
public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
40
scripts/dev-set-password.ts
Normal file
40
scripts/dev-set-password.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Dev helper: set a user's password directly (bypasses email reset).
|
||||||
|
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { hashPassword } from 'better-auth/crypto';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { user, account } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [, , email, password] = process.argv;
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
|
||||||
|
if (!u) {
|
||||||
|
console.error(`User not found: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
const result = await db
|
||||||
|
.update(account)
|
||||||
|
.set({ password: hash, updatedAt: new Date() })
|
||||||
|
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||||
|
.returning({ id: account.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.error(`No credential account row for ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function BackupManagementPage() {
|
export default function BackupManagementPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Backup Management" description="Manage system backups and restoration" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
|
||||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const FIELDS: SettingFieldDef[] = [
|
const FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -47,13 +48,10 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
export default function BrandingSettingsPage() {
|
export default function BrandingSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Branding</h1>
|
title="Branding"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||||
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell
|
/>
|
||||||
and outgoing email templates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Identity"
|
title="Identity"
|
||||||
description="App name, logo, and primary color."
|
description="App name, logo, and primary color."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const API_FIELDS: SettingFieldDef[] = [
|
const API_FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -48,13 +49,10 @@ const EOI_FIELDS: SettingFieldDef[] = [
|
|||||||
export default function DocumensoSettingsPage() {
|
export default function DocumensoSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Documenso & EOI</h1>
|
title="Documenso & EOI"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="API credentials and default EOI generation pathway. Use the test-connection button to verify a saved configuration before relying on it."
|
||||||
API credentials and default EOI generation pathway. Use the test-connection button to
|
/>
|
||||||
verify a saved configuration before relying on it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Documenso API"
|
title="Documenso API"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const FIELDS: SettingFieldDef[] = [
|
const FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -79,13 +80,10 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
export default function EmailSettingsPage() {
|
export default function EmailSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Email Settings</h1>
|
title="Email Settings"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||||
Per-port outgoing email configuration. SMTP credentials and the From address default to
|
/>
|
||||||
environment variables when these fields are blank.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="From address & signature"
|
title="From address & signature"
|
||||||
description="Identity headers and shared HTML used by system-generated emails."
|
description="Identity headers and shared HTML used by system-generated emails."
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function DataImportPage() {
|
export default function DataImportPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Data Import" description="Import data from external sources" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
|
||||||
<p className="text-muted-foreground">Import data from external sources</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function InvitationsPage() {
|
export default function InvitationsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Invitations</h1>
|
title="Invitations"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
|
||||||
Send a single-use invitation to a new CRM user. The recipient sets their own password via
|
/>
|
||||||
the link in the email.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<InvitationsManager />
|
<InvitationsManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||||
|
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||||
|
*/
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile?.isSuperAdmin) {
|
||||||
|
redirect(`/${portSlug}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Onboarding" description="Guided setup for new port configurations" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
|
|
||||||
<p className="text-muted-foreground">Guided setup for new port configurations</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
interface AdminSection {
|
interface AdminSection {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -165,13 +166,10 @@ export default async function AdminLandingPage({
|
|||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Administration</h1>
|
title="Administration"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||||
Per-port configuration and system administration. Each card below opens a dedicated
|
/>
|
||||||
settings page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{SECTIONS.map((s) => {
|
{SECTIONS.map((s) => {
|
||||||
const Icon = s.icon;
|
const Icon = s.icon;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -53,14 +54,10 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
|
|||||||
export default function ReminderSettingsPage() {
|
export default function ReminderSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Reminders</h1>
|
title="Reminders"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
|
||||||
Default reminder behaviour for new interests and the optional daily-digest delivery
|
/>
|
||||||
window. Individual users can still configure their own digest preferences in Notifications
|
|
||||||
→ Preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Defaults for new interests"
|
title="Defaults for new interests"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function ScheduledReportsPage() {
|
export default function ScheduledReportsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
|
title="Scheduled Reports"
|
||||||
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
|
description="Configure and manage automated report delivery"
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -36,7 +37,11 @@ export default function WebhooksPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [regenerating, setRegenerating] = useState<string | null>(null);
|
const [regenerating, setRegenerating] = useState<string | null>(null);
|
||||||
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null);
|
const [newSecret, setNewSecret] = useState<{
|
||||||
|
webhookId: string;
|
||||||
|
secret: string;
|
||||||
|
masked: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const loadWebhooks = useCallback(async () => {
|
const loadWebhooks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -98,15 +103,20 @@ export default function WebhooksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Webhooks"
|
||||||
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
|
description="Configure outgoing webhook integrations"
|
||||||
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
|
actions={
|
||||||
</div>
|
<Button
|
||||||
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
onClick={() => {
|
||||||
Add Webhook
|
setEditTarget(null);
|
||||||
</Button>
|
setFormOpen(true);
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
Add Webhook
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
@@ -116,7 +126,13 @@ export default function WebhooksPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Add a webhook to receive real-time notifications of CRM events.
|
Add a webhook to receive real-time notifications of CRM events.
|
||||||
</p>
|
</p>
|
||||||
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
setEditTarget(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Add Webhook
|
Add Webhook
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,17 +157,16 @@ export default function WebhooksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleToggleActive(webhook)}
|
|
||||||
>
|
|
||||||
{webhook.isActive ? 'Disable' : 'Enable'}
|
{webhook.isActive ? 'Disable' : 'Enable'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
|
onClick={() => {
|
||||||
|
setEditTarget(webhook);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -163,11 +178,7 @@ export default function WebhooksPage() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleExpand(webhook.id)}
|
|
||||||
>
|
|
||||||
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,18 +239,26 @@ export default function WebhooksPage() {
|
|||||||
onSuccess={loadWebhooks}
|
onSuccess={loadWebhooks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history. This action
|
Delete "{deleteTarget?.name}"? This will also delete all delivery history.
|
||||||
cannot be undone.
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
||||||
|
|
||||||
|
export default function BerthReservationsPage() {
|
||||||
|
return <BerthReservationsList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level loading UI for the client detail page. Renders while the
|
||||||
|
* server component resolves the session and the client component bootstraps
|
||||||
|
* its initial query — replaces the previous empty-header flash on direct
|
||||||
|
* URL visits.
|
||||||
|
*/
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 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="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<div className="flex gap-2 border-b border-border pb-1">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column overview */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
|||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||||
|
import { ExpenseCard } from '@/components/expenses/expense-card';
|
||||||
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||||
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
||||||
setArchiveExpense(null);
|
setArchiveExpense(null);
|
||||||
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<ExpenseCard
|
||||||
|
expense={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditExpense}
|
||||||
|
onArchive={setArchiveExpense}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No expenses found"
|
title="No expenses found"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Upload, Loader2, ScanLine } from 'lucide-react';
|
import { Camera, Loader2, ScanLine, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -33,9 +35,16 @@ export default function ScanReceiptPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: 'Scan Receipt', showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [setChrome]);
|
||||||
|
|
||||||
// Editable fields from scan
|
// Editable fields from scan
|
||||||
const [establishment, setEstablishment] = useState('');
|
const [establishment, setEstablishment] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
@@ -94,7 +103,7 @@ export default function ScanReceiptPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
<div>
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-2xl font-bold">Scan Receipt</h1>
|
<h1 className="text-2xl font-bold">Scan Receipt</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Upload a receipt image and we will extract the expense details automatically.
|
Upload a receipt image and we will extract the expense details automatically.
|
||||||
@@ -109,28 +118,44 @@ export default function ScanReceiptPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
{previewUrl ? (
|
||||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
{previewUrl ? (
|
>
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Receipt preview"
|
alt="Receipt preview"
|
||||||
className="max-h-64 mx-auto rounded object-contain"
|
className="max-h-64 mx-auto rounded object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button
|
||||||
Click to upload or drag and drop
|
type="button"
|
||||||
</p>
|
size="lg"
|
||||||
<p className="text-xs text-muted-foreground">
|
className="w-full h-14 sm:hidden"
|
||||||
JPEG, PNG, WebP up to 10MB
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Camera className="mr-2 h-5 w-5" />
|
||||||
)}
|
Take photo
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full h-14"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
|
<span className="sm:hidden">Choose from library</span>
|
||||||
|
<span className="hidden sm:inline">Click to upload or drag and drop</span>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
|
||||||
|
JPEG, PNG, WebP up to 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -138,6 +163,14 @@ export default function ScanReceiptPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{scanMutation.isPending && (
|
{scanMutation.isPending && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
|
||||||
@@ -222,25 +255,18 @@ export default function ScanReceiptPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
|
||||||
{(saveMutation.error as Error).message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/${params.portSlug}/expenses`)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={saveMutation.isPending || !amount}
|
disabled={saveMutation.isPending || !amount}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending && (
|
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save as Expense
|
Save as Expense
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useForm, FormProvider } from 'react-hook-form';
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -43,9 +45,35 @@ export default function NewInvoicePage() {
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
|
||||||
|
const prefilledKind =
|
||||||
|
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: 'New Invoice', showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [setChrome]);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the review step. Doubles as the source of truth for the billing entity prefill.
|
||||||
|
const { data: prefilledInterest } = useQuery<{
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string | null;
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
};
|
||||||
|
}>({
|
||||||
|
queryKey: ['interest-prefill', prefilledInterestId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
|
||||||
|
enabled: !!prefilledInterestId,
|
||||||
|
});
|
||||||
|
|
||||||
const methods = useForm<CreateInvoiceInput>({
|
const methods = useForm<CreateInvoiceInput>({
|
||||||
resolver: zodResolver(createInvoiceSchema),
|
resolver: zodResolver(createInvoiceSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -53,6 +81,8 @@ export default function NewInvoicePage() {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
lineItems: [],
|
lineItems: [],
|
||||||
expenseIds: [],
|
expenseIds: [],
|
||||||
|
interestId: prefilledInterestId,
|
||||||
|
kind: prefilledKind,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +95,43 @@ export default function NewInvoicePage() {
|
|||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
|
const isDepositInvoice = watchedValues.kind === 'deposit';
|
||||||
|
|
||||||
|
// Resolve the selected billing entity to a human name so the review step
|
||||||
|
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
|
||||||
|
const billingEntityRef = watchedValues.billingEntity ?? null;
|
||||||
|
const { data: billingEntityName } = useQuery<{ name: string }>({
|
||||||
|
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!billingEntityRef) return { name: '' };
|
||||||
|
const path =
|
||||||
|
billingEntityRef.type === 'company'
|
||||||
|
? `/api/v1/companies/${billingEntityRef.id}`
|
||||||
|
: `/api/v1/clients/${billingEntityRef.id}`;
|
||||||
|
const res = await apiFetch<{
|
||||||
|
data: { fullName?: string; name?: string };
|
||||||
|
}>(path);
|
||||||
|
return {
|
||||||
|
name: res?.data?.fullName ?? res?.data?.name ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!billingEntityRef?.id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-fill the billing entity from the linked interest's client on launch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
||||||
|
setValue(
|
||||||
|
'billingEntity',
|
||||||
|
{ type: 'client', id: prefilledInterest.data.clientId },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// We only want this to run when the interest data first arrives.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [prefilledInterest?.data?.clientId]);
|
||||||
|
|
||||||
const lineItems = watchedValues.lineItems ?? [];
|
const lineItems = watchedValues.lineItems ?? [];
|
||||||
const subtotal = lineItems.reduce(
|
const subtotal = lineItems.reduce(
|
||||||
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
|
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
|
||||||
@@ -117,8 +184,8 @@ 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 */}
|
{/* Header — desktop only; mobile gets the title from the topbar */}
|
||||||
<div className="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" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -157,6 +224,23 @@ export default function NewInvoicePage() {
|
|||||||
<CardTitle className="text-base">Client Information</CardTitle>
|
<CardTitle className="text-base">Client Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{isDepositInvoice ? (
|
||||||
|
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
|
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">Deposit invoice</p>
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
{prefilledInterest?.data
|
||||||
|
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||||
|
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 linked interest to "Deposit 10%".'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
Billing entity <span className="text-destructive">*</span>
|
Billing entity <span className="text-destructive">*</span>
|
||||||
@@ -294,9 +378,13 @@ export default function NewInvoicePage() {
|
|||||||
<p className="font-medium mt-0.5">
|
<p className="font-medium mt-0.5">
|
||||||
{watchedValues.billingEntity ? (
|
{watchedValues.billingEntity ? (
|
||||||
<>
|
<>
|
||||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
{billingEntityName?.name ? (
|
||||||
<span className="text-xs opacity-60">
|
<span>{billingEntityName.name}</span>
|
||||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
) : (
|
||||||
|
<span className="text-muted-foreground">Loading…</span>
|
||||||
|
)}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
({watchedValues.billingEntity.type})
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { InvoiceCard } from '@/components/invoices/invoice-card';
|
||||||
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
||||||
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sendMutation = useMutation({
|
const sendMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
||||||
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
},
|
},
|
||||||
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
|
|||||||
const columns = getInvoiceColumns({
|
const columns = getInvoiceColumns({
|
||||||
portSlug,
|
portSlug,
|
||||||
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
||||||
onRecordPayment: (invoice) =>
|
onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
||||||
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
|
||||||
onDelete: (invoice) => setDeleteTarget(invoice),
|
onDelete: (invoice) => setDeleteTarget(invoice),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<InvoiceCard
|
||||||
|
invoice={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onSend={(invoice) => sendMutation.mutate(invoice.id)}
|
||||||
|
onRecordPayment={(invoice) =>
|
||||||
|
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
|
||||||
|
}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No invoices found"
|
title="No invoices found"
|
||||||
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
|
|||||||
<h3 className="font-semibold">Delete Invoice?</h3>
|
<h3 className="font-semibold">Delete Invoice?</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This will permanently delete invoice{' '}
|
This will permanently delete invoice{' '}
|
||||||
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
|
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
|
||||||
This action cannot be undone.
|
action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { PortProvider } from '@/providers/port-provider';
|
|||||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
|
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||||
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -37,7 +39,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<RealtimeToasts />
|
||||||
|
{/* Desktop shell — hidden by CSS on mobile */}
|
||||||
|
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||||
@@ -57,6 +61,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile shell — hidden by CSS on desktop */}
|
||||||
|
<MobileLayout>{children}</MobileLayout>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</PortProvider>
|
</PortProvider>
|
||||||
|
|||||||
@@ -5,28 +5,19 @@ import type { Metadata } from 'next';
|
|||||||
import { getPortalSession } from '@/lib/portal/auth';
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
import { getClientInterests } from '@/lib/services/portal.service';
|
import { getClientInterests } from '@/lib/services/portal.service';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Interests' };
|
export const metadata: Metadata = { title: 'Interests' };
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'EOI / NDA Signed',
|
|
||||||
deposit_10pct: 'Deposit Received',
|
|
||||||
contract: 'Contract Stage',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
open: 'secondary',
|
open: 'secondary',
|
||||||
details_sent: 'secondary',
|
details_sent: 'secondary',
|
||||||
in_communication: 'default',
|
in_communication: 'default',
|
||||||
visited: 'default',
|
eoi_sent: 'default',
|
||||||
signed_eoi_nda: 'default',
|
eoi_signed: 'default',
|
||||||
deposit_10pct: 'default',
|
deposit_10pct: 'default',
|
||||||
contract: 'default',
|
contract_sent: 'default',
|
||||||
|
contract_signed: 'default',
|
||||||
completed: 'outline',
|
completed: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">Your berth enquiries and applications</p>
|
||||||
Your berth enquiries and applications
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{interests.length === 0 ? (
|
{interests.length === 0 ? (
|
||||||
@@ -56,10 +45,7 @@ export default async function PortalInterestsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{interests.map((interest) => (
|
{interests.map((interest) => (
|
||||||
<div
|
<div key={interest.id} className="bg-white rounded-lg border p-5">
|
||||||
key={interest.id}
|
|
||||||
className="bg-white rounded-lg border p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
|
<Badge variant={STAGE_VARIANT[safeStage(interest.pipelineStage)]}>
|
||||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
{stageLabel(interest.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
src/app/api/v1/berth-reservations/[id]/handlers.ts
Normal file
107
src/app/api/v1/berth-reservations/[id]/handlers.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { requirePermission } from '@/lib/auth/permissions';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
activate,
|
||||||
|
cancel,
|
||||||
|
endReservation,
|
||||||
|
getById,
|
||||||
|
} from '@/lib/services/berth-reservations.service';
|
||||||
|
|
||||||
|
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
||||||
|
|
||||||
|
const patchBodySchema = z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('activate'),
|
||||||
|
contractFileId: z.string().optional(),
|
||||||
|
effectiveDate: z.coerce.date().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('end'),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('cancel'),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const reservation = await getById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: reservation });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, patchBodySchema);
|
||||||
|
const meta = {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.action === 'activate') {
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await activate(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{
|
||||||
|
contractFileId: body.contractFileId,
|
||||||
|
effectiveDate: body.effectiveDate,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'end') {
|
||||||
|
// `end` is lifecycle progression; same privilege as activate.
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await endReservation(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate: body.endDate, notes: body.notes },
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === 'cancel'
|
||||||
|
requirePermission(ctx, 'reservations', 'cancel');
|
||||||
|
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await cancel(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,110 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { requirePermission } from '@/lib/auth/permissions';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import {
|
|
||||||
activate,
|
|
||||||
cancel,
|
|
||||||
endReservation,
|
|
||||||
getById,
|
|
||||||
} from '@/lib/services/berth-reservations.service';
|
|
||||||
|
|
||||||
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
|
||||||
|
|
||||||
const patchBodySchema = z.discriminatedUnion('action', [
|
|
||||||
z.object({
|
|
||||||
action: z.literal('activate'),
|
|
||||||
contractFileId: z.string().optional(),
|
|
||||||
effectiveDate: z.coerce.date().optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
action: z.literal('end'),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
notes: z.string().optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
action: z.literal('cancel'),
|
|
||||||
reason: z.string().optional(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const reservation = await getById(params.id!, ctx.portId);
|
|
||||||
return NextResponse.json({ data: reservation });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, patchBodySchema);
|
|
||||||
const meta = {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.action === 'activate') {
|
|
||||||
requirePermission(ctx, 'reservations', 'activate');
|
|
||||||
const result = await activate(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{
|
|
||||||
contractFileId: body.contractFileId,
|
|
||||||
effectiveDate: body.effectiveDate,
|
|
||||||
},
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.action === 'end') {
|
|
||||||
// `end` is lifecycle progression; same privilege as activate.
|
|
||||||
requirePermission(ctx, 'reservations', 'activate');
|
|
||||||
const result = await endReservation(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{ endDate: body.endDate, notes: body.notes },
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
}
|
|
||||||
|
|
||||||
// action === 'cancel'
|
|
||||||
requirePermission(ctx, 'reservations', 'cancel');
|
|
||||||
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
await cancel(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new NextResponse(null, { status: 204 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||||
|
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port-scoped global list of reservations across all berths. Inner handler
|
||||||
|
* lives here so it can be invoked directly from integration tests without
|
||||||
|
* the `withAuth(withPermission(...))` wrappers (matches the convention
|
||||||
|
* used throughout `src/app/api/v1/*`).
|
||||||
|
*/
|
||||||
|
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req as never, listReservationsSchema);
|
||||||
|
const result = await listReservations(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/app/api/v1/berth-reservations/route.ts
Normal file
4
src/app/api/v1/berth-reservations/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { listHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||||
47
src/app/api/v1/companies/[id]/members/[mid]/handlers.ts
Normal file
47
src/app/api/v1/companies/[id]/members/[mid]/handlers.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
||||||
|
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateMembershipSchema);
|
||||||
|
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
let endDate = new Date();
|
||||||
|
const text = await req.text();
|
||||||
|
if (text.length > 0) {
|
||||||
|
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
||||||
|
endDate = parsed.endDate;
|
||||||
|
}
|
||||||
|
await endMembership(
|
||||||
|
params.mid!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate },
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,50 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { patchHandler, deleteHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
|
||||||
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
|
||||||
|
|
||||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, updateMembershipSchema);
|
|
||||||
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: updated });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
let endDate = new Date();
|
|
||||||
const text = await req.text();
|
|
||||||
if (text.length > 0) {
|
|
||||||
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
|
||||||
endDate = parsed.endDate;
|
|
||||||
}
|
|
||||||
await endMembership(
|
|
||||||
params.mid!,
|
|
||||||
ctx.portId,
|
|
||||||
{ endDate },
|
|
||||||
{
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new NextResponse(null, { status: 204 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||||
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { setPrimary } from '@/lib/services/company-memberships.service';
|
||||||
|
|
||||||
|
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const membership = await setPrimary(params.mid!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { setPrimaryHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { setPrimary } from '@/lib/services/company-memberships.service';
|
|
||||||
|
|
||||||
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const membership = await setPrimary(params.mid!, ctx.portId, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: membership });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
||||||
|
|||||||
40
src/app/api/v1/companies/[id]/members/handlers.ts
Normal file
40
src/app/api/v1/companies/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
||||||
|
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
const listQuerySchema = z.object({
|
||||||
|
activeOnly: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.transform((v) => v === 'true')
|
||||||
|
.default('true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const { activeOnly } = parseQuery(req, listQuerySchema);
|
||||||
|
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
||||||
|
return NextResponse.json({ data: memberships });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, addMembershipSchema);
|
||||||
|
const membership = await addMembership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,43 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { listHandler, createHandler } from './handlers';
|
||||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
|
||||||
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
|
||||||
|
|
||||||
const listQuerySchema = z.object({
|
|
||||||
activeOnly: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.transform((v) => v === 'true')
|
|
||||||
.default('true'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const { activeOnly } = parseQuery(req, listQuerySchema);
|
|
||||||
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
|
||||||
return NextResponse.json({ data: memberships });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, addMembershipSchema);
|
|
||||||
const membership = await addMembership(params.id!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: membership }, { status: 201 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||||
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
||||||
|
|||||||
18
src/app/api/v1/companies/autocomplete/handlers.ts
Normal file
18
src/app/api/v1/companies/autocomplete/handlers.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/companies.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const companies = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: companies });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { autocompleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { autocomplete } from '@/lib/services/companies.service';
|
|
||||||
|
|
||||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
|
||||||
try {
|
|
||||||
const q = req.nextUrl.searchParams.get('q');
|
|
||||||
if (!q) {
|
|
||||||
return NextResponse.json({ data: [] });
|
|
||||||
}
|
|
||||||
const companies = await autocomplete(ctx.portId, q);
|
|
||||||
return NextResponse.json({ data: companies });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
||||||
|
|||||||
41
src/app/api/v1/interests/[id]/outcome/route.ts
Normal file
41
src/app/api/v1/interests/[id]/outcome/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { clearInterestOutcome, setInterestOutcome } from '@/lib/services/interests.service';
|
||||||
|
import { clearOutcomeSchema, setOutcomeSchema } from '@/lib/validators/interests';
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('interests', 'change_stage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, setOutcomeSchema);
|
||||||
|
const result = await setInterestOutcome(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DELETE = withAuth(
|
||||||
|
withPermission('interests', 'change_stage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, clearOutcomeSchema);
|
||||||
|
const result = await clearInterestOutcome(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -7,6 +7,26 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { user } from '@/lib/db/schema/users';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
|
won: 'Won',
|
||||||
|
lost_other_marina: 'Lost — went to another marina',
|
||||||
|
lost_unqualified: 'Lost — unqualified',
|
||||||
|
lost_no_response: 'Lost — no response',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOC_EVENT_LABELS: Record<string, string> = {
|
||||||
|
sent: 'sent for signing',
|
||||||
|
completed: 'fully signed',
|
||||||
|
signed: 'signed by recipient',
|
||||||
|
rejected: 'rejected',
|
||||||
|
expired: 'expired',
|
||||||
|
cancelled: 'cancelled',
|
||||||
|
reminder_sent: 'reminder sent',
|
||||||
|
};
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +34,10 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
/** Resolved display name for `userId`. `'system'` for auto-events; null when
|
||||||
|
* the user has been deleted or the event has no actor. Falls back to
|
||||||
|
* email-localpart if the user has no display name. */
|
||||||
|
userName: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -33,12 +57,7 @@ export const GET = withAuth(
|
|||||||
const auditRows = await db
|
const auditRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(auditLogs)
|
.from(auditLogs)
|
||||||
.where(
|
.where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
|
||||||
and(
|
|
||||||
eq(auditLogs.entityType, 'interest'),
|
|
||||||
eq(auditLogs.entityId, interestId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(auditLogs.createdAt))
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|
||||||
@@ -67,28 +86,82 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
||||||
|
|
||||||
|
// Resolve display names for any `userId` that is a real user row (the
|
||||||
|
// sentinel value 'system' is used for auto-events and isn't joined).
|
||||||
|
const realUserIds = Array.from(
|
||||||
|
new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
|
||||||
|
);
|
||||||
|
const userRows =
|
||||||
|
realUserIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(inArray(user.id, realUserIds))
|
||||||
|
: [];
|
||||||
|
const userNameById = new Map<string, string>(
|
||||||
|
userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
|
||||||
|
);
|
||||||
|
const resolveUserName = (userId: string | null): string | null => {
|
||||||
|
if (!userId) return null;
|
||||||
|
if (userId === 'system') return 'system';
|
||||||
|
return userNameById.get(userId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
// Union and sort
|
// Union and sort
|
||||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: 'audit',
|
type: 'audit',
|
||||||
action: row.action,
|
action: row.action,
|
||||||
description: buildAuditDescription(row.action, row.newValue as Record<string, unknown> | null),
|
description: buildAuditDescription(
|
||||||
|
row.action,
|
||||||
|
row.newValue as Record<string, unknown> | null,
|
||||||
|
(row.metadata as Record<string, unknown>) ?? {},
|
||||||
|
row.userId,
|
||||||
|
),
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
|
userName: resolveUserName(row.userId),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({
|
const docEvents: TimelineEvent[] = docEventRows.map((row) => {
|
||||||
id: row.id,
|
const title = docTitles[row.documentId] ?? row.documentId;
|
||||||
type: 'document_event',
|
const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
|
||||||
action: row.eventType,
|
return {
|
||||||
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`,
|
id: row.id,
|
||||||
userId: null,
|
type: 'document_event',
|
||||||
createdAt: row.createdAt,
|
action: row.eventType,
|
||||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
description: `Document "${title}" ${action}`,
|
||||||
}));
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const allEvents = [...auditEvents, ...docEvents];
|
const allEvents = [...auditEvents, ...docEvents];
|
||||||
|
|
||||||
|
// Fallback: when no audit-log entries exist for this interest (typical
|
||||||
|
// for seed/imported data inserted directly into the table without going
|
||||||
|
// through the service), synthesize a "Created at <stage>" event so the
|
||||||
|
// tab isn't empty when the interest is clearly past `open`.
|
||||||
|
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
||||||
|
if (!hasCreateAudit) {
|
||||||
|
const stage = stageLabel(interest.pipelineStage);
|
||||||
|
const created = interest.createdAt ?? new Date();
|
||||||
|
allEvents.push({
|
||||||
|
id: `synth-${interest.id}-create`,
|
||||||
|
type: 'audit',
|
||||||
|
action: 'create',
|
||||||
|
description:
|
||||||
|
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
||||||
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
createdAt: created,
|
||||||
|
metadata: { synthetic: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
||||||
@@ -101,12 +174,39 @@ export const GET = withAuth(
|
|||||||
function buildAuditDescription(
|
function buildAuditDescription(
|
||||||
action: string,
|
action: string,
|
||||||
newValue: Record<string, unknown> | null,
|
newValue: Record<string, unknown> | null,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
userId: string | null,
|
||||||
): string {
|
): string {
|
||||||
if (action === 'create') return 'Interest created';
|
if (action === 'create') return 'Interest created';
|
||||||
if (action === 'archive') return 'Interest archived';
|
if (action === 'archive') return 'Interest archived';
|
||||||
if (action === 'restore') return 'Interest restored';
|
if (action === 'restore') return 'Interest restored';
|
||||||
|
|
||||||
|
const type = metadata.type;
|
||||||
|
|
||||||
|
if (type === 'outcome_set') {
|
||||||
|
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||||
|
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||||
|
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||||
|
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'outcome_cleared') {
|
||||||
|
const stage = (newValue?.pipelineStage as string | undefined) ?? '';
|
||||||
|
return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stage_change' && newValue?.pipelineStage) {
|
||||||
|
const stage = stageLabel(newValue.pipelineStage as string);
|
||||||
|
const reason = (newValue.reason as string | undefined) ?? '';
|
||||||
|
const auto = userId === 'system';
|
||||||
|
if (auto) {
|
||||||
|
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
||||||
|
}
|
||||||
|
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'update' && newValue?.pipelineStage) {
|
if (action === 'update' && newValue?.pipelineStage) {
|
||||||
return `Stage changed to "${newValue.pipelineStage}"`;
|
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
|
||||||
}
|
}
|
||||||
if (action === 'update') return 'Interest updated';
|
if (action === 'update') return 'Interest updated';
|
||||||
return action;
|
return action;
|
||||||
|
|||||||
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { savedViews } from '@/lib/db/schema';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||||
|
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the view and enforces ownership before mutating.
|
||||||
|
*
|
||||||
|
* Returns a 404 when the view does not exist (or lives in a different port)
|
||||||
|
* and a 403 when it belongs to a different user. The 404-before-403 split
|
||||||
|
* matches the rest of the API and avoids leaking the existence of another
|
||||||
|
* user's saved view via timing or status code.
|
||||||
|
*/
|
||||||
|
async function assertViewOwner(
|
||||||
|
id: string,
|
||||||
|
portId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NextResponse | null> {
|
||||||
|
const view = await db.query.savedViews.findFirst({
|
||||||
|
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!view) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (view.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchHandler(
|
||||||
|
req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||||
|
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||||
|
return NextResponse.json({ data: view });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHandler(
|
||||||
|
_req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||||
|
return NextResponse.json({ data: null }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { patchHandler, deleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
|
||||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
|
||||||
|
|
||||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
export const PATCH = withAuth(patchHandler);
|
||||||
try {
|
export const DELETE = withAuth(deleteHandler);
|
||||||
const id = params.id ?? '';
|
|
||||||
const body = await parseBody(req, updateSavedViewSchema);
|
|
||||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
|
||||||
return NextResponse.json({ data: view });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const id = params.id ?? '';
|
|
||||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
|
||||||
return NextResponse.json({ data: null }, { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
14
src/app/api/v1/yachts/[id]/ownership-history/handlers.ts
Normal file
14
src/app/api/v1/yachts/[id]/ownership-history/handlers.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const historyHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: history });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { historyHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
|
||||||
|
|
||||||
export const historyHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
|
||||||
return NextResponse.json({ data: history });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
||||||
|
|||||||
22
src/app/api/v1/yachts/[id]/transfer/handlers.ts
Normal file
22
src/app/api/v1/yachts/[id]/transfer/handlers.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { transferOwnership } from '@/lib/services/yachts.service';
|
||||||
|
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, transferOwnershipSchema);
|
||||||
|
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: yacht });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,24 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { transferHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { transferOwnership } from '@/lib/services/yachts.service';
|
|
||||||
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
|
||||||
|
|
||||||
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, transferOwnershipSchema);
|
|
||||||
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: yacht });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
||||||
|
|||||||
18
src/app/api/v1/yachts/autocomplete/handlers.ts
Normal file
18
src/app/api/v1/yachts/autocomplete/handlers.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const yachts = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: yachts });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { autocompleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { autocomplete } from '@/lib/services/yachts.service';
|
|
||||||
|
|
||||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
|
||||||
try {
|
|
||||||
const q = req.nextUrl.searchParams.get('q');
|
|
||||||
if (!q) {
|
|
||||||
return NextResponse.json({ data: [] });
|
|
||||||
}
|
|
||||||
const yachts = await autocomplete(ctx.portId, q);
|
|
||||||
return NextResponse.json({ data: yachts });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
|||||||
import {
|
import {
|
||||||
handleRecipientSigned,
|
handleRecipientSigned,
|
||||||
handleDocumentCompleted,
|
handleDocumentCompleted,
|
||||||
|
handleDocumentExpired,
|
||||||
handleDocumentOpened,
|
handleDocumentOpened,
|
||||||
handleDocumentRejected,
|
handleDocumentRejected,
|
||||||
handleDocumentCancelled,
|
handleDocumentCancelled,
|
||||||
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'DOCUMENT_EXPIRED':
|
||||||
|
await handleDocumentExpired({ documentId: documensoId });
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,3 +127,45 @@
|
|||||||
@apply bg-muted-foreground/30;
|
@apply bg-muted-foreground/30;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Form-factor shell visibility ──────────────────────────────────────────
|
||||||
|
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
|
||||||
|
* the inactive one. The data-form-factor body attribute is set server-side
|
||||||
|
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||||
|
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||||||
|
*
|
||||||
|
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
|
||||||
|
* display, because the desktop shell uses Tailwind's `flex` class which would
|
||||||
|
* be overridden by `display: block` (same specificity, later cascade).
|
||||||
|
*/
|
||||||
|
[data-shell='mobile'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
[data-shell='desktop'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-shell='mobile'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-form-factor='mobile'] [data-shell='desktop'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body[data-form-factor='mobile'] [data-shell='mobile'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* React Query Devtools floating button collides with the bottom tab bar's
|
||||||
|
* "More" tab on mobile. The devtools panel itself remains accessible from
|
||||||
|
* desktop where the toggle is positioned out of the way of any UI.
|
||||||
|
*/
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
.tsqd-open-btn-container,
|
||||||
|
.tsqd-parent-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import Script from 'next/script';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
|
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -15,20 +19,55 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#1e2844',
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: 'Port Nimara CRM',
|
default: 'Port Nimara CRM',
|
||||||
template: '%s | Port Nimara CRM',
|
template: '%s | Port Nimara CRM',
|
||||||
},
|
},
|
||||||
description: 'Marina management system for Port Nimara',
|
description: 'Marina management system for Port Nimara',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
title: 'Port Nimara',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: '/apple-touch-icon.png',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const headerList = await headers();
|
||||||
|
const formFactor = classifyFormFactor(headerList.get('user-agent'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
|
<head>
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<Script
|
||||||
|
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-form-factor={formFactor}
|
||||||
|
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
151
src/components/admin/audit/audit-log-card.tsx
Normal file
151
src/components/admin/audit/audit-log-card.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string | null;
|
||||||
|
fieldChanged: string | null;
|
||||||
|
oldValue: Record<string, unknown> | null;
|
||||||
|
newValue: Record<string, unknown> | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actor: { id: string; email: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_ACCENT: Record<string, string> = {
|
||||||
|
create: 'bg-emerald-400',
|
||||||
|
update: 'bg-blue-400',
|
||||||
|
delete: 'bg-rose-400',
|
||||||
|
viewed: 'bg-slate-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_BADGE_COLORS: Record<string, string> = {
|
||||||
|
create: 'bg-green-600',
|
||||||
|
update: 'bg-blue-500',
|
||||||
|
delete: 'bg-red-600',
|
||||||
|
archive: 'bg-orange-500',
|
||||||
|
restore: 'bg-teal-500',
|
||||||
|
login: 'bg-gray-500',
|
||||||
|
permission_denied: 'bg-red-800',
|
||||||
|
merge: 'bg-purple-500',
|
||||||
|
revert: 'bg-amber-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ActionIcon({ action }: { action: string }) {
|
||||||
|
if (action === 'create') return <Plus className="h-5 w-5" />;
|
||||||
|
if (action === 'update') return <Pencil className="h-5 w-5" />;
|
||||||
|
if (action === 'delete') return <Trash2 className="h-5 w-5" />;
|
||||||
|
if (action === 'viewed') return <Eye className="h-5 w-5" />;
|
||||||
|
return <Activity className="h-5 w-5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionVerb(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
create: 'Created',
|
||||||
|
update: 'Updated',
|
||||||
|
delete: 'Deleted',
|
||||||
|
archive: 'Archived',
|
||||||
|
restore: 'Restored',
|
||||||
|
login: 'Logged in',
|
||||||
|
permission_denied: 'Permission denied',
|
||||||
|
merge: 'Merged',
|
||||||
|
revert: 'Reverted',
|
||||||
|
viewed: 'Viewed',
|
||||||
|
};
|
||||||
|
return map[action] ?? action.charAt(0).toUpperCase() + action.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogCardProps {
|
||||||
|
entry: AuditEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogCard({ entry }: AuditLogCardProps) {
|
||||||
|
const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300';
|
||||||
|
const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500';
|
||||||
|
|
||||||
|
const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${
|
||||||
|
entry.entityId ? ` ${entry.entityId.slice(0, 8)}…` : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const actorName = entry.actor?.name ?? (entry.userId ? `${entry.userId.slice(0, 8)}…` : 'system');
|
||||||
|
|
||||||
|
// Changed-fields chip line: prefer fieldChanged (single field), then newValue keys
|
||||||
|
let changedFields: string[] = [];
|
||||||
|
if (entry.fieldChanged) {
|
||||||
|
changedFields = [entry.fieldChanged];
|
||||||
|
} else if (entry.newValue) {
|
||||||
|
changedFields = Object.keys(entry.newValue);
|
||||||
|
}
|
||||||
|
const visibleFields = changedFields.slice(0, 3);
|
||||||
|
const overflowCount = changedFields.length - visibleFields.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href="#"
|
||||||
|
ariaLabel={`Audit: ${actionVerb(entry.action)} ${entityTitle}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<ActionIcon action={entry.action} />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title: entity type + short ID */}
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{entityTitle}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Subtitle: action verb + actor */}
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="truncate">
|
||||||
|
{actionVerb(entry.action)} by {actorName}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Timestamp meta line */}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
||||||
|
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
|
||||||
|
</ListCardMeta>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action badge + changed-fields chips */}
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white',
|
||||||
|
badgeColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{visibleFields.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{visibleFields.map((field) => (
|
||||||
|
<span
|
||||||
|
key={field}
|
||||||
|
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{overflowCount > 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">+{overflowCount}</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { AuditLogCard } from './audit-log-card';
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -357,6 +358,7 @@ export function AuditLogList() {
|
|||||||
data={entries}
|
data={entries}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => <AuditLogCard entry={row.original} />}
|
||||||
emptyState={
|
emptyState={
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
open: 0.05,
|
open: 0.05,
|
||||||
details_sent: 0.1,
|
details_sent: 0.1,
|
||||||
in_communication: 0.2,
|
in_communication: 0.2,
|
||||||
signed_eoi_nda: 0.4,
|
eoi_sent: 0.4,
|
||||||
deposit_10pct: 0.6,
|
eoi_signed: 0.6,
|
||||||
contract: 0.8,
|
deposit_10pct: 0.75,
|
||||||
|
contract_sent: 0.85,
|
||||||
|
contract_signed: 0.95,
|
||||||
completed: 1.0,
|
completed: 1.0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'json',
|
type: 'json',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'eoi_signers',
|
||||||
|
label: 'EOI Signers',
|
||||||
|
description:
|
||||||
|
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
|
||||||
|
type: 'json',
|
||||||
|
defaultValue: {
|
||||||
|
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
|
||||||
|
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsManager() {
|
export function SettingsManager() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ServiceHealthCard } from './service-health-card';
|
import { ServiceHealthCard } from './service-health-card';
|
||||||
import { QueueOverview } from './queue-overview';
|
import { QueueOverview } from './queue-overview';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import type {
|
import type {
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
QueueStatus,
|
QueueStatus,
|
||||||
@@ -17,16 +18,14 @@ import type {
|
|||||||
export function SystemMonitoringDashboard() {
|
export function SystemMonitoringDashboard() {
|
||||||
const { data: healthData } = useQuery({
|
const { data: healthData } = useQuery({
|
||||||
queryKey: ['system', 'health'],
|
queryKey: ['system', 'health'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||||
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: queuesData } = useQuery({
|
const { data: queuesData } = useQuery({
|
||||||
queryKey: ['system', 'queues'],
|
queryKey: ['system', 'queues'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||||
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
@@ -47,11 +46,10 @@ export function SystemMonitoringDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
<PageHeader
|
||||||
<div>
|
title="System Monitoring"
|
||||||
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
|
description="Real-time health, queue status and connection tracking"
|
||||||
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service health */}
|
{/* Service health */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -79,10 +77,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div
|
<div key={i} className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,9 +119,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">{queues.reduce((sum, q) => sum + q.active, 0)}</p>
|
||||||
{queues.reduce((sum, q) => sum + q.active, 0)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -141,10 +134,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div
|
<div key={i} className="h-[110px] rounded-xl border bg-card animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="h-[110px] rounded-xl border bg-card animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -159,8 +149,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
function RecentErrorsPanel() {
|
function RecentErrorsPanel() {
|
||||||
const { data: errorsData } = useQuery({
|
const { data: errorsData } = useQuery({
|
||||||
queryKey: ['system', 'errors'],
|
queryKey: ['system', 'errors'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||||
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
149
src/components/admin/users/user-card.tsx
Normal file
149
src/components/admin/users/user-card.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||||
|
import {
|
||||||
|
ListCard,
|
||||||
|
ListCardAvatar,
|
||||||
|
ListCardMeta,
|
||||||
|
deriveInitials,
|
||||||
|
} from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
role: { id: string; name: string };
|
||||||
|
assignedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: UserRow;
|
||||||
|
onEdit: (user: UserRow) => void;
|
||||||
|
onRemove: (userId: string) => void;
|
||||||
|
isRemoving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
|
||||||
|
const initials = deriveInitials(user.displayName || user.email);
|
||||||
|
|
||||||
|
const accentClass = user.isSuperAdmin
|
||||||
|
? 'bg-violet-400'
|
||||||
|
: !user.isActive
|
||||||
|
? 'bg-slate-400'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href="#"
|
||||||
|
ariaLabel={`User: ${user.displayName}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${user.displayName}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onEdit(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<ConfirmationDialog
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
title="Remove User"
|
||||||
|
description={`Remove "${user.displayName}" from this port? They will lose access but their account remains.`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
onConfirm={() => onRemove(user.userId)}
|
||||||
|
loading={isRemoving}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar initials={initials} className={cn(!user.isActive && 'opacity-50')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
'truncate text-base font-semibold tracking-tight',
|
||||||
|
user.isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.displayName || user.email}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email subtitle — only when display name is shown as title */}
|
||||||
|
{user.displayName && user.displayName !== user.email ? (
|
||||||
|
<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 />
|
||||||
|
<span className="truncate">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Role + last login meta */}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
|
||||||
|
|
||||||
|
{user.lastLoginAt ? (
|
||||||
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
||||||
|
{formatDistanceToNow(new Date(user.lastLoginAt), { addSuffix: true })}
|
||||||
|
</ListCardMeta>
|
||||||
|
) : (
|
||||||
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>Never logged in</ListCardMeta>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + super-admin pills */}
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{!user.isActive ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{user.isSuperAdmin ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
|
||||||
|
Super Admin
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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 { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { UserCard } from './user-card';
|
||||||
import { UserForm } from './user-form';
|
import { UserForm } from './user-form';
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
@@ -152,6 +153,14 @@ export function UserList() {
|
|||||||
data={users}
|
data={users}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
getRowId={(row) => row.userId}
|
getRowId={(row) => row.userId}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<UserCard
|
||||||
|
user={row.original}
|
||||||
|
onEdit={handleEditUser}
|
||||||
|
onRemove={handleRemoveUser}
|
||||||
|
isRemoving={deletingId === row.original.userId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground">No users assigned to this port.</p>
|
<p className="text-muted-foreground">No users assigned to this port.</p>
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export function AlertRail() {
|
|||||||
<section
|
<section
|
||||||
data-testid="alert-rail"
|
data-testid="alert-rail"
|
||||||
aria-label="Active alerts"
|
aria-label="Active alerts"
|
||||||
className="flex h-full flex-col gap-3"
|
// `h-full` is intentional only at xl: where the parent dashboard grid
|
||||||
|
// gives this rail a sibling column whose height it should match. On
|
||||||
|
// mobile (single-column stack) there's no fixed-height context, so
|
||||||
|
// forcing 100% height makes the section overflow / look stretched.
|
||||||
|
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>
|
||||||
|
|||||||
177
src/components/berths/berth-card.tsx
Normal file
177
src/components/berths/berth-card.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { BerthRow } from './berth-columns';
|
||||||
|
|
||||||
|
const STATUS_VARIANTS: Record<string, string> = {
|
||||||
|
available: 'bg-green-100 text-green-800 border-green-200',
|
||||||
|
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
|
sold: 'bg-red-100 text-red-800 border-red-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
available: 'Available',
|
||||||
|
under_offer: 'Under Offer',
|
||||||
|
sold: 'Sold',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT_CLASS: Record<string, string> = {
|
||||||
|
available: 'bg-emerald-400',
|
||||||
|
under_offer: 'bg-amber-400',
|
||||||
|
sold: 'bg-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrice(price: string, currency: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(price));
|
||||||
|
} catch {
|
||||||
|
return `${currency} ${price}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BerthCardProps {
|
||||||
|
berth: BerthRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthCard({ berth }: BerthCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
const accentClass = ACCENT_CLASS[berth.status] ?? 'bg-slate-300';
|
||||||
|
|
||||||
|
// Dimensions string
|
||||||
|
let dimText: string | null = null;
|
||||||
|
if (berth.lengthM || berth.widthM) {
|
||||||
|
const l = berth.lengthM ?? '?';
|
||||||
|
const w = berth.widthM ?? '?';
|
||||||
|
dimText = `${l}m × ${w}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
if (dimText) metaParts.push(dimText);
|
||||||
|
if (berth.price) metaParts.push(formatPrice(berth.price, berth.priceCurrency));
|
||||||
|
|
||||||
|
const tags = berth.tags ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/berths/${berth.id}`}
|
||||||
|
ariaLabel={`Berth ${berth.mooringNumber}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for berth ${berth.mooringNumber}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/${portSlug}/berths/${berth.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Activity className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{berth.mooringNumber}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area subtitle */}
|
||||||
|
{berth.area ? (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="truncate">{berth.area}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Dimensions · Price meta line */}
|
||||||
|
{metaParts.length > 0 ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||||
|
{metaParts.map((part, i) => (
|
||||||
|
<span key={part} className="inline-flex items-center gap-1">
|
||||||
|
{i > 0 ? <span aria-hidden>·</span> : null}
|
||||||
|
<ListCardMeta>{part}</ListCardMeta>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
statusColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
{tags.length > 2 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||||
|
+{tags.length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,7 +170,9 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
<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">Berth {berth.mooringNumber}</h1>
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground">
|
||||||
|
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'}`}
|
||||||
>
|
>
|
||||||
@@ -180,7 +182,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
<PermissionGate resource="berths" action="edit">
|
<PermissionGate resource="berths" action="edit">
|
||||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { BerthDetailHeader } from './berth-detail-header';
|
import { BerthDetailHeader } from './berth-detail-header';
|
||||||
|
import { BerthForm } from './berth-form';
|
||||||
import { buildBerthTabs } from './berth-tabs';
|
import { buildBerthTabs } from './berth-tabs';
|
||||||
|
|
||||||
interface BerthDetailProps {
|
interface BerthDetailProps {
|
||||||
@@ -26,15 +30,45 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
|||||||
'berth:statusChanged': [['berth', berthId]],
|
'berth:statusChanged': [['berth', berthId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.mooringNumber ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
// Auto-open edit sheet when ?edit=true is present in the URL
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('edit') === 'true') {
|
||||||
|
setEditOpen(true);
|
||||||
|
// Strip the param without adding a history entry
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('edit');
|
||||||
|
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||||
|
// typedRoutes can't statically validate this dynamic path; cast is safe
|
||||||
|
// because we're always replacing within the same route segment.
|
||||||
|
router.replace(newUrl as never);
|
||||||
|
}
|
||||||
|
// Only run once on mount / when searchParams changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const berth = data as any;
|
const berth = data as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<>
|
||||||
isLoading={isLoading}
|
<DetailLayout
|
||||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
isLoading={isLoading}
|
||||||
tabs={berth ? buildBerthTabs(berth) : []}
|
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||||
defaultTab="overview"
|
tabs={berth ? buildBerthTabs(berth) : []}
|
||||||
/>
|
defaultTab="overview"
|
||||||
|
/>
|
||||||
|
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/berths/berth-interest-pulse.tsx
Normal file
166
src/components/berths/berth-interest-pulse.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ChevronRight, Users } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: InterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-overview pulse for the berth detail page. Lists the active
|
||||||
|
* interested parties with their stage + last activity, so the rep can do
|
||||||
|
* berth-level triage ("who's on this slip and how warm are they?")
|
||||||
|
* without clicking into the Interests tab.
|
||||||
|
*
|
||||||
|
* Borrows from the old Nuxt CRM's BerthDetailsModal "Interested Parties"
|
||||||
|
* pattern but uses the new at-a-glance signals (urgency badges, last
|
||||||
|
* activity).
|
||||||
|
*/
|
||||||
|
export function BerthInterestPulse({ berthId }: { berthId: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<InterestsResponse>(
|
||||||
|
`/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`,
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = data?.data ?? [];
|
||||||
|
const active = all.filter((i) => !i.archivedAt && !i.outcome);
|
||||||
|
const preview = active.slice(0, PREVIEW_LIMIT);
|
||||||
|
const more = active.length - preview.length;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Interested parties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground">No active interests on this berth.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{active.length}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{preview.map((i) => {
|
||||||
|
const lastIso = i.dateLastContact ?? i.updatedAt ?? null;
|
||||||
|
const lastActivity = lastIso
|
||||||
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
: null;
|
||||||
|
const urgency = computeUrgencyBadges(i);
|
||||||
|
const initials = (i.clientName ?? '?')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${i.id}`}
|
||||||
|
className="group flex items-center gap-3 px-1 py-2.5 transition-colors hover:bg-foreground/5 rounded-md -mx-1"
|
||||||
|
>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700">
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{i.clientName ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
stageBadgeClass(i.pipelineStage),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stageLabel(i.pipelineStage)}
|
||||||
|
</span>
|
||||||
|
{urgency.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
b.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lastActivity ? (
|
||||||
|
<p className="text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
Last activity {lastActivity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{more > 0 ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths/${berthId}?tab=interests`}
|
||||||
|
className="mt-2 inline-flex text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all {active.length} interests →
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { Bookmark } from 'lucide-react';
|
import { Bookmark } from 'lucide-react';
|
||||||
|
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
|
||||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
|
||||||
interface BerthInterestsTabProps {
|
interface BerthInterestsTabProps {
|
||||||
@@ -28,27 +29,10 @@ interface BerthInterestsTabProps {
|
|||||||
type StageFilter = 'all' | 'active' | 'lost';
|
type StageFilter = 'all' | 'active' | 'lost';
|
||||||
type SortMode = 'newest' | 'stage' | 'category';
|
type SortMode = 'newest' | 'stage' | 'category';
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
function stageRank(stage: string): number {
|
||||||
open: 'Open',
|
const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]);
|
||||||
details_sent: 'Details Sent',
|
return idx === -1 ? 99 : idx;
|
||||||
in_communication: 'In Communication',
|
}
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAGE_ORDER: Record<string, number> = {
|
|
||||||
open: 0,
|
|
||||||
details_sent: 1,
|
|
||||||
in_communication: 2,
|
|
||||||
visited: 3,
|
|
||||||
signed_eoi_nda: 4,
|
|
||||||
deposit_10pct: 5,
|
|
||||||
contract: 6,
|
|
||||||
completed: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_RANK: Record<string, number> = {
|
const CATEGORY_RANK: Record<string, number> = {
|
||||||
hot_lead: 0,
|
hot_lead: 0,
|
||||||
@@ -104,8 +88,8 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
});
|
});
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
if (sortMode === 'stage') {
|
if (sortMode === 'stage') {
|
||||||
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
|
const sa = stageRank(a.pipelineStage);
|
||||||
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
|
const sb = stageRank(b.pipelineStage);
|
||||||
if (sa !== sb) return sb - sa; // furthest along first
|
if (sa !== sb) return sb - sa; // furthest along first
|
||||||
}
|
}
|
||||||
if (sortMode === 'category') {
|
if (sortMode === 'category') {
|
||||||
@@ -189,7 +173,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
{stageLabel(i.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
|||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { BerthCard } from './berth-card';
|
||||||
import { berthColumns, type BerthRow } from './berth-columns';
|
import { berthColumns, type BerthRow } from './berth-columns';
|
||||||
import { berthFilterDefinitions } from './berth-filters';
|
import { berthFilterDefinitions } from './berth-filters';
|
||||||
import { Anchor } from 'lucide-react';
|
import { Anchor } from 'lucide-react';
|
||||||
@@ -73,6 +74,7 @@ export function BerthList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||||
|
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Anchor}
|
icon={Anchor}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||||
import { BerthInterestsTab } from './berth-interests-tab';
|
import { BerthInterestsTab } from './berth-interests-tab';
|
||||||
|
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||||
|
|
||||||
type BerthData = {
|
type BerthData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,93 +73,99 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Specifications */}
|
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||||
<Card>
|
who's interested + how warm without clicking into the Interests tab. */}
|
||||||
<CardHeader className="pb-3">
|
<BerthInterestPulse berthId={berth.id} />
|
||||||
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 divide-y">
|
|
||||||
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
|
|
||||||
<SpecRow
|
|
||||||
label="Width"
|
|
||||||
value={
|
|
||||||
formatDim(berth.widthFt, berth.widthM)
|
|
||||||
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
|
||||||
<SpecRow
|
|
||||||
label="Nominal Boat Size"
|
|
||||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
|
||||||
/>
|
|
||||||
<SpecRow
|
|
||||||
label="Water Depth"
|
|
||||||
value={
|
|
||||||
berth.waterDepth || berth.waterDepthM
|
|
||||||
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SpecRow label="Mooring Type" value={berth.mooringType} />
|
|
||||||
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
|
|
||||||
<SpecRow label="Bow Facing" value={berth.bowFacing} />
|
|
||||||
<SpecRow label="Access" value={berth.access} />
|
|
||||||
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Infrastructure & Pricing */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-6">
|
{/* Specifications */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 divide-y">
|
|
||||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
|
||||||
<SpecRow label="Voltage" value={berth.voltage} />
|
|
||||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
|
||||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
|
||||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
|
||||||
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 divide-y">
|
<CardContent className="pt-0 divide-y">
|
||||||
|
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
|
||||||
<SpecRow
|
<SpecRow
|
||||||
label="Tenure Type"
|
label="Width"
|
||||||
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
value={
|
||||||
|
formatDim(berth.widthFt, berth.widthM)
|
||||||
|
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{berth.tenureType === 'fixed_term' && (
|
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||||
<>
|
<SpecRow
|
||||||
<SpecRow label="Years" value={berth.tenureYears} />
|
label="Nominal Boat Size"
|
||||||
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||||
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
/>
|
||||||
</>
|
<SpecRow
|
||||||
)}
|
label="Water Depth"
|
||||||
<SpecRow label="Price" value={price} />
|
value={
|
||||||
|
berth.waterDepth || berth.waterDepthM
|
||||||
|
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SpecRow label="Mooring Type" value={berth.mooringType} />
|
||||||
|
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
|
||||||
|
<SpecRow label="Bow Facing" value={berth.bowFacing} />
|
||||||
|
<SpecRow label="Access" value={berth.access} />
|
||||||
|
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{berth.tags.length > 0 && (
|
{/* Infrastructure & Pricing */}
|
||||||
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0 divide-y">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||||
{berth.tags.map((tag) => (
|
<SpecRow label="Voltage" value={berth.voltage} />
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||||
))}
|
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||||
</div>
|
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||||
|
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 divide-y">
|
||||||
|
<SpecRow
|
||||||
|
label="Tenure Type"
|
||||||
|
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
||||||
|
/>
|
||||||
|
{berth.tenureType === 'fixed_term' && (
|
||||||
|
<>
|
||||||
|
<SpecRow label="Years" value={berth.tenureYears} />
|
||||||
|
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
||||||
|
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SpecRow label="Price" value={price} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{berth.tags.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{berth.tags.map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
142
src/components/clients/client-card.tsx
Normal file
142
src/components/clients/client-card.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import {
|
||||||
|
ListCard,
|
||||||
|
ListCardAvatar,
|
||||||
|
ListCardMeta,
|
||||||
|
deriveInitials,
|
||||||
|
} from '@/components/shared/list-card';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import type { ClientRow } from './client-columns';
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
manual: 'Manual',
|
||||||
|
referral: 'Referral',
|
||||||
|
broker: 'Broker',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClientCardProps {
|
||||||
|
client: ClientRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (client: ClientRow) => void;
|
||||||
|
onArchive: (client: ClientRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
|
||||||
|
const primary = client.contacts?.find((c) => c.isPrimary);
|
||||||
|
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||||
|
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||||
|
const tags = client.tags ?? [];
|
||||||
|
|
||||||
|
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const interest = client.latestInterest ?? null;
|
||||||
|
const interestCount = client.interestCount ?? 0;
|
||||||
|
const interestBerthLabel = interest
|
||||||
|
? interest.mooringNumber
|
||||||
|
? `Berth ${interest.mooringNumber}`
|
||||||
|
: 'General interest'
|
||||||
|
: null;
|
||||||
|
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
|
||||||
|
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
|
||||||
|
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/clients/${client.id}`}
|
||||||
|
ariaLabel={`Client ${client.fullName}`}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${client.fullName}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(client)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar initials={deriveInitials(client.fullName)} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{client.fullName}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{primary ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{primary.value}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{meta.length > 0 ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||||
|
{meta.map((m, i) => (
|
||||||
|
<span key={m} className="inline-flex items-center gap-1">
|
||||||
|
{i > 0 ? <span aria-hidden>·</span> : null}
|
||||||
|
<ListCardMeta>{m}</ListCardMeta>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{interest ? (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{interestBerthLabel}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${interestStageBadge}`}
|
||||||
|
>
|
||||||
|
{interestStageLabel}
|
||||||
|
</span>
|
||||||
|
{extraInterests > 0 ? (
|
||||||
|
<span className="shrink-0 text-muted-foreground/80">+{extraInterests}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
{tags.length > 2 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||||
|
+{tags.length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ export interface ClientRow {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
yachtCount?: number;
|
yachtCount?: number;
|
||||||
companyCount?: number;
|
companyCount?: number;
|
||||||
|
interestCount?: number;
|
||||||
|
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -12,31 +13,28 @@ 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 { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
interface ClientDetailHeaderProps {
|
interface ClientDetailHeaderProps {
|
||||||
client: {
|
client: {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
nationality?: string | null;
|
nationalityIso?: string | null;
|
||||||
preferredContactMethod?: string | null;
|
|
||||||
preferredLanguage?: string | null;
|
|
||||||
timezone?: string | null;
|
|
||||||
source?: string | null;
|
|
||||||
sourceDetails?: string | null;
|
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
createdAt?: string;
|
||||||
|
contacts?: Array<{
|
||||||
|
channel: string;
|
||||||
|
value: string;
|
||||||
|
valueE164?: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
label?: string | null;
|
||||||
|
}>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
clientPortalEnabled?: boolean;
|
clientPortalEnabled?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
website: 'Website',
|
|
||||||
manual: 'Manual',
|
|
||||||
referral: 'Referral',
|
|
||||||
broker: 'Broker',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
@@ -62,19 +60,36 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryEmail =
|
const primaryEmail =
|
||||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||||
client.contacts?.find((c) => c.channel === 'email');
|
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||||
const primaryPhone =
|
const primaryPhoneContact =
|
||||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||||
client.contacts?.find((c) => c.channel === 'phone');
|
client.contacts?.find((c) => c.channel === 'phone');
|
||||||
|
const primaryPhone = primaryPhoneContact?.value;
|
||||||
|
// wa.me requires the E.164 number without the leading "+". Strip from the
|
||||||
|
// canonical E.164 form when available; otherwise strip non-digits from the
|
||||||
|
// display value as a best-effort fallback.
|
||||||
|
const whatsappNumber = primaryPhoneContact?.valueE164
|
||||||
|
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
||||||
|
: primaryPhoneContact?.value
|
||||||
|
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||||
|
const addedLabel = client.createdAt
|
||||||
|
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
|
||||||
|
: null;
|
||||||
|
const meta = [country, addedLabel].filter(Boolean) as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailHeaderStrip>
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
|
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
|
||||||
|
{client.fullName}
|
||||||
|
</h1>
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Archived
|
Archived
|
||||||
@@ -82,31 +97,71 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
{meta.length > 0 ? (
|
||||||
{client.source && (
|
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
|
||||||
<span>
|
) : null}
|
||||||
Source:{' '}
|
|
||||||
<span className="text-foreground">
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
{SOURCE_LABELS[client.source] ?? client.source}
|
{primaryEmail ? (
|
||||||
</span>
|
<Button
|
||||||
</span>
|
asChild
|
||||||
)}
|
variant="outline"
|
||||||
{primaryEmail && (
|
size="sm"
|
||||||
<span className="flex items-center gap-1">
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
<Mail className="h-3.5 w-3.5" />
|
>
|
||||||
{primaryEmail.value}
|
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
||||||
</span>
|
<Mail />
|
||||||
)}
|
Email
|
||||||
{primaryPhone && (
|
</a>
|
||||||
<span className="flex items-center gap-1">
|
</Button>
|
||||||
<Phone className="h-3.5 w-3.5" />
|
) : null}
|
||||||
{primaryPhone.value}
|
{primaryPhone ? (
|
||||||
</span>
|
<Button
|
||||||
)}
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
||||||
|
<Phone />
|
||||||
|
Call
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{whatsappNumber ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${whatsappNumber}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||||
|
>
|
||||||
|
<MessageCircle />
|
||||||
|
WhatsApp
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!isArchived && client.clientPortalEnabled !== false ? (
|
||||||
|
<div className="hidden sm:inline-flex">
|
||||||
|
<PortalInviteButton
|
||||||
|
clientId={client.id}
|
||||||
|
clientName={client.fullName}
|
||||||
|
defaultEmail={primaryEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="hidden sm:inline-flex">
|
||||||
|
<GdprExportButton clientId={client.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1">
|
||||||
{client.tags.map((tag) => (
|
{client.tags.map((tag) => (
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
))}
|
))}
|
||||||
@@ -114,34 +169,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Top-right: archive/restore as a small icon button — destructive
|
||||||
<div className="flex items-center gap-2">
|
action sits out of the primary action flow. */}
|
||||||
{!isArchived && client.clientPortalEnabled !== false && (
|
<button
|
||||||
<PortalInviteButton
|
type="button"
|
||||||
clientId={client.id}
|
onClick={() => setArchiveOpen(true)}
|
||||||
clientName={client.fullName}
|
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
||||||
defaultEmail={primaryEmail?.value}
|
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||||
/>
|
className={cn(
|
||||||
|
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||||
|
'hover:bg-foreground/5 hover:text-foreground',
|
||||||
|
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
||||||
)}
|
)}
|
||||||
<GdprExportButton clientId={client.id} />
|
>
|
||||||
<Button
|
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||||
variant={isArchived ? 'outline' : 'outline'}
|
</button>
|
||||||
size="sm"
|
|
||||||
onClick={() => setArchiveOpen(true)}
|
|
||||||
>
|
|
||||||
{isArchived ? (
|
|
||||||
<>
|
|
||||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Restore
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Archive
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DetailHeaderStrip>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -27,6 +29,8 @@ interface ClientData {
|
|||||||
id: string;
|
id: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
valueE164: string | null;
|
||||||
|
valueCountry: string | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -80,6 +84,13 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
|||||||
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.fullName ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'client:updated': [['clients', clientId]],
|
'client:updated': [['clients', clientId]],
|
||||||
'client:archived': [['clients', clientId]],
|
'client:archived': [['clients', clientId]],
|
||||||
|
|||||||
@@ -339,10 +339,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Preferred Language</Label>
|
|
||||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<TimezoneCombobox
|
<TimezoneCombobox
|
||||||
|
|||||||
460
src/components/clients/client-interests-tab.tsx
Normal file
460
src/components/clients/client-interests-tab.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
||||||
|
import {
|
||||||
|
StageStepper,
|
||||||
|
useClientInterests,
|
||||||
|
type ClientInterestRow,
|
||||||
|
} from '@/components/clients/client-pipeline-summary';
|
||||||
|
import { InterestForm } from '@/components/interests/interest-form';
|
||||||
|
|
||||||
|
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
general_interest: 'General interest',
|
||||||
|
specific_qualified: 'Specific qualified',
|
||||||
|
hot_lead: 'Hot lead',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InterestRowItem({
|
||||||
|
interest,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
interest: ClientInterestRow;
|
||||||
|
onOpen: (i: ClientInterestRow) => void;
|
||||||
|
}) {
|
||||||
|
const stage = safeStage(interest.pipelineStage);
|
||||||
|
|
||||||
|
const berthLabel = interest.berthMooringNumber
|
||||||
|
? `Berth ${interest.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
|
||||||
|
const yachtLabel = interest.yachtName ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Tap opens a bottom-sheet preview drawer rather than navigating to the
|
||||||
|
// full interest page. The drawer covers ~80% of mobile interactions
|
||||||
|
// ("what stage is this at, when did we last touch it"). For deeper
|
||||||
|
// edits the drawer has an "Open full page" CTA.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(interest)}
|
||||||
|
className={cn(
|
||||||
|
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
|
||||||
|
'hover:border-border/70 hover:shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{berthLabel}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{yachtLabel ? (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<StageStepper current={stage} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActivityFor(interest: ClientInterestRow): string | null {
|
||||||
|
const candidates = [interest.dateLastContact, interest.updatedAt]
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
.map((v) => new Date(v).getTime())
|
||||||
|
.filter((t) => !Number.isNaN(t));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
|
||||||
|
* the drawer actually reads are typed here; the API returns more. */
|
||||||
|
interface InterestDetail {
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
leadCategory: string | null;
|
||||||
|
source: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
dateLastContact: string | null;
|
||||||
|
dateEoiSent: string | null;
|
||||||
|
dateEoiSigned: string | null;
|
||||||
|
dateDepositReceived: string | null;
|
||||||
|
dateContractSent: string | null;
|
||||||
|
dateContractSigned: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInterestDetail(id: string | null) {
|
||||||
|
return useQuery<{ data: InterestDetail }>({
|
||||||
|
queryKey: ['interest-detail-drawer', id],
|
||||||
|
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
|
||||||
|
enabled: id !== null,
|
||||||
|
// Detail rarely changes during a single drawer-open session; stale-time
|
||||||
|
// keeps re-opens snappy without preventing background refetch.
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
|
||||||
|
* empty input so callers can render an "empty" state. */
|
||||||
|
function formatDate(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return format(d, 'MMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single milestone row inside the drawer's milestone summary. Filled
|
||||||
|
* circle when the step is done, hollow when pending. Trailing meta line
|
||||||
|
* shows the date stamp or a "pending" hint. */
|
||||||
|
function MilestoneRow({
|
||||||
|
label,
|
||||||
|
done,
|
||||||
|
date,
|
||||||
|
hint = 'pending',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
done: boolean;
|
||||||
|
date: string | null;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-2 py-1">
|
||||||
|
{done ? (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-sheet preview of a single interest. Designed for the mobile
|
||||||
|
* "tap an interest → see what's happening without leaving the client
|
||||||
|
* page" flow. Shows the pipeline progress, a compact milestone summary
|
||||||
|
* (EOI / Deposit / Contract), lead context, last contact, and a notes
|
||||||
|
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
|
||||||
|
* away via "Open full page →".
|
||||||
|
*/
|
||||||
|
function InterestPreviewDrawer({
|
||||||
|
interest,
|
||||||
|
portSlug,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
interest: ClientInterestRow | null;
|
||||||
|
portSlug: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
// Pin the most recently selected interest so the drawer stays populated
|
||||||
|
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||||
|
// after `open=false`). Conditional setState is safe here — the guard
|
||||||
|
// ensures it only fires when the prop actually changes to a new row.
|
||||||
|
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||||
|
if (interest && interest !== pinned) setPinned(interest);
|
||||||
|
const showing = pinned;
|
||||||
|
|
||||||
|
const detail = useInterestDetail(showing?.id ?? null);
|
||||||
|
const fullDetail = detail.data?.data ?? null;
|
||||||
|
|
||||||
|
const open = interest !== null;
|
||||||
|
const stage = showing ? safeStage(showing.pipelineStage) : null;
|
||||||
|
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
|
||||||
|
const reached = (target: PipelineStage) =>
|
||||||
|
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
||||||
|
|
||||||
|
const berthLabel = showing
|
||||||
|
? showing.berthMooringNumber
|
||||||
|
? `Berth ${showing.berthMooringNumber}`
|
||||||
|
: 'General interest'
|
||||||
|
: '';
|
||||||
|
const yachtLabel = showing?.yachtName ?? null;
|
||||||
|
const activity = showing ? lastActivityFor(showing) : null;
|
||||||
|
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
|
||||||
|
|
||||||
|
const leadLabel = fullDetail?.leadCategory
|
||||||
|
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
|
||||||
|
: null;
|
||||||
|
const sourceLabel = fullDetail?.source
|
||||||
|
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||||
|
: null;
|
||||||
|
const lastContactDate = formatDate(fullDetail?.dateLastContact);
|
||||||
|
const notesPreview = fullDetail?.notes?.trim() || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent className="max-h-[85vh]">
|
||||||
|
<DrawerHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
|
||||||
|
{yachtLabel ? (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{stage ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||||
|
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
||||||
|
row card, so the at-a-glance progress hint is consistent
|
||||||
|
across surfaces. */}
|
||||||
|
{stage ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Pipeline progress
|
||||||
|
</p>
|
||||||
|
<StageStepper current={stage} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Milestones — three sections matching the full interest detail
|
||||||
|
page (EOI / Deposit / Contract). Done-state is derived from
|
||||||
|
the pipeline stage so seed data without per-step dates still
|
||||||
|
renders correctly. The full milestone columns + per-step
|
||||||
|
actions live behind "Open full page". */}
|
||||||
|
<section>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Milestones
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||||
|
<p className="mb-1 text-sm font-semibold">EOI</p>
|
||||||
|
<ul>
|
||||||
|
<MilestoneRow
|
||||||
|
label="EOI sent"
|
||||||
|
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
|
||||||
|
date={formatDate(fullDetail?.dateEoiSent)}
|
||||||
|
/>
|
||||||
|
<MilestoneRow
|
||||||
|
label="EOI signed"
|
||||||
|
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
|
||||||
|
date={formatDate(fullDetail?.dateEoiSigned)}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||||
|
<p className="mb-1 text-sm font-semibold">Deposit</p>
|
||||||
|
<ul>
|
||||||
|
<MilestoneRow
|
||||||
|
label="Deposit received"
|
||||||
|
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
|
||||||
|
date={formatDate(fullDetail?.dateDepositReceived)}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||||
|
<p className="mb-1 text-sm font-semibold">Contract</p>
|
||||||
|
<ul>
|
||||||
|
<MilestoneRow
|
||||||
|
label="Contract sent"
|
||||||
|
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
|
||||||
|
date={formatDate(fullDetail?.dateContractSent)}
|
||||||
|
/>
|
||||||
|
<MilestoneRow
|
||||||
|
label="Contract signed"
|
||||||
|
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
|
||||||
|
date={formatDate(fullDetail?.dateContractSigned)}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Compact key/value pairs — lead category, source, last contact,
|
||||||
|
activity. Each row collapses cleanly when its value is
|
||||||
|
missing so the drawer scales from sparse seed data to full
|
||||||
|
records without empty placeholders. */}
|
||||||
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
{leadLabel ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">Lead</dt>
|
||||||
|
<dd className="text-right font-medium">{leadLabel}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{sourceLabel ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">Source</dt>
|
||||||
|
<dd className="text-right font-medium">{sourceLabel}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{lastContactDate ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">Last contact</dt>
|
||||||
|
<dd className="text-right font-medium">{lastContactDate}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{activity ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">Last activity</dt>
|
||||||
|
<dd className="text-right font-medium">{activity}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{notesPreview ? (
|
||||||
|
<section>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Notes
|
||||||
|
</p>
|
||||||
|
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
|
||||||
|
{notesPreview}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button asChild className="w-full" size="lg">
|
||||||
|
<Link href={fullHref}>
|
||||||
|
Open full page
|
||||||
|
<ArrowRight className="ml-1.5 size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InterestSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-24" />
|
||||||
|
<Skeleton className="mt-3 h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientInterestsTabProps {
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useClientInterests(clientId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InterestSkeleton />
|
||||||
|
<InterestSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EmptyState
|
||||||
|
title="No interests yet"
|
||||||
|
description="When this client expresses interest in a berth, the sales process will appear here."
|
||||||
|
action={{
|
||||||
|
label: 'Add interest',
|
||||||
|
onClick: () => setCreateOpen(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = interests.filter((i) => !i.archivedAt);
|
||||||
|
const archived = interests.filter((i) => i.archivedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
Add interest
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{active.map((i) => (
|
||||||
|
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{archived.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Archived
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3 opacity-60">
|
||||||
|
{archived.map((i) => (
|
||||||
|
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<InterestPreviewDrawer
|
||||||
|
interest={previewInterest}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onClose={() => setPreviewInterest(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
|||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { ClientForm } from '@/components/clients/client-form';
|
import { ClientForm } from '@/components/clients/client-form';
|
||||||
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
||||||
|
import { ClientCard } from '@/components/clients/client-card';
|
||||||
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -118,6 +119,14 @@ export function ClientList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<ClientCard
|
||||||
|
client={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditClient}
|
||||||
|
onArchive={setArchiveClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No clients found"
|
title="No clients found"
|
||||||
|
|||||||
311
src/components/clients/client-pipeline-summary.tsx
Normal file
311
src/components/clients/client-pipeline-summary.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
safeStage,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/components/clients/pipeline-constants';
|
||||||
|
|
||||||
|
export interface ClientInterestRow {
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
dateLastContact: string | null;
|
||||||
|
berthMooringNumber?: string | null;
|
||||||
|
yachtName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: ClientInterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientInterests(clientId: string) {
|
||||||
|
return useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { clientId }],
|
||||||
|
queryFn: () => apiFetch<InterestsResponse>(`/api/v1/interests?clientId=${clientId}&limit=50`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageStepper({
|
||||||
|
current,
|
||||||
|
size = 'sm',
|
||||||
|
}: {
|
||||||
|
current: PipelineStage;
|
||||||
|
size?: 'xs' | 'sm';
|
||||||
|
}) {
|
||||||
|
const idx = PIPELINE_STAGES.indexOf(current);
|
||||||
|
// Segmented progress bar: each stage is a slice of equal width that
|
||||||
|
// lights up once the interest has reached it. Reads at-a-glance, scales
|
||||||
|
// to any container width, and works with 9 stages without becoming
|
||||||
|
// micro-dots that vanish under cramped layouts.
|
||||||
|
const height = size === 'xs' ? 'h-1' : 'h-1.5';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Pipeline progress"
|
||||||
|
aria-valuenow={idx + 1}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={PIPELINE_STAGES.length}
|
||||||
|
>
|
||||||
|
{PIPELINE_STAGES.map((stage, i) => {
|
||||||
|
const isReached = i <= idx;
|
||||||
|
const isCurrent = i === idx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage}
|
||||||
|
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 transition-colors',
|
||||||
|
isReached ? STAGE_DOT[stage] : 'bg-transparent',
|
||||||
|
i > 0 ? 'border-l border-card' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null {
|
||||||
|
const active = interests.filter((i) => !i.archivedAt);
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
return [...active].sort((a, b) => {
|
||||||
|
const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage));
|
||||||
|
const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage));
|
||||||
|
if (ai !== bi) return bi - ai;
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
})[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||||
|
const candidates = interests
|
||||||
|
.flatMap((i) => [i.dateLastContact, i.updatedAt])
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
.map((v) => new Date(v).getTime())
|
||||||
|
.filter((t) => !Number.isNaN(t));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const latest = new Date(Math.max(...candidates));
|
||||||
|
return `${formatDistanceToNowStrict(latest)} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineSummaryProps {
|
||||||
|
clientId: string;
|
||||||
|
/**
|
||||||
|
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||||
|
* `panel` — compact list of every active interest, for the Overview tab.
|
||||||
|
*/
|
||||||
|
variant?: 'hero' | 'panel';
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
const top = pickHighest(interests);
|
||||||
|
const activeCount = interests.filter((i) => !i.archivedAt).length;
|
||||||
|
const activity = lastActivityLabel(interests);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!top) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = safeStage(top.pipelineStage);
|
||||||
|
const berthLabel = top.berthMooringNumber
|
||||||
|
? `Berth ${top.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline
|
||||||
|
</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
· {activeCount} active
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={detailsHref}
|
||||||
|
className="group -m-1 block rounded-lg p-1 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{berthLabel}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{activity ? `Last activity ${activity}` : 'No activity recorded'}</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
View all {activeCount}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = (data?.data ?? []).filter((i) => !i.archivedAt);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...interests].sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline · {interests.length} active
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sorted.map((i) => {
|
||||||
|
const stage = safeStage(i.pipelineStage);
|
||||||
|
const berthLabel = i.berthMooringNumber
|
||||||
|
? `Berth ${i.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const href = `/${portSlug}/interests/${i.id}` as Route;
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{berthLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
return variant === 'hero' ? (
|
||||||
|
<HeroVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
) : (
|
||||||
|
<PanelVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
|||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||||
|
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||||
@@ -131,82 +133,82 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Personal Info */}
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
<div className="space-y-1">
|
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
|
||||||
<dl>
|
|
||||||
<EditableRow label="Full Name">
|
|
||||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Nationality">
|
|
||||||
<InlineCountryField
|
|
||||||
value={client.nationalityIso ?? null}
|
|
||||||
onSave={async (iso) => {
|
|
||||||
await mutation.mutateAsync({ nationalityIso: iso });
|
|
||||||
}}
|
|
||||||
data-testid="client-nationality-inline"
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Preferred Language">
|
|
||||||
<InlineEditableField
|
|
||||||
value={client.preferredLanguage}
|
|
||||||
onSave={save('preferredLanguage')}
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Timezone">
|
|
||||||
<InlineTimezoneField
|
|
||||||
value={client.timezone}
|
|
||||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
|
||||||
onSave={async (tz) => {
|
|
||||||
await mutation.mutateAsync({ timezone: tz });
|
|
||||||
}}
|
|
||||||
data-testid="client-timezone-inline"
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Preferred Contact">
|
|
||||||
<InlineEditableField
|
|
||||||
variant="select"
|
|
||||||
options={CONTACT_METHOD_OPTIONS}
|
|
||||||
value={client.preferredContactMethod}
|
|
||||||
onSave={save('preferredContactMethod')}
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contacts */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-1">
|
{/* Personal Info */}
|
||||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
<div className="space-y-1">
|
||||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||||
</div>
|
<dl>
|
||||||
|
<EditableRow label="Full Name">
|
||||||
|
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||||
|
</EditableRow>
|
||||||
|
<EditableRow label="Nationality">
|
||||||
|
<InlineCountryField
|
||||||
|
value={client.nationalityIso ?? null}
|
||||||
|
onSave={async (iso) => {
|
||||||
|
await mutation.mutateAsync({ nationalityIso: iso });
|
||||||
|
}}
|
||||||
|
data-testid="client-nationality-inline"
|
||||||
|
/>
|
||||||
|
</EditableRow>
|
||||||
|
<EditableRow label="Timezone">
|
||||||
|
<InlineTimezoneField
|
||||||
|
value={client.timezone}
|
||||||
|
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||||
|
onSave={async (tz) => {
|
||||||
|
await mutation.mutateAsync({ timezone: tz });
|
||||||
|
}}
|
||||||
|
data-testid="client-timezone-inline"
|
||||||
|
/>
|
||||||
|
</EditableRow>
|
||||||
|
<EditableRow label="Preferred Contact">
|
||||||
|
<InlineEditableField
|
||||||
|
variant="select"
|
||||||
|
options={CONTACT_METHOD_OPTIONS}
|
||||||
|
value={client.preferredContactMethod}
|
||||||
|
onSave={save('preferredContactMethod')}
|
||||||
|
/>
|
||||||
|
</EditableRow>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Source */}
|
{/* Contacts */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||||
<dl>
|
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||||
<EditableRow label="Source">
|
</div>
|
||||||
<InlineEditableField
|
|
||||||
variant="select"
|
|
||||||
options={SOURCE_OPTIONS}
|
|
||||||
value={client.source}
|
|
||||||
onSave={save('source')}
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Source Details">
|
|
||||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
|
||||||
</EditableRow>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Source */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||||
<InlineTagEditor
|
<dl>
|
||||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
<EditableRow label="Source">
|
||||||
currentTags={client.tags ?? []}
|
<InlineEditableField
|
||||||
invalidateKey={['clients', clientId]}
|
variant="select"
|
||||||
/>
|
options={SOURCE_OPTIONS}
|
||||||
|
value={client.source}
|
||||||
|
onSave={save('source')}
|
||||||
|
/>
|
||||||
|
</EditableRow>
|
||||||
|
<EditableRow label="Source Details">
|
||||||
|
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||||
|
</EditableRow>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||||
|
<InlineTagEditor
|
||||||
|
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||||
|
currentTags={client.tags ?? []}
|
||||||
|
invalidateKey={['clients', clientId]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -219,6 +221,11 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab clientId={clientId} client={client} />,
|
content: <OverviewTab clientId={clientId} client={client} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'interests',
|
||||||
|
label: 'Interests',
|
||||||
|
content: <ClientInterestsTab clientId={clientId} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'yachts',
|
id: 'yachts',
|
||||||
label: 'Yachts',
|
label: 'Yachts',
|
||||||
@@ -251,15 +258,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'interests',
|
|
||||||
label: 'Interests',
|
|
||||||
content: (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<p>Interests will appear here once created.</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function ContactRow({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
||||||
|
const [phoneEditing, setPhoneEditing] = useState(false);
|
||||||
|
|
||||||
async function togglePrimary() {
|
async function togglePrimary() {
|
||||||
try {
|
try {
|
||||||
@@ -174,17 +175,31 @@ function ContactRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
<div
|
||||||
{/* Left: channel + value */}
|
data-editing={phoneEditing ? 'true' : undefined}
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
className={cn(
|
||||||
|
'group rounded-lg border text-sm transition-all duration-150',
|
||||||
|
// Active-edit dilation: lift the row out of the muted baseline with a
|
||||||
|
// soft primary ring + slightly brighter surface. Single visual signal
|
||||||
|
// replaces the need for any "now editing" label.
|
||||||
|
phoneEditing
|
||||||
|
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
|
||||||
|
: 'bg-muted/30 p-2 gap-2',
|
||||||
|
// Stack value editor / action cluster on mobile; single row on sm+.
|
||||||
|
'flex flex-col sm:flex-row sm:items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Top / left: channel + value */}
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
||||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</ChannelPicker>
|
</ChannelPicker>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||||
<InlinePhoneField
|
<InlinePhoneField
|
||||||
e164={contact.valueE164 ?? null}
|
e164={contact.valueE164 ?? null}
|
||||||
country={contact.valueCountry ?? null}
|
country={contact.valueCountry ?? null}
|
||||||
|
onEditingChange={setPhoneEditing}
|
||||||
onSave={async ({ e164, country }) => {
|
onSave={async ({ e164, country }) => {
|
||||||
if (!e164) {
|
if (!e164) {
|
||||||
toast.error('Phone number is required');
|
toast.error('Phone number is required');
|
||||||
@@ -208,42 +223,46 @@ function ContactRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: tag + actions */}
|
{/* Bottom / right: tag + actions. Hidden while the phone editor is active
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
to keep focus on the form — no chips fighting for space, no noise. */}
|
||||||
<div className="w-28 text-xs text-muted-foreground text-right">
|
{!phoneEditing ? (
|
||||||
<InlineEditableField
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||||
value={
|
<div className="w-28 text-right text-xs text-muted-foreground">
|
||||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
<InlineEditableField
|
||||||
}
|
value={
|
||||||
emptyText="Add tag"
|
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||||
placeholder="work, home…"
|
}
|
||||||
onSave={async (v) => {
|
emptyText="Add tag"
|
||||||
await onUpdate({ label: v });
|
placeholder="work, home…"
|
||||||
}}
|
onSave={async (v) => {
|
||||||
/>
|
await onUpdate({ label: v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePrimary}
|
||||||
|
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||||
|
className={cn(
|
||||||
|
'rounded p-1 transition-colors hover:bg-background/60',
|
||||||
|
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove"
|
||||||
|
// Trash is opacity-0 on desktop hover-only; on touch, always show.
|
||||||
|
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={togglePrimary}
|
|
||||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
|
||||||
className={cn(
|
|
||||||
'p-1 rounded hover:bg-background/60 transition-colors',
|
|
||||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
title="Remove"
|
|
||||||
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -330,7 +349,9 @@ function NewContactForm({
|
|||||||
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
// Single row on sm+; wraps onto multiple lines below 640px so the channel
|
||||||
|
// picker, value field, label, and buttons each get their own usable width.
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
|
||||||
<Select
|
<Select
|
||||||
value={channel}
|
value={channel}
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
@@ -353,7 +374,7 @@ function NewContactForm({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{isPhoneChannel ? (
|
{isPhoneChannel ? (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
value={phoneValue}
|
value={phoneValue}
|
||||||
onChange={(v) => setPhoneValue(v)}
|
onChange={(v) => setPhoneValue(v)}
|
||||||
@@ -365,7 +386,7 @@ function NewContactForm({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
||||||
className="h-7 text-sm flex-1 min-w-0"
|
className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -382,7 +403,7 @@ function NewContactForm({
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="tag (optional)"
|
placeholder="tag (optional)"
|
||||||
className="h-7 text-xs w-28"
|
className="h-7 w-28 text-xs"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -393,12 +414,14 @@ function NewContactForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
<div className="ml-auto flex gap-2">
|
||||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||||
</Button>
|
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
</Button>
|
||||||
Cancel
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/components/clients/pipeline-constants.ts
Normal file
14
src/components/clients/pipeline-constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Re-export from the canonical source so legacy imports keep working.
|
||||||
|
export {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_LABELS,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_WEIGHTS,
|
||||||
|
STAGE_TRANSITIONS,
|
||||||
|
safeStage,
|
||||||
|
stageLabel,
|
||||||
|
stageBadgeClass,
|
||||||
|
stageDotClass,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/lib/constants';
|
||||||
141
src/components/companies/company-card.tsx
Normal file
141
src/components/companies/company-card.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Archive, Building2, Eye, Hash, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import type { CompanyRow } from './company-columns';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CompanyCardProps {
|
||||||
|
company: CompanyRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (company: CompanyRow) => void;
|
||||||
|
onArchive: (company: CompanyRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyCard({ company, portSlug, onEdit, onArchive }: CompanyCardProps) {
|
||||||
|
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
const country = company.incorporationCountryIso
|
||||||
|
? getCountryName(company.incorporationCountryIso, 'en')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const memberCount = company.memberCount ?? 0;
|
||||||
|
const yachtCount = company.yachtCount ?? 0;
|
||||||
|
const countParts: string[] = [];
|
||||||
|
if (memberCount > 0)
|
||||||
|
countParts.push(`${memberCount} ${memberCount === 1 ? 'member' : 'members'}`);
|
||||||
|
if (yachtCount > 0) countParts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`);
|
||||||
|
|
||||||
|
// Skip legalName if it is identical to name or absent
|
||||||
|
const showLegalName =
|
||||||
|
company.legalName && company.legalName.toLowerCase() !== company.name.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/companies/${company.id}`}
|
||||||
|
ariaLabel={`Company ${company.name}`}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${company.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${portSlug}/companies/${company.id}`}>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(company)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(company)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<Building2 className="h-5 w-5" />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{company.name}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal name subtitle */}
|
||||||
|
{showLegalName ? (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{company.legalName}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Country + Tax ID meta line */}
|
||||||
|
{country || company.taxId ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{country ? (
|
||||||
|
<ListCardMeta icon={<MapPin className="h-3 w-3" />}>{country}</ListCardMeta>
|
||||||
|
) : null}
|
||||||
|
{company.taxId ? (
|
||||||
|
<ListCardMeta icon={<Hash className="h-3 w-3" />}>{company.taxId}</ListCardMeta>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Member / yacht counts */}
|
||||||
|
{countParts.length > 0 ? (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{countParts.join(' · ')}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
{company.status ? (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||||
|
statusColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,7 +80,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
<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">
|
||||||
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1>
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
>
|
>
|
||||||
@@ -100,7 +102,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<PermissionGate resource="companies" action="edit">
|
<PermissionGate resource="companies" action="edit">
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -45,6 +47,13 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
|||||||
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.name ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'company:updated': [['companies', companyId]],
|
'company:updated': [['companies', companyId]],
|
||||||
'company:archived': [['companies', companyId]],
|
'company:archived': [['companies', companyId]],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { EmptyState } from '@/components/shared/empty-state';
|
|||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { CompanyCard } from '@/components/companies/company-card';
|
||||||
import { CompanyForm } from '@/components/companies/company-form';
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||||
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||||
@@ -123,6 +124,14 @@ export function CompanyList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<CompanyCard
|
||||||
|
company={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditCompany}
|
||||||
|
onArchive={setArchiveCompany}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No companies yet"
|
title="No companies yet"
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ function ActivityFeedInner() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recent activity yet — your team's actions (interests created, stages changed,
|
||||||
|
invoices sent) will appear here.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
|||||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
import { LeadSourceChart } from './lead-source-chart';
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
|
import { MyRemindersRail } from './my-reminders-rail';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
@@ -49,7 +50,7 @@ export function DashboardShell() {
|
|||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-4">
|
||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +69,10 @@ export function DashboardShell() {
|
|||||||
<LeadSourceChart range={range} />
|
<LeadSourceChart range={range} />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<aside className="min-w-0">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<MyRemindersRail />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<AlertRail />
|
<AlertRail />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ function formatPercent(value: number): string {
|
|||||||
|
|
||||||
function KpiTileSkeleton() {
|
function KpiTileSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm">
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5">
|
||||||
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-20" />
|
||||||
<Skeleton className="mt-3 h-7 w-32" />
|
<Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" />
|
||||||
<Skeleton className="mt-2 h-3 w-12" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !slices.length ? (
|
) : !slices.length ? (
|
||||||
<EmptyState title="No interests in range" />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
// Percentage radii + center-anchored chart so the pie scales with
|
||||||
|
// the container instead of being clipped to a constant 90px ring at
|
||||||
|
// narrow widths. Legend is reserved a fixed footer height.
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="45%"
|
||||||
outerRadius={90}
|
outerRadius="70%"
|
||||||
innerRadius={50}
|
innerRadius="40%"
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{chartData.map((_, i) => (
|
{chartData.map((_, i) => (
|
||||||
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={40}
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
||||||
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
156
src/components/dashboard/my-reminders-rail.tsx
Normal file
156
src/components/dashboard/my-reminders-rail.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { formatDistanceToNowStrict, isAfter, isBefore } from 'date-fns';
|
||||||
|
import { AlarmClock, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ReminderRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueAt: string;
|
||||||
|
status: string;
|
||||||
|
priority?: string | null;
|
||||||
|
interestId?: string | null;
|
||||||
|
clientId?: string | null;
|
||||||
|
entityType?: string | null;
|
||||||
|
entityId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyRemindersResponse {
|
||||||
|
data: ReminderRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_BADGE: Record<string, string> = {
|
||||||
|
high: 'bg-rose-100 text-rose-700',
|
||||||
|
medium: 'bg-amber-100 text-amber-700',
|
||||||
|
low: 'bg-slate-100 text-slate-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||||
|
* to the current user (overdue first, then upcoming). Each item links to its
|
||||||
|
* subject — interest preferred, then client, then the generic entity ref.
|
||||||
|
*
|
||||||
|
* Limited to 6 items; "View all" routes to /reminders.
|
||||||
|
*/
|
||||||
|
export function MyRemindersRail() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<MyRemindersResponse>({
|
||||||
|
queryKey: ['reminders', 'my'],
|
||||||
|
queryFn: () => apiFetch<MyRemindersResponse>('/api/v1/reminders/my'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.data ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
// Overdue first, then upcoming, capped at 6 for the rail.
|
||||||
|
const sorted = [...items]
|
||||||
|
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
|
||||||
|
.slice(0, 6);
|
||||||
|
const overdueCount = items.filter((r) => isBefore(new Date(r.dueAt), now)).length;
|
||||||
|
|
||||||
|
function hrefFor(r: ReminderRow): string {
|
||||||
|
if (r.interestId) return `/${portSlug}/interests/${r.interestId}`;
|
||||||
|
if (r.clientId) return `/${portSlug}/clients/${r.clientId}`;
|
||||||
|
if (r.entityType === 'client' && r.entityId) return `/${portSlug}/clients/${r.entityId}`;
|
||||||
|
if (r.entityType === 'interest' && r.entityId) return `/${portSlug}/interests/${r.entityId}`;
|
||||||
|
if (r.entityType === 'berth' && r.entityId) return `/${portSlug}/berths/${r.entityId}`;
|
||||||
|
return `/${portSlug}/reminders`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
||||||
|
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
||||||
|
// tall empty card.
|
||||||
|
return (
|
||||||
|
<Card className="xl:h-full">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
|
<AlarmClock className="size-4" />
|
||||||
|
Reminders
|
||||||
|
</CardTitle>
|
||||||
|
{overdueCount > 0 ? (
|
||||||
|
<p className="text-xs text-rose-700">{overdueCount} overdue</p>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{items.length} pending</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/reminders` as never}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-9 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||||
|
All caught up — no reminders.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sorted.map((r) => {
|
||||||
|
const due = new Date(r.dueAt);
|
||||||
|
const isOverdue = isBefore(due, now);
|
||||||
|
const isUpcoming = isAfter(due, now);
|
||||||
|
return (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link
|
||||||
|
href={hrefFor(r) as never}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||||
|
'hover:bg-foreground/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'size-1.5 shrink-0 rounded-full',
|
||||||
|
isOverdue ? 'bg-rose-500' : 'bg-amber-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{r.title}</span>
|
||||||
|
{r.priority && r.priority !== 'low' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'border-transparent text-[10px]',
|
||||||
|
PRIORITY_BADGE[r.priority] ?? 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.priority}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{isOverdue
|
||||||
|
? formatDistanceToNowStrict(due) + ' overdue'
|
||||||
|
: isUpcoming
|
||||||
|
? 'in ' + formatDistanceToNowStrict(due)
|
||||||
|
: 'now'}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
|
||||||
interface PipelineRow {
|
interface PipelineRow {
|
||||||
@@ -21,18 +15,8 @@ interface PipelineRow {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
function PipelineChartInner() {
|
function PipelineChartInner() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data, isLoading } = useQuery<PipelineRow[]>({
|
const { data, isLoading } = useQuery<PipelineRow[]>({
|
||||||
queryKey: ['dashboard', 'pipeline'],
|
queryKey: ['dashboard', 'pipeline'],
|
||||||
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
||||||
@@ -45,7 +29,7 @@ function PipelineChartInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chartData = (data ?? []).map((row) => ({
|
const chartData = (data ?? []).map((row) => ({
|
||||||
stage: STAGE_LABELS[row.stage] ?? row.stage,
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
|
||||||
count: row.count,
|
count: row.count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +4,24 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
|||||||
|
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { ChartCard } from './chart-card';
|
import { ChartCard } from './chart-card';
|
||||||
import { useFunnel } from './use-analytics';
|
import { useFunnel } from './use-analytics';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
range: DateRange;
|
range: DateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineFunnelChart({ range }: Props) {
|
export function PipelineFunnelChart({ range }: Props) {
|
||||||
const { data, isLoading } = useFunnel(range);
|
const { data, isLoading } = useFunnel(range);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const stages = data?.stages ?? [];
|
const stages = data?.stages ?? [];
|
||||||
|
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
|
||||||
const chartData = stages.map((s) => ({
|
const chartData = stages.map((s) => ({
|
||||||
stage: STAGE_LABELS[s.stage] ?? s.stage,
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
|
||||||
count: s.count,
|
count: s.count,
|
||||||
conversionPct: s.conversionPct,
|
conversionPct: s.conversionPct,
|
||||||
}));
|
}));
|
||||||
@@ -51,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : allZero ? (
|
) : allZero ? (
|
||||||
<EmptyState title="No interests in range" description="Try a longer date range." />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !bars.length ? (
|
) : !bars.length ? (
|
||||||
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
<EmptyState
|
||||||
|
title="No invoices in range"
|
||||||
|
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
|
||||||
interface StageBreakdownRow {
|
interface StageBreakdownRow {
|
||||||
@@ -20,17 +21,6 @@ interface ForecastData {
|
|||||||
weightsSource: 'db' | 'default';
|
weightsSource: 'db' | 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -66,9 +56,7 @@ function RevenueForecastInner() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
|
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">{formatCurrency(data?.totalWeightedValue ?? 0)}</p>
|
||||||
{formatCurrency(data?.totalWeightedValue ?? 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeStages.length > 0 && (
|
{activeStages.length > 0 && (
|
||||||
@@ -76,12 +64,10 @@ function RevenueForecastInner() {
|
|||||||
{activeStages.map((s) => (
|
{activeStages.map((s) => (
|
||||||
<div key={s.stage} className="flex items-center justify-between text-sm">
|
<div key={s.stage} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{STAGE_LABELS[s.stage] ?? s.stage}
|
{stageLabel(s.stage)}
|
||||||
<span className="ml-1 text-xs">({s.count})</span>
|
<span className="ml-1 text-xs">({s.count})</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums">{formatCurrency(s.weightedValue)}</span>
|
||||||
{formatCurrency(s.weightedValue)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
type Edge = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
|
||||||
|
interface ToolbarState {
|
||||||
|
edge: Edge;
|
||||||
|
ratio: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
defaultAction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactGrabAPI {
|
||||||
|
setToolbarState: (state: Partial<ToolbarState>) => void;
|
||||||
|
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__REACT_GRAB__?: ReactGrabAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||||
|
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
|
||||||
|
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
|
||||||
|
|
||||||
|
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
|
||||||
|
edge: 'bottom',
|
||||||
|
ratio: 0.5,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOBILE_DEFAULT: Partial<ToolbarState> = {
|
||||||
|
edge: 'right',
|
||||||
|
ratio: 0.5,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReactGrabViewportSync() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
let pollId: number | undefined;
|
||||||
|
|
||||||
|
const wireUp = (api: ReactGrabAPI) => {
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
|
||||||
|
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
|
||||||
|
|
||||||
|
let suppressNextWrite = false;
|
||||||
|
const apply = () => {
|
||||||
|
const stored = localStorage.getItem(keyFor());
|
||||||
|
suppressNextWrite = true;
|
||||||
|
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
|
||||||
|
const unsubscribe = api.onToolbarStateChange((state) => {
|
||||||
|
if (suppressNextWrite) {
|
||||||
|
suppressNextWrite = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(keyFor(), JSON.stringify(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
mql.addEventListener('change', apply);
|
||||||
|
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryWire = () => {
|
||||||
|
const api = window.__REACT_GRAB__;
|
||||||
|
if (!api) return false;
|
||||||
|
wireUp(api);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tryWire()) {
|
||||||
|
pollId = window.setInterval(() => {
|
||||||
|
if (tryWire() && pollId !== undefined) {
|
||||||
|
window.clearInterval(pollId);
|
||||||
|
pollId = undefined;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (pollId !== undefined) {
|
||||||
|
window.clearInterval(pollId);
|
||||||
|
pollId = undefined;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollId !== undefined) window.clearInterval(pollId);
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ interface DocumentRow {
|
|||||||
interface DocumentListProps {
|
interface DocumentListProps {
|
||||||
interestId?: string;
|
interestId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
|
/** 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. */
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
other: 'Other',
|
other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>;
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
if (emptyState) return <>{emptyState}</>;
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,22 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|||||||
rejected: 'rejected',
|
rejected: 'rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
sent: 'Sent',
|
||||||
|
signed: 'Signed',
|
||||||
|
declined: 'Declined',
|
||||||
|
expired: 'Expired',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
interface DocumentsHubProps {
|
interface DocumentsHubProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
|
initialTab?: DocumentsHubTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
|
||||||
const [tab, setTab] = useState<DocumentsHubTab>('all');
|
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
const [signatureOnly, setSignatureOnly] = useState(true);
|
const [signatureOnly, setSignatureOnly] = useState(true);
|
||||||
@@ -186,7 +196,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||||
{signer.status}
|
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,8 +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
|
||||||
|
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||||
|
* belong to Section 3 and may be left blank. */
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
|
hasEmail: boolean;
|
||||||
|
hasAddress: boolean;
|
||||||
|
/** Optional — info-only checks. Generation proceeds without them. */
|
||||||
hasYacht: boolean;
|
hasYacht: boolean;
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
|
|||||||
prerequisites: EoiPrerequisites;
|
prerequisites: EoiPrerequisites;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
{ key: 'hasName', label: 'Client has full name' },
|
{ key: 'hasName', label: 'Client name' },
|
||||||
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
{ key: 'hasAddress', label: 'Client address' },
|
||||||
{ key: 'hasBerth', label: 'Berth linked to interest' },
|
{ key: 'hasEmail', label: 'Client email' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
|
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
|
||||||
|
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||||
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
||||||
|
|
||||||
const allMet = Object.values(prerequisites).every(Boolean);
|
const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
|
||||||
|
|
||||||
// Load in-app EOI templates so the operator can pick one as an alternative
|
// Load in-app EOI templates so the operator can pick one as an alternative
|
||||||
// to the Documenso external-signing flow.
|
// to the Documenso external-signing flow.
|
||||||
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
|
|||||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!allMet) return;
|
if (!requiredMet) return;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,7 +109,13 @@ export function EoiGenerateDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
|
// Invalidate all document list queries (hub counts + per-interest lists).
|
||||||
|
// The DocumentList component uses ['documents', { interestId, clientId }]
|
||||||
|
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
|
||||||
|
// Using a predicate avoids key-shape drift between callers.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === 'documents',
|
||||||
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||||
@@ -138,22 +155,59 @@ export function EoiGenerateDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
|
<div className="space-y-1.5">
|
||||||
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<div key={key} className="flex items-center gap-3">
|
Required (Section 2 of the EOI)
|
||||||
<span
|
</p>
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
{REQUIRED_LABELS.map(({ key, label }) => (
|
||||||
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
}`}
|
<span
|
||||||
>
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
{prerequisites[key] ? '✓' : '✗'}
|
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
</span>
|
}`}
|
||||||
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
|
>
|
||||||
{label}
|
{prerequisites[key] ? '✓' : '✗'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span
|
||||||
))}
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Optional (Section 3 — left blank if absent)
|
||||||
|
</p>
|
||||||
|
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
|
prerequisites[key]
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{prerequisites[key] ? '✓' : '–'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!requiredMet ? (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
|
Add the missing required details on the client's record before generating the
|
||||||
|
EOI.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
|
|||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
|
||||||
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user