Compare commits
56 Commits
refactor/d
...
e2398099c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc | ||
|
|
868b1f40c0 | ||
|
|
dbbd03fd22 | ||
|
|
ba5fb6db5e | ||
|
|
886119cbde | ||
|
|
0d357731ad | ||
|
|
a75d4f5d69 | ||
|
|
0fb7920db5 | ||
|
|
16ad61ce15 | ||
|
|
d080bc52fa | ||
|
|
a653c8e039 | ||
|
|
7e8110b2ff | ||
|
|
9eadaf035e | ||
|
|
bcea28cd71 | ||
|
|
722491a9dd | ||
|
|
6009ccb7de | ||
|
|
71da6e8fdc | ||
|
|
c405124bc3 | ||
|
|
53cbee1d3d | ||
|
|
ac7f1db62c | ||
|
|
5d44f3cfa4 | ||
|
|
d0540dca55 | ||
|
|
0e9c24e222 | ||
|
|
3aba2181dc | ||
|
|
6237ad1567 | ||
|
|
34916d855e | ||
|
|
41ae8a328f | ||
|
|
1ff3160eac | ||
|
|
5698d742d3 | ||
|
|
e6ce265be0 | ||
|
|
19bc2f2a54 | ||
|
|
b0a11f1785 | ||
|
|
3cbf2444fe | ||
|
|
0330be1312 | ||
|
|
210360738d | ||
|
|
4df04e1a58 | ||
|
|
0c3baf04c5 | ||
|
|
79667b24da | ||
|
|
c4fdb29bbe | ||
|
|
38527d71fc | ||
|
|
3fbfba6598 | ||
|
|
e3a835675b | ||
|
|
1b085f81ed | ||
|
|
9f786fbcf3 | ||
|
|
906127a292 | ||
|
|
737b43589b | ||
|
|
fbb1f1f366 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -20,10 +20,17 @@ tsconfig.tsbuildinfo
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
.remember/
|
.remember/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
eoi/
|
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||||
|
/eoi/
|
||||||
|
|
||||||
# Brainstorming companion mockup files
|
# Brainstorming companion mockup files
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||||
/*.png
|
/*.png
|
||||||
|
|
||||||
|
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||||
|
/client-portal/
|
||||||
|
|
||||||
|
# Mobile audit screenshots — generated locally, regenerable
|
||||||
|
/.audit/
|
||||||
|
|||||||
Submodule client-portal deleted from 84f89f9409
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Mobile Optimization Design
|
||||||
|
|
||||||
|
**Status**: Design approved 2026-04-29 — pending plan.
|
||||||
|
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
|
||||||
|
**Branch base**: stacks on `refactor/data-model`.
|
||||||
|
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Background
|
||||||
|
|
||||||
|
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
|
||||||
|
|
||||||
|
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
|
||||||
|
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
|
||||||
|
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
|
||||||
|
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
|
||||||
|
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
|
||||||
|
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
|
||||||
|
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
|
||||||
|
|
||||||
|
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
|
||||||
|
|
||||||
|
## 2. Approach
|
||||||
|
|
||||||
|
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
|
||||||
|
|
||||||
|
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
|
||||||
|
|
||||||
|
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
|
||||||
|
|
||||||
|
### 2.1 Target iPhone viewport range
|
||||||
|
|
||||||
|
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
|
||||||
|
|
||||||
|
| Tier | Models | Viewport |
|
||||||
|
| ------------------------------------------ | ----------------------------------------------- | -------- |
|
||||||
|
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
|
||||||
|
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
|
||||||
|
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
|
||||||
|
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
|
||||||
|
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
|
||||||
|
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
|
||||||
|
|
||||||
|
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
|
||||||
|
|
||||||
|
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
|
||||||
|
|
||||||
|
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
|
||||||
|
|
||||||
|
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
|
||||||
|
|
||||||
|
## 3. Foundation PR
|
||||||
|
|
||||||
|
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
|
||||||
|
|
||||||
|
### 3.1 Infrastructure
|
||||||
|
|
||||||
|
- `viewport` export in `src/app/layout.tsx` — `width=device-width, initial-scale=1, viewport-fit=cover`.
|
||||||
|
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
|
||||||
|
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
|
||||||
|
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
|
||||||
|
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
|
||||||
|
|
||||||
|
### 3.2 Mobile shell
|
||||||
|
|
||||||
|
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
|
||||||
|
|
||||||
|
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
|
||||||
|
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
|
||||||
|
|
||||||
|
- **`<MobileTopbar>`**
|
||||||
|
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
|
||||||
|
|
||||||
|
- **`<MobileBottomTabs>`**
|
||||||
|
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
|
||||||
|
|
||||||
|
- **`<MoreSheet>`**
|
||||||
|
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
|
||||||
|
|
||||||
|
- **`<MobileLayoutProvider>`**
|
||||||
|
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
|
||||||
|
|
||||||
|
### 3.3 Primitives
|
||||||
|
|
||||||
|
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
|
||||||
|
|
||||||
|
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
|
||||||
|
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
|
||||||
|
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
|
||||||
|
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
|
||||||
|
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
|
||||||
|
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
|
||||||
|
|
||||||
|
### 3.4 Default style adjustments
|
||||||
|
|
||||||
|
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
|
||||||
|
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
|
||||||
|
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
|
||||||
|
|
||||||
|
### 3.5 Bundle impact
|
||||||
|
|
||||||
|
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
|
||||||
|
|
||||||
|
### 3.6 PWA assets
|
||||||
|
|
||||||
|
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
|
||||||
|
|
||||||
|
## 4. Per-page playbook
|
||||||
|
|
||||||
|
Once foundation lands, each page follows the same workflow:
|
||||||
|
|
||||||
|
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
|
||||||
|
2. Replace any `<Dialog>` with `<Sheet>`.
|
||||||
|
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
|
||||||
|
4. Replace the ad-hoc page header with `<PageHeader>`.
|
||||||
|
5. Replace ad-hoc action button rows with `<ActionRow>`.
|
||||||
|
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
|
||||||
|
7. User reviews live in the headed browser, points out tweaks, iterate.
|
||||||
|
|
||||||
|
Most pages take 5–15 minutes in this loop. Heavy pages (email inbox, documents hub) may take 30–60 because the embedded widgets need their own mobile treatment beyond the primitives.
|
||||||
|
|
||||||
|
## 5. Migration sequence
|
||||||
|
|
||||||
|
After foundation PR:
|
||||||
|
|
||||||
|
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
|
||||||
|
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
|
||||||
|
2. **List pages** (~1–2 days) — convert via `<DataView>` + per-page `cardRender`:
|
||||||
|
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
|
||||||
|
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
|
||||||
|
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
|
||||||
|
4. **Detail pages** (~1–2 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
|
||||||
|
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
|
||||||
|
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
|
||||||
|
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
|
||||||
|
6. **Portal** — same patterns, smaller scope:
|
||||||
|
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
|
||||||
|
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
|
||||||
|
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
|
||||||
|
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
|
||||||
|
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
|
||||||
|
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
|
||||||
|
|
||||||
|
## 7. Open questions
|
||||||
|
|
||||||
|
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
|
||||||
|
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
|
||||||
|
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
|
||||||
|
|
||||||
|
## 8. Files to create
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/use-is-mobile.ts
|
||||||
|
src/components/layout/mobile/
|
||||||
|
mobile-layout.tsx
|
||||||
|
mobile-topbar.tsx
|
||||||
|
mobile-bottom-tabs.tsx
|
||||||
|
more-sheet.tsx
|
||||||
|
mobile-layout-provider.tsx
|
||||||
|
src/components/shared/
|
||||||
|
sheet.tsx (new — vaul wrapper)
|
||||||
|
data-view.tsx (new — table↔card)
|
||||||
|
page-header.tsx (new)
|
||||||
|
action-row.tsx (new)
|
||||||
|
detail-page-shell.tsx (new)
|
||||||
|
filter-chips.tsx (new)
|
||||||
|
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
|
||||||
|
public/icon-192.png (placeholder PWA asset)
|
||||||
|
public/icon-512.png (placeholder PWA asset)
|
||||||
|
public/icon-512-maskable.png (placeholder PWA asset)
|
||||||
|
public/apple-touch-icon.png (placeholder PWA asset)
|
||||||
|
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
|
||||||
|
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Files to modify per page
|
||||||
|
|
||||||
|
Per the playbook in §4, each page typically needs:
|
||||||
|
|
||||||
|
- One swap of header markup → `<PageHeader>`.
|
||||||
|
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
|
||||||
|
- For detail pages: wrap in `<DetailPageShell>`.
|
||||||
|
- Replace `<Dialog>` imports with `<Sheet>`.
|
||||||
|
- No service, validator, query, or schema changes anywhere.
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.24.0",
|
"zod": "^3.24.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
"lint-staged": "^15.2.0",
|
"lint-staged": "^15.2.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
"react-grab": "^0.1.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ export default defineConfig({
|
|||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||||
|
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||||
|
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||||
|
name: 'mobile-audit',
|
||||||
|
testMatch: /audit\/mobile\.spec\.ts/,
|
||||||
|
dependencies: ['setup'],
|
||||||
|
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
|
||||||
|
// 30 min headroom keeps us well under the wall-clock cost.
|
||||||
|
timeout: 1_800_000,
|
||||||
|
use: {
|
||||||
|
headless: false,
|
||||||
|
launchOptions: { slowMo: 200 },
|
||||||
|
screenshot: 'off',
|
||||||
|
video: 'off',
|
||||||
|
trace: 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Don't start the dev server — we expect it to already be running
|
// Don't start the dev server — we expect it to already be running
|
||||||
|
|||||||
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@@ -206,6 +206,9 @@ importers:
|
|||||||
tesseract.js:
|
tesseract.js:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
vaul:
|
||||||
|
specifier: ^1.1.2
|
||||||
|
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -276,6 +279,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
react-grab:
|
||||||
|
specifier: ^0.1.32
|
||||||
|
version: 0.1.32(react@19.2.4)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||||
@@ -339,6 +345,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
|
|
||||||
|
'@antfu/ni@0.23.2':
|
||||||
|
resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1':
|
'@babel/helper-string-parser@7.27.1':
|
||||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -2077,6 +2087,10 @@ packages:
|
|||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
react-dom: '>=16.9.0'
|
react-dom: '>=16.9.0'
|
||||||
|
|
||||||
|
'@react-grab/cli@0.1.32':
|
||||||
|
resolution: {integrity: sha512-TI4SHATLH2yM1DMRXgH3dt/8b3Rj51BplDOqOQiHQKAMOuKVAR9WE2WGWJRT3LwFpl8BXR9ytAM9vrGDrB7QGw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2':
|
'@reduxjs/toolkit@2.11.2':
|
||||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2848,6 +2862,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bippy@0.5.39:
|
||||||
|
resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0.1'
|
||||||
|
|
||||||
block-stream2@2.1.0:
|
block-stream2@2.1.0:
|
||||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||||
|
|
||||||
@@ -2953,6 +2972,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cli-spinners@2.9.2:
|
||||||
|
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
cli-truncate@4.0.0:
|
cli-truncate@4.0.0:
|
||||||
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3008,6 +3031,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
commander@14.0.3:
|
||||||
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
commander@4.1.1:
|
commander@4.1.1:
|
||||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3982,6 +4009,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-interactive@2.0.0:
|
||||||
|
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-map@2.0.3:
|
is-map@2.0.3:
|
||||||
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4030,6 +4061,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-unicode-supported@1.3.0:
|
||||||
|
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
is-unicode-supported@2.1.0:
|
||||||
|
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
is-url@1.2.4:
|
is-url@1.2.4:
|
||||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
@@ -4121,6 +4160,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1:
|
||||||
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -4128,6 +4170,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kleur@3.0.3:
|
||||||
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
kysely@0.28.11:
|
kysely@0.28.11:
|
||||||
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -4274,6 +4320,10 @@ packages:
|
|||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
|
log-symbols@6.0.0:
|
||||||
|
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
log-update@6.1.0:
|
log-update@6.1.0:
|
||||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4582,6 +4632,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
ora@8.2.0:
|
||||||
|
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4781,6 +4835,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
|
prompts@2.4.2:
|
||||||
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -5071,6 +5129,15 @@ packages:
|
|||||||
react-fast-compare@3.2.2:
|
react-fast-compare@3.2.2:
|
||||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||||
|
|
||||||
|
react-grab@0.1.32:
|
||||||
|
resolution: {integrity: sha512-ODZkzu4zjwX/5a1VxTdIkagPD6uPnp8IkSN2v5FDgFMZkH5r/YEMq43hIsdpHV5/R2ymqS9zLxp4H7SNSRx5ng==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-hook-form@7.71.2:
|
react-hook-form@7.71.2:
|
||||||
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -5357,6 +5424,9 @@ packages:
|
|||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
sisteransi@1.0.5:
|
||||||
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
slice-ansi@5.0.0:
|
slice-ansi@5.0.0:
|
||||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -5369,6 +5439,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
smol-toml@1.6.1:
|
||||||
|
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
socket.io-adapter@2.5.6:
|
||||||
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
||||||
|
|
||||||
@@ -5431,6 +5505,10 @@ packages:
|
|||||||
std-env@4.0.0:
|
std-env@4.0.0:
|
||||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||||
|
|
||||||
|
stdin-discarder@0.2.2:
|
||||||
|
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5763,6 +5841,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
vaul@1.1.2:
|
||||||
|
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
@@ -6065,6 +6149,8 @@ snapshots:
|
|||||||
resize-observer-polyfill: 1.5.1
|
resize-observer-polyfill: 1.5.1
|
||||||
throttle-debounce: 5.0.2
|
throttle-debounce: 5.0.2
|
||||||
|
|
||||||
|
'@antfu/ni@0.23.2': {}
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
@@ -7467,6 +7553,17 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@react-grab/cli@0.1.32':
|
||||||
|
dependencies:
|
||||||
|
'@antfu/ni': 0.23.2
|
||||||
|
commander: 14.0.3
|
||||||
|
ignore: 7.0.5
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
ora: 8.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
prompts: 2.4.2
|
||||||
|
smol-toml: 1.6.1
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -8233,6 +8330,10 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bippy@0.5.39(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
block-stream2@2.1.0:
|
block-stream2@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
@@ -8353,6 +8454,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
|
|
||||||
|
cli-spinners@2.9.2: {}
|
||||||
|
|
||||||
cli-truncate@4.0.0:
|
cli-truncate@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
slice-ansi: 5.0.0
|
slice-ansi: 5.0.0
|
||||||
@@ -8409,6 +8512,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@13.1.0: {}
|
commander@13.1.0: {}
|
||||||
|
|
||||||
|
commander@14.0.3: {}
|
||||||
|
|
||||||
commander@4.1.1: {}
|
commander@4.1.1: {}
|
||||||
|
|
||||||
component-classes@1.2.6:
|
component-classes@1.2.6:
|
||||||
@@ -9561,6 +9666,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-interactive@2.0.0: {}
|
||||||
|
|
||||||
is-map@2.0.3: {}
|
is-map@2.0.3: {}
|
||||||
|
|
||||||
is-negative-zero@2.0.3: {}
|
is-negative-zero@2.0.3: {}
|
||||||
@@ -9604,6 +9711,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
which-typed-array: 1.1.20
|
which-typed-array: 1.1.20
|
||||||
|
|
||||||
|
is-unicode-supported@1.3.0: {}
|
||||||
|
|
||||||
|
is-unicode-supported@2.1.0: {}
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
is-url@1.2.4: {}
|
||||||
|
|
||||||
is-weakmap@2.0.2: {}
|
is-weakmap@2.0.2: {}
|
||||||
@@ -9685,6 +9796,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1: {}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -9696,6 +9809,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
kysely@0.28.11: {}
|
kysely@0.28.11: {}
|
||||||
|
|
||||||
language-subtag-registry@0.3.23: {}
|
language-subtag-registry@0.3.23: {}
|
||||||
@@ -9823,6 +9938,11 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
|
log-symbols@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
chalk: 5.6.2
|
||||||
|
is-unicode-supported: 1.3.0
|
||||||
|
|
||||||
log-update@6.1.0:
|
log-update@6.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-escapes: 7.3.0
|
ansi-escapes: 7.3.0
|
||||||
@@ -10121,6 +10241,18 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
ora@8.2.0:
|
||||||
|
dependencies:
|
||||||
|
chalk: 5.6.2
|
||||||
|
cli-cursor: 5.0.0
|
||||||
|
cli-spinners: 2.9.2
|
||||||
|
is-interactive: 2.0.0
|
||||||
|
is-unicode-supported: 2.1.0
|
||||||
|
log-symbols: 6.0.0
|
||||||
|
stdin-discarder: 0.2.2
|
||||||
|
string-width: 7.2.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
@@ -10313,6 +10445,11 @@ snapshots:
|
|||||||
|
|
||||||
process@0.11.10: {}
|
process@0.11.10: {}
|
||||||
|
|
||||||
|
prompts@2.4.2:
|
||||||
|
dependencies:
|
||||||
|
kleur: 3.0.3
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -10728,6 +10865,13 @@ snapshots:
|
|||||||
|
|
||||||
react-fast-compare@3.2.2: {}
|
react-fast-compare@3.2.2: {}
|
||||||
|
|
||||||
|
react-grab@0.1.32(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@react-grab/cli': 0.1.32
|
||||||
|
bippy: 0.5.39(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react-hook-form@7.71.2(react@19.2.4):
|
react-hook-form@7.71.2(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -11075,6 +11219,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
slice-ansi@5.0.0:
|
slice-ansi@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
@@ -11087,6 +11233,8 @@ snapshots:
|
|||||||
|
|
||||||
smart-buffer@4.2.0: {}
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
|
smol-toml@1.6.1: {}
|
||||||
|
|
||||||
socket.io-adapter@2.5.6:
|
socket.io-adapter@2.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -11167,6 +11315,8 @@ snapshots:
|
|||||||
|
|
||||||
std-env@4.0.0: {}
|
std-env@4.0.0: {}
|
||||||
|
|
||||||
|
stdin-discarder@0.2.2: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -11584,6 +11734,15 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.2.2
|
'@types/d3-array': 3.2.2
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
BIN
public/icon-512-maskable.png
Normal file
BIN
public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
40
scripts/dev-set-password.ts
Normal file
40
scripts/dev-set-password.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Dev helper: set a user's password directly (bypasses email reset).
|
||||||
|
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { hashPassword } from 'better-auth/crypto';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { user, account } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [, , email, password] = process.argv;
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
|
||||||
|
if (!u) {
|
||||||
|
console.error(`User not found: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
const result = await db
|
||||||
|
.update(account)
|
||||||
|
.set({ password: hash, updatedAt: new Date() })
|
||||||
|
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||||
|
.returning({ id: account.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.error(`No credential account row for ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function BackupManagementPage() {
|
export default function BackupManagementPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Backup Management" description="Manage system backups and restoration" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
|
||||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const FIELDS: SettingFieldDef[] = [
|
const FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -47,13 +48,10 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
export default function BrandingSettingsPage() {
|
export default function BrandingSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Branding</h1>
|
title="Branding"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||||
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell
|
/>
|
||||||
and outgoing email templates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Identity"
|
title="Identity"
|
||||||
description="App name, logo, and primary color."
|
description="App name, logo, and primary color."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const API_FIELDS: SettingFieldDef[] = [
|
const API_FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -48,13 +49,10 @@ const EOI_FIELDS: SettingFieldDef[] = [
|
|||||||
export default function DocumensoSettingsPage() {
|
export default function DocumensoSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Documenso & EOI</h1>
|
title="Documenso & EOI"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="API credentials and default EOI generation pathway. Use the test-connection button to verify a saved configuration before relying on it."
|
||||||
API credentials and default EOI generation pathway. Use the test-connection button to
|
/>
|
||||||
verify a saved configuration before relying on it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Documenso API"
|
title="Documenso API"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const FIELDS: SettingFieldDef[] = [
|
const FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -79,13 +80,10 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
export default function EmailSettingsPage() {
|
export default function EmailSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Email Settings</h1>
|
title="Email Settings"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||||
Per-port outgoing email configuration. SMTP credentials and the From address default to
|
/>
|
||||||
environment variables when these fields are blank.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="From address & signature"
|
title="From address & signature"
|
||||||
description="Identity headers and shared HTML used by system-generated emails."
|
description="Identity headers and shared HTML used by system-generated emails."
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function DataImportPage() {
|
export default function DataImportPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Data Import" description="Import data from external sources" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
|
||||||
<p className="text-muted-foreground">Import data from external sources</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function InvitationsPage() {
|
export default function InvitationsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Invitations</h1>
|
title="Invitations"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
|
||||||
Send a single-use invitation to a new CRM user. The recipient sets their own password via
|
/>
|
||||||
the link in the email.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<InvitationsManager />
|
<InvitationsManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||||
|
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||||
|
*/
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile?.isSuperAdmin) {
|
||||||
|
redirect(`/${portSlug}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="Onboarding" description="Guided setup for new port configurations" />
|
||||||
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
|
|
||||||
<p className="text-muted-foreground">Guided setup for new port configurations</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
interface AdminSection {
|
interface AdminSection {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -165,13 +166,10 @@ export default async function AdminLandingPage({
|
|||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Administration</h1>
|
title="Administration"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||||
Per-port configuration and system administration. Each card below opens a dedicated
|
/>
|
||||||
settings page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{SECTIONS.map((s) => {
|
{SECTIONS.map((s) => {
|
||||||
const Icon = s.icon;
|
const Icon = s.icon;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SettingsFormCard,
|
SettingsFormCard,
|
||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
@@ -53,14 +54,10 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
|
|||||||
export default function ReminderSettingsPage() {
|
export default function ReminderSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold">Reminders</h1>
|
title="Reminders"
|
||||||
<p className="text-sm text-muted-foreground">
|
description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
|
||||||
Default reminder behaviour for new interests and the optional daily-digest delivery
|
/>
|
||||||
window. Individual users can still configure their own digest preferences in Notifications
|
|
||||||
→ Preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Defaults for new interests"
|
title="Defaults for new interests"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
export default function ScheduledReportsPage() {
|
export default function ScheduledReportsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
|
title="Scheduled Reports"
|
||||||
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
|
description="Configure and manage automated report delivery"
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -36,7 +37,11 @@ export default function WebhooksPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [regenerating, setRegenerating] = useState<string | null>(null);
|
const [regenerating, setRegenerating] = useState<string | null>(null);
|
||||||
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null);
|
const [newSecret, setNewSecret] = useState<{
|
||||||
|
webhookId: string;
|
||||||
|
secret: string;
|
||||||
|
masked: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const loadWebhooks = useCallback(async () => {
|
const loadWebhooks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -98,15 +103,20 @@ export default function WebhooksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Webhooks"
|
||||||
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
|
description="Configure outgoing webhook integrations"
|
||||||
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
|
actions={
|
||||||
</div>
|
<Button
|
||||||
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
onClick={() => {
|
||||||
Add Webhook
|
setEditTarget(null);
|
||||||
</Button>
|
setFormOpen(true);
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
Add Webhook
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
@@ -116,7 +126,13 @@ export default function WebhooksPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Add a webhook to receive real-time notifications of CRM events.
|
Add a webhook to receive real-time notifications of CRM events.
|
||||||
</p>
|
</p>
|
||||||
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
setEditTarget(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Add Webhook
|
Add Webhook
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,17 +157,16 @@ export default function WebhooksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleToggleActive(webhook)}
|
|
||||||
>
|
|
||||||
{webhook.isActive ? 'Disable' : 'Enable'}
|
{webhook.isActive ? 'Disable' : 'Enable'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
|
onClick={() => {
|
||||||
|
setEditTarget(webhook);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -163,11 +178,7 @@ export default function WebhooksPage() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleExpand(webhook.id)}
|
|
||||||
>
|
|
||||||
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
{expandedId === webhook.id ? 'Collapse' : 'Details'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,18 +239,26 @@ export default function WebhooksPage() {
|
|||||||
onSuccess={loadWebhooks}
|
onSuccess={loadWebhooks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Delete "{deleteTarget?.name}"? This will also delete all delivery history. This action
|
Delete "{deleteTarget?.name}"? This will also delete all delivery history.
|
||||||
cannot be undone.
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
||||||
|
|
||||||
|
export default function BerthReservationsPage() {
|
||||||
|
return <BerthReservationsList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level loading UI for the client detail page. Renders while the
|
||||||
|
* server component resolves the session and the client component bootstraps
|
||||||
|
* its initial query — replaces the previous empty-header flash on direct
|
||||||
|
* URL visits.
|
||||||
|
*/
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header strip — title, badges, action buttons */}
|
||||||
|
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<div className="flex gap-2 border-b border-border pb-1">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column overview */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
|||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||||
|
import { ExpenseCard } from '@/components/expenses/expense-card';
|
||||||
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||||
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
||||||
setArchiveExpense(null);
|
setArchiveExpense(null);
|
||||||
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<ExpenseCard
|
||||||
|
expense={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditExpense}
|
||||||
|
onArchive={setArchiveExpense}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No expenses found"
|
title="No expenses found"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Upload, Loader2, ScanLine } from 'lucide-react';
|
import { Camera, Loader2, ScanLine, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -33,9 +35,16 @@ export default function ScanReceiptPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: 'Scan Receipt', showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [setChrome]);
|
||||||
|
|
||||||
// Editable fields from scan
|
// Editable fields from scan
|
||||||
const [establishment, setEstablishment] = useState('');
|
const [establishment, setEstablishment] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
@@ -94,7 +103,7 @@ export default function ScanReceiptPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
<div>
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-2xl font-bold">Scan Receipt</h1>
|
<h1 className="text-2xl font-bold">Scan Receipt</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Upload a receipt image and we will extract the expense details automatically.
|
Upload a receipt image and we will extract the expense details automatically.
|
||||||
@@ -109,28 +118,44 @@ export default function ScanReceiptPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
{previewUrl ? (
|
||||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
{previewUrl ? (
|
>
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Receipt preview"
|
alt="Receipt preview"
|
||||||
className="max-h-64 mx-auto rounded object-contain"
|
className="max-h-64 mx-auto rounded object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button
|
||||||
Click to upload or drag and drop
|
type="button"
|
||||||
</p>
|
size="lg"
|
||||||
<p className="text-xs text-muted-foreground">
|
className="w-full h-14 sm:hidden"
|
||||||
JPEG, PNG, WebP up to 10MB
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Camera className="mr-2 h-5 w-5" />
|
||||||
)}
|
Take photo
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full h-14"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
|
<span className="sm:hidden">Choose from library</span>
|
||||||
|
<span className="hidden sm:inline">Click to upload or drag and drop</span>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
|
||||||
|
JPEG, PNG, WebP up to 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -138,6 +163,14 @@ export default function ScanReceiptPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{scanMutation.isPending && (
|
{scanMutation.isPending && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
|
||||||
@@ -222,25 +255,18 @@ export default function ScanReceiptPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
|
||||||
{(saveMutation.error as Error).message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/${params.portSlug}/expenses`)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={saveMutation.isPending || !amount}
|
disabled={saveMutation.isPending || !amount}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending && (
|
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save as Expense
|
Save as Expense
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useForm, FormProvider } from 'react-hook-form';
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -43,9 +45,35 @@ export default function NewInvoicePage() {
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
|
||||||
|
const prefilledKind =
|
||||||
|
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: 'New Invoice', showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [setChrome]);
|
||||||
|
|
||||||
|
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
||||||
|
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
|
||||||
|
// the review step. Doubles as the source of truth for the billing entity prefill.
|
||||||
|
const { data: prefilledInterest } = useQuery<{
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string | null;
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
};
|
||||||
|
}>({
|
||||||
|
queryKey: ['interest-prefill', prefilledInterestId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
|
||||||
|
enabled: !!prefilledInterestId,
|
||||||
|
});
|
||||||
|
|
||||||
const methods = useForm<CreateInvoiceInput>({
|
const methods = useForm<CreateInvoiceInput>({
|
||||||
resolver: zodResolver(createInvoiceSchema),
|
resolver: zodResolver(createInvoiceSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -53,6 +81,8 @@ export default function NewInvoicePage() {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
lineItems: [],
|
lineItems: [],
|
||||||
expenseIds: [],
|
expenseIds: [],
|
||||||
|
interestId: prefilledInterestId,
|
||||||
|
kind: prefilledKind,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +95,43 @@ export default function NewInvoicePage() {
|
|||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
|
const isDepositInvoice = watchedValues.kind === 'deposit';
|
||||||
|
|
||||||
|
// Resolve the selected billing entity to a human name so the review step
|
||||||
|
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
|
||||||
|
const billingEntityRef = watchedValues.billingEntity ?? null;
|
||||||
|
const { data: billingEntityName } = useQuery<{ name: string }>({
|
||||||
|
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!billingEntityRef) return { name: '' };
|
||||||
|
const path =
|
||||||
|
billingEntityRef.type === 'company'
|
||||||
|
? `/api/v1/companies/${billingEntityRef.id}`
|
||||||
|
: `/api/v1/clients/${billingEntityRef.id}`;
|
||||||
|
const res = await apiFetch<{
|
||||||
|
data: { fullName?: string; name?: string };
|
||||||
|
}>(path);
|
||||||
|
return {
|
||||||
|
name: res?.data?.fullName ?? res?.data?.name ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!billingEntityRef?.id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-fill the billing entity from the linked interest's client on launch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
||||||
|
setValue(
|
||||||
|
'billingEntity',
|
||||||
|
{ type: 'client', id: prefilledInterest.data.clientId },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// We only want this to run when the interest data first arrives.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [prefilledInterest?.data?.clientId]);
|
||||||
|
|
||||||
const lineItems = watchedValues.lineItems ?? [];
|
const lineItems = watchedValues.lineItems ?? [];
|
||||||
const subtotal = lineItems.reduce(
|
const subtotal = lineItems.reduce(
|
||||||
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
|
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
|
||||||
@@ -117,8 +184,8 @@ export default function NewInvoicePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header — desktop only; mobile gets the title from the topbar */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -157,6 +224,23 @@ export default function NewInvoicePage() {
|
|||||||
<CardTitle className="text-base">Client Information</CardTitle>
|
<CardTitle className="text-base">Client Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{isDepositInvoice ? (
|
||||||
|
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
|
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">Deposit invoice</p>
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
{prefilledInterest?.data
|
||||||
|
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||||
|
prefilledInterest.data.berthMooringNumber
|
||||||
|
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
|
||||||
|
: ''
|
||||||
|
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
||||||
|
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
Billing entity <span className="text-destructive">*</span>
|
Billing entity <span className="text-destructive">*</span>
|
||||||
@@ -294,9 +378,13 @@ export default function NewInvoicePage() {
|
|||||||
<p className="font-medium mt-0.5">
|
<p className="font-medium mt-0.5">
|
||||||
{watchedValues.billingEntity ? (
|
{watchedValues.billingEntity ? (
|
||||||
<>
|
<>
|
||||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
{billingEntityName?.name ? (
|
||||||
<span className="text-xs opacity-60">
|
<span>{billingEntityName.name}</span>
|
||||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
) : (
|
||||||
|
<span className="text-muted-foreground">Loading…</span>
|
||||||
|
)}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
({watchedValues.billingEntity.type})
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { InvoiceCard } from '@/components/invoices/invoice-card';
|
||||||
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
||||||
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sendMutation = useMutation({
|
const sendMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
||||||
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
},
|
},
|
||||||
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
|
|||||||
const columns = getInvoiceColumns({
|
const columns = getInvoiceColumns({
|
||||||
portSlug,
|
portSlug,
|
||||||
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
||||||
onRecordPayment: (invoice) =>
|
onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
||||||
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
|
||||||
onDelete: (invoice) => setDeleteTarget(invoice),
|
onDelete: (invoice) => setDeleteTarget(invoice),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<InvoiceCard
|
||||||
|
invoice={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onSend={(invoice) => sendMutation.mutate(invoice.id)}
|
||||||
|
onRecordPayment={(invoice) =>
|
||||||
|
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
|
||||||
|
}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No invoices found"
|
title="No invoices found"
|
||||||
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
|
|||||||
<h3 className="font-semibold">Delete Invoice?</h3>
|
<h3 className="font-semibold">Delete Invoice?</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This will permanently delete invoice{' '}
|
This will permanently delete invoice{' '}
|
||||||
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
|
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
|
||||||
This action cannot be undone.
|
action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { PortProvider } from '@/providers/port-provider';
|
|||||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
|
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||||
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -37,7 +39,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<RealtimeToasts />
|
||||||
|
{/* Desktop shell — hidden by CSS on mobile */}
|
||||||
|
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||||
@@ -57,6 +61,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile shell — hidden by CSS on desktop */}
|
||||||
|
<MobileLayout>{children}</MobileLayout>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</PortProvider>
|
</PortProvider>
|
||||||
|
|||||||
@@ -5,28 +5,19 @@ import type { Metadata } from 'next';
|
|||||||
import { getPortalSession } from '@/lib/portal/auth';
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
import { getClientInterests } from '@/lib/services/portal.service';
|
import { getClientInterests } from '@/lib/services/portal.service';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Interests' };
|
export const metadata: Metadata = { title: 'Interests' };
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'EOI / NDA Signed',
|
|
||||||
deposit_10pct: 'Deposit Received',
|
|
||||||
contract: 'Contract Stage',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
open: 'secondary',
|
open: 'secondary',
|
||||||
details_sent: 'secondary',
|
details_sent: 'secondary',
|
||||||
in_communication: 'default',
|
in_communication: 'default',
|
||||||
visited: 'default',
|
eoi_sent: 'default',
|
||||||
signed_eoi_nda: 'default',
|
eoi_signed: 'default',
|
||||||
deposit_10pct: 'default',
|
deposit_10pct: 'default',
|
||||||
contract: 'default',
|
contract_sent: 'default',
|
||||||
|
contract_signed: 'default',
|
||||||
completed: 'outline',
|
completed: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">Your berth enquiries and applications</p>
|
||||||
Your berth enquiries and applications
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{interests.length === 0 ? (
|
{interests.length === 0 ? (
|
||||||
@@ -56,10 +45,7 @@ export default async function PortalInterestsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{interests.map((interest) => (
|
{interests.map((interest) => (
|
||||||
<div
|
<div key={interest.id} className="bg-white rounded-lg border p-5">
|
||||||
key={interest.id}
|
|
||||||
className="bg-white rounded-lg border p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
|
<Badge variant={STAGE_VARIANT[safeStage(interest.pipelineStage)]}>
|
||||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
{stageLabel(interest.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
src/app/api/v1/berth-reservations/[id]/handlers.ts
Normal file
107
src/app/api/v1/berth-reservations/[id]/handlers.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { requirePermission } from '@/lib/auth/permissions';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
activate,
|
||||||
|
cancel,
|
||||||
|
endReservation,
|
||||||
|
getById,
|
||||||
|
} from '@/lib/services/berth-reservations.service';
|
||||||
|
|
||||||
|
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
||||||
|
|
||||||
|
const patchBodySchema = z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('activate'),
|
||||||
|
contractFileId: z.string().optional(),
|
||||||
|
effectiveDate: z.coerce.date().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('end'),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('cancel'),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const reservation = await getById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: reservation });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, patchBodySchema);
|
||||||
|
const meta = {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.action === 'activate') {
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await activate(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{
|
||||||
|
contractFileId: body.contractFileId,
|
||||||
|
effectiveDate: body.effectiveDate,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'end') {
|
||||||
|
// `end` is lifecycle progression; same privilege as activate.
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await endReservation(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate: body.endDate, notes: body.notes },
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === 'cancel'
|
||||||
|
requirePermission(ctx, 'reservations', 'cancel');
|
||||||
|
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await cancel(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,110 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { requirePermission } from '@/lib/auth/permissions';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import {
|
|
||||||
activate,
|
|
||||||
cancel,
|
|
||||||
endReservation,
|
|
||||||
getById,
|
|
||||||
} from '@/lib/services/berth-reservations.service';
|
|
||||||
|
|
||||||
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
|
||||||
|
|
||||||
const patchBodySchema = z.discriminatedUnion('action', [
|
|
||||||
z.object({
|
|
||||||
action: z.literal('activate'),
|
|
||||||
contractFileId: z.string().optional(),
|
|
||||||
effectiveDate: z.coerce.date().optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
action: z.literal('end'),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
notes: z.string().optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
action: z.literal('cancel'),
|
|
||||||
reason: z.string().optional(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const reservation = await getById(params.id!, ctx.portId);
|
|
||||||
return NextResponse.json({ data: reservation });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, patchBodySchema);
|
|
||||||
const meta = {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.action === 'activate') {
|
|
||||||
requirePermission(ctx, 'reservations', 'activate');
|
|
||||||
const result = await activate(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{
|
|
||||||
contractFileId: body.contractFileId,
|
|
||||||
effectiveDate: body.effectiveDate,
|
|
||||||
},
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.action === 'end') {
|
|
||||||
// `end` is lifecycle progression; same privilege as activate.
|
|
||||||
requirePermission(ctx, 'reservations', 'activate');
|
|
||||||
const result = await endReservation(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{ endDate: body.endDate, notes: body.notes },
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
}
|
|
||||||
|
|
||||||
// action === 'cancel'
|
|
||||||
requirePermission(ctx, 'reservations', 'cancel');
|
|
||||||
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
|
||||||
return NextResponse.json({ data: result });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
await cancel(
|
|
||||||
params.id!,
|
|
||||||
ctx.portId,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new NextResponse(null, { status: 204 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||||
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
||||||
|
|||||||
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||||
|
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port-scoped global list of reservations across all berths. Inner handler
|
||||||
|
* lives here so it can be invoked directly from integration tests without
|
||||||
|
* the `withAuth(withPermission(...))` wrappers (matches the convention
|
||||||
|
* used throughout `src/app/api/v1/*`).
|
||||||
|
*/
|
||||||
|
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req as never, listReservationsSchema);
|
||||||
|
const result = await listReservations(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/app/api/v1/berth-reservations/route.ts
Normal file
4
src/app/api/v1/berth-reservations/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { listHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||||
47
src/app/api/v1/companies/[id]/members/[mid]/handlers.ts
Normal file
47
src/app/api/v1/companies/[id]/members/[mid]/handlers.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
||||||
|
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateMembershipSchema);
|
||||||
|
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
let endDate = new Date();
|
||||||
|
const text = await req.text();
|
||||||
|
if (text.length > 0) {
|
||||||
|
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
||||||
|
endDate = parsed.endDate;
|
||||||
|
}
|
||||||
|
await endMembership(
|
||||||
|
params.mid!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate },
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,50 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { patchHandler, deleteHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
|
||||||
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
|
||||||
|
|
||||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, updateMembershipSchema);
|
|
||||||
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: updated });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
let endDate = new Date();
|
|
||||||
const text = await req.text();
|
|
||||||
if (text.length > 0) {
|
|
||||||
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
|
||||||
endDate = parsed.endDate;
|
|
||||||
}
|
|
||||||
await endMembership(
|
|
||||||
params.mid!,
|
|
||||||
ctx.portId,
|
|
||||||
{ endDate },
|
|
||||||
{
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new NextResponse(null, { status: 204 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||||
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { setPrimary } from '@/lib/services/company-memberships.service';
|
||||||
|
|
||||||
|
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const membership = await setPrimary(params.mid!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { setPrimaryHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { setPrimary } from '@/lib/services/company-memberships.service';
|
|
||||||
|
|
||||||
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const membership = await setPrimary(params.mid!, ctx.portId, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: membership });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
||||||
|
|||||||
40
src/app/api/v1/companies/[id]/members/handlers.ts
Normal file
40
src/app/api/v1/companies/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
||||||
|
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
const listQuerySchema = z.object({
|
||||||
|
activeOnly: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.transform((v) => v === 'true')
|
||||||
|
.default('true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const { activeOnly } = parseQuery(req, listQuerySchema);
|
||||||
|
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
||||||
|
return NextResponse.json({ data: memberships });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, addMembershipSchema);
|
||||||
|
const membership = await addMembership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,43 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { listHandler, createHandler } from './handlers';
|
||||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
|
||||||
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
|
||||||
|
|
||||||
const listQuerySchema = z.object({
|
|
||||||
activeOnly: z
|
|
||||||
.enum(['true', 'false'])
|
|
||||||
.transform((v) => v === 'true')
|
|
||||||
.default('true'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const { activeOnly } = parseQuery(req, listQuerySchema);
|
|
||||||
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
|
||||||
return NextResponse.json({ data: memberships });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, addMembershipSchema);
|
|
||||||
const membership = await addMembership(params.id!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: membership }, { status: 201 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||||
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
||||||
|
|||||||
18
src/app/api/v1/companies/autocomplete/handlers.ts
Normal file
18
src/app/api/v1/companies/autocomplete/handlers.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/companies.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const companies = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: companies });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { autocompleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { autocomplete } from '@/lib/services/companies.service';
|
|
||||||
|
|
||||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
|
||||||
try {
|
|
||||||
const q = req.nextUrl.searchParams.get('q');
|
|
||||||
if (!q) {
|
|
||||||
return NextResponse.json({ data: [] });
|
|
||||||
}
|
|
||||||
const companies = await autocomplete(ctx.portId, q);
|
|
||||||
return NextResponse.json({ data: companies });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
||||||
|
|||||||
41
src/app/api/v1/interests/[id]/outcome/route.ts
Normal file
41
src/app/api/v1/interests/[id]/outcome/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { clearInterestOutcome, setInterestOutcome } from '@/lib/services/interests.service';
|
||||||
|
import { clearOutcomeSchema, setOutcomeSchema } from '@/lib/validators/interests';
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('interests', 'change_stage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, setOutcomeSchema);
|
||||||
|
const result = await setInterestOutcome(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DELETE = withAuth(
|
||||||
|
withPermission('interests', 'change_stage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, clearOutcomeSchema);
|
||||||
|
const result = await clearInterestOutcome(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -7,6 +7,26 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { user } from '@/lib/db/schema/users';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
|
won: 'Won',
|
||||||
|
lost_other_marina: 'Lost — went to another marina',
|
||||||
|
lost_unqualified: 'Lost — unqualified',
|
||||||
|
lost_no_response: 'Lost — no response',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOC_EVENT_LABELS: Record<string, string> = {
|
||||||
|
sent: 'sent for signing',
|
||||||
|
completed: 'fully signed',
|
||||||
|
signed: 'signed by recipient',
|
||||||
|
rejected: 'rejected',
|
||||||
|
expired: 'expired',
|
||||||
|
cancelled: 'cancelled',
|
||||||
|
reminder_sent: 'reminder sent',
|
||||||
|
};
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +34,10 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
/** Resolved display name for `userId`. `'system'` for auto-events; null when
|
||||||
|
* the user has been deleted or the event has no actor. Falls back to
|
||||||
|
* email-localpart if the user has no display name. */
|
||||||
|
userName: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -33,12 +57,7 @@ export const GET = withAuth(
|
|||||||
const auditRows = await db
|
const auditRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(auditLogs)
|
.from(auditLogs)
|
||||||
.where(
|
.where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
|
||||||
and(
|
|
||||||
eq(auditLogs.entityType, 'interest'),
|
|
||||||
eq(auditLogs.entityId, interestId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(auditLogs.createdAt))
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|
||||||
@@ -67,28 +86,82 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
||||||
|
|
||||||
|
// Resolve display names for any `userId` that is a real user row (the
|
||||||
|
// sentinel value 'system' is used for auto-events and isn't joined).
|
||||||
|
const realUserIds = Array.from(
|
||||||
|
new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
|
||||||
|
);
|
||||||
|
const userRows =
|
||||||
|
realUserIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(inArray(user.id, realUserIds))
|
||||||
|
: [];
|
||||||
|
const userNameById = new Map<string, string>(
|
||||||
|
userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
|
||||||
|
);
|
||||||
|
const resolveUserName = (userId: string | null): string | null => {
|
||||||
|
if (!userId) return null;
|
||||||
|
if (userId === 'system') return 'system';
|
||||||
|
return userNameById.get(userId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
// Union and sort
|
// Union and sort
|
||||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: 'audit',
|
type: 'audit',
|
||||||
action: row.action,
|
action: row.action,
|
||||||
description: buildAuditDescription(row.action, row.newValue as Record<string, unknown> | null),
|
description: buildAuditDescription(
|
||||||
|
row.action,
|
||||||
|
row.newValue as Record<string, unknown> | null,
|
||||||
|
(row.metadata as Record<string, unknown>) ?? {},
|
||||||
|
row.userId,
|
||||||
|
),
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
|
userName: resolveUserName(row.userId),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({
|
const docEvents: TimelineEvent[] = docEventRows.map((row) => {
|
||||||
id: row.id,
|
const title = docTitles[row.documentId] ?? row.documentId;
|
||||||
type: 'document_event',
|
const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
|
||||||
action: row.eventType,
|
return {
|
||||||
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`,
|
id: row.id,
|
||||||
userId: null,
|
type: 'document_event',
|
||||||
createdAt: row.createdAt,
|
action: row.eventType,
|
||||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
description: `Document "${title}" ${action}`,
|
||||||
}));
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const allEvents = [...auditEvents, ...docEvents];
|
const allEvents = [...auditEvents, ...docEvents];
|
||||||
|
|
||||||
|
// Fallback: when no audit-log entries exist for this interest (typical
|
||||||
|
// for seed/imported data inserted directly into the table without going
|
||||||
|
// through the service), synthesize a "Created at <stage>" event so the
|
||||||
|
// tab isn't empty when the interest is clearly past `open`.
|
||||||
|
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
||||||
|
if (!hasCreateAudit) {
|
||||||
|
const stage = stageLabel(interest.pipelineStage);
|
||||||
|
const created = interest.createdAt ?? new Date();
|
||||||
|
allEvents.push({
|
||||||
|
id: `synth-${interest.id}-create`,
|
||||||
|
type: 'audit',
|
||||||
|
action: 'create',
|
||||||
|
description:
|
||||||
|
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
||||||
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
createdAt: created,
|
||||||
|
metadata: { synthetic: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
||||||
@@ -101,12 +174,39 @@ export const GET = withAuth(
|
|||||||
function buildAuditDescription(
|
function buildAuditDescription(
|
||||||
action: string,
|
action: string,
|
||||||
newValue: Record<string, unknown> | null,
|
newValue: Record<string, unknown> | null,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
userId: string | null,
|
||||||
): string {
|
): string {
|
||||||
if (action === 'create') return 'Interest created';
|
if (action === 'create') return 'Interest created';
|
||||||
if (action === 'archive') return 'Interest archived';
|
if (action === 'archive') return 'Interest archived';
|
||||||
if (action === 'restore') return 'Interest restored';
|
if (action === 'restore') return 'Interest restored';
|
||||||
|
|
||||||
|
const type = metadata.type;
|
||||||
|
|
||||||
|
if (type === 'outcome_set') {
|
||||||
|
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||||
|
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||||
|
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||||
|
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'outcome_cleared') {
|
||||||
|
const stage = (newValue?.pipelineStage as string | undefined) ?? '';
|
||||||
|
return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stage_change' && newValue?.pipelineStage) {
|
||||||
|
const stage = stageLabel(newValue.pipelineStage as string);
|
||||||
|
const reason = (newValue.reason as string | undefined) ?? '';
|
||||||
|
const auto = userId === 'system';
|
||||||
|
if (auto) {
|
||||||
|
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
||||||
|
}
|
||||||
|
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'update' && newValue?.pipelineStage) {
|
if (action === 'update' && newValue?.pipelineStage) {
|
||||||
return `Stage changed to "${newValue.pipelineStage}"`;
|
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
|
||||||
}
|
}
|
||||||
if (action === 'update') return 'Interest updated';
|
if (action === 'update') return 'Interest updated';
|
||||||
return action;
|
return action;
|
||||||
|
|||||||
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { savedViews } from '@/lib/db/schema';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||||
|
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the view and enforces ownership before mutating.
|
||||||
|
*
|
||||||
|
* Returns a 404 when the view does not exist (or lives in a different port)
|
||||||
|
* and a 403 when it belongs to a different user. The 404-before-403 split
|
||||||
|
* matches the rest of the API and avoids leaking the existence of another
|
||||||
|
* user's saved view via timing or status code.
|
||||||
|
*/
|
||||||
|
async function assertViewOwner(
|
||||||
|
id: string,
|
||||||
|
portId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NextResponse | null> {
|
||||||
|
const view = await db.query.savedViews.findFirst({
|
||||||
|
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!view) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (view.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchHandler(
|
||||||
|
req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||||
|
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||||
|
return NextResponse.json({ data: view });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHandler(
|
||||||
|
_req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||||
|
return NextResponse.json({ data: null }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { patchHandler, deleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
|
||||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
|
||||||
|
|
||||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
export const PATCH = withAuth(patchHandler);
|
||||||
try {
|
export const DELETE = withAuth(deleteHandler);
|
||||||
const id = params.id ?? '';
|
|
||||||
const body = await parseBody(req, updateSavedViewSchema);
|
|
||||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
|
||||||
return NextResponse.json({ data: view });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const id = params.id ?? '';
|
|
||||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
|
||||||
return NextResponse.json({ data: null }, { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
14
src/app/api/v1/yachts/[id]/ownership-history/handlers.ts
Normal file
14
src/app/api/v1/yachts/[id]/ownership-history/handlers.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const historyHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: history });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { historyHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
|
||||||
|
|
||||||
export const historyHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
|
||||||
return NextResponse.json({ data: history });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
||||||
|
|||||||
22
src/app/api/v1/yachts/[id]/transfer/handlers.ts
Normal file
22
src/app/api/v1/yachts/[id]/transfer/handlers.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { transferOwnership } from '@/lib/services/yachts.service';
|
||||||
|
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, transferOwnershipSchema);
|
||||||
|
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: yacht });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,24 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { transferHandler } from './handlers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { transferOwnership } from '@/lib/services/yachts.service';
|
|
||||||
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
|
||||||
|
|
||||||
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const body = await parseBody(req, transferOwnershipSchema);
|
|
||||||
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
|
||||||
userId: ctx.userId,
|
|
||||||
portId: ctx.portId,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ data: yacht });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
||||||
|
|||||||
18
src/app/api/v1/yachts/autocomplete/handlers.ts
Normal file
18
src/app/api/v1/yachts/autocomplete/handlers.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const yachts = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: yachts });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { autocompleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { autocomplete } from '@/lib/services/yachts.service';
|
|
||||||
|
|
||||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
|
||||||
try {
|
|
||||||
const q = req.nextUrl.searchParams.get('q');
|
|
||||||
if (!q) {
|
|
||||||
return NextResponse.json({ data: [] });
|
|
||||||
}
|
|
||||||
const yachts = await autocomplete(ctx.portId, q);
|
|
||||||
return NextResponse.json({ data: yachts });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
|||||||
import {
|
import {
|
||||||
handleRecipientSigned,
|
handleRecipientSigned,
|
||||||
handleDocumentCompleted,
|
handleDocumentCompleted,
|
||||||
|
handleDocumentExpired,
|
||||||
handleDocumentOpened,
|
handleDocumentOpened,
|
||||||
handleDocumentRejected,
|
handleDocumentRejected,
|
||||||
handleDocumentCancelled,
|
handleDocumentCancelled,
|
||||||
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'DOCUMENT_EXPIRED':
|
||||||
|
await handleDocumentExpired({ documentId: documensoId });
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,3 +127,45 @@
|
|||||||
@apply bg-muted-foreground/30;
|
@apply bg-muted-foreground/30;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Form-factor shell visibility ──────────────────────────────────────────
|
||||||
|
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
|
||||||
|
* the inactive one. The data-form-factor body attribute is set server-side
|
||||||
|
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||||
|
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||||||
|
*
|
||||||
|
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
|
||||||
|
* display, because the desktop shell uses Tailwind's `flex` class which would
|
||||||
|
* be overridden by `display: block` (same specificity, later cascade).
|
||||||
|
*/
|
||||||
|
[data-shell='mobile'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
[data-shell='desktop'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-shell='mobile'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-form-factor='mobile'] [data-shell='desktop'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body[data-form-factor='mobile'] [data-shell='mobile'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* React Query Devtools floating button collides with the bottom tab bar's
|
||||||
|
* "More" tab on mobile. The devtools panel itself remains accessible from
|
||||||
|
* desktop where the toggle is positioned out of the way of any UI.
|
||||||
|
*/
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
.tsqd-open-btn-container,
|
||||||
|
.tsqd-parent-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,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 { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -15,18 +18,52 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#1e2844',
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: 'Port Nimara CRM',
|
default: 'Port Nimara CRM',
|
||||||
template: '%s | Port Nimara CRM',
|
template: '%s | Port Nimara CRM',
|
||||||
},
|
},
|
||||||
description: 'Marina management system for Port Nimara',
|
description: 'Marina management system for Port Nimara',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
title: 'Port Nimara',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: '/apple-touch-icon.png',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const headerList = await headers();
|
||||||
|
const formFactor = classifyFormFactor(headerList.get('user-agent'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
|
<head>
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<Script
|
||||||
|
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-form-factor={formFactor}
|
||||||
|
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
</body>
|
</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,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { AuditLogCard } from './audit-log-card';
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -357,6 +358,7 @@ export function AuditLogList() {
|
|||||||
data={entries}
|
data={entries}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => <AuditLogCard entry={row.original} />}
|
||||||
emptyState={
|
emptyState={
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
open: 0.05,
|
open: 0.05,
|
||||||
details_sent: 0.1,
|
details_sent: 0.1,
|
||||||
in_communication: 0.2,
|
in_communication: 0.2,
|
||||||
signed_eoi_nda: 0.4,
|
eoi_sent: 0.4,
|
||||||
deposit_10pct: 0.6,
|
eoi_signed: 0.6,
|
||||||
contract: 0.8,
|
deposit_10pct: 0.75,
|
||||||
|
contract_sent: 0.85,
|
||||||
|
contract_signed: 0.95,
|
||||||
completed: 1.0,
|
completed: 1.0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'json',
|
type: 'json',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'eoi_signers',
|
||||||
|
label: 'EOI Signers',
|
||||||
|
description:
|
||||||
|
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
|
||||||
|
type: 'json',
|
||||||
|
defaultValue: {
|
||||||
|
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
|
||||||
|
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsManager() {
|
export function SettingsManager() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ServiceHealthCard } from './service-health-card';
|
import { ServiceHealthCard } from './service-health-card';
|
||||||
import { QueueOverview } from './queue-overview';
|
import { QueueOverview } from './queue-overview';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import type {
|
import type {
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
QueueStatus,
|
QueueStatus,
|
||||||
@@ -17,16 +18,14 @@ import type {
|
|||||||
export function SystemMonitoringDashboard() {
|
export function SystemMonitoringDashboard() {
|
||||||
const { data: healthData } = useQuery({
|
const { data: healthData } = useQuery({
|
||||||
queryKey: ['system', 'health'],
|
queryKey: ['system', 'health'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||||
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: queuesData } = useQuery({
|
const { data: queuesData } = useQuery({
|
||||||
queryKey: ['system', 'queues'],
|
queryKey: ['system', 'queues'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||||
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
@@ -47,11 +46,10 @@ export function SystemMonitoringDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
<PageHeader
|
||||||
<div>
|
title="System Monitoring"
|
||||||
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
|
description="Real-time health, queue status and connection tracking"
|
||||||
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service health */}
|
{/* Service health */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -79,10 +77,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div
|
<div key={i} className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,9 +119,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">{queues.reduce((sum, q) => sum + q.active, 0)}</p>
|
||||||
{queues.reduce((sum, q) => sum + q.active, 0)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -141,10 +134,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div
|
<div key={i} className="h-[110px] rounded-xl border bg-card animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="h-[110px] rounded-xl border bg-card animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -159,8 +149,7 @@ export function SystemMonitoringDashboard() {
|
|||||||
function RecentErrorsPanel() {
|
function RecentErrorsPanel() {
|
||||||
const { data: errorsData } = useQuery({
|
const { data: errorsData } = useQuery({
|
||||||
queryKey: ['system', 'errors'],
|
queryKey: ['system', 'errors'],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||||
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
149
src/components/admin/users/user-card.tsx
Normal file
149
src/components/admin/users/user-card.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||||
|
import {
|
||||||
|
ListCard,
|
||||||
|
ListCardAvatar,
|
||||||
|
ListCardMeta,
|
||||||
|
deriveInitials,
|
||||||
|
} from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
role: { id: string; name: string };
|
||||||
|
assignedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: UserRow;
|
||||||
|
onEdit: (user: UserRow) => void;
|
||||||
|
onRemove: (userId: string) => void;
|
||||||
|
isRemoving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
|
||||||
|
const initials = deriveInitials(user.displayName || user.email);
|
||||||
|
|
||||||
|
const accentClass = user.isSuperAdmin
|
||||||
|
? 'bg-violet-400'
|
||||||
|
: !user.isActive
|
||||||
|
? 'bg-slate-400'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href="#"
|
||||||
|
ariaLabel={`User: ${user.displayName}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${user.displayName}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onEdit(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<ConfirmationDialog
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
title="Remove User"
|
||||||
|
description={`Remove "${user.displayName}" from this port? They will lose access but their account remains.`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
onConfirm={() => onRemove(user.userId)}
|
||||||
|
loading={isRemoving}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar initials={initials} className={cn(!user.isActive && 'opacity-50')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
'truncate text-base font-semibold tracking-tight',
|
||||||
|
user.isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.displayName || user.email}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email subtitle — only when display name is shown as title */}
|
||||||
|
{user.displayName && user.displayName !== user.email ? (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="truncate">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Role + last login meta */}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
|
||||||
|
|
||||||
|
{user.lastLoginAt ? (
|
||||||
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
||||||
|
{formatDistanceToNow(new Date(user.lastLoginAt), { addSuffix: true })}
|
||||||
|
</ListCardMeta>
|
||||||
|
) : (
|
||||||
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>Never logged in</ListCardMeta>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + super-admin pills */}
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{!user.isActive ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{user.isSuperAdmin ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
|
||||||
|
Super Admin
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { UserCard } from './user-card';
|
||||||
import { UserForm } from './user-form';
|
import { UserForm } from './user-form';
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
@@ -152,6 +153,14 @@ export function UserList() {
|
|||||||
data={users}
|
data={users}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
getRowId={(row) => row.userId}
|
getRowId={(row) => row.userId}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<UserCard
|
||||||
|
user={row.original}
|
||||||
|
onEdit={handleEditUser}
|
||||||
|
onRemove={handleRemoveUser}
|
||||||
|
isRemoving={deletingId === row.original.userId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground">No users assigned to this port.</p>
|
<p className="text-muted-foreground">No users assigned to this port.</p>
|
||||||
|
|||||||
177
src/components/berths/berth-card.tsx
Normal file
177
src/components/berths/berth-card.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { BerthRow } from './berth-columns';
|
||||||
|
|
||||||
|
const STATUS_VARIANTS: Record<string, string> = {
|
||||||
|
available: 'bg-green-100 text-green-800 border-green-200',
|
||||||
|
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
|
sold: 'bg-red-100 text-red-800 border-red-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
available: 'Available',
|
||||||
|
under_offer: 'Under Offer',
|
||||||
|
sold: 'Sold',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT_CLASS: Record<string, string> = {
|
||||||
|
available: 'bg-emerald-400',
|
||||||
|
under_offer: 'bg-amber-400',
|
||||||
|
sold: 'bg-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrice(price: string, currency: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(price));
|
||||||
|
} catch {
|
||||||
|
return `${currency} ${price}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BerthCardProps {
|
||||||
|
berth: BerthRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthCard({ berth }: BerthCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
const accentClass = ACCENT_CLASS[berth.status] ?? 'bg-slate-300';
|
||||||
|
|
||||||
|
// Dimensions string
|
||||||
|
let dimText: string | null = null;
|
||||||
|
if (berth.lengthM || berth.widthM) {
|
||||||
|
const l = berth.lengthM ?? '?';
|
||||||
|
const w = berth.widthM ?? '?';
|
||||||
|
dimText = `${l}m × ${w}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
if (dimText) metaParts.push(dimText);
|
||||||
|
if (berth.price) metaParts.push(formatPrice(berth.price, berth.priceCurrency));
|
||||||
|
|
||||||
|
const tags = berth.tags ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/berths/${berth.id}`}
|
||||||
|
ariaLabel={`Berth ${berth.mooringNumber}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for berth ${berth.mooringNumber}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/${portSlug}/berths/${berth.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Activity className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{berth.mooringNumber}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area subtitle */}
|
||||||
|
{berth.area ? (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="truncate">{berth.area}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Dimensions · Price meta line */}
|
||||||
|
{metaParts.length > 0 ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||||
|
{metaParts.map((part, i) => (
|
||||||
|
<span key={part} className="inline-flex items-center gap-1">
|
||||||
|
{i > 0 ? <span aria-hidden>·</span> : null}
|
||||||
|
<ListCardMeta>{part}</ListCardMeta>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
statusColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
{tags.length > 2 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||||
|
+{tags.length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,7 +170,9 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Berth {berth.mooringNumber}</h1>
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground">
|
||||||
|
Berth {berth.mooringNumber}
|
||||||
|
</h1>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
||||||
>
|
>
|
||||||
@@ -180,7 +182,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
<PermissionGate resource="berths" action="edit">
|
<PermissionGate resource="berths" action="edit">
|
||||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { BerthDetailHeader } from './berth-detail-header';
|
import { BerthDetailHeader } from './berth-detail-header';
|
||||||
|
import { BerthForm } from './berth-form';
|
||||||
import { buildBerthTabs } from './berth-tabs';
|
import { buildBerthTabs } from './berth-tabs';
|
||||||
|
|
||||||
interface BerthDetailProps {
|
interface BerthDetailProps {
|
||||||
@@ -26,15 +30,45 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
|||||||
'berth:statusChanged': [['berth', berthId]],
|
'berth:statusChanged': [['berth', berthId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.mooringNumber ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
// Auto-open edit sheet when ?edit=true is present in the URL
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('edit') === 'true') {
|
||||||
|
setEditOpen(true);
|
||||||
|
// Strip the param without adding a history entry
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('edit');
|
||||||
|
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||||
|
// typedRoutes can't statically validate this dynamic path; cast is safe
|
||||||
|
// because we're always replacing within the same route segment.
|
||||||
|
router.replace(newUrl as never);
|
||||||
|
}
|
||||||
|
// Only run once on mount / when searchParams changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const berth = data as any;
|
const berth = data as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<>
|
||||||
isLoading={isLoading}
|
<DetailLayout
|
||||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
isLoading={isLoading}
|
||||||
tabs={berth ? buildBerthTabs(berth) : []}
|
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||||
defaultTab="overview"
|
tabs={berth ? buildBerthTabs(berth) : []}
|
||||||
/>
|
defaultTab="overview"
|
||||||
|
/>
|
||||||
|
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/berths/berth-interest-pulse.tsx
Normal file
166
src/components/berths/berth-interest-pulse.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ChevronRight, Users } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: InterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-overview pulse for the berth detail page. Lists the active
|
||||||
|
* interested parties with their stage + last activity, so the rep can do
|
||||||
|
* berth-level triage ("who's on this slip and how warm are they?")
|
||||||
|
* without clicking into the Interests tab.
|
||||||
|
*
|
||||||
|
* Borrows from the old Nuxt CRM's BerthDetailsModal "Interested Parties"
|
||||||
|
* pattern but uses the new at-a-glance signals (urgency badges, last
|
||||||
|
* activity).
|
||||||
|
*/
|
||||||
|
export function BerthInterestPulse({ berthId }: { berthId: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<InterestsResponse>(
|
||||||
|
`/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`,
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = data?.data ?? [];
|
||||||
|
const active = all.filter((i) => !i.archivedAt && !i.outcome);
|
||||||
|
const preview = active.slice(0, PREVIEW_LIMIT);
|
||||||
|
const more = active.length - preview.length;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Interested parties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground">No active interests on this berth.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{active.length}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{preview.map((i) => {
|
||||||
|
const lastIso = i.dateLastContact ?? i.updatedAt ?? null;
|
||||||
|
const lastActivity = lastIso
|
||||||
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
: null;
|
||||||
|
const urgency = computeUrgencyBadges(i);
|
||||||
|
const initials = (i.clientName ?? '?')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${i.id}`}
|
||||||
|
className="group flex items-center gap-3 px-1 py-2.5 transition-colors hover:bg-foreground/5 rounded-md -mx-1"
|
||||||
|
>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700">
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{i.clientName ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
stageBadgeClass(i.pipelineStage),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stageLabel(i.pipelineStage)}
|
||||||
|
</span>
|
||||||
|
{urgency.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
b.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lastActivity ? (
|
||||||
|
<p className="text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
Last activity {lastActivity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{more > 0 ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths/${berthId}?tab=interests`}
|
||||||
|
className="mt-2 inline-flex text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all {active.length} interests →
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { Bookmark } from 'lucide-react';
|
import { Bookmark } from 'lucide-react';
|
||||||
|
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
|
||||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
|
||||||
interface BerthInterestsTabProps {
|
interface BerthInterestsTabProps {
|
||||||
@@ -28,27 +29,10 @@ interface BerthInterestsTabProps {
|
|||||||
type StageFilter = 'all' | 'active' | 'lost';
|
type StageFilter = 'all' | 'active' | 'lost';
|
||||||
type SortMode = 'newest' | 'stage' | 'category';
|
type SortMode = 'newest' | 'stage' | 'category';
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
function stageRank(stage: string): number {
|
||||||
open: 'Open',
|
const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]);
|
||||||
details_sent: 'Details Sent',
|
return idx === -1 ? 99 : idx;
|
||||||
in_communication: 'In Communication',
|
}
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAGE_ORDER: Record<string, number> = {
|
|
||||||
open: 0,
|
|
||||||
details_sent: 1,
|
|
||||||
in_communication: 2,
|
|
||||||
visited: 3,
|
|
||||||
signed_eoi_nda: 4,
|
|
||||||
deposit_10pct: 5,
|
|
||||||
contract: 6,
|
|
||||||
completed: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_RANK: Record<string, number> = {
|
const CATEGORY_RANK: Record<string, number> = {
|
||||||
hot_lead: 0,
|
hot_lead: 0,
|
||||||
@@ -104,8 +88,8 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
});
|
});
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
if (sortMode === 'stage') {
|
if (sortMode === 'stage') {
|
||||||
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
|
const sa = stageRank(a.pipelineStage);
|
||||||
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
|
const sb = stageRank(b.pipelineStage);
|
||||||
if (sa !== sb) return sb - sa; // furthest along first
|
if (sa !== sb) return sb - sa; // furthest along first
|
||||||
}
|
}
|
||||||
if (sortMode === 'category') {
|
if (sortMode === 'category') {
|
||||||
@@ -189,7 +173,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
{stageLabel(i.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
|||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { BerthCard } from './berth-card';
|
||||||
import { berthColumns, type BerthRow } from './berth-columns';
|
import { berthColumns, type BerthRow } from './berth-columns';
|
||||||
import { berthFilterDefinitions } from './berth-filters';
|
import { berthFilterDefinitions } from './berth-filters';
|
||||||
import { Anchor } from 'lucide-react';
|
import { Anchor } from 'lucide-react';
|
||||||
@@ -73,6 +74,7 @@ export function BerthList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||||
|
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Anchor}
|
icon={Anchor}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||||
import { BerthInterestsTab } from './berth-interests-tab';
|
import { BerthInterestsTab } from './berth-interests-tab';
|
||||||
|
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||||
|
|
||||||
type BerthData = {
|
type BerthData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,93 +73,99 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Specifications */}
|
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||||
<Card>
|
who's interested + how warm without clicking into the Interests tab. */}
|
||||||
<CardHeader className="pb-3">
|
<BerthInterestPulse berthId={berth.id} />
|
||||||
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 divide-y">
|
|
||||||
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
|
|
||||||
<SpecRow
|
|
||||||
label="Width"
|
|
||||||
value={
|
|
||||||
formatDim(berth.widthFt, berth.widthM)
|
|
||||||
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
|
||||||
<SpecRow
|
|
||||||
label="Nominal Boat Size"
|
|
||||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
|
||||||
/>
|
|
||||||
<SpecRow
|
|
||||||
label="Water Depth"
|
|
||||||
value={
|
|
||||||
berth.waterDepth || berth.waterDepthM
|
|
||||||
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SpecRow label="Mooring Type" value={berth.mooringType} />
|
|
||||||
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
|
|
||||||
<SpecRow label="Bow Facing" value={berth.bowFacing} />
|
|
||||||
<SpecRow label="Access" value={berth.access} />
|
|
||||||
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Infrastructure & Pricing */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-6">
|
{/* Specifications */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 divide-y">
|
|
||||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
|
||||||
<SpecRow label="Voltage" value={berth.voltage} />
|
|
||||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
|
||||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
|
||||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
|
||||||
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 divide-y">
|
<CardContent className="pt-0 divide-y">
|
||||||
|
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
|
||||||
<SpecRow
|
<SpecRow
|
||||||
label="Tenure Type"
|
label="Width"
|
||||||
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
value={
|
||||||
|
formatDim(berth.widthFt, berth.widthM)
|
||||||
|
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{berth.tenureType === 'fixed_term' && (
|
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||||
<>
|
<SpecRow
|
||||||
<SpecRow label="Years" value={berth.tenureYears} />
|
label="Nominal Boat Size"
|
||||||
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||||
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
/>
|
||||||
</>
|
<SpecRow
|
||||||
)}
|
label="Water Depth"
|
||||||
<SpecRow label="Price" value={price} />
|
value={
|
||||||
|
berth.waterDepth || berth.waterDepthM
|
||||||
|
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SpecRow label="Mooring Type" value={berth.mooringType} />
|
||||||
|
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
|
||||||
|
<SpecRow label="Bow Facing" value={berth.bowFacing} />
|
||||||
|
<SpecRow label="Access" value={berth.access} />
|
||||||
|
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{berth.tags.length > 0 && (
|
{/* Infrastructure & Pricing */}
|
||||||
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0 divide-y">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||||
{berth.tags.map((tag) => (
|
<SpecRow label="Voltage" value={berth.voltage} />
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||||
))}
|
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||||
</div>
|
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||||
|
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 divide-y">
|
||||||
|
<SpecRow
|
||||||
|
label="Tenure Type"
|
||||||
|
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
||||||
|
/>
|
||||||
|
{berth.tenureType === 'fixed_term' && (
|
||||||
|
<>
|
||||||
|
<SpecRow label="Years" value={berth.tenureYears} />
|
||||||
|
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
||||||
|
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SpecRow label="Price" value={price} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{berth.tags.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{berth.tags.map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
142
src/components/clients/client-card.tsx
Normal file
142
src/components/clients/client-card.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import {
|
||||||
|
ListCard,
|
||||||
|
ListCardAvatar,
|
||||||
|
ListCardMeta,
|
||||||
|
deriveInitials,
|
||||||
|
} from '@/components/shared/list-card';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import type { ClientRow } from './client-columns';
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
manual: 'Manual',
|
||||||
|
referral: 'Referral',
|
||||||
|
broker: 'Broker',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClientCardProps {
|
||||||
|
client: ClientRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (client: ClientRow) => void;
|
||||||
|
onArchive: (client: ClientRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
|
||||||
|
const primary = client.contacts?.find((c) => c.isPrimary);
|
||||||
|
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||||
|
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||||
|
const tags = client.tags ?? [];
|
||||||
|
|
||||||
|
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const interest = client.latestInterest ?? null;
|
||||||
|
const interestCount = client.interestCount ?? 0;
|
||||||
|
const interestBerthLabel = interest
|
||||||
|
? interest.mooringNumber
|
||||||
|
? `Berth ${interest.mooringNumber}`
|
||||||
|
: 'General interest'
|
||||||
|
: null;
|
||||||
|
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
|
||||||
|
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
|
||||||
|
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/clients/${client.id}`}
|
||||||
|
ariaLabel={`Client ${client.fullName}`}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${client.fullName}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(client)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar initials={deriveInitials(client.fullName)} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{client.fullName}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{primary ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{primary.value}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{meta.length > 0 ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||||
|
{meta.map((m, i) => (
|
||||||
|
<span key={m} className="inline-flex items-center gap-1">
|
||||||
|
{i > 0 ? <span aria-hidden>·</span> : null}
|
||||||
|
<ListCardMeta>{m}</ListCardMeta>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{interest ? (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{interestBerthLabel}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${interestStageBadge}`}
|
||||||
|
>
|
||||||
|
{interestStageLabel}
|
||||||
|
</span>
|
||||||
|
{extraInterests > 0 ? (
|
||||||
|
<span className="shrink-0 text-muted-foreground/80">+{extraInterests}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
{tags.length > 2 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||||
|
+{tags.length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,7 +74,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||||
|
{client.fullName}
|
||||||
|
</h1>
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Archived
|
Archived
|
||||||
@@ -115,7 +117,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{!isArchived && client.clientPortalEnabled !== false && (
|
{!isArchived && client.clientPortalEnabled !== false && (
|
||||||
<PortalInviteButton
|
<PortalInviteButton
|
||||||
clientId={client.id}
|
clientId={client.id}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -80,6 +82,13 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
|||||||
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.fullName ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'client:updated': [['clients', clientId]],
|
'client:updated': [['clients', clientId]],
|
||||||
'client:archived': [['clients', clientId]],
|
'client:archived': [['clients', clientId]],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
|||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { ClientForm } from '@/components/clients/client-form';
|
import { ClientForm } from '@/components/clients/client-form';
|
||||||
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
||||||
|
import { ClientCard } from '@/components/clients/client-card';
|
||||||
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -118,6 +119,14 @@ export function ClientList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<ClientCard
|
||||||
|
client={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditClient}
|
||||||
|
onArchive={setArchiveClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No clients found"
|
title="No clients found"
|
||||||
|
|||||||
311
src/components/clients/client-pipeline-summary.tsx
Normal file
311
src/components/clients/client-pipeline-summary.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
safeStage,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/components/clients/pipeline-constants';
|
||||||
|
|
||||||
|
export interface ClientInterestRow {
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
dateLastContact: string | null;
|
||||||
|
berthMooringNumber?: string | null;
|
||||||
|
yachtName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: ClientInterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientInterests(clientId: string) {
|
||||||
|
return useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { clientId }],
|
||||||
|
queryFn: () => apiFetch<InterestsResponse>(`/api/v1/interests?clientId=${clientId}&limit=50`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageStepper({
|
||||||
|
current,
|
||||||
|
size = 'sm',
|
||||||
|
}: {
|
||||||
|
current: PipelineStage;
|
||||||
|
size?: 'xs' | 'sm';
|
||||||
|
}) {
|
||||||
|
const idx = PIPELINE_STAGES.indexOf(current);
|
||||||
|
// Segmented progress bar: each stage is a slice of equal width that
|
||||||
|
// lights up once the interest has reached it. Reads at-a-glance, scales
|
||||||
|
// to any container width, and works with 9 stages without becoming
|
||||||
|
// micro-dots that vanish under cramped layouts.
|
||||||
|
const height = size === 'xs' ? 'h-1' : 'h-1.5';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Pipeline progress"
|
||||||
|
aria-valuenow={idx + 1}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={PIPELINE_STAGES.length}
|
||||||
|
>
|
||||||
|
{PIPELINE_STAGES.map((stage, i) => {
|
||||||
|
const isReached = i <= idx;
|
||||||
|
const isCurrent = i === idx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage}
|
||||||
|
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 transition-colors',
|
||||||
|
isReached ? STAGE_DOT[stage] : 'bg-transparent',
|
||||||
|
i > 0 ? 'border-l border-card' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null {
|
||||||
|
const active = interests.filter((i) => !i.archivedAt);
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
return [...active].sort((a, b) => {
|
||||||
|
const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage));
|
||||||
|
const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage));
|
||||||
|
if (ai !== bi) return bi - ai;
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
})[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||||
|
const candidates = interests
|
||||||
|
.flatMap((i) => [i.dateLastContact, i.updatedAt])
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
.map((v) => new Date(v).getTime())
|
||||||
|
.filter((t) => !Number.isNaN(t));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const latest = new Date(Math.max(...candidates));
|
||||||
|
return `${formatDistanceToNowStrict(latest)} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineSummaryProps {
|
||||||
|
clientId: string;
|
||||||
|
/**
|
||||||
|
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||||
|
* `panel` — compact list of every active interest, for the Overview tab.
|
||||||
|
*/
|
||||||
|
variant?: 'hero' | 'panel';
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
const top = pickHighest(interests);
|
||||||
|
const activeCount = interests.filter((i) => !i.archivedAt).length;
|
||||||
|
const activity = lastActivityLabel(interests);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!top) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = safeStage(top.pipelineStage);
|
||||||
|
const berthLabel = top.berthMooringNumber
|
||||||
|
? `Berth ${top.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline
|
||||||
|
</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
· {activeCount} active
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={detailsHref}
|
||||||
|
className="group -m-1 block rounded-lg p-1 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{berthLabel}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{activity ? `Last activity ${activity}` : 'No activity recorded'}</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
View all {activeCount}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = (data?.data ?? []).filter((i) => !i.archivedAt);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...interests].sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline · {interests.length} active
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sorted.map((i) => {
|
||||||
|
const stage = safeStage(i.pipelineStage);
|
||||||
|
const berthLabel = i.berthMooringNumber
|
||||||
|
? `Berth ${i.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const href = `/${portSlug}/interests/${i.id}` as Route;
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{berthLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
return variant === 'hero' ? (
|
||||||
|
<HeroVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
) : (
|
||||||
|
<PanelVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/clients/pipeline-constants.ts
Normal file
14
src/components/clients/pipeline-constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Re-export from the canonical source so legacy imports keep working.
|
||||||
|
export {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_LABELS,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_WEIGHTS,
|
||||||
|
STAGE_TRANSITIONS,
|
||||||
|
safeStage,
|
||||||
|
stageLabel,
|
||||||
|
stageBadgeClass,
|
||||||
|
stageDotClass,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/lib/constants';
|
||||||
141
src/components/companies/company-card.tsx
Normal file
141
src/components/companies/company-card.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Archive, Building2, Eye, Hash, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import type { CompanyRow } from './company-columns';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CompanyCardProps {
|
||||||
|
company: CompanyRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (company: CompanyRow) => void;
|
||||||
|
onArchive: (company: CompanyRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyCard({ company, portSlug, onEdit, onArchive }: CompanyCardProps) {
|
||||||
|
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
const country = company.incorporationCountryIso
|
||||||
|
? getCountryName(company.incorporationCountryIso, 'en')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const memberCount = company.memberCount ?? 0;
|
||||||
|
const yachtCount = company.yachtCount ?? 0;
|
||||||
|
const countParts: string[] = [];
|
||||||
|
if (memberCount > 0)
|
||||||
|
countParts.push(`${memberCount} ${memberCount === 1 ? 'member' : 'members'}`);
|
||||||
|
if (yachtCount > 0) countParts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`);
|
||||||
|
|
||||||
|
// Skip legalName if it is identical to name or absent
|
||||||
|
const showLegalName =
|
||||||
|
company.legalName && company.legalName.toLowerCase() !== company.name.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/companies/${company.id}`}
|
||||||
|
ariaLabel={`Company ${company.name}`}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${company.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${portSlug}/companies/${company.id}`}>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(company)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(company)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<Building2 className="h-5 w-5" />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{company.name}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal name subtitle */}
|
||||||
|
{showLegalName ? (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{company.legalName}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Country + Tax ID meta line */}
|
||||||
|
{country || company.taxId ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{country ? (
|
||||||
|
<ListCardMeta icon={<MapPin className="h-3 w-3" />}>{country}</ListCardMeta>
|
||||||
|
) : null}
|
||||||
|
{company.taxId ? (
|
||||||
|
<ListCardMeta icon={<Hash className="h-3 w-3" />}>{company.taxId}</ListCardMeta>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Member / yacht counts */}
|
||||||
|
{countParts.length > 0 ? (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{countParts.join(' · ')}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
{company.status ? (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||||
|
statusColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,7 +80,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1>
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
>
|
>
|
||||||
@@ -100,7 +102,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<PermissionGate resource="companies" action="edit">
|
<PermissionGate resource="companies" action="edit">
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -45,6 +47,13 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
|||||||
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null = data?.name ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'company:updated': [['companies', companyId]],
|
'company:updated': [['companies', companyId]],
|
||||||
'company:archived': [['companies', companyId]],
|
'company:archived': [['companies', companyId]],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { EmptyState } from '@/components/shared/empty-state';
|
|||||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { CompanyCard } from '@/components/companies/company-card';
|
||||||
import { CompanyForm } from '@/components/companies/company-form';
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||||
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||||
@@ -123,6 +124,14 @@ export function CompanyList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => (
|
||||||
|
<CompanyCard
|
||||||
|
company={row.original}
|
||||||
|
portSlug={portSlug}
|
||||||
|
onEdit={setEditCompany}
|
||||||
|
onArchive={setArchiveCompany}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
emptyState={
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No companies yet"
|
title="No companies yet"
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ function ActivityFeedInner() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recent activity yet — your team's actions (interests created, stages changed,
|
||||||
|
invoices sent) will appear here.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
|||||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
import { LeadSourceChart } from './lead-source-chart';
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
|
import { MyRemindersRail } from './my-reminders-rail';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
@@ -49,7 +50,7 @@ export function DashboardShell() {
|
|||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-4">
|
||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +69,10 @@ export function DashboardShell() {
|
|||||||
<LeadSourceChart range={range} />
|
<LeadSourceChart range={range} />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<aside className="min-w-0">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<MyRemindersRail />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<AlertRail />
|
<AlertRail />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
|
|||||||
@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !slices.length ? (
|
) : !slices.length ? (
|
||||||
<EmptyState title="No interests in range" />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
// Percentage radii + center-anchored chart so the pie scales with
|
||||||
|
// the container instead of being clipped to a constant 90px ring at
|
||||||
|
// narrow widths. Legend is reserved a fixed footer height.
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="45%"
|
||||||
outerRadius={90}
|
outerRadius="70%"
|
||||||
innerRadius={50}
|
innerRadius="40%"
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{chartData.map((_, i) => (
|
{chartData.map((_, i) => (
|
||||||
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={40}
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
||||||
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
|
||||||
interface PipelineRow {
|
interface PipelineRow {
|
||||||
@@ -21,18 +15,8 @@ interface PipelineRow {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
function PipelineChartInner() {
|
function PipelineChartInner() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data, isLoading } = useQuery<PipelineRow[]>({
|
const { data, isLoading } = useQuery<PipelineRow[]>({
|
||||||
queryKey: ['dashboard', 'pipeline'],
|
queryKey: ['dashboard', 'pipeline'],
|
||||||
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
||||||
@@ -45,7 +29,7 @@ function PipelineChartInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chartData = (data ?? []).map((row) => ({
|
const chartData = (data ?? []).map((row) => ({
|
||||||
stage: STAGE_LABELS[row.stage] ?? row.stage,
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
|
||||||
count: row.count,
|
count: row.count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +4,24 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
|||||||
|
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { ChartCard } from './chart-card';
|
import { ChartCard } from './chart-card';
|
||||||
import { useFunnel } from './use-analytics';
|
import { useFunnel } from './use-analytics';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
range: DateRange;
|
range: DateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineFunnelChart({ range }: Props) {
|
export function PipelineFunnelChart({ range }: Props) {
|
||||||
const { data, isLoading } = useFunnel(range);
|
const { data, isLoading } = useFunnel(range);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const stages = data?.stages ?? [];
|
const stages = data?.stages ?? [];
|
||||||
|
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
|
||||||
const chartData = stages.map((s) => ({
|
const chartData = stages.map((s) => ({
|
||||||
stage: STAGE_LABELS[s.stage] ?? s.stage,
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
|
||||||
count: s.count,
|
count: s.count,
|
||||||
conversionPct: s.conversionPct,
|
conversionPct: s.conversionPct,
|
||||||
}));
|
}));
|
||||||
@@ -51,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : allZero ? (
|
) : allZero ? (
|
||||||
<EmptyState title="No interests in range" description="Try a longer date range." />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !bars.length ? (
|
) : !bars.length ? (
|
||||||
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
<EmptyState
|
||||||
|
title="No invoices in range"
|
||||||
|
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
|
||||||
interface StageBreakdownRow {
|
interface StageBreakdownRow {
|
||||||
@@ -20,17 +21,6 @@ interface ForecastData {
|
|||||||
weightsSource: 'db' | 'default';
|
weightsSource: 'db' | 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -66,9 +56,7 @@ function RevenueForecastInner() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
|
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">{formatCurrency(data?.totalWeightedValue ?? 0)}</p>
|
||||||
{formatCurrency(data?.totalWeightedValue ?? 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeStages.length > 0 && (
|
{activeStages.length > 0 && (
|
||||||
@@ -76,12 +64,10 @@ function RevenueForecastInner() {
|
|||||||
{activeStages.map((s) => (
|
{activeStages.map((s) => (
|
||||||
<div key={s.stage} className="flex items-center justify-between text-sm">
|
<div key={s.stage} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{STAGE_LABELS[s.stage] ?? s.stage}
|
{stageLabel(s.stage)}
|
||||||
<span className="ml-1 text-xs">({s.count})</span>
|
<span className="ml-1 text-xs">({s.count})</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums">{formatCurrency(s.weightedValue)}</span>
|
||||||
{formatCurrency(s.weightedValue)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface DocumentRow {
|
|||||||
interface DocumentListProps {
|
interface DocumentListProps {
|
||||||
interestId?: string;
|
interestId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
|
/** Override the default empty state ("No documents yet.") with a contextual
|
||||||
|
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
other: 'Other',
|
other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>;
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
if (emptyState) return <>{emptyState}</>;
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,22 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|||||||
rejected: 'rejected',
|
rejected: 'rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
sent: 'Sent',
|
||||||
|
signed: 'Signed',
|
||||||
|
declined: 'Declined',
|
||||||
|
expired: 'Expired',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
interface DocumentsHubProps {
|
interface DocumentsHubProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
|
initialTab?: DocumentsHubTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
|
||||||
const [tab, setTab] = useState<DocumentsHubTab>('all');
|
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
const [signatureOnly, setSignatureOnly] = useState(true);
|
const [signatureOnly, setSignatureOnly] = useState(true);
|
||||||
@@ -186,7 +196,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||||
{signer.status}
|
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import {
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
/** Required for the EOI's top paragraph (Section 2) — without these the
|
||||||
|
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||||
|
* belong to Section 3 and may be left blank. */
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
|
hasEmail: boolean;
|
||||||
|
hasAddress: boolean;
|
||||||
|
/** Optional — info-only checks. Generation proceeds without them. */
|
||||||
hasYacht: boolean;
|
hasYacht: boolean;
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
|
|||||||
prerequisites: EoiPrerequisites;
|
prerequisites: EoiPrerequisites;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
{ key: 'hasName', label: 'Client has full name' },
|
{ key: 'hasName', label: 'Client name' },
|
||||||
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
{ key: 'hasAddress', label: 'Client address' },
|
||||||
{ key: 'hasBerth', label: 'Berth linked to interest' },
|
{ key: 'hasEmail', label: 'Client email' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
|
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
|
||||||
|
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||||
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
||||||
|
|
||||||
const allMet = Object.values(prerequisites).every(Boolean);
|
const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
|
||||||
|
|
||||||
// Load in-app EOI templates so the operator can pick one as an alternative
|
// Load in-app EOI templates so the operator can pick one as an alternative
|
||||||
// to the Documenso external-signing flow.
|
// to the Documenso external-signing flow.
|
||||||
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
|
|||||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!allMet) return;
|
if (!requiredMet) return;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,7 +109,13 @@ export function EoiGenerateDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
|
// Invalidate all document list queries (hub counts + per-interest lists).
|
||||||
|
// The DocumentList component uses ['documents', { interestId, clientId }]
|
||||||
|
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
|
||||||
|
// Using a predicate avoids key-shape drift between callers.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === 'documents',
|
||||||
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||||
@@ -138,22 +155,59 @@ export function EoiGenerateDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
|
<div className="space-y-1.5">
|
||||||
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<div key={key} className="flex items-center gap-3">
|
Required (Section 2 of the EOI)
|
||||||
<span
|
</p>
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
{REQUIRED_LABELS.map(({ key, label }) => (
|
||||||
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
}`}
|
<span
|
||||||
>
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
{prerequisites[key] ? '✓' : '✗'}
|
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
</span>
|
}`}
|
||||||
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
|
>
|
||||||
{label}
|
{prerequisites[key] ? '✓' : '✗'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span
|
||||||
))}
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Optional (Section 3 — left blank if absent)
|
||||||
|
</p>
|
||||||
|
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
|
prerequisites[key]
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{prerequisites[key] ? '✓' : '–'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!requiredMet ? (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
|
Add the missing required details on the client's record before generating the
|
||||||
|
EOI.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
|
|||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
|
||||||
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
194
src/components/expenses/expense-card.tsx
Normal file
194
src/components/expenses/expense-card.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Archive, Calendar, Eye, MoreHorizontal, Pencil, Receipt, Tag } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ExpenseRow } from './expense-columns';
|
||||||
|
|
||||||
|
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||||
|
unpaid: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
paid: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||||
|
reconciled: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
flagged: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||||
|
pending: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accent bar by payment status:
|
||||||
|
* paid / reconciled → emerald
|
||||||
|
* pending → amber
|
||||||
|
* flagged → rose
|
||||||
|
* other / null → slate
|
||||||
|
* If duplicateOf is set, override to amber-500.
|
||||||
|
*/
|
||||||
|
function deriveAccent(status: string | null, duplicateOf: string | null): string {
|
||||||
|
if (duplicateOf) return 'bg-amber-500';
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
case 'reconciled':
|
||||||
|
return 'bg-emerald-400';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-amber-400';
|
||||||
|
case 'flagged':
|
||||||
|
return 'bg-rose-400';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount: string, currency: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(amount));
|
||||||
|
} catch {
|
||||||
|
return `${currency} ${amount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpenseCardProps {
|
||||||
|
expense: ExpenseRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (expense: ExpenseRow) => void;
|
||||||
|
onArchive: (expense: ExpenseRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCardProps) {
|
||||||
|
const accentClass = deriveAccent(expense.paymentStatus, expense.duplicateOf);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
expense.establishmentName ??
|
||||||
|
(expense.description ? expense.description.slice(0, 40) : null) ??
|
||||||
|
'Untitled expense';
|
||||||
|
|
||||||
|
// Subtitle: category (capitalized) or "{paymentMethod} by {payer}"
|
||||||
|
let subtitle: string | null = null;
|
||||||
|
if (expense.category) {
|
||||||
|
subtitle = expense.category.replace(/_/g, ' ');
|
||||||
|
// Capitalize first letter
|
||||||
|
subtitle = subtitle.charAt(0).toUpperCase() + subtitle.slice(1);
|
||||||
|
} else if (expense.paymentMethod || expense.payer) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (expense.paymentMethod) parts.push(expense.paymentMethod);
|
||||||
|
if (expense.payer) parts.push(`by ${expense.payer}`);
|
||||||
|
subtitle = parts.join(' ');
|
||||||
|
}
|
||||||
|
const hasCategory = !!expense.category;
|
||||||
|
|
||||||
|
let dateFormatted: string | null = null;
|
||||||
|
try {
|
||||||
|
dateFormatted = format(new Date(expense.expenseDate), 'MMM d, yyyy');
|
||||||
|
} catch {
|
||||||
|
dateFormatted = expense.expenseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountFormatted = formatAmount(expense.amount, expense.currency);
|
||||||
|
|
||||||
|
const statusColor = expense.paymentStatus
|
||||||
|
? (PAYMENT_STATUS_COLORS[expense.paymentStatus] ??
|
||||||
|
'bg-muted text-muted-foreground border-muted')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/expenses/${expense.id}`}
|
||||||
|
ariaLabel={`Expense: ${title}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for expense: ${title}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${portSlug}/expenses/${expense.id}`}>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(expense)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(expense)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar icon={<Receipt className="h-5 w-5" />} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row + spacer for actions button */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category / payer subtitle */}
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
{hasCategory ? (
|
||||||
|
<Tag className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{subtitle}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Amount — prominent */}
|
||||||
|
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||||
|
{amountFormatted}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Date meta */}
|
||||||
|
{dateFormatted ? (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||||
|
<ListCardMeta icon={<Calendar className="h-3 w-3" />}>{dateFormatted}</ListCardMeta>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status + duplicate pills */}
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
{statusColor && expense.paymentStatus ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
|
||||||
|
statusColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{expense.paymentStatus}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{expense.duplicateOf ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||||
|
Possible duplicate
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
|
import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
|
||||||
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import type { ExpenseRow } from './expense-columns';
|
import type { ExpenseRow } from './expense-columns';
|
||||||
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
|
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
|
||||||
|
|
||||||
@@ -34,6 +35,14 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
|
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
const titleForChrome: string | null =
|
||||||
|
data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
|
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
163
src/components/interests/inline-stage-picker.tsx
Normal file
163
src/components/interests/inline-stage-picker.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Check, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
safeStage,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/components/clients/pipeline-constants';
|
||||||
|
|
||||||
|
interface InlineStagePickerProps {
|
||||||
|
interestId: string;
|
||||||
|
currentStage: string;
|
||||||
|
/** Whether to render the chevron after the stage label. Default true. */
|
||||||
|
showChevron?: boolean;
|
||||||
|
/** Stop the parent's click propagation when used inside a clickable card. */
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||||
|
* for inline editing — user clicks the chip, picks a new stage from the
|
||||||
|
* popover (with optional reason), commits in one click. The popover stays
|
||||||
|
* compact: a small reason field above the stage list, and clicking any stage
|
||||||
|
* fires the mutation immediately.
|
||||||
|
*/
|
||||||
|
export function InlineStagePicker({
|
||||||
|
interestId,
|
||||||
|
currentStage,
|
||||||
|
showChevron = true,
|
||||||
|
stopPropagation = false,
|
||||||
|
className,
|
||||||
|
}: InlineStagePickerProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stage = safeStage(currentStage);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (next: PipelineStage) =>
|
||||||
|
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { pipelineStage: next, reason: reason.trim() || undefined },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, next) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
|
setOpen(false);
|
||||||
|
setReason('');
|
||||||
|
setPendingStage(null);
|
||||||
|
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setPendingStage(null);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to change stage');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(next: PipelineStage) {
|
||||||
|
if (next === stage) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingStage(next);
|
||||||
|
mutation.mutate(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!mutation.isPending) setOpen(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (stopPropagation) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||||
|
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||||
|
>
|
||||||
|
<span>{STAGE_LABELS[stage]}</span>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : showChevron ? (
|
||||||
|
<ChevronDown className="size-3 opacity-70" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-64 p-0"
|
||||||
|
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="border-b px-2 py-1">
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Reason (optional)…"
|
||||||
|
rows={1}
|
||||||
|
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||||
|
{PIPELINE_STAGES.map((s) => {
|
||||||
|
const isCurrent = s === stage;
|
||||||
|
const isPending = pendingStage === s && mutation.isPending;
|
||||||
|
return (
|
||||||
|
<li key={s}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isCurrent}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => pick(s)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||||
|
'transition-colors hover:bg-muted/60 disabled:opacity-60',
|
||||||
|
isCurrent && 'font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||||
|
the picker into a visual scan rather than just a list. */}
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<Check className="size-3.5 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/interests/interest-card.tsx
Normal file
196
src/components/interests/interest-card.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import {
|
||||||
|
ListCard,
|
||||||
|
ListCardAvatar,
|
||||||
|
ListCardMeta,
|
||||||
|
deriveInitials,
|
||||||
|
} from '@/components/shared/list-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
|
import type { InterestRow } from './interest-columns';
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
general_interest: 'General',
|
||||||
|
specific_qualified: 'Qualified',
|
||||||
|
hot_lead: 'Hot lead',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
manual: 'Manual',
|
||||||
|
referral: 'Referral',
|
||||||
|
broker: 'Broker',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InterestCardProps {
|
||||||
|
interest: InterestRow;
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (interest: InterestRow) => void;
|
||||||
|
onArchive: (interest: InterestRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InterestCard({ interest, portSlug, onEdit, onArchive }: InterestCardProps) {
|
||||||
|
const stageLabel = toStageLabel(interest.pipelineStage);
|
||||||
|
const stagePill = stageBadgeClass(interest.pipelineStage);
|
||||||
|
const accentClass = stageDotClass(interest.pipelineStage);
|
||||||
|
const isHotLead = interest.leadCategory === 'hot_lead';
|
||||||
|
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||||
|
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||||
|
const tags = interest.tags ?? [];
|
||||||
|
const notesCount = interest.notesCount ?? 0;
|
||||||
|
const urgencyBadges = computeUrgencyBadges(interest);
|
||||||
|
|
||||||
|
const clientName = interest.clientName ?? 'Unknown client';
|
||||||
|
const berthLabel = interest.berthMooringNumber;
|
||||||
|
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||||
|
const lastActivity = lastIso
|
||||||
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListCard
|
||||||
|
href={`/${portSlug}/interests/${interest.id}`}
|
||||||
|
ariaLabel={`Interest for ${clientName}`}
|
||||||
|
accentClassName={accentClass}
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${clientName}'s interest`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(interest)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(interest)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ListCardAvatar initials={deriveInitials(clientName)} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title row: name + comment-icon when notes exist + spacer for actions */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
|
{clientName}
|
||||||
|
</h3>
|
||||||
|
{notesCount > 0 ? (
|
||||||
|
<span
|
||||||
|
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Berth or general-interest line */}
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
|
{berthLabel ? (
|
||||||
|
<>
|
||||||
|
<Anchor className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="truncate">{berthLabel}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Compass className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
<span className="italic">General interest</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stage pill + category + source */}
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
stagePill,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stageLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isHotLead ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-rose-700">
|
||||||
|
Hot
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(categoryLabel && !isHotLead) || sourceLabel ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||||
|
{categoryLabel && !isHotLead ? <ListCardMeta>{categoryLabel}</ListCardMeta> : null}
|
||||||
|
{categoryLabel && !isHotLead && sourceLabel ? <span aria-hidden>·</span> : null}
|
||||||
|
{sourceLabel ? <ListCardMeta>{sourceLabel}</ListCardMeta> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{urgencyBadges.length > 0 ? (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{urgencyBadges.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
b.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.slice(0, 2).map((tag) => (
|
||||||
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
|
))}
|
||||||
|
{tags.length > 2 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||||
|
+{tags.length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lastActivity ? (
|
||||||
|
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
Last activity {lastActivity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { format } from 'date-fns';
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||||
|
|
||||||
export interface InterestRow {
|
export interface InterestRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,31 +28,18 @@ export interface InterestRow {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Surfaced by listInterests for the row-level sales-triage signals
|
||||||
|
* (last-activity relative time, comment-icon, urgency badges). */
|
||||||
|
updatedAt?: string;
|
||||||
|
dateLastContact?: string | null;
|
||||||
|
dateEoiSent?: string | null;
|
||||||
|
dateDepositReceived?: string | null;
|
||||||
|
eoiStatus?: string | null;
|
||||||
|
outcome?: string | null;
|
||||||
|
notesCount?: number;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
open: 'Open',
|
|
||||||
details_sent: 'Details Sent',
|
|
||||||
in_communication: 'In Communication',
|
|
||||||
visited: 'Visited',
|
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, string> = {
|
|
||||||
open: 'bg-slate-100 text-slate-700',
|
|
||||||
details_sent: 'bg-blue-100 text-blue-700',
|
|
||||||
in_communication: 'bg-sky-100 text-sky-700',
|
|
||||||
visited: 'bg-violet-100 text-violet-700',
|
|
||||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
|
||||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
|
||||||
contract: 'bg-green-100 text-green-700',
|
|
||||||
completed: 'bg-emerald-100 text-emerald-700',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General Interest',
|
general_interest: 'General Interest',
|
||||||
specific_qualified: 'Specific Qualified',
|
specific_qualified: 'Specific Qualified',
|
||||||
@@ -80,15 +69,29 @@ export function getInterestColumns({
|
|||||||
id: 'clientName',
|
id: 'clientName',
|
||||||
accessorKey: 'clientName',
|
accessorKey: 'clientName',
|
||||||
header: 'Client',
|
header: 'Client',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Link
|
const notesCount = row.original.notesCount ?? 0;
|
||||||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
return (
|
||||||
className="font-medium text-primary hover:underline"
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Link
|
||||||
>
|
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||||||
{row.original.clientName ?? '—'}
|
className="truncate font-medium text-primary hover:underline"
|
||||||
</Link>
|
onClick={(e) => e.stopPropagation()}
|
||||||
),
|
>
|
||||||
|
{row.original.clientName ?? '—'}
|
||||||
|
</Link>
|
||||||
|
{notesCount > 0 ? (
|
||||||
|
<span
|
||||||
|
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
className="inline-flex items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'berthMooringNumber',
|
id: 'berthMooringNumber',
|
||||||
@@ -113,14 +116,31 @@ export function getInterestColumns({
|
|||||||
id: 'pipelineStage',
|
id: 'pipelineStage',
|
||||||
accessorKey: 'pipelineStage',
|
accessorKey: 'pipelineStage',
|
||||||
header: 'Stage',
|
header: 'Stage',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ row }) => {
|
||||||
const stage = getValue() as string;
|
const stage = row.original.pipelineStage;
|
||||||
|
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="flex flex-col gap-1 items-start">
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`}
|
<span
|
||||||
>
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||||||
{STAGE_LABELS[stage] ?? stage}
|
>
|
||||||
</span>
|
{stageLabel(stage)}
|
||||||
|
</span>
|
||||||
|
{badges.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
aria-label={b.detail}
|
||||||
|
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -174,14 +194,24 @@ export function getInterestColumns({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'createdAt',
|
// Sales-triage default: prefer the explicit dateLastContact, fall back
|
||||||
accessorKey: 'createdAt',
|
// to updatedAt. Sortable on dateLastContact server-side; the column
|
||||||
header: 'Created',
|
// header label ("Last activity") makes the fallback semantics clear.
|
||||||
cell: ({ getValue }) => (
|
id: 'dateLastContact',
|
||||||
<span className="text-muted-foreground text-sm">
|
accessorKey: 'dateLastContact',
|
||||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
header: 'Last activity',
|
||||||
</span>
|
cell: ({ row }) => {
|
||||||
),
|
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||||
|
if (!lastIso) {
|
||||||
|
return <span className="text-muted-foreground text-sm">—</span>;
|
||||||
|
}
|
||||||
|
const d = new Date(lastIso);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||||
|
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -205,10 +235,7 @@ export function getInterestColumns({
|
|||||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onArchive(row.original)}
|
|
||||||
>
|
|
||||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Pencil, Archive, RotateCcw, TrendingUp } from 'lucide-react';
|
import {
|
||||||
|
Pencil,
|
||||||
|
Archive,
|
||||||
|
RotateCcw,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
RefreshCcw,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
|
AlarmClock,
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -12,33 +23,35 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
|||||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { InterestForm } from '@/components/interests/interest-form';
|
import { InterestForm } from '@/components/interests/interest-form';
|
||||||
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
|
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
|
||||||
|
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||||
open: 'Open',
|
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
|
||||||
details_sent: 'Details Sent',
|
lost_other_marina: { label: 'Lost — other marina', className: 'bg-rose-100 text-rose-700' },
|
||||||
in_communication: 'In Communication',
|
lost_unqualified: { label: 'Lost — unqualified', className: 'bg-rose-100 text-rose-700' },
|
||||||
visited: 'Visited',
|
lost_no_response: { label: 'Lost — no response', className: 'bg-rose-100 text-rose-700' },
|
||||||
signed_eoi_nda: 'Signed EOI/NDA',
|
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||||
deposit_10pct: 'Deposit 10%',
|
|
||||||
contract: 'Contract',
|
|
||||||
completed: 'Completed',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, string> = {
|
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
|
||||||
open: 'bg-slate-100 text-slate-700',
|
// renders as a closed-state badge instead of falling back to the open-state
|
||||||
details_sent: 'bg-blue-100 text-blue-700',
|
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
|
||||||
in_communication: 'bg-sky-100 text-sky-700',
|
function resolveOutcomeBadge(outcome: string | null | undefined) {
|
||||||
visited: 'bg-violet-100 text-violet-700',
|
if (!outcome) return null;
|
||||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
const known = OUTCOME_BADGE[outcome];
|
||||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
if (known) return known;
|
||||||
contract: 'bg-green-100 text-green-700',
|
const isLoss = outcome.startsWith('lost');
|
||||||
completed: 'bg-emerald-100 text-emerald-700',
|
return {
|
||||||
};
|
label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||||
|
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General Interest',
|
general_interest: 'General',
|
||||||
specific_qualified: 'Specific Qualified',
|
specific_qualified: 'Specific Qualified',
|
||||||
hot_lead: 'Hot Lead',
|
hot_lead: 'Hot Lead',
|
||||||
};
|
};
|
||||||
@@ -49,6 +62,16 @@ interface InterestDetailHeaderProps {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
|
/** Primary contact channels resolved from the linked client. The header
|
||||||
|
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||||
|
* doesn't have to navigate to the client page just to reach out. */
|
||||||
|
clientPrimaryEmail?: string | null;
|
||||||
|
clientPrimaryPhone?: string | null;
|
||||||
|
clientPrimaryPhoneE164?: string | null;
|
||||||
|
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||||
|
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
||||||
|
* doesn't have to remember to check /reminders. */
|
||||||
|
activeReminderCount?: number;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
@@ -58,17 +81,50 @@ interface InterestDetailHeaderProps {
|
|||||||
reminderEnabled: boolean;
|
reminderEnabled: boolean;
|
||||||
reminderDays: number | null;
|
reminderDays: number | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
|
outcome?: string | null;
|
||||||
|
outcomeReason?: string | null;
|
||||||
|
dateLastContact?: string | null;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLastContactAge(iso: string): string {
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return 'today';
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||||
|
return `${Math.floor(days / 365)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
const [stageOpen, setStageOpen] = useState(false);
|
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||||
|
|
||||||
const isArchived = !!interest.archivedAt;
|
const isArchived = !!interest.archivedAt;
|
||||||
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
|
const isClosed = !!interest.outcome;
|
||||||
|
|
||||||
|
// Contact deep-links — resolved from the linked client's primary channels.
|
||||||
|
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||||
|
// stripping non-digits from the display value when the canonical form is
|
||||||
|
// missing.
|
||||||
|
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||||
|
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||||
|
: interest.clientPrimaryPhone
|
||||||
|
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const reopenMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
||||||
@@ -88,13 +144,50 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const meta: Array<{ key: string; node: React.ReactNode }> = [];
|
||||||
|
if (interest.berthMooringNumber) {
|
||||||
|
meta.push({
|
||||||
|
key: 'berth',
|
||||||
|
node: (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Berth {interest.berthMooringNumber}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (interest.leadCategory) {
|
||||||
|
meta.push({
|
||||||
|
key: 'cat',
|
||||||
|
node: <span>{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (interest.source) {
|
||||||
|
meta.push({
|
||||||
|
key: 'src',
|
||||||
|
node: <span className="capitalize">{interest.source}</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (interest.dateLastContact) {
|
||||||
|
meta.push({
|
||||||
|
key: 'last',
|
||||||
|
node: (
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
Last contact {formatLastContactAge(interest.dateLastContact)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailHeaderStrip>
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="truncate text-lg font-bold text-foreground sm:text-xl">
|
||||||
{interest.clientName ?? 'Unknown Client'}
|
{interest.clientName ?? 'Unknown Client'}
|
||||||
</h1>
|
</h1>
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
@@ -102,95 +195,227 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
Archived
|
Archived
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span
|
{outcomeBadge ? (
|
||||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
|
<span
|
||||||
>
|
className={cn(
|
||||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
</span>
|
outcomeBadge.className,
|
||||||
|
)}
|
||||||
|
title={interest.outcomeReason ?? undefined}
|
||||||
|
>
|
||||||
|
{outcomeBadge.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<PermissionGate
|
||||||
|
resource="interests"
|
||||||
|
action="change_stage"
|
||||||
|
fallback={
|
||||||
|
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
||||||
|
{interest.pipelineStage}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineStagePicker
|
||||||
|
interestId={interest.id}
|
||||||
|
currentStage={interest.pipelineStage}
|
||||||
|
className="-ml-2.5"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
)}
|
||||||
|
{(interest.activeReminderCount ?? 0) > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||||
|
title={`${interest.activeReminderCount} pending reminder${
|
||||||
|
interest.activeReminderCount === 1 ? '' : 's'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlarmClock className="size-3" />
|
||||||
|
{interest.activeReminderCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
{meta.length > 0 ? (
|
||||||
{interest.berthMooringNumber && (
|
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||||
<span>
|
{meta.map((m, i) => (
|
||||||
Berth:{' '}
|
<span key={m.key}>
|
||||||
<Link
|
{i > 0 ? (
|
||||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
<span aria-hidden className="mx-1.5">
|
||||||
className="text-foreground hover:underline"
|
·
|
||||||
>
|
</span>
|
||||||
{interest.berthMooringNumber}
|
) : null}
|
||||||
</Link>
|
{m.node}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{interest.leadCategory && (
|
|
||||||
<span>
|
|
||||||
Category:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
))}
|
||||||
)}
|
</p>
|
||||||
{interest.source && (
|
) : null}
|
||||||
<span>
|
|
||||||
Source: <span className="text-foreground capitalize">{interest.source}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{interest.tags && interest.tags.length > 0 && (
|
{interest.tags && interest.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
{interest.tags.map((tag) => (
|
{interest.tags.map((tag) => (
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
||||||
|
client without leaving the interest workspace. Resolved from
|
||||||
|
the linked client's primary contact channels (server-side
|
||||||
|
fetch in getInterestById). */}
|
||||||
|
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{interest.clientPrimaryEmail ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||||
|
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||||
|
>
|
||||||
|
<Mail />
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{interest.clientPrimaryPhone ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`tel:${interest.clientPrimaryPhone}`}
|
||||||
|
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||||
|
>
|
||||||
|
<Phone />
|
||||||
|
Call
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{whatsappNumber ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${whatsappNumber}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Message on WhatsApp`}
|
||||||
|
>
|
||||||
|
<MessageCircle />
|
||||||
|
WhatsApp
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
buttons on desktop; Edit/Archive stay icon-only. On mobile,
|
||||||
<PermissionGate resource="interests" action="edit">
|
Won/Lost shrink to icon buttons to keep the cluster from
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
wrapping. */}
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</PermissionGate>
|
|
||||||
<PermissionGate resource="interests" action="change_stage">
|
<PermissionGate resource="interests" action="change_stage">
|
||||||
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}>
|
{isClosed ? (
|
||||||
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
|
<button
|
||||||
Change Stage
|
type="button"
|
||||||
</Button>
|
onClick={() => reopenMutation.mutate()}
|
||||||
|
disabled={reopenMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
|
||||||
|
'hover:bg-foreground/5 disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-3.5" />
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOutcomeDialog('won')}
|
||||||
|
aria-label="Mark as won"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
'border border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
'hover:bg-emerald-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trophy className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Mark won</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOutcomeDialog('lost')}
|
||||||
|
aria-label="Close as lost"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
'border border-rose-200 text-rose-700',
|
||||||
|
'hover:bg-rose-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Close as lost</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate resource="interests" action="edit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
aria-label="Edit interest"
|
||||||
|
title="Edit interest"
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||||
|
'hover:bg-foreground/5 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
<PermissionGate resource="interests" action="delete">
|
<PermissionGate resource="interests" action="delete">
|
||||||
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}>
|
<button
|
||||||
{isArchived ? (
|
type="button"
|
||||||
<>
|
onClick={() => setArchiveOpen(true)}
|
||||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
aria-label={isArchived ? 'Restore interest' : 'Archive interest'}
|
||||||
Restore
|
title={isArchived ? 'Restore interest' : 'Archive interest'}
|
||||||
</>
|
className={cn(
|
||||||
) : (
|
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||||
<>
|
'hover:bg-foreground/5',
|
||||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
||||||
Archive
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
>
|
||||||
|
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||||
|
</button>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DetailHeaderStrip>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
|
{outcomeDialog && (
|
||||||
|
<InterestOutcomeDialog
|
||||||
|
interestId={interest.id}
|
||||||
|
mode={outcomeDialog}
|
||||||
|
open={outcomeDialog !== null}
|
||||||
|
onOpenChange={(open) => !open && setOutcomeDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<InterestForm
|
<InterestForm
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
|
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InterestStagePicker
|
|
||||||
open={stageOpen}
|
|
||||||
onOpenChange={setStageOpen}
|
|
||||||
interestId={interest.id}
|
|
||||||
currentStage={interest.pipelineStage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArchiveConfirmDialog
|
<ArchiveConfirmDialog
|
||||||
open={archiveOpen}
|
open={archiveOpen}
|
||||||
onOpenChange={setArchiveOpen}
|
onOpenChange={setArchiveOpen}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -14,6 +16,29 @@ interface InterestData {
|
|||||||
portId: string;
|
portId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
|
/** Linked client's primary email (display value). Powers the header
|
||||||
|
* "Email" button and the EOI prereq checklist. */
|
||||||
|
clientPrimaryEmail: string | null;
|
||||||
|
/** Linked client's primary phone (display value). Powers the header
|
||||||
|
* "Call" button. */
|
||||||
|
clientPrimaryPhone: string | null;
|
||||||
|
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
|
||||||
|
* by wa.me to assemble the WhatsApp deep-link. */
|
||||||
|
clientPrimaryPhoneE164: string | null;
|
||||||
|
/** True when the linked client has any primary address row. Used by
|
||||||
|
* the EOI prereq checklist on the Documents tab. */
|
||||||
|
clientHasAddress: boolean;
|
||||||
|
/** Surfaced for the bell badge on the detail header (pending/snoozed
|
||||||
|
* reminders linked to this interest). */
|
||||||
|
activeReminderCount?: number;
|
||||||
|
/** Surfaced for the most-recent-note teaser on the Overview tab. */
|
||||||
|
notesCount?: number;
|
||||||
|
recentNote?: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
@@ -37,6 +62,8 @@ interface InterestData {
|
|||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
outcome?: string | null;
|
||||||
|
outcomeReason?: string | null;
|
||||||
tags: Array<{ id: string; name: string; color: string }>;
|
tags: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +79,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
const { data, isLoading } = useQuery<InterestData>({
|
const { data, isLoading } = useQuery<InterestData>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
|
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||||
(r) => r.data,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
@@ -65,17 +90,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
'interest:berthUnlinked': [['interests', interestId]],
|
'interest:berthUnlinked': [['interests', interestId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = data
|
const { setChrome } = useMobileChrome();
|
||||||
? getInterestTabs({ interestId, currentUserId, interest: data })
|
const titleForChrome: string | null = data?.clientName ?? null;
|
||||||
: [];
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<DetailLayout
|
||||||
header={
|
header={data ? <InterestDetailHeader portSlug={portSlug} interest={data} /> : null}
|
||||||
data ? (
|
|
||||||
<InterestDetailHeader portSlug={portSlug} interest={data} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
defaultTab="overview"
|
defaultTab="overview"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FileSignature } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DocumentList } from '@/components/documents/document-list';
|
import { DocumentList } from '@/components/documents/document-list';
|
||||||
@@ -17,20 +18,29 @@ interface InterestData {
|
|||||||
yachtId?: string | null;
|
yachtId?: string | null;
|
||||||
berthId?: string | null;
|
berthId?: string | null;
|
||||||
clientName?: string | null;
|
clientName?: string | null;
|
||||||
|
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
|
||||||
|
clientPrimaryEmail?: string | null;
|
||||||
|
clientHasAddress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: interestRes } = useQuery({
|
// Same query key + queryFn shape as InterestDetail's parent query, so the
|
||||||
|
// cache is consistent. (Mismatched shapes on the same key clobber each other
|
||||||
|
// and the parent header degenerates to "Unknown Client".)
|
||||||
|
const { data: interest } = useQuery<InterestData>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const interest = interestRes?.data;
|
|
||||||
|
|
||||||
const prerequisites = {
|
const prerequisites = {
|
||||||
|
// Required (EOI Section 2 — top paragraph): name, address, email.
|
||||||
hasName: Boolean(interest?.clientName),
|
hasName: Boolean(interest?.clientName),
|
||||||
|
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||||
|
hasAddress: Boolean(interest?.clientHasAddress),
|
||||||
|
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
|
||||||
hasYacht: Boolean(interest?.yachtId),
|
hasYacht: Boolean(interest?.yachtId),
|
||||||
hasBerth: Boolean(interest?.berthId),
|
hasBerth: Boolean(interest?.berthId),
|
||||||
};
|
};
|
||||||
@@ -39,12 +49,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||||
Generate EOI
|
Generate EOI
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentList interestId={interestId} />
|
<DocumentList
|
||||||
|
interestId={interestId}
|
||||||
|
emptyState={
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||||
|
<FileSignature className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">No documents yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Generate the EOI to send it for signing in one click.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||||
|
Generate EOI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<EoiGenerateDialog
|
<EoiGenerateDialog
|
||||||
interestId={interestId}
|
interestId={interestId}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||||
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
|
||||||
|
|
||||||
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 CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General Interest',
|
general_interest: 'General Interest',
|
||||||
@@ -30,7 +19,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
|
|||||||
label: 'Stage',
|
label: 'Stage',
|
||||||
type: 'multi-select',
|
type: 'multi-select',
|
||||||
options: PIPELINE_STAGES.map((s) => ({
|
options: PIPELINE_STAGES.map((s) => ({
|
||||||
label: STAGE_LABELS[s] ?? s,
|
label: STAGE_LABELS[s],
|
||||||
value: s,
|
value: s,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user