Compare commits
59 Commits
ba89b61b3f
...
feat/berth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 | ||
|
|
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 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -20,10 +20,17 @@ tsconfig.tsbuildinfo
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
eoi/
|
||||
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||
/eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
|
||||
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",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
@@ -112,6 +113,7 @@
|
||||
"lint-staged": "^15.2.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.4.0",
|
||||
"react-grab": "^0.1.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -75,6 +75,24 @@ export default defineConfig({
|
||||
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
|
||||
|
||||
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@@ -206,6 +206,9 @@ importers:
|
||||
tesseract.js:
|
||||
specifier: ^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:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
@@ -276,6 +279,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.4.0
|
||||
version: 3.8.1
|
||||
react-grab:
|
||||
specifier: ^0.1.32
|
||||
version: 0.1.32(react@19.2.4)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -339,6 +345,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.9.0'
|
||||
|
||||
'@antfu/ni@0.23.2':
|
||||
resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==}
|
||||
hasBin: true
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -2077,6 +2087,10 @@ packages:
|
||||
react: '>=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':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
@@ -2848,6 +2862,11 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bippy@0.5.39:
|
||||
resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==}
|
||||
peerDependencies:
|
||||
react: '>=17.0.1'
|
||||
|
||||
block-stream2@2.1.0:
|
||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||
|
||||
@@ -2953,6 +2972,10 @@ packages:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cli-spinners@2.9.2:
|
||||
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cli-truncate@4.0.0:
|
||||
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3008,6 +3031,10 @@ packages:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3982,6 +4009,10 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4030,6 +4061,14 @@ packages:
|
||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
@@ -4121,6 +4160,9 @@ packages:
|
||||
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -4128,6 +4170,10 @@ packages:
|
||||
keyv@4.5.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -4274,6 +4320,10 @@ packages:
|
||||
lodash@4.17.23:
|
||||
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:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4582,6 +4632,10 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
ora@8.2.0:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4781,6 +4835,10 @@ packages:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -5071,6 +5129,15 @@ packages:
|
||||
react-fast-compare@3.2.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -5357,6 +5424,9 @@ packages:
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5369,6 +5439,10 @@ packages:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
||||
|
||||
@@ -5431,6 +5505,10 @@ packages:
|
||||
std-env@4.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5763,6 +5841,12 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -6065,6 +6149,8 @@ snapshots:
|
||||
resize-observer-polyfill: 1.5.1
|
||||
throttle-debounce: 5.0.2
|
||||
|
||||
'@antfu/ni@0.23.2': {}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
@@ -7467,6 +7553,17 @@ snapshots:
|
||||
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)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -8233,6 +8330,10 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bippy@0.5.39(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
block-stream2@2.1.0:
|
||||
dependencies:
|
||||
readable-stream: 3.6.2
|
||||
@@ -8353,6 +8454,8 @@ snapshots:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
cli-spinners@2.9.2: {}
|
||||
|
||||
cli-truncate@4.0.0:
|
||||
dependencies:
|
||||
slice-ansi: 5.0.0
|
||||
@@ -8409,6 +8512,8 @@ snapshots:
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
component-classes@1.2.6:
|
||||
@@ -9561,6 +9666,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-interactive@2.0.0: {}
|
||||
|
||||
is-map@2.0.3: {}
|
||||
|
||||
is-negative-zero@2.0.3: {}
|
||||
@@ -9604,6 +9711,10 @@ snapshots:
|
||||
dependencies:
|
||||
which-typed-array: 1.1.20
|
||||
|
||||
is-unicode-supported@1.3.0: {}
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
@@ -9685,6 +9796,8 @@ snapshots:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
dependencies:
|
||||
array-includes: 3.1.9
|
||||
@@ -9696,6 +9809,8 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
kysely@0.28.11: {}
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
@@ -9823,6 +9938,11 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
ansi-escapes: 7.3.0
|
||||
@@ -10121,6 +10241,18 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
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:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -10313,6 +10445,11 @@ snapshots:
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -10728,6 +10865,13 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -11075,6 +11219,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -11087,6 +11233,8 @@ snapshots:
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
socket.io-adapter@2.5.6:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -11167,6 +11315,8 @@ snapshots:
|
||||
|
||||
std-env@4.0.0: {}
|
||||
|
||||
stdin-discarder@0.2.2: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -11584,6 +11734,15 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@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() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
||||
</div>
|
||||
<PageHeader title="Backup Management" description="Manage system backups and restoration" />
|
||||
<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-sm text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -47,13 +48,10 @@ const FIELDS: SettingFieldDef[] = [
|
||||
export default function BrandingSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell
|
||||
and outgoing email templates.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Branding"
|
||||
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Identity"
|
||||
description="App name, logo, and primary color."
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const API_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -48,13 +49,10 @@ const EOI_FIELDS: SettingFieldDef[] = [
|
||||
export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Documenso & EOI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
API credentials and default EOI generation pathway. Use the test-connection button to
|
||||
verify a saved configuration before relying on it.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Documenso & EOI"
|
||||
description="API credentials and default EOI generation pathway. Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Documenso API"
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -79,13 +80,10 @@ const FIELDS: SettingFieldDef[] = [
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Email Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-port outgoing email configuration. SMTP credentials and the From address default to
|
||||
environment variables when these fields are blank.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Email Settings"
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="From address & signature"
|
||||
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() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
||||
<p className="text-muted-foreground">Import data from external sources</p>
|
||||
</div>
|
||||
<PageHeader title="Data Import" description="Import data from external sources" />
|
||||
<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-sm text-muted-foreground">
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function InvitationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Invitations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a single-use invitation to a new CRM user. The recipient sets their own password via
|
||||
the link in the email.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Invitations"
|
||||
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
|
||||
/>
|
||||
<InvitationsManager />
|
||||
</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() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
|
||||
<p className="text-muted-foreground">Guided setup for new port configurations</p>
|
||||
</div>
|
||||
<PageHeader title="Onboarding" description="Guided setup for new port configurations" />
|
||||
<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-sm text-muted-foreground">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
interface AdminSection {
|
||||
href: string;
|
||||
@@ -165,13 +166,10 @@ export default async function AdminLandingPage({
|
||||
const { portSlug } = await params;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Administration</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-port configuration and system administration. Each card below opens a dedicated
|
||||
settings page.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{SECTIONS.map((s) => {
|
||||
const Icon = s.icon;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -53,14 +54,10 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
|
||||
export default function ReminderSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Reminders</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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>
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
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."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Defaults for new interests"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function ScheduledReportsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
|
||||
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Scheduled Reports"
|
||||
description="Configure and manage automated report delivery"
|
||||
/>
|
||||
<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-sm text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -36,7 +37,11 @@ export default function WebhooksPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
||||
const [expandedId, setExpandedId] = 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 () => {
|
||||
try {
|
||||
@@ -98,15 +103,20 @@ export default function WebhooksPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
|
||||
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
||||
<PageHeader
|
||||
title="Webhooks"
|
||||
description="Configure outgoing webhook integrations"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditTarget(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<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">
|
||||
Add a webhook to receive real-time notifications of CRM events.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setEditTarget(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
@@ -141,17 +157,16 @@ export default function WebhooksPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleActive(webhook)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
|
||||
{webhook.isActive ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
|
||||
onClick={() => {
|
||||
setEditTarget(webhook);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -163,11 +178,7 @@ export default function WebhooksPage() {
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(webhook.id)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
|
||||
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -228,18 +239,26 @@ export default function WebhooksPage() {
|
||||
onSuccess={loadWebhooks}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history. This action
|
||||
cannot be undone.
|
||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</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 { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||
import { ExpenseCard } from '@/components/expenses/expense-card';
|
||||
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
||||
setArchiveExpense(null);
|
||||
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
cardRender={(row) => (
|
||||
<ExpenseCard
|
||||
expense={row.original}
|
||||
portSlug={portSlug}
|
||||
onEdit={setEditExpense}
|
||||
onArchive={setArchiveExpense}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No expenses found"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
@@ -33,9 +35,16 @@ export default function ScanReceiptPage() {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | 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
|
||||
const [establishment, setEstablishment] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
@@ -94,7 +103,7 @@ export default function ScanReceiptPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Upload a receipt image and we will extract the expense details automatically.
|
||||
@@ -109,28 +118,44 @@ export default function ScanReceiptPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{previewUrl ? (
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
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
|
||||
src={previewUrl}
|
||||
alt="Receipt preview"
|
||||
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" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click to upload or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full h-14 sm:hidden"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
>
|
||||
<Camera className="mr-2 h-5 w-5" />
|
||||
Take photo
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -138,6 +163,14 @@ export default function ScanReceiptPage() {
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{scanMutation.isPending && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
|
||||
@@ -222,25 +255,18 @@ export default function ScanReceiptPage() {
|
||||
</div>
|
||||
|
||||
{saveMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(saveMutation.error as Error).message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/${params.portSlug}/expenses`)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !amount}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save as Expense
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
@@ -43,9 +45,35 @@ export default function NewInvoicePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
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 { 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>({
|
||||
resolver: zodResolver(createInvoiceSchema),
|
||||
defaultValues: {
|
||||
@@ -53,6 +81,8 @@ export default function NewInvoicePage() {
|
||||
currency: 'USD',
|
||||
lineItems: [],
|
||||
expenseIds: [],
|
||||
interestId: prefilledInterestId,
|
||||
kind: prefilledKind,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,6 +95,43 @@ export default function NewInvoicePage() {
|
||||
} = methods;
|
||||
|
||||
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 subtotal = lineItems.reduce(
|
||||
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
|
||||
@@ -117,8 +184,8 @@ export default function NewInvoicePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Header — desktop only; mobile gets the title from the topbar */}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -157,6 +224,23 @@ export default function NewInvoicePage() {
|
||||
<CardTitle className="text-base">Client Information</CardTitle>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<Label>
|
||||
Billing entity <span className="text-destructive">*</span>
|
||||
@@ -294,9 +378,13 @@ export default function NewInvoicePage() {
|
||||
<p className="font-medium mt-0.5">
|
||||
{watchedValues.billingEntity ? (
|
||||
<>
|
||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
||||
<span className="text-xs opacity-60">
|
||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
||||
{billingEntityName?.name ? (
|
||||
<span>{billingEntityName.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Loading…</span>
|
||||
)}{' '}
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
({watchedValues.billingEntity.type})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InvoiceCard } from '@/components/invoices/invoice-card';
|
||||
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
||||
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setDeleteTarget(null);
|
||||
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
|
||||
const columns = getInvoiceColumns({
|
||||
portSlug,
|
||||
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
||||
onRecordPayment: (invoice) =>
|
||||
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
||||
onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
||||
onDelete: (invoice) => setDeleteTarget(invoice),
|
||||
});
|
||||
|
||||
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
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
|
||||
title="No invoices found"
|
||||
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
|
||||
<h3 className="font-semibold">Delete Invoice?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will permanently delete invoice{' '}
|
||||
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
|
||||
This action cannot be undone.
|
||||
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,8 @@ import { PortProvider } from '@/providers/port-provider';
|
||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
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 }) {
|
||||
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}>
|
||||
<PermissionsProvider>
|
||||
<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
|
||||
portRoles={portRoles}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile shell — hidden by CSS on desktop */}
|
||||
<MobileLayout>{children}</MobileLayout>
|
||||
</SocketProvider>
|
||||
</PermissionsProvider>
|
||||
</PortProvider>
|
||||
|
||||
@@ -5,28 +5,19 @@ import type { Metadata } from 'next';
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getClientInterests } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
|
||||
|
||||
export const metadata: Metadata = { title: 'Interests' };
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
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'> = {
|
||||
const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
open: 'secondary',
|
||||
details_sent: 'secondary',
|
||||
in_communication: 'default',
|
||||
visited: 'default',
|
||||
signed_eoi_nda: 'default',
|
||||
eoi_sent: 'default',
|
||||
eoi_signed: 'default',
|
||||
deposit_10pct: 'default',
|
||||
contract: 'default',
|
||||
contract_sent: 'default',
|
||||
contract_signed: 'default',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
@@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your berth enquiries and applications
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Your berth enquiries and applications</p>
|
||||
</div>
|
||||
|
||||
{interests.length === 0 ? (
|
||||
@@ -56,10 +45,7 @@ export default async function PortalInterestsPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{interests.map((interest) => (
|
||||
<div
|
||||
key={interest.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div key={interest.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
|
||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
||||
<Badge variant={STAGE_VARIANT[safeStage(interest.pipelineStage)]}>
|
||||
{stageLabel(interest.pipelineStage)}
|
||||
</Badge>
|
||||
</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 { z } from 'zod';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, 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);
|
||||
}
|
||||
};
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||
// 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 { 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);
|
||||
}
|
||||
};
|
||||
import { patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { setPrimaryHandler } from './handlers';
|
||||
|
||||
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 { z } from 'zod';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, 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);
|
||||
}
|
||||
};
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { autocompleteHandler } from './handlers';
|
||||
|
||||
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 { auditLogs } from '@/lib/db/schema/system';
|
||||
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 {
|
||||
id: string;
|
||||
@@ -14,6 +34,10 @@ interface TimelineEvent {
|
||||
action: string;
|
||||
description: string;
|
||||
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;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
@@ -33,12 +57,7 @@ export const GET = withAuth(
|
||||
const auditRows = await db
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(auditLogs.entityType, 'interest'),
|
||||
eq(auditLogs.entityId, interestId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(50);
|
||||
|
||||
@@ -67,28 +86,82 @@ export const GET = withAuth(
|
||||
|
||||
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
|
||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||
id: row.id,
|
||||
type: 'audit',
|
||||
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,
|
||||
userName: resolveUserName(row.userId),
|
||||
createdAt: row.createdAt,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||
}));
|
||||
|
||||
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({
|
||||
const docEvents: TimelineEvent[] = docEventRows.map((row) => {
|
||||
const title = docTitles[row.documentId] ?? row.documentId;
|
||||
const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
|
||||
return {
|
||||
id: row.id,
|
||||
type: 'document_event',
|
||||
action: row.eventType,
|
||||
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`,
|
||||
description: `Document "${title}" ${action}`,
|
||||
userId: null,
|
||||
userName: null,
|
||||
createdAt: row.createdAt,
|
||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
||||
@@ -101,12 +174,39 @@ export const GET = withAuth(
|
||||
function buildAuditDescription(
|
||||
action: string,
|
||||
newValue: Record<string, unknown> | null,
|
||||
metadata: Record<string, unknown>,
|
||||
userId: string | null,
|
||||
): string {
|
||||
if (action === 'create') return 'Interest created';
|
||||
if (action === 'archive') return 'Interest archived';
|
||||
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) {
|
||||
return `Stage changed to "${newValue.pipelineStage}"`;
|
||||
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
|
||||
}
|
||||
if (action === 'update') return 'Interest updated';
|
||||
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 { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||
import { patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
});
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
export const DELETE = withAuth(deleteHandler);
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { historyHandler } from './handlers';
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { transferHandler } from './handlers';
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { autocompleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
||||
import {
|
||||
handleRecipientSigned,
|
||||
handleDocumentCompleted,
|
||||
handleDocumentExpired,
|
||||
handleDocumentOpened,
|
||||
handleDocumentRejected,
|
||||
handleDocumentCancelled,
|
||||
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||
break;
|
||||
|
||||
case 'DOCUMENT_EXPIRED':
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||
}
|
||||
|
||||
@@ -127,3 +127,45 @@
|
||||
@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,9 @@
|
||||
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 { Toaster } from 'sonner';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
@@ -15,18 +18,52 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#1e2844',
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Port Nimara CRM',
|
||||
template: '%s | Port Nimara CRM',
|
||||
},
|
||||
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 (
|
||||
<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}
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
|
||||
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,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { AuditLogCard } from './audit-log-card';
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
@@ -357,6 +358,7 @@ export function AuditLogList() {
|
||||
data={entries}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
cardRender={(row) => <AuditLogCard entry={row.original} />}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||
|
||||
@@ -68,9 +68,11 @@ const KNOWN_SETTINGS: Array<{
|
||||
open: 0.05,
|
||||
details_sent: 0.1,
|
||||
in_communication: 0.2,
|
||||
signed_eoi_nda: 0.4,
|
||||
deposit_10pct: 0.6,
|
||||
contract: 0.8,
|
||||
eoi_sent: 0.4,
|
||||
eoi_signed: 0.6,
|
||||
deposit_10pct: 0.75,
|
||||
contract_sent: 0.85,
|
||||
contract_signed: 0.95,
|
||||
completed: 1.0,
|
||||
},
|
||||
},
|
||||
@@ -105,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'json',
|
||||
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() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ServiceHealthCard } from './service-health-card';
|
||||
import { QueueOverview } from './queue-overview';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import type {
|
||||
HealthStatus,
|
||||
QueueStatus,
|
||||
@@ -17,16 +18,14 @@ import type {
|
||||
export function SystemMonitoringDashboard() {
|
||||
const { data: healthData } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||
queryFn: () => apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: queuesData } = useQuery({
|
||||
queryKey: ['system', 'queues'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||
queryFn: () => apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
@@ -47,11 +46,10 @@ export function SystemMonitoringDashboard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
|
||||
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="System Monitoring"
|
||||
description="Real-time health, queue status and connection tracking"
|
||||
/>
|
||||
|
||||
{/* Service health */}
|
||||
<section className="space-y-3">
|
||||
@@ -79,10 +77,7 @@ export function SystemMonitoringDashboard() {
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
<div key={i} className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -124,9 +119,7 @@ export function SystemMonitoringDashboard() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">
|
||||
{queues.reduce((sum, q) => sum + q.active, 0)}
|
||||
</p>
|
||||
<p className="text-3xl font-bold">{queues.reduce((sum, q) => sum + q.active, 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -141,10 +134,7 @@ export function SystemMonitoringDashboard() {
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[110px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
<div key={i} className="h-[110px] rounded-xl border bg-card animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -159,8 +149,7 @@ export function SystemMonitoringDashboard() {
|
||||
function RecentErrorsPanel() {
|
||||
const { data: errorsData } = useQuery({
|
||||
queryKey: ['system', 'errors'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||
queryFn: () => apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||
staleTime: 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 { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { UserCard } from './user-card';
|
||||
import { UserForm } from './user-form';
|
||||
|
||||
interface UserRow {
|
||||
@@ -152,6 +153,14 @@ export function UserList() {
|
||||
data={users}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.userId}
|
||||
cardRender={(row) => (
|
||||
<UserCard
|
||||
user={row.original}
|
||||
onEdit={handleEditUser}
|
||||
onRemove={handleRemoveUser}
|
||||
isRemoving={deletingId === row.original.userId}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No users assigned to this port.</p>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,17 @@ type BerthDetailData = {
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
sidePontoon: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
bowFacing: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
@@ -170,7 +181,9 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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
|
||||
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 +193,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
||||
</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">
|
||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthDetailHeader } from './berth-detail-header';
|
||||
import { BerthForm } from './berth-form';
|
||||
import { buildBerthTabs } from './berth-tabs';
|
||||
|
||||
interface BerthDetailProps {
|
||||
@@ -26,15 +30,45 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
'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
|
||||
const berth = data as any;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||
tabs={berth ? buildBerthTabs(berth) : []}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,18 +16,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
||||
import {
|
||||
BERTH_AREAS,
|
||||
BERTH_SIDE_PONTOON_OPTIONS,
|
||||
BERTH_MOORING_TYPES,
|
||||
BERTH_CLEAT_TYPES,
|
||||
BERTH_CLEAT_CAPACITIES,
|
||||
BERTH_BOLLARD_TYPES,
|
||||
BERTH_BOLLARD_CAPACITIES,
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
} from '@/lib/constants';
|
||||
|
||||
interface BerthFormProps {
|
||||
berth: {
|
||||
@@ -42,16 +46,27 @@ interface BerthFormProps {
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
bowFacing: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
access: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
@@ -59,10 +74,42 @@ interface BerthFormProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Optional select that allows clearing back to "no value". */
|
||||
function SelectOrEmpty({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChange: (next: string | undefined) => void;
|
||||
options: readonly string[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const NONE = '__none';
|
||||
return (
|
||||
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
|
||||
|
||||
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
resolver: zodResolver(updateBerthSchema),
|
||||
defaultValues: {
|
||||
area: berth.area ?? undefined,
|
||||
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
|
||||
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
|
||||
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
|
||||
widthM: berth.widthM ? Number(berth.widthM) : undefined,
|
||||
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
|
||||
draftM: berth.draftM ? Number(berth.draftM) : undefined,
|
||||
lengthFt: numOrUndef(berth.lengthFt),
|
||||
lengthM: numOrUndef(berth.lengthM),
|
||||
widthFt: numOrUndef(berth.widthFt),
|
||||
widthM: numOrUndef(berth.widthM),
|
||||
draftFt: numOrUndef(berth.draftFt),
|
||||
draftM: numOrUndef(berth.draftM),
|
||||
widthIsMinimum: berth.widthIsMinimum ?? false,
|
||||
price: berth.price ? Number(berth.price) : undefined,
|
||||
nominalBoatSize: numOrUndef(berth.nominalBoatSize),
|
||||
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
|
||||
waterDepth: numOrUndef(berth.waterDepth),
|
||||
waterDepthM: numOrUndef(berth.waterDepthM),
|
||||
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
|
||||
sidePontoon: berth.sidePontoon ?? undefined,
|
||||
powerCapacity: numOrUndef(berth.powerCapacity),
|
||||
voltage: numOrUndef(berth.voltage),
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
cleatType: berth.cleatType ?? undefined,
|
||||
cleatCapacity: berth.cleatCapacity ?? undefined,
|
||||
bollardType: berth.bollardType ?? undefined,
|
||||
bollardCapacity: berth.bollardCapacity ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
bowFacing: berth.bowFacing ?? undefined,
|
||||
price: numOrUndef(berth.price),
|
||||
priceCurrency: berth.priceCurrency,
|
||||
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
|
||||
tenureYears: berth.tenureYears ?? undefined,
|
||||
tenureStartDate: berth.tenureStartDate ?? undefined,
|
||||
tenureEndDate: berth.tenureEndDate ?? undefined,
|
||||
powerCapacity: berth.powerCapacity ?? undefined,
|
||||
voltage: berth.voltage ?? undefined,
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
berthApproved: berth.berthApproved ?? false,
|
||||
},
|
||||
});
|
||||
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
}
|
||||
|
||||
const tenureType = watch('tenureType');
|
||||
const area = watch('area');
|
||||
const sidePontoon = watch('sidePontoon');
|
||||
const mooringType = watch('mooringType');
|
||||
const cleatType = watch('cleatType');
|
||||
const cleatCapacity = watch('cleatCapacity');
|
||||
const bollardType = watch('bollardType');
|
||||
const bollardCapacity = watch('bollardCapacity');
|
||||
const access = watch('access');
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Area</Label>
|
||||
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
|
||||
<Label>Area</Label>
|
||||
<SelectOrEmpty
|
||||
value={area}
|
||||
onChange={(v) => setValue('area', v)}
|
||||
options={BERTH_AREAS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mooringType">Mooring Type</Label>
|
||||
<Input id="mooringType" {...register('mooringType')} />
|
||||
<Label htmlFor="bowFacing">Bow Facing</Label>
|
||||
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access">Access</Label>
|
||||
<Input id="access" {...register('access')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="berthApproved"
|
||||
@@ -168,29 +234,46 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Length (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthFt')} />
|
||||
<Input type="number" step="0.01" {...register('lengthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Length (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthM')} />
|
||||
<Input type="number" step="0.01" {...register('lengthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthFt')} />
|
||||
<Input type="number" step="0.01" {...register('widthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthM')} />
|
||||
<Input type="number" step="0.01" {...register('widthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftFt')} />
|
||||
<Input type="number" step="0.01" {...register('draftFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftM')} />
|
||||
<Input type="number" step="0.01" {...register('draftM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nominal Boat Size (ft)</Label>
|
||||
<Input type="number" step="1" {...register('nominalBoatSize')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nominal Boat Size (m)</Label>
|
||||
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Water Depth (ft)</Label>
|
||||
<Input type="number" step="0.01" {...register('waterDepth')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Water Depth (m)</Label>
|
||||
<Input type="number" step="0.01" {...register('waterDepthM')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
/>
|
||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="waterDepthIsMinimum"
|
||||
checked={watch('waterDepthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Mooring & Hardware */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Mooring & Hardware
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>Side Pontoon</Label>
|
||||
<SelectOrEmpty
|
||||
value={sidePontoon}
|
||||
onChange={(v) => setValue('sidePontoon', v)}
|
||||
options={BERTH_SIDE_PONTOON_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Mooring Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={mooringType}
|
||||
onChange={(v) => setValue('mooringType', v)}
|
||||
options={BERTH_MOORING_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Cleat Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={cleatType}
|
||||
onChange={(v) => setValue('cleatType', v)}
|
||||
options={BERTH_CLEAT_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleat Capacity</Label>
|
||||
<SelectOrEmpty
|
||||
value={cleatCapacity}
|
||||
onChange={(v) => setValue('cleatCapacity', v)}
|
||||
options={BERTH_CLEAT_CAPACITIES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bollard Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={bollardType}
|
||||
onChange={(v) => setValue('bollardType', v)}
|
||||
options={BERTH_BOLLARD_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bollard Capacity</Label>
|
||||
<SelectOrEmpty
|
||||
value={bollardCapacity}
|
||||
onChange={(v) => setValue('bollardCapacity', v)}
|
||||
options={BERTH_BOLLARD_CAPACITIES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Power */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Power
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity (kW)</Label>
|
||||
<Input type="number" step="1" {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage (V at 60Hz)</Label>
|
||||
<Input type="number" step="1" {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Access */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Access
|
||||
</h3>
|
||||
<SelectOrEmpty
|
||||
value={access}
|
||||
onChange={(v) => setValue('access', v)}
|
||||
options={BERTH_ACCESS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Infrastructure
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity</Label>
|
||||
<Input {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage</Label>
|
||||
<Input {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
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 { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
|
||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||
|
||||
interface BerthInterestsTabProps {
|
||||
@@ -28,27 +29,10 @@ interface BerthInterestsTabProps {
|
||||
type StageFilter = 'all' | 'active' | 'lost';
|
||||
type SortMode = 'newest' | 'stage' | 'category';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
function stageRank(stage: string): number {
|
||||
const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]);
|
||||
return idx === -1 ? 99 : idx;
|
||||
}
|
||||
|
||||
const CATEGORY_RANK: Record<string, number> = {
|
||||
hot_lead: 0,
|
||||
@@ -104,8 +88,8 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
});
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sortMode === 'stage') {
|
||||
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
|
||||
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
|
||||
const sa = stageRank(a.pipelineStage);
|
||||
const sb = stageRank(b.pipelineStage);
|
||||
if (sa !== sb) return sb - sa; // furthest along first
|
||||
}
|
||||
if (sortMode === 'category') {
|
||||
@@ -189,7 +173,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||
{stageLabel(i.pipelineStage)}
|
||||
</Badge>
|
||||
</td>
|
||||
<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 { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthCard } from './berth-card';
|
||||
import { berthColumns, type BerthRow } from './berth-columns';
|
||||
import { berthFilterDefinitions } from './berth-filters';
|
||||
import { Anchor } from 'lucide-react';
|
||||
@@ -73,6 +74,7 @@ export function BerthList() {
|
||||
onSortChange={setSort}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||
import { BerthInterestsTab } from './berth-interests-tab';
|
||||
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||
|
||||
type BerthData = {
|
||||
id: string;
|
||||
@@ -56,13 +57,45 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
|
||||
const fmt = (v: string | null, fractionDigits = 2): string | null => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
return n.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDim = (ft: string | null, m: string | null) => {
|
||||
const parts = [];
|
||||
if (ft) parts.push(`${ft} ft`);
|
||||
if (m) parts.push(`${m} m`);
|
||||
const ftFmt = fmt(ft);
|
||||
const mFmt = fmt(m);
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
|
||||
const ftFmt = fmt(ft, 0);
|
||||
const mFmt = fmt(m);
|
||||
const parts: string[] = [];
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const formatPower = (kw: string | null) => {
|
||||
const v = fmt(kw, 0);
|
||||
return v ? `${v} kW` : null;
|
||||
};
|
||||
|
||||
const formatVoltage = (v: string | null) => {
|
||||
const fv = fmt(v, 0);
|
||||
return fv ? `${fv} V` : null;
|
||||
};
|
||||
|
||||
const price = berth.price
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -72,6 +105,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||
who's interested + how warm without clicking into the Interests tab. */}
|
||||
<BerthInterestPulse berthId={berth.id} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Specifications */}
|
||||
<Card>
|
||||
@@ -91,7 +129,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||
<SpecRow
|
||||
label="Nominal Boat Size"
|
||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Water Depth"
|
||||
@@ -116,8 +154,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||
<SpecRow label="Voltage" value={berth.voltage} />
|
||||
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
|
||||
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
|
||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||
@@ -161,6 +199,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||
{client.fullName}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Archived
|
||||
@@ -115,7 +117,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isArchived && client.clientPortalEnabled !== false && (
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
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 { getClientTabs } from '@/components/clients/client-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -80,6 +82,13 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
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({
|
||||
'client:updated': [['clients', clientId]],
|
||||
'client:archived': [['clients', clientId]],
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { ClientForm } from '@/components/clients/client-form';
|
||||
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
||||
import { ClientCard } from '@/components/clients/client-card';
|
||||
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -118,6 +119,14 @@ export function ClientList() {
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
cardRender={(row) => (
|
||||
<ClientCard
|
||||
client={row.original}
|
||||
portSlug={portSlug}
|
||||
onEdit={setEditClient}
|
||||
onArchive={setArchiveClient}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
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} />
|
||||
);
|
||||
}
|
||||
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-1 min-w-0">
|
||||
<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
|
||||
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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PermissionGate resource="companies" action="edit">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
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 { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||
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),
|
||||
});
|
||||
|
||||
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({
|
||||
'company:updated': [['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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { CompanyCard } from '@/components/companies/company-card';
|
||||
import { CompanyForm } from '@/components/companies/company-form';
|
||||
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||
@@ -123,6 +124,14 @@ export function CompanyList() {
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
cardRender={(row) => (
|
||||
<CompanyCard
|
||||
company={row.original}
|
||||
portSlug={portSlug}
|
||||
onEdit={setEditCompany}
|
||||
onArchive={setArchiveCompany}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No companies yet"
|
||||
|
||||
@@ -57,7 +57,10 @@ function ActivityFeedInner() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
@@ -49,7 +50,7 @@ export function DashboardShell() {
|
||||
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 />
|
||||
</div>
|
||||
|
||||
@@ -68,7 +69,10 @@ export function DashboardShell() {
|
||||
<LeadSourceChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
<aside className="min-w-0">
|
||||
<aside className="min-w-0 space-y-4">
|
||||
<WidgetErrorBoundary>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<AlertRail />
|
||||
</WidgetErrorBoundary>
|
||||
|
||||
@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !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>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
innerRadius={50}
|
||||
cy="45%"
|
||||
outerRadius="70%"
|
||||
innerRadius="40%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((_, i) => (
|
||||
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={40}
|
||||
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
153
src/components/dashboard/my-reminders-rail.tsx
Normal file
153
src/components/dashboard/my-reminders-rail.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'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`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="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';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
interface PipelineRow {
|
||||
@@ -21,18 +15,8 @@ interface PipelineRow {
|
||||
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() {
|
||||
const isMobile = useIsMobile();
|
||||
const { data, isLoading } = useQuery<PipelineRow[]>({
|
||||
queryKey: ['dashboard', 'pipeline'],
|
||||
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
||||
@@ -45,7 +29,7 @@ function PipelineChartInner() {
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,31 +4,24 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
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 { useFunnel } from './use-analytics';
|
||||
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 {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
export function PipelineFunnelChart({ range }: Props) {
|
||||
const { data, isLoading } = useFunnel(range);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const stages = data?.stages ?? [];
|
||||
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
|
||||
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,
|
||||
conversionPct: s.conversionPct,
|
||||
}));
|
||||
@@ -51,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : 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}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||
|
||||
@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !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}>
|
||||
<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 { Badge } from '@/components/ui/badge';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
|
||||
interface StageBreakdownRow {
|
||||
@@ -20,17 +21,6 @@ interface ForecastData {
|
||||
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 {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -66,9 +56,7 @@ function RevenueForecastInner() {
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(data?.totalWeightedValue ?? 0)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(data?.totalWeightedValue ?? 0)}</p>
|
||||
</div>
|
||||
|
||||
{activeStages.length > 0 && (
|
||||
@@ -76,12 +64,10 @@ function RevenueForecastInner() {
|
||||
{activeStages.map((s) => (
|
||||
<div key={s.stage} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{STAGE_LABELS[s.stage] ?? s.stage}
|
||||
{stageLabel(s.stage)}
|
||||
<span className="ml-1 text-xs">({s.count})</span>
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(s.weightedValue)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{formatCurrency(s.weightedValue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,9 @@ interface DocumentRow {
|
||||
interface DocumentListProps {
|
||||
interestId?: 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'> = {
|
||||
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
||||
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
||||
};
|
||||
|
||||
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 (emptyState) return <>{emptyState}</>;
|
||||
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',
|
||||
};
|
||||
|
||||
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
sent: 'Sent',
|
||||
signed: 'Signed',
|
||||
declined: 'Declined',
|
||||
expired: 'Expired',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
interface DocumentsHubProps {
|
||||
portSlug: string;
|
||||
initialTab?: DocumentsHubTab;
|
||||
}
|
||||
|
||||
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
const [tab, setTab] = useState<DocumentsHubTab>('all');
|
||||
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
|
||||
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [signatureOnly, setSignatureOnly] = useState(true);
|
||||
@@ -186,7 +196,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||
</div>
|
||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||
{signer.status}
|
||||
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||
</StatusPill>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -22,8 +22,14 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 {
|
||||
hasName: boolean;
|
||||
hasEmail: boolean;
|
||||
hasAddress: boolean;
|
||||
/** Optional — info-only checks. Generation proceeds without them. */
|
||||
hasYacht: boolean;
|
||||
hasBerth: boolean;
|
||||
}
|
||||
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
|
||||
prerequisites: EoiPrerequisites;
|
||||
}
|
||||
|
||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||
{ key: 'hasName', label: 'Client has full name' },
|
||||
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
||||
{ key: 'hasBerth', label: 'Berth linked to interest' },
|
||||
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||
{ key: 'hasName', label: 'Client name' },
|
||||
{ key: 'hasAddress', label: 'Client address' },
|
||||
{ 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';
|
||||
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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
|
||||
// to the Documenso external-signing flow.
|
||||
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
|
||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!allMet) return;
|
||||
if (!requiredMet) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
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);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||
@@ -138,10 +155,13 @@ export function EoiGenerateDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
|
||||
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Required (Section 2 of the EOI)
|
||||
</p>
|
||||
{REQUIRED_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-red-100 text-red-700'
|
||||
@@ -149,12 +169,46 @@ export function EoiGenerateDialog({
|
||||
>
|
||||
{prerequisites[key] ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
<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>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
||||
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
|
||||
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
194
src/components/expenses/expense-card.tsx
Normal file
194
src/components/expenses/expense-card.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { Archive, Calendar, Eye, MoreHorizontal, Pencil, Receipt, Tag } 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 type { ExpenseRow } from './expense-columns';
|
||||
|
||||
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||
unpaid: 'bg-red-100 text-red-700 border-red-200',
|
||||
paid: 'bg-green-100 text-green-700 border-green-200',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
reconciled: 'bg-green-100 text-green-700 border-green-200',
|
||||
flagged: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
pending: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
};
|
||||
|
||||
/**
|
||||
* Accent bar by payment status:
|
||||
* paid / reconciled → emerald
|
||||
* pending → amber
|
||||
* flagged → rose
|
||||
* other / null → slate
|
||||
* If duplicateOf is set, override to amber-500.
|
||||
*/
|
||||
function deriveAccent(status: string | null, duplicateOf: string | null): string {
|
||||
if (duplicateOf) return 'bg-amber-500';
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
case 'reconciled':
|
||||
return 'bg-emerald-400';
|
||||
case 'pending':
|
||||
return 'bg-amber-400';
|
||||
case 'flagged':
|
||||
return 'bg-rose-400';
|
||||
default:
|
||||
return 'bg-slate-300';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(amount: string, currency: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(amount));
|
||||
} catch {
|
||||
return `${currency} ${amount}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpenseCardProps {
|
||||
expense: ExpenseRow;
|
||||
portSlug: string;
|
||||
onEdit: (expense: ExpenseRow) => void;
|
||||
onArchive: (expense: ExpenseRow) => void;
|
||||
}
|
||||
|
||||
export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCardProps) {
|
||||
const accentClass = deriveAccent(expense.paymentStatus, expense.duplicateOf);
|
||||
|
||||
const title =
|
||||
expense.establishmentName ??
|
||||
(expense.description ? expense.description.slice(0, 40) : null) ??
|
||||
'Untitled expense';
|
||||
|
||||
// Subtitle: category (capitalized) or "{paymentMethod} by {payer}"
|
||||
let subtitle: string | null = null;
|
||||
if (expense.category) {
|
||||
subtitle = expense.category.replace(/_/g, ' ');
|
||||
// Capitalize first letter
|
||||
subtitle = subtitle.charAt(0).toUpperCase() + subtitle.slice(1);
|
||||
} else if (expense.paymentMethod || expense.payer) {
|
||||
const parts: string[] = [];
|
||||
if (expense.paymentMethod) parts.push(expense.paymentMethod);
|
||||
if (expense.payer) parts.push(`by ${expense.payer}`);
|
||||
subtitle = parts.join(' ');
|
||||
}
|
||||
const hasCategory = !!expense.category;
|
||||
|
||||
let dateFormatted: string | null = null;
|
||||
try {
|
||||
dateFormatted = format(new Date(expense.expenseDate), 'MMM d, yyyy');
|
||||
} catch {
|
||||
dateFormatted = expense.expenseDate;
|
||||
}
|
||||
|
||||
const amountFormatted = formatAmount(expense.amount, expense.currency);
|
||||
|
||||
const statusColor = expense.paymentStatus
|
||||
? (PAYMENT_STATUS_COLORS[expense.paymentStatus] ??
|
||||
'bg-muted text-muted-foreground border-muted')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
href={`/${portSlug}/expenses/${expense.id}`}
|
||||
ariaLabel={`Expense: ${title}`}
|
||||
accentClassName={accentClass}
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for expense: ${title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${portSlug}/expenses/${expense.id}`}>
|
||||
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(expense)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(expense)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar icon={<Receipt 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">
|
||||
{title}
|
||||
</h3>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Category / payer subtitle */}
|
||||
{subtitle ? (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
{hasCategory ? (
|
||||
<Tag className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
) : null}
|
||||
<span className="truncate">{subtitle}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Amount — prominent */}
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||
{amountFormatted}
|
||||
</p>
|
||||
|
||||
{/* Date meta */}
|
||||
{dateFormatted ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<ListCardMeta icon={<Calendar className="h-3 w-3" />}>{dateFormatted}</ListCardMeta>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status + duplicate pills */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{statusColor && expense.paymentStatus ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
|
||||
statusColor,
|
||||
)}
|
||||
>
|
||||
{expense.paymentStatus}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{expense.duplicateOf ? (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||
Possible duplicate
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
|
||||
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import type { ExpenseRow } from './expense-columns';
|
||||
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
|
||||
|
||||
@@ -34,6 +35,14 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
const titleForChrome: string | null =
|
||||
data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null;
|
||||
useEffect(() => {
|
||||
setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
|
||||
163
src/components/interests/inline-stage-picker.tsx
Normal file
163
src/components/interests/inline-stage-picker.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PIPELINE_STAGES,
|
||||
STAGE_BADGE,
|
||||
STAGE_DOT,
|
||||
STAGE_LABELS,
|
||||
safeStage,
|
||||
type PipelineStage,
|
||||
} from '@/components/clients/pipeline-constants';
|
||||
|
||||
interface InlineStagePickerProps {
|
||||
interestId: string;
|
||||
currentStage: string;
|
||||
/** Whether to render the chevron after the stage label. Default true. */
|
||||
showChevron?: boolean;
|
||||
/** Stop the parent's click propagation when used inside a clickable card. */
|
||||
stopPropagation?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||
* for inline editing — user clicks the chip, picks a new stage from the
|
||||
* popover (with optional reason), commits in one click. The popover stays
|
||||
* compact: a small reason field above the stage list, and clicking any stage
|
||||
* fires the mutation immediately.
|
||||
*/
|
||||
export function InlineStagePicker({
|
||||
interestId,
|
||||
currentStage,
|
||||
showChevron = true,
|
||||
stopPropagation = false,
|
||||
className,
|
||||
}: InlineStagePickerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
||||
|
||||
const stage = safeStage(currentStage);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: PipelineStage) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: next, reason: reason.trim() || undefined },
|
||||
}),
|
||||
onSuccess: (_data, next) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setOpen(false);
|
||||
setReason('');
|
||||
setPendingStage(null);
|
||||
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setPendingStage(null);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to change stage');
|
||||
},
|
||||
});
|
||||
|
||||
function pick(next: PipelineStage) {
|
||||
if (next === stage) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingStage(next);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!mutation.isPending) setOpen(o);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
STAGE_BADGE[stage],
|
||||
className,
|
||||
)}
|
||||
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||
>
|
||||
<span>{STAGE_LABELS[stage]}</span>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : showChevron ? (
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 p-0"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Reason (optional)…"
|
||||
rows={1}
|
||||
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const isCurrent = s === stage;
|
||||
const isPending = pendingStage === s && mutation.isPending;
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => pick(s)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'transition-colors hover:bg-muted/60 disabled:opacity-60',
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||
the picker into a visual scan rather than just a list. */}
|
||||
<span
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
196
src/components/interests/interest-card.tsx
Normal file
196
src/components/interests/interest-card.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import type { InterestRow } from './interest-columns';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General',
|
||||
specific_qualified: 'Qualified',
|
||||
hot_lead: 'Hot lead',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface InterestCardProps {
|
||||
interest: InterestRow;
|
||||
portSlug: string;
|
||||
onEdit: (interest: InterestRow) => void;
|
||||
onArchive: (interest: InterestRow) => void;
|
||||
}
|
||||
|
||||
export function InterestCard({ interest, portSlug, onEdit, onArchive }: InterestCardProps) {
|
||||
const stageLabel = toStageLabel(interest.pipelineStage);
|
||||
const stagePill = stageBadgeClass(interest.pipelineStage);
|
||||
const accentClass = stageDotClass(interest.pipelineStage);
|
||||
const isHotLead = interest.leadCategory === 'hot_lead';
|
||||
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||
const tags = interest.tags ?? [];
|
||||
const notesCount = interest.notesCount ?? 0;
|
||||
const urgencyBadges = computeUrgencyBadges(interest);
|
||||
|
||||
const clientName = interest.clientName ?? 'Unknown client';
|
||||
const berthLabel = interest.berthMooringNumber;
|
||||
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||
const lastActivity = lastIso
|
||||
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
href={`/${portSlug}/interests/${interest.id}`}
|
||||
ariaLabel={`Interest for ${clientName}`}
|
||||
accentClassName={accentClass}
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${clientName}'s interest`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(interest)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(interest)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar initials={deriveInitials(clientName)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title row: name + comment-icon when notes exist + spacer for actions */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{clientName}
|
||||
</h3>
|
||||
{notesCount > 0 ? (
|
||||
<span
|
||||
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Berth or general-interest line */}
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
{berthLabel ? (
|
||||
<>
|
||||
<Anchor className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
<span className="truncate">{berthLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Compass className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
<span className="italic">General interest</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Stage pill + category + source */}
|
||||
<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',
|
||||
stagePill,
|
||||
)}
|
||||
>
|
||||
{stageLabel}
|
||||
</span>
|
||||
|
||||
{isHotLead ? (
|
||||
<span className="inline-flex items-center rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-rose-700">
|
||||
Hot
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{(categoryLabel && !isHotLead) || sourceLabel ? (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||
{categoryLabel && !isHotLead ? <ListCardMeta>{categoryLabel}</ListCardMeta> : null}
|
||||
{categoryLabel && !isHotLead && sourceLabel ? <span aria-hidden>·</span> : null}
|
||||
{sourceLabel ? <ListCardMeta>{sourceLabel}</ListCardMeta> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{urgencyBadges.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{urgencyBadges.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>
|
||||
) : 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}
|
||||
|
||||
{lastActivity ? (
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
Last activity {lastActivity}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||
|
||||
export interface InterestRow {
|
||||
id: string;
|
||||
@@ -26,31 +28,18 @@ export interface InterestRow {
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
/** Surfaced by listInterests for the row-level sales-triage signals
|
||||
* (last-activity relative time, comment-icon, urgency badges). */
|
||||
updatedAt?: string;
|
||||
dateLastContact?: string | null;
|
||||
dateEoiSent?: string | null;
|
||||
dateDepositReceived?: string | null;
|
||||
eoiStatus?: string | null;
|
||||
outcome?: string | null;
|
||||
notesCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
in_communication: 'bg-sky-100 text-sky-700',
|
||||
visited: 'bg-violet-100 text-violet-700',
|
||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
||||
contract: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
@@ -80,15 +69,29 @@ export function getInterestColumns({
|
||||
id: 'clientName',
|
||||
accessorKey: 'clientName',
|
||||
header: 'Client',
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row }) => {
|
||||
const notesCount = row.original.notesCount ?? 0;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
className="truncate font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.clientName ?? '—'}
|
||||
</Link>
|
||||
),
|
||||
{notesCount > 0 ? (
|
||||
<span
|
||||
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
className="inline-flex items-center text-muted-foreground"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'berthMooringNumber',
|
||||
@@ -113,14 +116,31 @@ export function getInterestColumns({
|
||||
id: 'pipelineStage',
|
||||
accessorKey: 'pipelineStage',
|
||||
header: 'Stage',
|
||||
cell: ({ getValue }) => {
|
||||
const stage = getValue() as string;
|
||||
cell: ({ row }) => {
|
||||
const stage = row.original.pipelineStage;
|
||||
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`}
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||||
>
|
||||
{STAGE_LABELS[stage] ?? stage}
|
||||
{stageLabel(stage)}
|
||||
</span>
|
||||
{badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
aria-label={b.detail}
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -174,14 +194,24 @@ export function getInterestColumns({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
||||
// Sales-triage default: prefer the explicit dateLastContact, fall back
|
||||
// to updatedAt. Sortable on dateLastContact server-side; the column
|
||||
// header label ("Last activity") makes the fallback semantics clear.
|
||||
id: 'dateLastContact',
|
||||
accessorKey: 'dateLastContact',
|
||||
header: 'Last activity',
|
||||
cell: ({ row }) => {
|
||||
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||
if (!lastIso) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const d = new Date(lastIso);
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -205,10 +235,7 @@ export function getInterestColumns({
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Archive, RotateCcw, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Pencil,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Trophy,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,33 +23,35 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
|
||||
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
|
||||
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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',
|
||||
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
|
||||
lost_other_marina: { label: 'Lost — other marina', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_unqualified: { label: 'Lost — unqualified', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_no_response: { label: 'Lost — no response', className: 'bg-rose-100 text-rose-700' },
|
||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
in_communication: 'bg-sky-100 text-sky-700',
|
||||
visited: 'bg-violet-100 text-violet-700',
|
||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
||||
contract: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
|
||||
// renders as a closed-state badge instead of falling back to the open-state
|
||||
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
|
||||
function resolveOutcomeBadge(outcome: string | null | undefined) {
|
||||
if (!outcome) return null;
|
||||
const known = OUTCOME_BADGE[outcome];
|
||||
if (known) return known;
|
||||
const isLoss = outcome.startsWith('lost');
|
||||
return {
|
||||
label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
general_interest: 'General',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
hot_lead: 'Hot Lead',
|
||||
};
|
||||
@@ -49,6 +62,16 @@ interface InterestDetailHeaderProps {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Primary contact channels resolved from the linked client. The header
|
||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||
* doesn't have to navigate to the client page just to reach out. */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientPrimaryPhone?: string | null;
|
||||
clientPrimaryPhoneE164?: string | null;
|
||||
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
||||
* doesn't have to remember to check /reminders. */
|
||||
activeReminderCount?: number;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
@@ -58,17 +81,50 @@ interface InterestDetailHeaderProps {
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
archivedAt: string | null;
|
||||
outcome?: string | null;
|
||||
outcomeReason?: string | null;
|
||||
dateLastContact?: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function formatLastContactAge(iso: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
if (days <= 0) return 'today';
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [stageOpen, setStageOpen] = useState(false);
|
||||
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
const isClosed = !!interest.outcome;
|
||||
|
||||
// Contact deep-links — resolved from the linked client's primary channels.
|
||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||
// stripping non-digits from the display value when the canonical form is
|
||||
// missing.
|
||||
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||
: interest.clientPrimaryPhone
|
||||
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
||||
@@ -88,13 +144,50 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
},
|
||||
});
|
||||
|
||||
const meta: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
if (interest.berthMooringNumber) {
|
||||
meta.push({
|
||||
key: 'berth',
|
||||
node: (
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Berth {interest.berthMooringNumber}
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (interest.leadCategory) {
|
||||
meta.push({
|
||||
key: 'cat',
|
||||
node: <span>{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}</span>,
|
||||
});
|
||||
}
|
||||
if (interest.source) {
|
||||
meta.push({
|
||||
key: 'src',
|
||||
node: <span className="capitalize">{interest.source}</span>,
|
||||
});
|
||||
}
|
||||
if (interest.dateLastContact) {
|
||||
meta.push({
|
||||
key: 'last',
|
||||
node: (
|
||||
<span className="text-foreground/70">
|
||||
Last contact {formatLastContactAge(interest.dateLastContact)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailHeaderStrip>
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
<h1 className="truncate text-lg font-bold text-foreground sm:text-xl">
|
||||
{interest.clientName ?? 'Unknown Client'}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
@@ -102,95 +195,227 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
{outcomeBadge ? (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
outcomeBadge.className,
|
||||
)}
|
||||
title={interest.outcomeReason ?? undefined}
|
||||
>
|
||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
||||
{outcomeBadge.label}
|
||||
</span>
|
||||
) : (
|
||||
<PermissionGate
|
||||
resource="interests"
|
||||
action="change_stage"
|
||||
fallback={
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
||||
{interest.pipelineStage}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineStagePicker
|
||||
interestId={interest.id}
|
||||
currentStage={interest.pipelineStage}
|
||||
className="-ml-2.5"
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
{(interest.activeReminderCount ?? 0) > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
title={`${interest.activeReminderCount} pending reminder${
|
||||
interest.activeReminderCount === 1 ? '' : 's'
|
||||
}`}
|
||||
>
|
||||
<AlarmClock className="size-3" />
|
||||
{interest.activeReminderCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||
{interest.berthMooringNumber && (
|
||||
<span>
|
||||
Berth:{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{interest.berthMooringNumber}
|
||||
</Link>
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{meta.map((m, i) => (
|
||||
<span key={m.key}>
|
||||
{i > 0 ? (
|
||||
<span aria-hidden className="mx-1.5">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
{interest.leadCategory && (
|
||||
<span>
|
||||
Category:{' '}
|
||||
<span className="text-foreground">
|
||||
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
|
||||
) : null}
|
||||
{m.node}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{interest.source && (
|
||||
<span>
|
||||
Source: <span className="text-foreground capitalize">{interest.source}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{interest.tags && interest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{interest.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
||||
client without leaving the interest workspace. Resolved from
|
||||
the linked client's primary contact channels (server-side
|
||||
fetch in getInterestById). */}
|
||||
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientPrimaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||
>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`tel:${interest.clientPrimaryPhone}`}
|
||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||
>
|
||||
<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 on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||
buttons on desktop; Edit/Archive stay icon-only. On mobile,
|
||||
Won/Lost shrink to icon buttons to keep the cluster from
|
||||
wrapping. */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PermissionGate resource="interests" action="change_stage">
|
||||
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}>
|
||||
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
|
||||
Change Stage
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="interests" action="delete">
|
||||
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restore
|
||||
</>
|
||||
{isClosed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reopenMutation.mutate()}
|
||||
disabled={reopenMutation.isPending}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
|
||||
'hover:bg-foreground/5 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RefreshCcw className="size-3.5" />
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('won')}
|
||||
aria-label="Mark as won"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'border border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
'hover:bg-emerald-100',
|
||||
)}
|
||||
>
|
||||
<Trophy className="size-3.5" />
|
||||
<span className="hidden sm:inline">Mark won</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('lost')}
|
||||
aria-label="Close as lost"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'border border-rose-200 text-rose-700',
|
||||
'hover:bg-rose-50',
|
||||
)}
|
||||
>
|
||||
<XCircle className="size-3.5" />
|
||||
<span className="hidden sm:inline">Close as lost</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
aria-label="Edit interest"
|
||||
title="Edit interest"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="interests" action="delete">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
aria-label={isArchived ? 'Restore interest' : 'Archive interest'}
|
||||
title={isArchived ? 'Restore interest' : 'Archive interest'}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5',
|
||||
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
||||
)}
|
||||
>
|
||||
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
</DetailHeaderStrip>
|
||||
|
||||
{outcomeDialog && (
|
||||
<InterestOutcomeDialog
|
||||
interestId={interest.id}
|
||||
mode={outcomeDialog}
|
||||
open={outcomeDialog !== null}
|
||||
onOpenChange={(open) => !open && setOutcomeDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InterestForm
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
|
||||
/>
|
||||
|
||||
<InterestStagePicker
|
||||
open={stageOpen}
|
||||
onOpenChange={setStageOpen}
|
||||
interestId={interest.id}
|
||||
currentStage={interest.pipelineStage}
|
||||
/>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
@@ -14,6 +16,29 @@ interface InterestData {
|
||||
portId: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Linked client's primary email (display value). Powers the header
|
||||
* "Email" button and the EOI prereq checklist. */
|
||||
clientPrimaryEmail: string | null;
|
||||
/** Linked client's primary phone (display value). Powers the header
|
||||
* "Call" button. */
|
||||
clientPrimaryPhone: string | null;
|
||||
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
|
||||
* by wa.me to assemble the WhatsApp deep-link. */
|
||||
clientPrimaryPhoneE164: string | null;
|
||||
/** True when the linked client has any primary address row. Used by
|
||||
* the EOI prereq checklist on the Documents tab. */
|
||||
clientHasAddress: boolean;
|
||||
/** Surfaced for the bell badge on the detail header (pending/snoozed
|
||||
* reminders linked to this interest). */
|
||||
activeReminderCount?: number;
|
||||
/** Surfaced for the most-recent-note teaser on the Overview tab. */
|
||||
notesCount?: number;
|
||||
recentNote?: {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
@@ -37,6 +62,8 @@ interface InterestData {
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
outcome?: string | null;
|
||||
outcomeReason?: string | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
@@ -52,9 +79,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
const { data, isLoading } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
|
||||
(r) => r.data,
|
||||
),
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
@@ -65,17 +90,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
'interest:berthUnlinked': [['interests', interestId]],
|
||||
});
|
||||
|
||||
const tabs = data
|
||||
? getInterestTabs({ interestId, currentUserId, interest: data })
|
||||
: [];
|
||||
const { setChrome } = useMobileChrome();
|
||||
const titleForChrome: string | null = data?.clientName ?? null;
|
||||
useEffect(() => {
|
||||
setChrome({ title: titleForChrome, showBackButton: true });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
header={
|
||||
data ? (
|
||||
<InterestDetailHeader portSlug={portSlug} interest={data} />
|
||||
) : null
|
||||
}
|
||||
header={data ? <InterestDetailHeader portSlug={portSlug} interest={data} /> : null}
|
||||
tabs={tabs}
|
||||
defaultTab="overview"
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileSignature } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentList } from '@/components/documents/document-list';
|
||||
@@ -17,20 +18,29 @@ interface InterestData {
|
||||
yachtId?: string | null;
|
||||
berthId?: string | null;
|
||||
clientName?: string | null;
|
||||
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientHasAddress?: boolean;
|
||||
}
|
||||
|
||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||
|
||||
const { data: interestRes } = useQuery({
|
||||
// Same query key + queryFn shape as InterestDetail's parent query, so the
|
||||
// cache is consistent. (Mismatched shapes on the same key clobber each other
|
||||
// and the parent header degenerates to "Unknown Client".)
|
||||
const { data: interest } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const interest = interestRes?.data;
|
||||
|
||||
const prerequisites = {
|
||||
// Required (EOI Section 2 — top paragraph): name, address, email.
|
||||
hasName: Boolean(interest?.clientName),
|
||||
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||
hasAddress: Boolean(interest?.clientHasAddress),
|
||||
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
|
||||
hasYacht: Boolean(interest?.yachtId),
|
||||
hasBerth: Boolean(interest?.berthId),
|
||||
};
|
||||
@@ -39,12 +49,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentList interestId={interestId} />
|
||||
<DocumentList
|
||||
interestId={interestId}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||
<FileSignature className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">No documents yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate the EOI to send it for signing in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user