Compare commits
60 Commits
ba89b61b3f
...
feat/dedup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7574c3b575 | ||
|
|
4bcc7f8be6 | ||
|
|
18e5c124b0 | ||
|
|
8b077e1999 | ||
|
|
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 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -20,10 +20,18 @@ 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/
|
||||
.migration/
|
||||
|
||||
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();
|
||||
144
scripts/migrate-from-nocodb.ts
Normal file
144
scripts/migrate-from-nocodb.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* One-shot migration: legacy NocoDB Interests → new client/interest split.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale
|
||||
* Same, but tags the planned writes with the named port (matters for
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
* [Not yet implemented — apply phase comes in a follow-up PR.]
|
||||
*
|
||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
Apply phase. (Not yet implemented.)
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write to the new DB. (Not yet supported.)
|
||||
--port-slug <slug> Port slug to attach to all imported entities.
|
||||
Defaults to the first available port if omitted.
|
||||
--report <dir> Path to a previously-generated report dir
|
||||
(only used by --apply).
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify --dry-run or --apply');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.apply) {
|
||||
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.');
|
||||
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── Dry-run path ───────────────────────────────────────────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
console.log(`[migrate] Source: ${config.url}`);
|
||||
|
||||
console.log('[migrate] Fetching snapshot from NocoDB…');
|
||||
const start = Date.now();
|
||||
const snapshot = await fetchSnapshot(config);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
|
||||
);
|
||||
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root (the script itself
|
||||
// lives in scripts/; we want the .migration dir at the repo root).
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
const paths = resolveReportPaths(repoRoot);
|
||||
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Console summary ──────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
console.log('=== Migration Plan Summary ===');
|
||||
console.log(
|
||||
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
|
||||
);
|
||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||
console.log(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
console.log(` Full report: ${paths.summaryPath}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[migrate] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
|
||||
|
||||
export default function DuplicatesAdminPage() {
|
||||
return <DuplicatesReviewQueue />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
4
src/app/api/v1/admin/duplicates/[id]/dismiss/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { dismissHandler } from '../../handlers';
|
||||
|
||||
export const POST = withAuth(withPermission('clients', 'edit', dismissHandler));
|
||||
4
src/app/api/v1/admin/duplicates/[id]/merge/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/[id]/merge/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { confirmMergeHandler } from '../../handlers';
|
||||
|
||||
export const POST = withAuth(withPermission('clients', 'edit', confirmMergeHandler));
|
||||
160
src/app/api/v1/admin/duplicates/handlers.ts
Normal file
160
src/app/api/v1/admin/duplicates/handlers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
listPendingMergeCandidates,
|
||||
mergeClients,
|
||||
type MergeFieldChoices,
|
||||
} from '@/lib/services/client-merge.service';
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/duplicates
|
||||
*
|
||||
* Pending merge candidates for the current port, sorted by score.
|
||||
* Each row hydrates its two client summaries so the review-queue UI
|
||||
* can render side-by-side cards without an N+1 fetch.
|
||||
*/
|
||||
export async function listHandler(_req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||
try {
|
||||
const pairs = await listPendingMergeCandidates(ctx.portId);
|
||||
if (pairs.length === 0) return NextResponse.json({ data: [] });
|
||||
|
||||
const ids = Array.from(new Set(pairs.flatMap((p) => [p.clientAId, p.clientBId])));
|
||||
const clientRows = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
fullName: clients.fullName,
|
||||
archivedAt: clients.archivedAt,
|
||||
mergedIntoClientId: clients.mergedIntoClientId,
|
||||
createdAt: clients.createdAt,
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.id, ids));
|
||||
const clientById = new Map(clientRows.map((c) => [c.id, c]));
|
||||
|
||||
const data = pairs
|
||||
.map((p) => {
|
||||
const a = clientById.get(p.clientAId);
|
||||
const b = clientById.get(p.clientBId);
|
||||
if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive
|
||||
// Skip pairs where one side has already been merged or archived.
|
||||
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||
return {
|
||||
id: p.id,
|
||||
score: p.score,
|
||||
reasons: p.reasons,
|
||||
createdAt: p.createdAt,
|
||||
clientA: { id: a.id, fullName: a.fullName, createdAt: a.createdAt },
|
||||
clientB: { id: b.id, fullName: b.fullName, createdAt: b.createdAt },
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/duplicates/[id]/merge
|
||||
*
|
||||
* Body: { winnerId: string, fieldChoices?: MergeFieldChoices }
|
||||
*
|
||||
* Confirms a merge candidate. The winner is the one the user picked
|
||||
* to keep; the other side becomes the loser. Calls into the merge
|
||||
* service which is the only path that touches client_merge_log.
|
||||
*/
|
||||
export async function confirmMergeHandler(
|
||||
req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
winnerId?: string;
|
||||
fieldChoices?: MergeFieldChoices;
|
||||
};
|
||||
if (!body.winnerId) {
|
||||
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [candidate] = await db
|
||||
.select()
|
||||
.from(clientMergeCandidates)
|
||||
.where(
|
||||
and(
|
||||
eq(clientMergeCandidates.id, id),
|
||||
eq(clientMergeCandidates.portId, ctx.portId),
|
||||
eq(clientMergeCandidates.status, 'pending'),
|
||||
),
|
||||
);
|
||||
if (!candidate) throw new NotFoundError('Merge candidate');
|
||||
|
||||
const loserId =
|
||||
body.winnerId === candidate.clientAId
|
||||
? candidate.clientBId
|
||||
: body.winnerId === candidate.clientBId
|
||||
? candidate.clientAId
|
||||
: null;
|
||||
if (!loserId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'winnerId must match one of the candidate clients' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await mergeClients({
|
||||
winnerId: body.winnerId,
|
||||
loserId,
|
||||
mergedBy: ctx.userId,
|
||||
fieldChoices: body.fieldChoices,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/duplicates/[id]/dismiss
|
||||
*
|
||||
* Mark a merge candidate as dismissed. The background scoring job
|
||||
* skips dismissed pairs on subsequent runs (a future score increase
|
||||
* can re-create them).
|
||||
*/
|
||||
export async function dismissHandler(
|
||||
_req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const result = await db
|
||||
.update(clientMergeCandidates)
|
||||
.set({
|
||||
status: 'dismissed',
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: ctx.userId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(clientMergeCandidates.id, id),
|
||||
eq(clientMergeCandidates.portId, ctx.portId),
|
||||
eq(clientMergeCandidates.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.returning({ id: clientMergeCandidates.id });
|
||||
|
||||
if (result.length === 0) throw new NotFoundError('Merge candidate');
|
||||
return NextResponse.json({ data: { id: result[0]!.id, status: 'dismissed' } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
4
src/app/api/v1/admin/duplicates/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { listHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('clients', 'view', listHandler));
|
||||
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));
|
||||
160
src/app/api/v1/clients/match-candidates/handlers.ts
Normal file
160
src/app/api/v1/clients/match-candidates/handlers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches';
|
||||
import { normalizeEmail, normalizeName, normalizePhone } from '@/lib/dedup/normalize';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
/**
|
||||
* GET /api/v1/clients/match-candidates
|
||||
*
|
||||
* Query parameters (any combination):
|
||||
* email Free-text email; gets normalized server-side.
|
||||
* phone Free-text phone; gets normalized to E.164 server-side.
|
||||
* name Free-text full name; used for surname-token blocking.
|
||||
* country Optional ISO country hint (default: AI for Port Nimara).
|
||||
*
|
||||
* Returns the top candidates that scored above the soft-warn threshold,
|
||||
* each with a small client summary the form's suggestion card can
|
||||
* render. Confidence tiers and rules are applied server-side from the
|
||||
* port's `system_settings` (when wired) or sensible defaults otherwise.
|
||||
*
|
||||
* Used by `useDedupSuggestion` in the new-client form. Debounced on
|
||||
* the client; this endpoint must be cheap (single port pool fetch +
|
||||
* an in-memory dedup pass).
|
||||
*/
|
||||
export async function getMatchCandidatesHandler(
|
||||
req: Request,
|
||||
ctx: AuthContext,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const rawEmail = url.searchParams.get('email');
|
||||
const rawPhone = url.searchParams.get('phone');
|
||||
const rawName = url.searchParams.get('name');
|
||||
const country = (url.searchParams.get('country') ?? 'AI') as CountryCode;
|
||||
|
||||
const email = rawEmail ? normalizeEmail(rawEmail) : null;
|
||||
const phoneResult = rawPhone ? normalizePhone(rawPhone, country) : null;
|
||||
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||
|
||||
// If the caller didn't give us anything useful to match on, return empty
|
||||
// — short-circuit rather than scan every client for nothing.
|
||||
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
|
||||
// Build the input candidate.
|
||||
const input: MatchCandidate = {
|
||||
id: '__incoming__',
|
||||
fullName: nameResult?.display ?? null,
|
||||
surnameToken: nameResult?.surnameToken ?? null,
|
||||
emails: email ? [email] : [],
|
||||
phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
|
||||
countryIso: country,
|
||||
};
|
||||
|
||||
// Fetch the live pool for this port. We keep this O(N) over clients
|
||||
// since the dedup library does its own blocking; for ports with
|
||||
// thousands of clients we can later restrict by surname-token /
|
||||
// contact lookups, but for current scale the simple full-pool fetch
|
||||
// is fine.
|
||||
const liveClients = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
fullName: clients.fullName,
|
||||
nationalityIso: clients.nationalityIso,
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, ctx.portId)));
|
||||
|
||||
if (liveClients.length === 0) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
|
||||
const clientIds = liveClients.map((c) => c.id);
|
||||
const contactRows = await db
|
||||
.select({
|
||||
clientId: clientContacts.clientId,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueE164: clientContacts.valueE164,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(inArray(clientContacts.clientId, clientIds));
|
||||
|
||||
// Group contacts by client for the candidate map.
|
||||
const emailsByClient = new Map<string, string[]>();
|
||||
const phonesByClient = new Map<string, string[]>();
|
||||
for (const c of contactRows) {
|
||||
if (c.channel === 'email') {
|
||||
const arr = emailsByClient.get(c.clientId) ?? [];
|
||||
arr.push(c.value.toLowerCase());
|
||||
emailsByClient.set(c.clientId, arr);
|
||||
} else if (c.channel === 'phone' || c.channel === 'whatsapp') {
|
||||
if (c.valueE164) {
|
||||
const arr = phonesByClient.get(c.clientId) ?? [];
|
||||
arr.push(c.valueE164);
|
||||
phonesByClient.set(c.clientId, arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pool: MatchCandidate[] = liveClients.map((c) => {
|
||||
const named = normalizeName(c.fullName);
|
||||
return {
|
||||
id: c.id,
|
||||
fullName: c.fullName,
|
||||
surnameToken: named.surnameToken ?? null,
|
||||
emails: emailsByClient.get(c.id) ?? [],
|
||||
phonesE164: phonesByClient.get(c.id) ?? [],
|
||||
countryIso: (c.nationalityIso as CountryCode | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const matches = findClientMatches(input, pool, {
|
||||
highScore: 90,
|
||||
mediumScore: 50,
|
||||
});
|
||||
|
||||
// Only return medium+ — low-confidence noise isn't useful at the
|
||||
// create-form layer (background scoring queue picks those up).
|
||||
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||
if (useful.length === 0) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
|
||||
// Pull a quick summary for each surfaced candidate so the suggestion
|
||||
// card has enough to render ("Marcus Laurent · 2 interests · last
|
||||
// contact 9d ago").
|
||||
const summarizedIds = useful.map((m) => m.candidate.id);
|
||||
const interestCounts = await db
|
||||
.select({ clientId: interests.clientId })
|
||||
.from(interests)
|
||||
.where(inArray(interests.clientId, summarizedIds));
|
||||
const interestsByClient = new Map<string, number>();
|
||||
for (const r of interestCounts) {
|
||||
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const data = useful.map((m) => ({
|
||||
clientId: m.candidate.id,
|
||||
fullName: m.candidate.fullName,
|
||||
score: m.score,
|
||||
confidence: m.confidence,
|
||||
reasons: m.reasons,
|
||||
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
|
||||
emails: m.candidate.emails,
|
||||
phonesE164: m.candidate.phonesE164,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
4
src/app/api/v1/clients/match-candidates/route.ts
Normal file
4
src/app/api/v1/clients/match-candidates/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getMatchCandidatesHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('clients', 'view', getMatchCandidatesHandler));
|
||||
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>
|
||||
|
||||
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRight, GitMerge, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CandidatePair {
|
||||
id: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
createdAt: string;
|
||||
clientA: { id: string; fullName: string; createdAt: string };
|
||||
clientB: { id: string; fullName: string; createdAt: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin review queue for the dedup background scoring job.
|
||||
*
|
||||
* Lists every pending merge candidate (pairs where score >=
|
||||
* `dedup_review_queue_threshold`). For each pair the admin can:
|
||||
* - Pick a winner via the side-by-side card → confirms a merge
|
||||
* - Dismiss → removes from the queue (a future score increase
|
||||
* re-creates the pair on the next scoring run)
|
||||
*
|
||||
* Only minimal merge UI here: the user picks which side is the winner
|
||||
* (no per-field choice), and the loser archives. A richer side-by-side
|
||||
* field-merge dialog is a future enhancement.
|
||||
*/
|
||||
export function DuplicatesReviewQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: CandidatePair[] }>({
|
||||
queryKey: ['admin', 'duplicates'],
|
||||
queryFn: () => apiFetch<{ data: CandidatePair[] }>('/api/v1/admin/duplicates'),
|
||||
});
|
||||
|
||||
const pairs = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Duplicate clients"
|
||||
description={
|
||||
pairs.length === 0
|
||||
? 'No pending pairs to review.'
|
||||
: `${pairs.length} pair${pairs.length === 1 ? '' : 's'} flagged for review.`
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : pairs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="All clear"
|
||||
description="The background scoring job hasn't surfaced any potential duplicates yet."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{pairs.map((pair) => (
|
||||
<li key={pair.id}>
|
||||
<CandidateRow pair={pair} queryClient={queryClient} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateRow({
|
||||
pair,
|
||||
queryClient,
|
||||
}: {
|
||||
pair: CandidatePair;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<'merge' | 'dismiss' | null>(null);
|
||||
const [winnerId, setWinnerId] = useState<string>(pair.clientA.id);
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/admin/duplicates/${pair.id}/merge`, {
|
||||
method: 'POST',
|
||||
body: { winnerId },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
const loserName =
|
||||
winnerId === pair.clientA.id ? pair.clientB.fullName : pair.clientA.fullName;
|
||||
const winnerName =
|
||||
winnerId === pair.clientA.id ? pair.clientA.fullName : pair.clientB.fullName;
|
||||
toast.success(`Merged "${loserName}" into "${winnerName}"`);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Merge failed'),
|
||||
onSettled: () => setBusy(null),
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/admin/duplicates/${pair.id}/dismiss`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.message('Dismissed');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Dismiss failed'),
|
||||
onSettled: () => setBusy(null),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
score {pair.score}
|
||||
</span>{' '}
|
||||
<span className="text-xs text-muted-foreground">{pair.reasons.join(' · ')}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
flagged {new Date(pair.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto_1fr]">
|
||||
<ClientCard
|
||||
client={pair.clientA}
|
||||
isSelected={winnerId === pair.clientA.id}
|
||||
onSelect={() => setWinnerId(pair.clientA.id)}
|
||||
/>
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</div>
|
||||
<ClientCard
|
||||
client={pair.clientB}
|
||||
isSelected={winnerId === pair.clientB.id}
|
||||
onSelect={() => setWinnerId(pair.clientB.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBusy('merge');
|
||||
mergeMutation.mutate();
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<GitMerge className="mr-1 size-3.5" aria-hidden />
|
||||
Merge into selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBusy('dismiss');
|
||||
dismissMutation.mutate();
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<X className="mr-1 size-3.5" aria-hidden />
|
||||
Dismiss
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The unselected card becomes the loser; its interests + contacts move to the selected
|
||||
client and the original is archived.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientCard({
|
||||
client,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
client: CandidatePair['clientA'];
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
||||
: 'border-border hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{client.fullName}</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
Created {new Date(client.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{isSelected ? (
|
||||
<span className="mt-1 inline-block rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
|
||||
KEEP
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -170,7 +170,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 +182,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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -72,6 +73,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>
|
||||
@@ -161,6 +167,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]],
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
@@ -30,6 +31,12 @@ import type { CountryCode } from '@/lib/i18n/countries';
|
||||
interface ClientFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Optional callback fired when the dedup suggestion panel reports
|
||||
* the user picked an existing client. The form closes; parent is
|
||||
* responsible for navigating to the existing client's detail page
|
||||
* or opening the create-interest dialog pre-filled with that
|
||||
* clientId. Skipped in edit mode. */
|
||||
onUseExistingClient?: (clientId: string) => void;
|
||||
/** If provided, form is in edit mode */
|
||||
client?: {
|
||||
id: string;
|
||||
@@ -53,7 +60,7 @@ interface ClientFormProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: ClientFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!client;
|
||||
|
||||
@@ -143,6 +150,26 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||
{/* Dedup suggestion — only on the create path. Watches the
|
||||
live form values for email / phone / name and surfaces
|
||||
an existing client when one matches. The user can
|
||||
attach the new interest to that client instead of
|
||||
creating a duplicate. */}
|
||||
{!isEdit ? (
|
||||
<DedupSuggestionPanel
|
||||
email={watch('contacts')?.find((c) => c?.channel === 'email')?.value ?? null}
|
||||
phone={
|
||||
watch('contacts')?.find((c) => c?.channel === 'phone' || c?.channel === 'whatsapp')
|
||||
?.valueE164 ?? null
|
||||
}
|
||||
name={watch('fullName') ?? null}
|
||||
onUseExisting={(match) => {
|
||||
onUseExistingClient?.(match.clientId);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
183
src/components/clients/dedup-suggestion-panel.tsx
Normal file
183
src/components/clients/dedup-suggestion-panel.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Briefcase, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MatchData {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
score: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
reasons: string[];
|
||||
interestCount: number;
|
||||
emails: string[];
|
||||
phonesE164: string[];
|
||||
}
|
||||
|
||||
interface DedupSuggestionPanelProps {
|
||||
/** Free-text inputs from the in-flight new-client form. The panel
|
||||
* debounces them and queries /api/v1/clients/match-candidates. */
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
name?: string | null;
|
||||
/** Caller wants to attach the new interest to an existing client
|
||||
* rather than creating a new one. The form switches to
|
||||
* interest-only mode and pre-fills the client. */
|
||||
onUseExisting: (match: MatchData) => void;
|
||||
/** User explicitly said "create new anyway." Hide the panel until
|
||||
* they change input again. */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces existing clients that match the form's in-flight inputs.
|
||||
*
|
||||
* Renders nothing while inputs are short / no useful match found.
|
||||
* On a high-confidence match, the panel interrupts visually with a
|
||||
* solid border and a primary "Use this client" button.
|
||||
*
|
||||
* Wired into the new-client form. Skipped in edit mode.
|
||||
*/
|
||||
export function DedupSuggestionPanel({
|
||||
email,
|
||||
phone,
|
||||
name,
|
||||
onUseExisting,
|
||||
onDismiss,
|
||||
}: DedupSuggestionPanelProps) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Debounce inputs by 300ms so we don't fire on every keystroke. Keep
|
||||
// the latest debounced values in component state.
|
||||
const [debounced, setDebounced] = useState({
|
||||
email: email ?? '',
|
||||
phone: phone ?? '',
|
||||
name: name ?? '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
||||
// Clear the dismissed flag when inputs change — the user typed
|
||||
// something new, so the prior dismissal no longer applies.
|
||||
setDismissed(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [email, phone, name]);
|
||||
|
||||
const hasSomething =
|
||||
debounced.email.length > 3 || debounced.phone.length > 3 || debounced.name.length > 2;
|
||||
|
||||
const { data, isFetching } = useQuery<{ data: MatchData[] }>({
|
||||
queryKey: ['dedup-match-candidates', debounced],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (debounced.email) params.set('email', debounced.email);
|
||||
if (debounced.phone) params.set('phone', debounced.phone);
|
||||
if (debounced.name) params.set('name', debounced.name);
|
||||
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||
},
|
||||
enabled: hasSomething && !dismissed,
|
||||
// Same query is fine to cache for a minute — moves are slow at this layer.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
if (!hasSomething) return null;
|
||||
if (isFetching && !data) return null;
|
||||
const matches = data?.data ?? [];
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const top = matches[0]!;
|
||||
const isHigh = top.confidence === 'high';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3 mb-3 transition-colors',
|
||||
isHigh
|
||||
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
|
||||
: 'border-border bg-muted/40',
|
||||
)}
|
||||
data-testid="dedup-suggestion"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<AlertCircle
|
||||
className={cn(
|
||||
'size-5',
|
||||
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{isHigh
|
||||
? 'This looks like an existing client'
|
||||
: 'Possible match — check before creating'}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{top.fullName}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
isHigh
|
||||
? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{top.confidence}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{top.emails[0] ? <span className="truncate">{top.emails[0]}</span> : null}
|
||||
{top.phonesE164[0] ? <span>{top.phonesE164[0]}</span> : null}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Briefcase className="size-3" aria-hidden />
|
||||
{top.interestCount} {top.interestCount === 1 ? 'interest' : 'interests'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">{top.reasons.join(' · ')}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => onUseExisting(top)}
|
||||
data-testid="dedup-use-existing"
|
||||
>
|
||||
Use this client
|
||||
<ArrowRight className="ml-1 size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
onDismiss?.();
|
||||
}}
|
||||
data-testid="dedup-dismiss"
|
||||
>
|
||||
<X className="mr-1 size-3.5" aria-hidden />
|
||||
Create new anyway
|
||||
</Button>
|
||||
{matches.length > 1 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{matches.length - 1} other possible{' '}
|
||||
{matches.length - 1 === 1 ? 'match' : 'matches'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/clients/pipeline-constants.ts
Normal file
14
src/components/clients/pipeline-constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Re-export from the canonical source so legacy imports keep working.
|
||||
export {
|
||||
PIPELINE_STAGES,
|
||||
STAGE_LABELS,
|
||||
STAGE_BADGE,
|
||||
STAGE_DOT,
|
||||
STAGE_WEIGHTS,
|
||||
STAGE_TRANSITIONS,
|
||||
safeStage,
|
||||
stageLabel,
|
||||
stageBadgeClass,
|
||||
stageDotClass,
|
||||
type PipelineStage,
|
||||
} from '@/lib/constants';
|
||||
141
src/components/companies/company-card.tsx
Normal file
141
src/components/companies/company-card.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { Archive, Building2, Eye, Hash, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import type { CompanyRow } from './company-columns';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-800 border-green-300',
|
||||
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
dissolved: 'Dissolved',
|
||||
};
|
||||
|
||||
interface CompanyCardProps {
|
||||
company: CompanyRow;
|
||||
portSlug: string;
|
||||
onEdit: (company: CompanyRow) => void;
|
||||
onArchive: (company: CompanyRow) => void;
|
||||
}
|
||||
|
||||
export function CompanyCard({ company, portSlug, onEdit, onArchive }: CompanyCardProps) {
|
||||
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
|
||||
const statusColor =
|
||||
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||
|
||||
const country = company.incorporationCountryIso
|
||||
? getCountryName(company.incorporationCountryIso, 'en')
|
||||
: null;
|
||||
|
||||
const memberCount = company.memberCount ?? 0;
|
||||
const yachtCount = company.yachtCount ?? 0;
|
||||
const countParts: string[] = [];
|
||||
if (memberCount > 0)
|
||||
countParts.push(`${memberCount} ${memberCount === 1 ? 'member' : 'members'}`);
|
||||
if (yachtCount > 0) countParts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`);
|
||||
|
||||
// Skip legalName if it is identical to name or absent
|
||||
const showLegalName =
|
||||
company.legalName && company.legalName.toLowerCase() !== company.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
href={`/${portSlug}/companies/${company.id}`}
|
||||
ariaLabel={`Company ${company.name}`}
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${company.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${portSlug}/companies/${company.id}`}>
|
||||
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(company)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(company)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar icon={<Building2 className="h-5 w-5" />} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title row + spacer for actions button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{company.name}
|
||||
</h3>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Legal name subtitle */}
|
||||
{showLegalName ? (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{company.legalName}</p>
|
||||
) : null}
|
||||
|
||||
{/* Country + Tax ID meta line */}
|
||||
{country || company.taxId ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{country ? (
|
||||
<ListCardMeta icon={<MapPin className="h-3 w-3" />}>{country}</ListCardMeta>
|
||||
) : null}
|
||||
{company.taxId ? (
|
||||
<ListCardMeta icon={<Hash className="h-3 w-3" />}>{company.taxId}</ListCardMeta>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Member / yacht counts */}
|
||||
{countParts.length > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{countParts.join(' · ')}</p>
|
||||
) : null}
|
||||
|
||||
{/* Status pill */}
|
||||
{company.status ? (
|
||||
<div className="mt-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||
statusColor,
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-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>;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user