feat(platform): residential module + admin UI + reliability fixes
Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -21,3 +21,9 @@ docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
|
||||
@@ -91,10 +91,17 @@ src/
|
||||
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- **Documenso API responses:** 2.x renamed `id` → `documentId` and recipient `id` → `recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
|
||||
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
|
||||
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` all wrap their content in `<PortalAuthShell>` (`src/components/portal/portal-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
|
||||
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
|
||||
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
|
||||
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
|
||||
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
|
||||
|
||||
## Schema migrations during dev
|
||||
|
||||
When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it.
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
|
||||
|
||||
160
docs/website-refactor.md
Normal file
160
docs/website-refactor.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Website → CRM wiring refactor
|
||||
|
||||
The `website/` subrepo (Nuxt) currently writes inquiry submissions to NocoDB.
|
||||
The new CRM exposes its own public ingestion endpoints, so the website needs
|
||||
to be re-pointed at the CRM and the website's local server-side helpers can
|
||||
eventually be retired.
|
||||
|
||||
This document describes **what needs to change in the website repo**. Nothing
|
||||
here applies to the CRM repo — that side is already done.
|
||||
|
||||
## Endpoints the CRM now exposes
|
||||
|
||||
Both are unauthenticated, IP-rate-limited (5/hour), and require an explicit
|
||||
port id (query param `?portId=…` or header `X-Port-Id`).
|
||||
|
||||
| Form intent | New CRM endpoint | Old NocoDB target |
|
||||
| -------------------- | ---------------------------------------- | ------------------------ |
|
||||
| Berth interest | `POST /api/public/interests` | `Interests` (NocoDB) |
|
||||
| Residential interest | `POST /api/public/residential-inquiries` | `Interests (Residences)` |
|
||||
|
||||
Notification emails (client confirmation + sales-team alert) are sent by the
|
||||
CRM itself when these endpoints succeed, so the website's
|
||||
`sendRegistrationEmails` helper (`server/utils/email.ts`) is no longer
|
||||
required for these flows.
|
||||
|
||||
## Required changes in the website repo
|
||||
|
||||
### 1. New env vars
|
||||
|
||||
Add to `.env` and the deploy environment:
|
||||
|
||||
```
|
||||
PN_CRM_BASE_URL=https://crm.portnimara.com
|
||||
PN_CRM_PORT_ID=<uuid of the Port Nimara port row in CRM>
|
||||
```
|
||||
|
||||
`PN_CRM_BASE_URL` defaults to the prod CRM. In dev it can point to the local
|
||||
tunnel (`shoulder-contain-…trycloudflare.com`) so submissions hit a dev DB.
|
||||
|
||||
### 2. Refactor `server/api/register.ts`
|
||||
|
||||
Today the file owns both the berth and residence branches and writes to
|
||||
NocoDB directly. After the refactor, both branches just relay to the CRM:
|
||||
|
||||
```ts
|
||||
const baseUrl = process.env.PN_CRM_BASE_URL;
|
||||
const portId = process.env.PN_CRM_PORT_ID;
|
||||
|
||||
if (category === 'Residences') {
|
||||
await $fetch(`${baseUrl}/api/public/residential-inquiries?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
placeOfResidence: body.address,
|
||||
preferredContactMethod: body.method_of_contact, // 'email' | 'phone'
|
||||
notes: body.notes,
|
||||
// preferences: collect via new optional textarea (see section 4)
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Berth branch
|
||||
await $fetch(`${baseUrl}/api/public/interests?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
// map to the CRM's publicInterestSchema (see src/lib/validators/interests.ts)
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
berthSize: body.berth_size,
|
||||
berthMinLength: body.berth_min_length,
|
||||
berthMinWidth: body.berth_min_width,
|
||||
berthMinDraught: body.berth_min_draught,
|
||||
yachtName: body.berth_yacht_name,
|
||||
preferredMethodOfContact: body.method_of_contact,
|
||||
specificBerthMooring: body.berth, // optional, links interest to a specific berth
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
```
|
||||
|
||||
The reCAPTCHA verification stays in the website handler — the CRM trusts the
|
||||
website to gate its public endpoints.
|
||||
|
||||
### 3. Retire dead code
|
||||
|
||||
After step 2, the following can be deleted from the website:
|
||||
|
||||
- `server/utils/websiteInterests.ts`
|
||||
- `server/utils/residentialInterests.ts`
|
||||
- `server/utils/nocodb.ts`
|
||||
- The NocoDB-specific call sites in `server/utils/email.ts` (the CRM
|
||||
sends its own confirmation/alert emails)
|
||||
- NocoDB env vars (`NOCODB_*`)
|
||||
|
||||
The Nuxt `/api/berths` route stays as-is — it reads from the
|
||||
`directus_items.berths` collection for the public site, not the CRM.
|
||||
|
||||
### 4. Form additions on `pages/register.vue`
|
||||
|
||||
The current residence branch only collects contact info. The CRM accepts an
|
||||
optional `preferences` field (free-text) and `notes` field. Add a
|
||||
"Preferences" textarea inside the residences block of
|
||||
`components/pn/specific/website/register/form.vue`:
|
||||
|
||||
```vue
|
||||
<transition name="fade-down">
|
||||
<div v-show="interest === 'residences'">
|
||||
<vee-field
|
||||
as="textarea"
|
||||
class="form-input py-3 px-0 md:text-lg border-0 border-t border-davysgrey ..."
|
||||
placeholder="Tell us what you're looking for (unit type, budget, timeline)"
|
||||
name="residence_preferences"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
```
|
||||
|
||||
Append `preferences: body.residence_preferences` in the POST body in
|
||||
`server/api/register.ts`.
|
||||
|
||||
### 5. Stand up a residential-only `residences.vue` form (optional)
|
||||
|
||||
Today the residences interest is captured on `register.vue` via a radio. If
|
||||
the marketing team wants a dedicated CTA on `residences.vue`, add a small
|
||||
inline form using the same submit handler from step 2. No new endpoint —
|
||||
this is purely a UX addition.
|
||||
|
||||
## Deployment order
|
||||
|
||||
1. **CRM first**: deploy this repo, ensure `/api/public/interests` and
|
||||
`/api/public/residential-inquiries` are reachable from the website host.
|
||||
2. **Verify in CRM**: configure `Inquiry Contact Email` and (for residential)
|
||||
`Residential Notification Recipients` per port in
|
||||
admin → settings.
|
||||
3. **Smoke test from a dev tunnel** (curl the public endpoints with a JSON
|
||||
payload). Confirm rows land in `clients`/`residential_clients` and
|
||||
notification emails are received.
|
||||
4. **Then deploy website changes** (sections 1–3 above). The form
|
||||
submissions immediately start landing in the new CRM.
|
||||
5. **Cut-over note**: once the website is pointed at the CRM, leave the
|
||||
NocoDB tables read-only as a historical archive. Don't delete them until
|
||||
prod data has been imported into the new CRM (see "Prod data import
|
||||
strategy" task #59 in the task list).
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Port routing for multi-port deploys**: today the website only knows about
|
||||
Port Nimara. If/when the website serves multiple ports, the `portId`
|
||||
resolution needs to happen per-domain or per-route, not a single env var.
|
||||
- **Brand/email domain**: confirm whether residential confirmations should
|
||||
send from the same `noreply@letsbe.solutions` address as marina, or a
|
||||
dedicated residential mailbox. The CRM uses `SMTP_FROM`, which is global.
|
||||
102
scripts/dev-create-crm-user.ts
Normal file
102
scripts/dev-create-crm-user.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Dev-only helper: create (or upsert) a CRM better-auth user and mark them
|
||||
* super_admin. Idempotent — re-running with the same email will reset the
|
||||
* password.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main() {
|
||||
const [email, password, displayNameArg] = process.argv.slice(2);
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
'Usage: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = displayNameArg ?? email.split('@')[0] ?? 'User';
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
|
||||
try {
|
||||
// 1. Check if better-auth user already exists.
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
|
||||
`;
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existing.length > 0) {
|
||||
const row = existing[0];
|
||||
if (!row) throw new Error('unreachable');
|
||||
userId = row.id;
|
||||
console.log(`User ${email} exists (id=${userId}); resetting password.`);
|
||||
// Use better-auth's internal context to hash and update the credential.
|
||||
const ctx = await auth.$context;
|
||||
const hash = await ctx.password.hash(password);
|
||||
await sql`
|
||||
UPDATE account
|
||||
SET password = ${hash}, updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND provider_id = 'credential'
|
||||
`;
|
||||
} else {
|
||||
console.log(`Creating better-auth user ${email}…`);
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email, password, name: displayName },
|
||||
});
|
||||
userId = result.user.id;
|
||||
console.log(`Created user_id=${userId}`);
|
||||
}
|
||||
|
||||
// 2. Upsert user_profiles entry as super admin.
|
||||
const profile = await db
|
||||
.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (profile.length === 0) {
|
||||
await db.insert(userProfiles).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
});
|
||||
console.log(`Created super_admin profile for ${userId}`);
|
||||
} else {
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ displayName, isSuperAdmin: true, isActive: true })
|
||||
.where(eq(userProfiles.userId, userId));
|
||||
console.log(`Updated profile for ${userId} (super_admin=true)`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`✓ Done. Sign in at http://localhost:3000/login with`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` password: ${password}`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
25
scripts/dev-list-users.ts
Normal file
25
scripts/dev-list-users.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
async function main() {
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
const users =
|
||||
await sql`SELECT id, email, name, email_verified, created_at FROM "user" ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user ---');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
const profiles =
|
||||
await sql`SELECT user_id, display_name, is_super_admin, is_active FROM user_profiles ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user_profiles ---');
|
||||
console.log(JSON.stringify(profiles, null, 2));
|
||||
const accounts =
|
||||
await sql`SELECT user_id, provider_id, account_id FROM account ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- account ---');
|
||||
console.log(JSON.stringify(accounts, null, 2));
|
||||
await sql.end();
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
36
scripts/dev-trigger-crm-invite.ts
Normal file
36
scripts/dev-trigger-crm-invite.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Dev-only helper: issue a CRM admin invite and send the activation email.
|
||||
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
|
||||
* lands in the dev inbox.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const email = args[0];
|
||||
if (!email) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
|
||||
process.exit(1);
|
||||
}
|
||||
const isSuperAdmin = args.includes('--super');
|
||||
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
|
||||
|
||||
const { inviteId, link } = await createCrmInvite({ email, name, isSuperAdmin });
|
||||
console.log(`✓ Invite created (id=${inviteId})`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` super_admin: ${isSuperAdmin}`);
|
||||
console.log(` activation link: ${link}`);
|
||||
console.log('');
|
||||
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,14 +8,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center wave-watermark"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<div className="w-full max-w-md px-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
@@ -55,64 +55,53 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Marina CRM</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const resetSchema = z.object({
|
||||
@@ -49,69 +49,55 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Reset your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Check your email</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If an account exists for that email address, we have sent a password reset link.
|
||||
Please check your inbox and spam folder.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.email && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">We'll email you a link</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="font-medium text-gray-900">Check your email</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
If an account exists for that email address, we have sent a password reset link. Please
|
||||
check your inbox and spam folder.
|
||||
</p>
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Remember your password?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-[#007bff] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const MIN_LENGTH = 9;
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
|
||||
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
@@ -31,25 +27,11 @@ const passwordSchema = z
|
||||
|
||||
type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
type Requirement = {
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
};
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
|
||||
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
|
||||
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
|
||||
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
|
||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||
];
|
||||
|
||||
function SetPasswordInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -61,7 +43,7 @@ function SetPasswordInner() {
|
||||
|
||||
async function onSubmit(data: SetPasswordFormData) {
|
||||
if (!token) {
|
||||
toast.error('Invalid or missing reset token. Please request a new password reset link.');
|
||||
toast.error('Invalid or missing reset token. Please request a new link.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +57,7 @@ function SetPasswordInner() {
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
toast.error(body.message ?? 'Failed to set password. Please try again.');
|
||||
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,102 +70,77 @@ function SetPasswordInner() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please use the link from the email we sent you. If the link is broken, ask your
|
||||
administrator for a new one.
|
||||
</p>
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Set your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!token ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
Invalid or missing token. Please request a new password reset link.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password', {
|
||||
onChange: (e) => setPasswordValue(e.target.value),
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1 pt-1">
|
||||
{requirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<li
|
||||
key={req.label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
|
||||
<SetPasswordInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
|
||||
|
||||
export default function FormTemplatesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
|
||||
<p className="text-muted-foreground">Create and manage intake form templates</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <FormTemplateList />;
|
||||
}
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/dashboard/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
}
|
||||
@@ -1,16 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { EmailAccountsList } from '@/components/email/email-accounts-list';
|
||||
import { EmailThreadsList } from '@/components/email/email-threads-list';
|
||||
import { ComposeDialog } from '@/components/email/compose-dialog';
|
||||
|
||||
export default function EmailPage() {
|
||||
const [tab, setTab] = useState('threads');
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
||||
<p className="text-muted-foreground">Send and manage client communications</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
||||
<p className="text-muted-foreground">Send and manage client communications</p>
|
||||
</div>
|
||||
<Button onClick={() => setComposeOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Compose
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="threads">Inbox</TabsTrigger>
|
||||
<TabsTrigger value="accounts">Accounts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="threads" className="pt-4">
|
||||
<EmailThreadsList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts" className="pt-4">
|
||||
<EmailAccountsList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ComposeDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||
|
||||
export default function NotificationPreferencesPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Notification Preferences</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which notifications you receive and how.
|
||||
</p>
|
||||
</div>
|
||||
<NotificationPreferencesForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
redirect(`/${portSlug}/dashboard` as any);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ResidentialClientDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialClientDetail clientId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ResidentialClientsList } from '@/components/residential/residential-clients-list';
|
||||
|
||||
export default function ResidentialClientsPage() {
|
||||
return <ResidentialClientsList />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ResidentialInterestDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialInterestDetail interestId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ResidentialInterestsList } from '@/components/residential/residential-interests-list';
|
||||
|
||||
export default function ResidentialInterestsPage() {
|
||||
return <ResidentialInterestsList />;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userPortRoles } from '@/lib/db/schema/users';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { SocketProvider } from '@/providers/socket-provider';
|
||||
import { PortProvider } from '@/providers/port-provider';
|
||||
@@ -16,26 +17,31 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user) redirect('/login');
|
||||
|
||||
// Load user's port assignments for PortProvider
|
||||
// Super admins have implicit access to every port; everyone else only sees
|
||||
// ports they have an explicit user_port_roles row for.
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
const portRoles = await db.query.userPortRoles.findMany({
|
||||
where: eq(userPortRoles.userId, session.user.id),
|
||||
with: { port: true, role: true },
|
||||
});
|
||||
|
||||
const ports = portRoles.map((pr) => pr.port);
|
||||
const ports = profile?.isSuperAdmin
|
||||
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
||||
: portRoles.map((pr) => pr.port);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
|
||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar portRoles={portRoles} />
|
||||
<Sidebar portRoles={portRoles} isSuperAdmin={profile?.isSuperAdmin ?? false} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar ports={ports} />
|
||||
<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>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Mail } from 'lucide-react';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
export default function PortalForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -31,77 +32,74 @@ export default function PortalForgotPasswordPage() {
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
||||
<Mail className="h-7 w-7 text-green-600" />
|
||||
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
If <strong>{email}</strong> matches a portal account, we've sent a reset link. The
|
||||
link expires in 30 minutes.
|
||||
</p>
|
||||
<Link
|
||||
href="/portal/login"
|
||||
className="mt-6 inline-block text-sm text-[#1e2844] hover:underline"
|
||||
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href="/portal/login"
|
||||
className="block mt-4 text-center text-xs text-gray-500 hover:underline"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link href="/portal/login" className="text-[#007bff] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -49,7 +49,7 @@ export default function PortalLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalAuthShell>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
|
||||
@@ -110,6 +110,6 @@ export default function PortalLoginPage() {
|
||||
<p className="text-center text-xs text-gray-400 mt-6">
|
||||
This portal is for existing clients only.
|
||||
</p>
|
||||
</PortalAuthShell>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/app/api/auth/set-password/route.ts
Normal file
37
src/app/api/auth/set-password/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await consumeCrmInvite({
|
||||
token: parsed.data.token,
|
||||
password: parsed.data.password,
|
||||
});
|
||||
return NextResponse.json({ success: true, email: result.email });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
176
src/app/api/public/residential-inquiries/route.ts
Normal file
176
src/app/api/public/residential-inquiries/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
|
||||
|
||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_HITS = 5;
|
||||
|
||||
function checkRateLimit(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = ipHits.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return;
|
||||
}
|
||||
if (entry.count >= MAX_HITS) {
|
||||
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
|
||||
}
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
||||
* the public website's residential interest form. Creates a
|
||||
* `residential_clients` row and an opening `residential_interests` row in a
|
||||
* single transaction.
|
||||
*
|
||||
* Required: `portId` query param or `X-Port-Id` header.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
checkRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicResidentialInquirySchema.parse(body);
|
||||
|
||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||
if (!portId) {
|
||||
throw new ValidationError('portId is required');
|
||||
}
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) {
|
||||
throw new ValidationError('Unknown port');
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [client] = await tx
|
||||
.insert(residentialClients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
source: 'website',
|
||||
status: 'prospect',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
if (!client) throw new Error('Failed to create residential client');
|
||||
|
||||
const [interest] = await tx
|
||||
.insert(residentialInterests)
|
||||
.values({
|
||||
portId,
|
||||
residentialClientId: client.id,
|
||||
pipelineStage: 'new',
|
||||
source: 'website',
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
})
|
||||
.returning();
|
||||
if (!interest) throw new Error('Failed to create residential interest');
|
||||
|
||||
return { clientId: client.id, interestId: interest.id };
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
||||
|
||||
// Send notification emails (non-blocking — failures shouldn't 500 the
|
||||
// public form).
|
||||
void sendResidentialNotifications({
|
||||
portId,
|
||||
data,
|
||||
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
||||
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
||||
|
||||
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResidentialNotifications(args: {
|
||||
portId: string;
|
||||
data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
};
|
||||
crmDeepLink: string;
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation({
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
});
|
||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||
|
||||
// Sales-team alert — pull recipients from system_settings if configured;
|
||||
// fall back to the inquiry_contact_email if available.
|
||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||
where: and(
|
||||
eq(systemSettings.key, 'residential_notification_recipients'),
|
||||
eq(systemSettings.portId, portId),
|
||||
),
|
||||
});
|
||||
const fallbackRow = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
|
||||
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
|
||||
const fallback =
|
||||
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
|
||||
? [fallbackRow.value]
|
||||
: [];
|
||||
const recipients = configured.length > 0 ? configured : fallback;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
logger.warn(
|
||||
{ portId },
|
||||
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert({
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
crmDeepLink,
|
||||
});
|
||||
|
||||
await sendEmail(recipients, alert.subject, alert.html);
|
||||
}
|
||||
58
src/app/api/v1/admin/form-templates/[id]/route.ts
Normal file
58
src/app/api/v1/admin/form-templates/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
deleteFormTemplate,
|
||||
getFormTemplateById,
|
||||
updateFormTemplate,
|
||||
} from '@/lib/services/form-templates.service';
|
||||
import { updateFormTemplateSchema } from '@/lib/validators/form-templates';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
const tpl = await getFormTemplateById(params.id, ctx.portId);
|
||||
return NextResponse.json({ data: tpl });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
const body = await parseBody(req, updateFormTemplateSchema);
|
||||
const tpl = await updateFormTemplate(params.id, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: tpl });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
await deleteFormTemplate(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
35
src/app/api/v1/admin/form-templates/route.ts
Normal file
35
src/app/api/v1/admin/form-templates/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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 { createFormTemplate, listFormTemplates } from '@/lib/services/form-templates.service';
|
||||
import { createFormTemplateSchema } from '@/lib/validators/form-templates';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listFormTemplates(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createFormTemplateSchema);
|
||||
const tpl = await createFormTemplate(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: tpl }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
65
src/app/api/v1/berths/[id]/reservations/handlers.ts
Normal file
65
src/app/api/v1/berths/[id]/reservations/handlers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { NotFoundError, errorResponse } from '@/lib/errors';
|
||||
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
|
||||
|
||||
// URL berthId is authoritative; make body berthId optional (ignored anyway).
|
||||
const createPendingBodySchema = createPendingSchema
|
||||
.omit({ berthId: true })
|
||||
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
|
||||
|
||||
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
}
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
const query = parseQuery(req, listReservationsSchema);
|
||||
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
const body = await parseBody(req, createPendingBodySchema);
|
||||
const reservation = await createPending(
|
||||
ctx.portId,
|
||||
{ ...body, berthId: params.id! },
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: reservation }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,72 +1,6 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { NotFoundError, errorResponse } from '@/lib/errors';
|
||||
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
|
||||
|
||||
// URL berthId is authoritative; make body berthId optional (ignored anyway).
|
||||
const createPendingBodySchema = createPendingSchema
|
||||
.omit({ berthId: true })
|
||||
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
|
||||
|
||||
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
}
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
|
||||
const query = parseQuery(req, listReservationsSchema);
|
||||
// URL berthId is authoritative; override any client-supplied value.
|
||||
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
|
||||
const body = await parseBody(req, createPendingBodySchema);
|
||||
// URL berthId is authoritative; any body-supplied berthId is ignored.
|
||||
const reservation = await createPending(
|
||||
ctx.portId,
|
||||
{ ...body, berthId: params.id! },
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: reservation }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('reservations', 'create', createHandler));
|
||||
|
||||
45
src/app/api/v1/companies/[id]/handlers.ts
Normal file
45
src/app/api/v1/companies/[id]/handlers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
|
||||
import { updateCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const company = await getCompanyById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: company });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateCompanySchema);
|
||||
const updated = await updateCompany(params.id!, 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 {
|
||||
await archiveCompany(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);
|
||||
}
|
||||
};
|
||||
63
src/app/api/v1/companies/[id]/notes/[noteId]/route.ts
Normal file
63
src/app/api/v1/companies/[id]/notes/[noteId]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { updateNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
const body = await parseBody(req, updateNoteSchema);
|
||||
const note = await notesService.update(ctx.portId, 'companies', companyId, noteId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'company_note',
|
||||
entityId: noteId,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('companies', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
await notesService.deleteNote(ctx.portId, 'companies', companyId, noteId);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'company_note',
|
||||
entityId: noteId,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/companies/[id]/notes/route.ts
Normal file
47
src/app/api/v1/companies/[id]/notes/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { createNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('companies', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
const notes = await notesService.listForEntity(ctx.portId, 'companies', companyId);
|
||||
return NextResponse.json({ data: notes });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
const body = await parseBody(req, createNoteSchema);
|
||||
const note = await notesService.create(ctx.portId, 'companies', companyId, ctx.userId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'company_note',
|
||||
entityId: note.id,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,48 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
|
||||
import { updateCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const company = await getCompanyById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: company });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateCompanySchema);
|
||||
const updated = await updateCompany(params.id!, 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 {
|
||||
await archiveCompany(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('companies', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));
|
||||
|
||||
28
src/app/api/v1/companies/[id]/tags/route.ts
Normal file
28
src/app/api/v1/companies/[id]/tags/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setCompanyTags } from '@/lib/services/companies.service';
|
||||
|
||||
const setTagsSchema = z.object({
|
||||
tagIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const { tagIds } = await parseBody(req, setTagsSchema);
|
||||
await setCompanyTags(params.id!, ctx.portId, tagIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/companies/handlers.ts
Normal file
44
src/app/api/v1/companies/handlers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listCompanies, createCompany } from '@/lib/services/companies.service';
|
||||
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listCompaniesSchema);
|
||||
const result = await listCompanies(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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createCompanySchema);
|
||||
const company = await createCompany(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: company }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,47 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listCompanies, createCompany } from '@/lib/services/companies.service';
|
||||
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listCompaniesSchema);
|
||||
const result = await listCompanies(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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createCompanySchema);
|
||||
const company = await createCompany(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: company }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('companies', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('companies', 'create', createHandler));
|
||||
|
||||
55
src/app/api/v1/residential/clients/[id]/route.ts
Normal file
55
src/app/api/v1/residential/clients/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 {
|
||||
archiveResidentialClient,
|
||||
getResidentialClientById,
|
||||
updateResidentialClient,
|
||||
} from '@/lib/services/residential.service';
|
||||
import { updateResidentialClientSchema } from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const client = await getResidentialClientById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: client });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('residential_clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateResidentialClientSchema);
|
||||
const updated = await updateResidentialClient(params.id!, 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 DELETE = withAuth(
|
||||
withPermission('residential_clients', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveResidentialClient(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
54
src/app/api/v1/residential/clients/route.ts
Normal file
54
src/app/api/v1/residential/clients/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createResidentialClient,
|
||||
listResidentialClients,
|
||||
} from '@/lib/services/residential.service';
|
||||
import {
|
||||
createResidentialClientSchema,
|
||||
listResidentialClientsSchema,
|
||||
} from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listResidentialClientsSchema);
|
||||
const result = await listResidentialClients(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('residential_clients', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createResidentialClientSchema);
|
||||
const client = await createResidentialClient(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: client }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
55
src/app/api/v1/residential/interests/[id]/route.ts
Normal file
55
src/app/api/v1/residential/interests/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 {
|
||||
archiveResidentialInterest,
|
||||
getResidentialInterestById,
|
||||
updateResidentialInterest,
|
||||
} from '@/lib/services/residential.service';
|
||||
import { updateResidentialInterestSchema } from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_interests', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const interest = await getResidentialInterestById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: interest });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('residential_interests', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateResidentialInterestSchema);
|
||||
const updated = await updateResidentialInterest(params.id!, 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 DELETE = withAuth(
|
||||
withPermission('residential_interests', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveResidentialInterest(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
54
src/app/api/v1/residential/interests/route.ts
Normal file
54
src/app/api/v1/residential/interests/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createResidentialInterest,
|
||||
listResidentialInterests,
|
||||
} from '@/lib/services/residential.service';
|
||||
import {
|
||||
createResidentialInterestSchema,
|
||||
listResidentialInterestsSchema,
|
||||
} from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_interests', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listResidentialInterestsSchema);
|
||||
const result = await listResidentialInterests(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('residential_interests', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createResidentialInterestSchema);
|
||||
const interest = await createResidentialInterest(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: interest }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
45
src/app/api/v1/yachts/[id]/handlers.ts
Normal file
45
src/app/api/v1/yachts/[id]/handlers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
|
||||
import { updateYachtSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const yacht = await getYachtById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: yacht });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateYachtSchema);
|
||||
const updated = await updateYacht(params.id!, 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 {
|
||||
await archiveYacht(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);
|
||||
}
|
||||
};
|
||||
63
src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts
Normal file
63
src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { updateNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
const body = await parseBody(req, updateNoteSchema);
|
||||
const note = await notesService.update(ctx.portId, 'yachts', yachtId, noteId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'yacht_note',
|
||||
entityId: noteId,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('yachts', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
await notesService.deleteNote(ctx.portId, 'yachts', yachtId, noteId);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'yacht_note',
|
||||
entityId: noteId,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/yachts/[id]/notes/route.ts
Normal file
47
src/app/api/v1/yachts/[id]/notes/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { createNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('yachts', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
const notes = await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
|
||||
return NextResponse.json({ data: notes });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
const body = await parseBody(req, createNoteSchema);
|
||||
const note = await notesService.create(ctx.portId, 'yachts', yachtId, ctx.userId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'yacht_note',
|
||||
entityId: note.id,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,48 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
|
||||
import { updateYachtSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const yacht = await getYachtById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: yacht });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateYachtSchema);
|
||||
const updated = await updateYacht(params.id!, 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 {
|
||||
await archiveYacht(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));
|
||||
|
||||
28
src/app/api/v1/yachts/[id]/tags/route.ts
Normal file
28
src/app/api/v1/yachts/[id]/tags/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setYachtTags } from '@/lib/services/yachts.service';
|
||||
|
||||
const setTagsSchema = z.object({
|
||||
tagIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const { tagIds } = await parseBody(req, setTagsSchema);
|
||||
await setYachtTags(params.id!, ctx.portId, tagIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/yachts/handlers.ts
Normal file
44
src/app/api/v1/yachts/handlers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listYachts, createYacht } from '@/lib/services/yachts.service';
|
||||
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listYachtsSchema);
|
||||
const result = await listYachts(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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createYachtSchema);
|
||||
const yacht = await createYacht(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: yacht }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,47 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listYachts, createYacht } from '@/lib/services/yachts.service';
|
||||
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listYachtsSchema);
|
||||
const result = await listYachts(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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createYachtSchema);
|
||||
const yacht = await createYacht(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: yacht }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('yachts', 'create', createHandler));
|
||||
|
||||
38
src/app/dashboard/page.tsx
Normal file
38
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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 { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||
|
||||
/**
|
||||
* Plain `/dashboard` lands users into their default port's dashboard. Used
|
||||
* by post-login redirects and any code that doesn't yet know the active
|
||||
* port slug.
|
||||
*/
|
||||
export default async function DashboardRedirectPage() {
|
||||
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),
|
||||
});
|
||||
|
||||
let slug: string | undefined;
|
||||
if (profile?.isSuperAdmin) {
|
||||
const first = await db.query.ports.findFirst({ orderBy: portsTable.name });
|
||||
slug = first?.slug;
|
||||
} else {
|
||||
const role = await db.query.userPortRoles.findFirst({
|
||||
where: eq(userPortRoles.userId, session.user.id),
|
||||
with: { port: true },
|
||||
});
|
||||
slug = role?.port.slug;
|
||||
}
|
||||
|
||||
if (!slug) redirect('/login?error=no-port-access');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
redirect(`/${slug}/dashboard` as any);
|
||||
}
|
||||
243
src/components/admin/forms/form-template-form.tsx
Normal file
243
src/components/admin/forms/form-template-form.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { FormField } from '@/lib/validators/form-templates';
|
||||
|
||||
interface FormTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
fields: FormField[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: FormTemplate | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FIELD: FormField = {
|
||||
key: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
};
|
||||
|
||||
const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'textarea', label: 'Long text' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'select', label: 'Select' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
];
|
||||
|
||||
export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [fields, setFields] = useState<FormField[]>([{ ...DEFAULT_FIELD }]);
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name);
|
||||
setDescription(template.description ?? '');
|
||||
setIsActive(template.isActive);
|
||||
setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]);
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setIsActive(true);
|
||||
setFields([{ ...DEFAULT_FIELD }]);
|
||||
}
|
||||
}, [template, open]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
fields,
|
||||
isActive,
|
||||
};
|
||||
if (template) {
|
||||
return apiFetch(`/api/v1/admin/form-templates/${template.id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
return apiFetch('/api/v1/admin/form-templates', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(template ? 'Template saved' : 'Template created');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
|
||||
});
|
||||
|
||||
function updateField(idx: number, patch: Partial<FormField>) {
|
||||
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
function addField() {
|
||||
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
|
||||
}
|
||||
|
||||
function removeField(idx: number) {
|
||||
setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{template ? 'Edit form template' : 'New form template'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Fields</Label>
|
||||
<Button variant="outline" size="sm" onClick={addField}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add field
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((f, i) => (
|
||||
<div key={i} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Field {i + 1}</span>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-7 w-7"
|
||||
onClick={() => removeField(i)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Key (no spaces)</Label>
|
||||
<Input
|
||||
value={f.key}
|
||||
onChange={(e) => updateField(i, { key: e.target.value })}
|
||||
placeholder="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={f.label}
|
||||
onChange={(e) => updateField(i, { label: e.target.value })}
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={f.type}
|
||||
onValueChange={(v) => updateField(i, { type: v as FormField['type'] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((ft) => (
|
||||
<SelectItem key={ft.value} value={ft.value}>
|
||||
{ft.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 flex items-end gap-2 pb-1">
|
||||
<Switch
|
||||
checked={!!f.required}
|
||||
onCheckedChange={(v) => updateField(i, { required: v })}
|
||||
/>
|
||||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
</div>
|
||||
{f.type === 'select' && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Options (comma-separated)</Label>
|
||||
<Input
|
||||
value={(f.options ?? []).join(', ')}
|
||||
onChange={(e) =>
|
||||
updateField(i, {
|
||||
options: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={
|
||||
saveMutation.isPending ||
|
||||
!name.trim() ||
|
||||
fields.some((f) => !f.key.trim() || !f.label.trim())
|
||||
}
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving…' : 'Save template'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
123
src/components/admin/forms/form-template-list.tsx
Normal file
123
src/components/admin/forms/form-template-list.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { FormField } from '@/lib/validators/form-templates';
|
||||
import { FormTemplateForm } from './form-template-form';
|
||||
|
||||
export interface FormTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
fields: FormField[];
|
||||
isActive: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function FormTemplateList() {
|
||||
const qc = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FormTemplate | null>(null);
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery<FormTemplate[]>({
|
||||
queryKey: ['admin', 'form-templates'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: FormTemplate[] }>('/api/v1/admin/form-templates').then((r) => r.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/admin/form-templates/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Template deleted');
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Form Templates"
|
||||
description="Public intake forms for clients (residential inquiries, EOI supplements, etc.)"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
New template
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">No form templates yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t.name}</span>
|
||||
{!t.isActive && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t.fields.length} field{t.fields.length === 1 ? '' : 's'} · updated{' '}
|
||||
{format(new Date(t.updatedAt), 'MMM d, yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditing(t);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Delete form template"
|
||||
description={`Delete "${t.name}"? Existing submissions will be preserved.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => deleteMutation.mutate(t.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormTemplateForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
template={editing}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,14 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
manage_tags: false,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -30,6 +30,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean' | 'number' | 'json' | 'string';
|
||||
defaultValue: unknown;
|
||||
}> = [
|
||||
{
|
||||
key: 'client_portal_enabled',
|
||||
label: 'Client Portal',
|
||||
description:
|
||||
'Allow clients of this port to sign in and manage their account through the client portal.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_interest_scoring',
|
||||
label: 'AI Interest Scoring',
|
||||
@@ -89,6 +97,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'json',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'residential_notification_recipients',
|
||||
label: 'Residential Notification Recipients',
|
||||
description:
|
||||
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'json',
|
||||
defaultValue: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsManager() {
|
||||
|
||||
@@ -30,6 +30,7 @@ interface UserFormProps {
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
role: { id: string; name: string };
|
||||
residentialAccess?: boolean;
|
||||
} | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
const [phone, setPhone] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [residentialAccess, setResidentialAccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -63,6 +65,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
setPhone(user.phone ?? '');
|
||||
setRoleId(user.role.id);
|
||||
setIsActive(user.isActive);
|
||||
setResidentialAccess(user.residentialAccess ?? false);
|
||||
setPassword('');
|
||||
} else {
|
||||
setName('');
|
||||
@@ -71,6 +74,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
setPhone('');
|
||||
setRoleId('');
|
||||
setIsActive(true);
|
||||
setResidentialAccess(false);
|
||||
setPassword('');
|
||||
}
|
||||
setError(null);
|
||||
@@ -91,6 +95,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
phone: phone || null,
|
||||
roleId,
|
||||
isActive,
|
||||
residentialAccess,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -103,6 +108,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
displayName,
|
||||
phone: phone || undefined,
|
||||
roleId,
|
||||
residentialAccess,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -190,6 +196,21 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label htmlFor="user-residential">Residential access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Grant this user access to residential clients and interests in addition to their
|
||||
primary role.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="user-residential"
|
||||
checked={residentialAccess}
|
||||
onCheckedChange={setResidentialAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function BerthStatusSuggestionDialog({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: suggestedStatus, reason }),
|
||||
body: { status: suggestedStatus, reason },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onApplied();
|
||||
@@ -66,21 +66,14 @@ export function BerthStatusSuggestionDialog({
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{reason && (
|
||||
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
|
||||
)}
|
||||
{reason && <p className="text-sm text-muted-foreground text-center px-4">{reason}</p>}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => applyMutation.mutate()}
|
||||
disabled={applyMutation.isPending}
|
||||
>
|
||||
{applyMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Button onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||
Apply Change
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -81,9 +77,7 @@ function SortableEntry({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{entry.clientId}</p>
|
||||
{entry.notes && (
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
|
||||
)}
|
||||
{entry.notes && <p className="text-xs text-muted-foreground truncate">{entry.notes}</p>}
|
||||
</div>
|
||||
|
||||
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
|
||||
@@ -118,7 +112,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
mutationFn: (body: { entryId: string; newPosition: number }) =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
body,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||
@@ -129,7 +123,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
mutationFn: (entries: WaitingListEntry[]) =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ entries }),
|
||||
body: { entries },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||
@@ -207,10 +201,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={newPriority}
|
||||
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
|
||||
>
|
||||
<Select value={newPriority} onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -226,9 +217,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||
{addMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{addMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||
Add to List
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
|
||||
@@ -243,22 +232,11 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
No entries on waiting list.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={entries.map((e) => e.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<SortableEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
<SortableEntry key={entry.id} entry={entry} onRemove={handleRemove} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ClientRow {
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
@@ -39,10 +41,6 @@ interface GetColumnsOptions {
|
||||
onArchive: (client: ClientRow) => void;
|
||||
}
|
||||
|
||||
// TODO: Add "Yachts" (count) and "Primary company" columns once the
|
||||
// GET /api/v1/clients list endpoint joins owned-yachts and primary-company
|
||||
// data into the row shape. Until then, the columns are omitted rather than
|
||||
// shown as empty placeholders.
|
||||
export function getClientColumns({
|
||||
portSlug,
|
||||
onEdit,
|
||||
@@ -100,6 +98,36 @@ export function getClientColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'yachtCount',
|
||||
header: 'Yachts',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.yachtCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'companyCount',
|
||||
header: 'Companies',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.companyCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { ClientForm } from '@/components/clients/client-form';
|
||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
@@ -25,22 +24,10 @@ interface ClientDetailHeaderProps {
|
||||
archivedAt?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
clientPortalEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ClientFormClient = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string; label?: string | null; isPrimary?: boolean }>;
|
||||
tags?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
@@ -50,7 +37,6 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
|
||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
const isArchived = !!client.archivedAt;
|
||||
@@ -128,17 +114,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isArchived && (
|
||||
{!isArchived && client.clientPortalEnabled !== false && (
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
defaultEmail={primaryEmail?.value}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={isArchived ? 'outline' : 'outline'}
|
||||
size="sm"
|
||||
@@ -160,12 +142,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientForm
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
client={client as unknown as ClientFormClient}
|
||||
/>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ClientData {
|
||||
source: string | null;
|
||||
sourceDetails: string | null;
|
||||
archivedAt: string | null;
|
||||
clientPortalEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
contacts: Array<{
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||
import { ContactsEditor } from '@/components/clients/contacts-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type ClientPatchField =
|
||||
| 'fullName'
|
||||
| 'nationality'
|
||||
| 'preferredContactMethod'
|
||||
| 'preferredLanguage'
|
||||
| 'timezone'
|
||||
| 'source'
|
||||
| 'sourceDetails';
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
];
|
||||
|
||||
const CONTACT_METHOD_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
];
|
||||
|
||||
function useClientPatch(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<Record<ClientPatchField, string | null>>) => {
|
||||
return apiFetch(`/api/v1/clients/${clientId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientTabsOptions {
|
||||
clientId: string;
|
||||
@@ -57,83 +109,83 @@ interface ClientTabsOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function OverviewTab({
|
||||
clientId,
|
||||
client,
|
||||
}: {
|
||||
clientId: string;
|
||||
client: ClientTabsOptions['client'];
|
||||
}) {
|
||||
const mutation = useClientPatch(clientId);
|
||||
const save = (field: ClientPatchField) => async (next: string | null) => {
|
||||
await mutation.mutateAsync({ [field]: next });
|
||||
};
|
||||
|
||||
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<InfoRow label="Full Name" value={client.fullName} />
|
||||
<InfoRow label="Nationality" value={client.nationality} />
|
||||
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
||||
<InfoRow label="Timezone" value={client.timezone} />
|
||||
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
|
||||
<EditableRow label="Full Name">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Nationality">
|
||||
<InlineEditableField value={client.nationality} onSave={save('nationality')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Language">
|
||||
<InlineEditableField
|
||||
value={client.preferredLanguage}
|
||||
onSave={save('preferredLanguage')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineEditableField value={client.timezone} onSave={save('timezone')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_METHOD_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
{client.contacts && client.contacts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{client.contacts.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
|
||||
>
|
||||
<span className="capitalize text-muted-foreground w-20 shrink-0">{c.channel}</span>
|
||||
<span className="flex-1">{c.value}</span>
|
||||
{c.label && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
|
||||
)}
|
||||
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No contacts added</p>
|
||||
)}
|
||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
{(client.source || client.sourceDetails) && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<InfoRow label="Source" value={client.source} />
|
||||
<InfoRow label="Source Details" value={client.sourceDetails} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source Details">
|
||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{client.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,7 +195,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab client={client} />,
|
||||
content: <OverviewTab clientId={clientId} client={client} />,
|
||||
},
|
||||
{
|
||||
id: 'yachts',
|
||||
|
||||
329
src/components/clients/contacts-editor.tsx
Normal file
329
src/components/clients/contacts-editor.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Phone,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
email: Mail,
|
||||
phone: Phone,
|
||||
whatsapp: MessageSquare,
|
||||
other: MoreHorizontal,
|
||||
};
|
||||
|
||||
export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) {
|
||||
const qc = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
function invalidate() {
|
||||
qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
contactId,
|
||||
patch,
|
||||
}: {
|
||||
contactId: string;
|
||||
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: { channel: string; value: string; label?: string }) =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/contacts`, {
|
||||
method: 'POST',
|
||||
body: { ...data, isPrimary: false },
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async (contactId: string) =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, { method: 'DELETE' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{contacts.length === 0 && !adding && (
|
||||
<p className="text-sm text-muted-foreground">No contacts yet</p>
|
||||
)}
|
||||
|
||||
{contacts.map((c) => (
|
||||
<ContactRow
|
||||
key={c.id}
|
||||
contact={c}
|
||||
onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })}
|
||||
onRemove={async () => {
|
||||
if (!confirm('Remove this contact?')) return;
|
||||
await removeMutation.mutateAsync(c.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<NewContactForm
|
||||
onCancel={() => setAdding(false)}
|
||||
onSave={async (data) => {
|
||||
await addMutation.mutateAsync(data);
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdding(true)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add contact
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
contact: Contact;
|
||||
onUpdate: (
|
||||
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>,
|
||||
) => Promise<unknown>;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
||||
|
||||
async function togglePrimary() {
|
||||
try {
|
||||
await onUpdate({ isPrimary: !contact.isPrimary });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
async function changeChannel(next: string) {
|
||||
if (next === contact.channel) return;
|
||||
try {
|
||||
await onUpdate({ channel: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
{/* Left: channel + value */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</ChannelPicker>
|
||||
<div className="min-w-0">
|
||||
<InlineEditableField
|
||||
value={contact.value}
|
||||
onSave={async (v) => {
|
||||
if (!v) {
|
||||
toast.error('Value is required');
|
||||
return;
|
||||
}
|
||||
await onUpdate({ value: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: tag + actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-28 text-xs text-muted-foreground text-right">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||
}
|
||||
emptyText="Add tag"
|
||||
placeholder="work, home…"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-background/60 transition-colors',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelPicker({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
className="h-7 w-7 p-0 border-none bg-transparent hover:bg-background/60 [&>svg]:hidden justify-center"
|
||||
aria-label="Channel"
|
||||
>
|
||||
<SelectValue>{children}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANNEL_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function NewContactForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
onSave: (data: { channel: string; value: string; label?: string }) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [channel, setChannel] = useState('email');
|
||||
const [value, setValue] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
if (!value.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ channel, value: value.trim(), label: label.trim() || undefined });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add contact');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
<Select value={channel} onValueChange={setChannel}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANNEL_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'}
|
||||
className="h-7 text-sm flex-1 min-w-0"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="tag (optional)"
|
||||
className="h-7 text-xs w-28"
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="button" size="sm" onClick={submit} disabled={!value.trim() || saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type CompanyPatchField =
|
||||
| 'name'
|
||||
| 'legalName'
|
||||
| 'taxId'
|
||||
| 'registrationNumber'
|
||||
| 'incorporationCountry'
|
||||
| 'incorporationDate'
|
||||
| 'status'
|
||||
| 'billingEmail'
|
||||
| 'notes';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'dissolved', label: 'Dissolved' },
|
||||
];
|
||||
|
||||
interface CompanyTabsCompany {
|
||||
id: string;
|
||||
@@ -16,6 +38,7 @@ interface CompanyTabsCompany {
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
interface CompanyTabsOptions {
|
||||
@@ -25,30 +48,34 @@ interface CompanyTabsOptions {
|
||||
company: CompanyTabsCompany;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
dissolved: 'Dissolved',
|
||||
};
|
||||
function useCompanyPatch(companyId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<Record<CompanyPatchField, string | null>>) =>
|
||||
apiFetch(`/api/v1/companies/${companyId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['companies', companyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
||||
const incorporationDate = formatDate(company.incorporationDate);
|
||||
function OverviewTab({ companyId, company }: { companyId: string; company: CompanyTabsCompany }) {
|
||||
const mutation = useCompanyPatch(companyId);
|
||||
const save = (field: CompanyPatchField) => async (next: string | null) => {
|
||||
await mutation.mutateAsync({ [field]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -56,47 +83,82 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
<dl>
|
||||
<InfoRow label="Name" value={company.name} />
|
||||
<InfoRow label="Legal Name" value={company.legalName} />
|
||||
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
|
||||
<EditableRow label="Name">
|
||||
<InlineEditableField value={company.name} onSave={save('name')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Legal Name">
|
||||
<InlineEditableField value={company.legalName} onSave={save('legalName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Status">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STATUS_OPTIONS}
|
||||
value={company.status}
|
||||
onSave={save('status')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Registration */}
|
||||
{(company.taxId ||
|
||||
company.registrationNumber ||
|
||||
company.incorporationCountry ||
|
||||
incorporationDate) && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
||||
<dl>
|
||||
<InfoRow label="Tax ID" value={company.taxId} />
|
||||
<InfoRow label="Registration Number" value={company.registrationNumber} />
|
||||
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
|
||||
<InfoRow label="Incorporation Date" value={incorporationDate} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
||||
<dl>
|
||||
<EditableRow label="Tax ID">
|
||||
<InlineEditableField value={company.taxId} onSave={save('taxId')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Registration Number">
|
||||
<InlineEditableField
|
||||
value={company.registrationNumber}
|
||||
onSave={save('registrationNumber')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Country">
|
||||
<InlineEditableField
|
||||
value={company.incorporationCountry}
|
||||
onSave={save('incorporationCountry')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Date">
|
||||
<InlineEditableField
|
||||
value={company.incorporationDate}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onSave={save('incorporationDate')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
{company.billingEmail && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<dl>
|
||||
<InfoRow label="Billing Email" value={company.billingEmail} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<dl>
|
||||
<EditableRow label="Billing Email">
|
||||
<InlineEditableField value={company.billingEmail} onSave={save('billingEmail')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{company.notes && (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||
{company.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={company.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes — click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/companies/${companyId}/tags`}
|
||||
currentTags={company.tags ?? []}
|
||||
invalidateKey={['companies', companyId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,17 +166,14 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
||||
export function getCompanyTabs({
|
||||
companyId,
|
||||
portSlug,
|
||||
// currentUserId reserved for when NotesList supports entityType='companies'.
|
||||
currentUserId: _currentUserId,
|
||||
currentUserId,
|
||||
company,
|
||||
}: CompanyTabsOptions): DetailTab[] {
|
||||
void _currentUserId;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab company={company} />,
|
||||
content: <OverviewTab companyId={companyId} company={company} />,
|
||||
},
|
||||
{
|
||||
id: 'members',
|
||||
@@ -129,7 +188,6 @@ export function getCompanyTabs({
|
||||
{
|
||||
id: 'addresses',
|
||||
label: 'Addresses',
|
||||
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
|
||||
content: (
|
||||
<EmptyState
|
||||
title="Addresses"
|
||||
@@ -145,22 +203,8 @@ export function getCompanyTabs({
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
|
||||
content: (
|
||||
<EmptyState
|
||||
title="Notes"
|
||||
description="Company notes coming soon — the notes endpoint is pending wiring."
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
// TODO: replace with an inline tag editor once one exists; company tags
|
||||
// can be edited via the Edit form in the meantime.
|
||||
content: (
|
||||
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
|
||||
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
153
src/components/email/compose-dialog.tsx
Normal file
153
src/components/email/compose-dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
emailAddress: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface ComposeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultTo?: string;
|
||||
defaultSubject?: string;
|
||||
}
|
||||
|
||||
export function ComposeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultTo = '',
|
||||
defaultSubject = '',
|
||||
}: ComposeDialogProps) {
|
||||
const qc = useQueryClient();
|
||||
const [accountId, setAccountId] = useState('');
|
||||
const [to, setTo] = useState(defaultTo);
|
||||
const [cc, setCc] = useState('');
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
const { data: accounts = [] } = useQuery<Account[]>({
|
||||
queryKey: ['email', 'accounts'],
|
||||
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
|
||||
});
|
||||
|
||||
const activeAccounts = accounts.filter((a) => a.isActive);
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/email/compose', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
accountId,
|
||||
to: to
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
cc: cc
|
||||
? cc
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
subject,
|
||||
bodyHtml: body.replace(/\n/g, '<br>'),
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Email sent');
|
||||
qc.invalidateQueries({ queryKey: ['email', 'threads'] });
|
||||
onOpenChange(false);
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Send failed'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Compose email</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>From</Label>
|
||||
<Select value={accountId} onValueChange={setAccountId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeAccounts.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
No active accounts
|
||||
</div>
|
||||
) : (
|
||||
activeAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.emailAddress}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>To (comma-separated)</Label>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>CC</Label>
|
||||
<Input value={cc} onChange={(e) => setCc(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Subject</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Message</Label>
|
||||
<Textarea rows={10} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={
|
||||
sendMutation.isPending || !accountId || !to.trim() || !subject.trim() || !body.trim()
|
||||
}
|
||||
>
|
||||
{sendMutation.isPending ? 'Sending…' : 'Send'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
264
src/components/email/email-accounts-list.tsx
Normal file
264
src/components/email/email-accounts-list.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mail, Plus, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
provider: 'google' | 'outlook' | 'custom';
|
||||
emailAddress: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
imapHost: string;
|
||||
imapPort: number;
|
||||
username: string;
|
||||
isActive: boolean;
|
||||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
const PROVIDER_DEFAULTS: Record<
|
||||
string,
|
||||
{ smtpHost: string; smtpPort: number; imapHost: string; imapPort: number }
|
||||
> = {
|
||||
google: { smtpHost: 'smtp.gmail.com', smtpPort: 587, imapHost: 'imap.gmail.com', imapPort: 993 },
|
||||
outlook: {
|
||||
smtpHost: 'smtp.office365.com',
|
||||
smtpPort: 587,
|
||||
imapHost: 'outlook.office365.com',
|
||||
imapPort: 993,
|
||||
},
|
||||
custom: { smtpHost: '', smtpPort: 587, imapHost: '', imapPort: 993 },
|
||||
};
|
||||
|
||||
export function EmailAccountsList() {
|
||||
const qc = useQueryClient();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
provider: 'google' as 'google' | 'outlook' | 'custom',
|
||||
emailAddress: '',
|
||||
smtpHost: PROVIDER_DEFAULTS.google!.smtpHost,
|
||||
smtpPort: PROVIDER_DEFAULTS.google!.smtpPort,
|
||||
imapHost: PROVIDER_DEFAULTS.google!.imapHost,
|
||||
imapPort: PROVIDER_DEFAULTS.google!.imapPort,
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||
queryKey: ['email', 'accounts'],
|
||||
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => apiFetch('/api/v1/email/accounts', { method: 'POST', body: form }),
|
||||
onSuccess: () => {
|
||||
toast.success('Account connected');
|
||||
setSheetOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to connect account'),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
apiFetch(`/api/v1/email/accounts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { isActive },
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['email', 'accounts'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Account removed');
|
||||
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}/sync`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Sync started');
|
||||
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
function setProvider(provider: 'google' | 'outlook' | 'custom') {
|
||||
const defaults = PROVIDER_DEFAULTS[provider]!;
|
||||
setForm({ ...form, provider, ...defaults });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Connected Accounts</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
IMAP/SMTP accounts used for sending and receiving client emails.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<Mail className="mx-auto h-6 w-6 mb-2" />
|
||||
<p className="text-sm">No email accounts connected.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{accounts.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 p-3">
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{a.emailAddress}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{a.provider} · {a.imapHost}
|
||||
{a.lastSyncAt && ` · last sync ${new Date(a.lastSyncAt).toLocaleString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={a.isActive}
|
||||
onCheckedChange={(v) => toggleMutation.mutate({ id: a.id, isActive: v })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => syncMutation.mutate(a.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
title="Sync now"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Remove account"
|
||||
description={`Disconnect ${a.emailAddress}?`}
|
||||
confirmLabel="Remove"
|
||||
onConfirm={() => deleteMutation.mutate(a.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Connect email account</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={form.provider}
|
||||
onValueChange={(v) => setProvider(v as 'google' | 'outlook' | 'custom')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google">Google</SelectItem>
|
||||
<SelectItem value="outlook">Outlook / Office 365</SelectItem>
|
||||
<SelectItem value="custom">Custom IMAP/SMTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Email address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.emailAddress}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, emailAddress: e.target.value, username: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Password / App password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>SMTP host</Label>
|
||||
<Input
|
||||
value={form.smtpHost}
|
||||
onChange={(e) => setForm({ ...form, smtpHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>SMTP port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.smtpPort}
|
||||
onChange={(e) => setForm({ ...form, smtpPort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>IMAP host</Label>
|
||||
<Input
|
||||
value={form.imapHost}
|
||||
onChange={(e) => setForm({ ...form, imapHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>IMAP port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.imapPort}
|
||||
onChange={(e) => setForm({ ...form, imapPort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="ghost" onClick={() => setSheetOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/email/email-threads-list.tsx
Normal file
70
src/components/email/email-threads-list.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Thread {
|
||||
id: string;
|
||||
subject: string;
|
||||
snippet: string | null;
|
||||
lastMessageAt: string;
|
||||
participants: string[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface ThreadsResponse {
|
||||
data: Thread[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function EmailThreadsList() {
|
||||
const { data, isLoading } = useQuery<ThreadsResponse>({
|
||||
queryKey: ['email', 'threads'],
|
||||
queryFn: () => apiFetch<ThreadsResponse>('/api/v1/email/threads'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading threads…</p>;
|
||||
}
|
||||
|
||||
const threads = data?.data ?? [];
|
||||
|
||||
if (threads.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<Mail className="mx-auto h-6 w-6 mb-2" />
|
||||
<p className="text-sm">No email threads yet.</p>
|
||||
<p className="text-xs">
|
||||
Connect an account and trigger a sync to see incoming threads here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{threads.map((t) => (
|
||||
<div key={t.id} className="p-3 hover:bg-muted/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium truncate">{t.subject || '(no subject)'}</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{formatDistanceToNow(new Date(t.lastMessageAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{t.participants.join(', ')}</div>
|
||||
{t.snippet && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">{t.snippet}</div>
|
||||
)}
|
||||
{t.unreadCount > 0 && (
|
||||
<span className="inline-block mt-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{t.unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { RecommendationList } from '@/components/interests/recommendation-list';
|
||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||
import { LEAD_CATEGORIES } from '@/lib/constants';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
|
||||
|
||||
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||
}));
|
||||
|
||||
interface InterestTabsOptions {
|
||||
interestId: string;
|
||||
currentUserId?: string;
|
||||
interest: {
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
eoiStatus: string | null;
|
||||
contractStatus: string | null;
|
||||
depositStatus: string | null;
|
||||
@@ -26,9 +40,33 @@ interface InterestTabsOptions {
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function useInterestPatch(interestId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<Record<InterestPatchField, string | null>>) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
@@ -44,10 +82,39 @@ function formatDate(date: string | null) {
|
||||
return format(new Date(date), 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }) {
|
||||
function OverviewTab({
|
||||
interestId,
|
||||
interest,
|
||||
}: {
|
||||
interestId: string;
|
||||
interest: InterestTabsOptions['interest'];
|
||||
}) {
|
||||
const mutation = useInterestPatch(interestId);
|
||||
const save = (field: InterestPatchField) => async (next: string | null) => {
|
||||
await mutation.mutateAsync({ [field]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* EOI & Contract Status */}
|
||||
{/* Lead & Source (editable) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Lead</h3>
|
||||
<dl>
|
||||
<EditableRow label="Lead Category">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={LEAD_CATEGORY_OPTIONS}
|
||||
value={interest.leadCategory}
|
||||
onSave={save('leadCategory')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField value={interest.source} onSave={save('source')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* EOI & Contract Status (read-only — derived) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
||||
<dl>
|
||||
@@ -58,8 +125,8 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Key Dates */}
|
||||
<div className="space-y-1">
|
||||
{/* Key Dates (read-only — set by workflow events) */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
|
||||
<dl>
|
||||
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
|
||||
@@ -81,23 +148,31 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
|
||||
label="Reminder Days"
|
||||
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Last Fired"
|
||||
value={formatDate(interest.reminderLastFired)}
|
||||
/>
|
||||
<InfoRow label="Last Fired" value={formatDate(interest.reminderLastFired)} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{interest.notes && (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{interest.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Notes (editable, multiline) */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={interest.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes — click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/interests/${interestId}/tags`}
|
||||
currentTags={interest.tags ?? []}
|
||||
invalidateKey={['interests', interestId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,17 +186,13 @@ export function getInterestTabs({
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab interest={interest} />,
|
||||
content: <OverviewTab interestId={interestId} interest={interest} />,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="interests"
|
||||
entityId={interestId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@ export function PipelineBoard() {
|
||||
let newStage = over.id as string;
|
||||
|
||||
// If dropped on a card (not a stage), find which stage that card belongs to
|
||||
if (!PIPELINE_STAGES.includes(newStage as typeof PIPELINE_STAGES[number])) {
|
||||
if (!PIPELINE_STAGES.includes(newStage as (typeof PIPELINE_STAGES)[number])) {
|
||||
const targetInterest = interests.find((i) => i.id === newStage);
|
||||
if (!targetInterest) return;
|
||||
newStage = targetInterest.pipelineStage;
|
||||
@@ -85,23 +85,18 @@ export function PipelineBoard() {
|
||||
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData<{ data: InterestRow[] }>(
|
||||
['interests-board', portSlug],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((i) =>
|
||||
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData<{ data: InterestRow[] }>(['interests-board', portSlug], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((i) => (i.id === interestId ? { ...i, pipelineStage: newStage } : i)),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ pipelineStage: newStage }),
|
||||
body: { pipelineStage: newStage },
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
} catch {
|
||||
|
||||
@@ -41,8 +41,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
|
||||
mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
@@ -58,7 +57,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
mutationFn: (values: RecordPaymentInput) =>
|
||||
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(values),
|
||||
body: values,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
||||
@@ -76,9 +75,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
|
||||
if (error || !data?.data) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
Failed to load invoice details.
|
||||
</div>
|
||||
<div className="p-6 text-center text-muted-foreground">Failed to load invoice details.</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,9 +227,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<CardTitle className="text-sm font-medium">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -249,9 +244,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
className="flex items-center justify-between p-3 border rounded-md text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{exp.establishmentName ?? 'Unnamed Expense'}
|
||||
</p>
|
||||
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{exp.category ?? '—'} · {exp.expenseDate}
|
||||
</p>
|
||||
@@ -271,10 +264,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
|
||||
{/* PDF Preview */}
|
||||
<TabsContent value="pdf" className="pt-4">
|
||||
<InvoicePdfPreview
|
||||
invoiceId={invoiceId}
|
||||
pdfFileId={invoice.pdfFileId}
|
||||
/>
|
||||
<InvoicePdfPreview invoiceId={invoiceId} pdfFileId={invoice.pdfFileId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Payment */}
|
||||
@@ -283,10 +273,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-100 text-green-700 border-green-200"
|
||||
>
|
||||
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
|
||||
Paid
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -297,9 +284,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<p className="mt-0.5 capitalize">
|
||||
{invoice.paymentMethod ?? '—'}
|
||||
</p>
|
||||
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Reference</span>
|
||||
@@ -315,18 +300,12 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={paymentForm.handleSubmit((values) =>
|
||||
paymentMutation.mutate(values),
|
||||
)}
|
||||
onSubmit={paymentForm.handleSubmit((values) => paymentMutation.mutate(values))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="paymentDate">Payment Date</Label>
|
||||
<Input
|
||||
id="paymentDate"
|
||||
type="date"
|
||||
{...paymentForm.register('paymentDate')}
|
||||
/>
|
||||
<Input id="paymentDate" type="date" {...paymentForm.register('paymentDate')} />
|
||||
{paymentForm.formState.errors.paymentDate && (
|
||||
<p className="text-xs text-destructive">
|
||||
{paymentForm.formState.errors.paymentDate.message}
|
||||
@@ -349,10 +328,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
{...paymentForm.register('paymentReference')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={paymentMutation.isPending}
|
||||
>
|
||||
<Button type="submit" disabled={paymentMutation.isPending}>
|
||||
{paymentMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
};
|
||||
|
||||
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
|
||||
// from the breadcrumbs since the page H1 already shows the entity name.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isIdSegment(segment: string): boolean {
|
||||
return UUID_RE.test(segment);
|
||||
}
|
||||
|
||||
function formatSegment(segment: string): string {
|
||||
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (
|
||||
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function Breadcrumbs() {
|
||||
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
|
||||
// Split pathname and filter empty segments
|
||||
const rawSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// Remove the portSlug segment from display
|
||||
const segments = currentPortSlug
|
||||
? rawSegments.filter((seg) => seg !== currentPortSlug)
|
||||
: rawSegments;
|
||||
// Remove the portSlug segment and any UUID-ish entity-id segments — the
|
||||
// page H1 already shows the entity name, no need to leak the raw id.
|
||||
const segments = (
|
||||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||
).filter((seg) => !isIdSegment(seg));
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Bell,
|
||||
Settings,
|
||||
Shield,
|
||||
Home,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
|
||||
|
||||
interface SidebarProps {
|
||||
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -51,6 +53,10 @@ interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
adminRequired?: boolean;
|
||||
/** When true, only render if the user has marina-side access. */
|
||||
marinaRequired?: boolean;
|
||||
/** When true, only render if the user has residential-side access. */
|
||||
residentialRequired?: boolean;
|
||||
}
|
||||
|
||||
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
return [
|
||||
{
|
||||
title: 'Main',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Residential',
|
||||
residentialRequired: true,
|
||||
items: [
|
||||
{
|
||||
href: `${base}/residential/clients`,
|
||||
label: 'Residential Clients',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
href: `${base}/residential/interests`,
|
||||
label: 'Residential Interests',
|
||||
icon: Bookmark,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Documents',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
||||
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
||||
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Financial',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||
@@ -150,11 +176,15 @@ function SidebarContent({
|
||||
portSlug,
|
||||
portRoles,
|
||||
hasAdminAccess,
|
||||
hasMarinaAccess,
|
||||
hasResidentialAccess,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
portSlug: string | undefined;
|
||||
portRoles: SidebarProps['portRoles'];
|
||||
hasAdminAccess: boolean;
|
||||
hasMarinaAccess: boolean;
|
||||
hasResidentialAccess: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [adminExpanded, setAdminExpanded] = useState(false);
|
||||
@@ -191,6 +221,8 @@ function SidebarContent({
|
||||
<nav className="px-2 space-y-4">
|
||||
{sections.map((section) => {
|
||||
if (section.adminRequired && !hasAdminAccess) return null;
|
||||
if (section.marinaRequired && !hasMarinaAccess) return null;
|
||||
if (section.residentialRequired && !hasResidentialAccess) return null;
|
||||
|
||||
return (
|
||||
<div key={section.title}>
|
||||
@@ -272,16 +304,25 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ portRoles }: SidebarProps) {
|
||||
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
|
||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
// Check for admin access based on role permissions
|
||||
const hasAdminAccess = portRoles.some(
|
||||
(pr) =>
|
||||
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
||||
);
|
||||
// Super admins see every section regardless of role rows.
|
||||
const hasAdminAccess =
|
||||
isSuperAdmin ||
|
||||
portRoles.some(
|
||||
(pr) =>
|
||||
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
||||
);
|
||||
|
||||
const hasMarinaAccess =
|
||||
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
|
||||
|
||||
const hasResidentialAccess =
|
||||
isSuperAdmin ||
|
||||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
||||
portSlug={currentPortSlug ?? undefined}
|
||||
portRoles={portRoles}
|
||||
hasAdminAccess={hasAdminAccess}
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
/>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
||||
portSlug={currentPortSlug ?? undefined}
|
||||
portRoles={portRoles}
|
||||
hasAdminAccess={hasAdminAccess}
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
|
||||
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => router.push(`${base}/notifications/preferences` as any)}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notification preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
||||
{darkMode ? (
|
||||
|
||||
131
src/components/notifications/notification-preferences-form.tsx
Normal file
131
src/components/notifications/notification-preferences-form.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Pref {
|
||||
notificationType: string;
|
||||
inApp: boolean;
|
||||
email: boolean;
|
||||
}
|
||||
|
||||
const KNOWN_TYPES: Array<{ key: string; label: string; description: string }> = [
|
||||
{
|
||||
key: 'mention',
|
||||
label: 'Mentions',
|
||||
description: 'When someone @-mentions you in a note.',
|
||||
},
|
||||
{
|
||||
key: 'reminder_overdue',
|
||||
label: 'Overdue Reminders',
|
||||
description: 'When an interest reminder you own becomes overdue.',
|
||||
},
|
||||
{
|
||||
key: 'interest_stage_changed',
|
||||
label: 'Interest Stage Changes',
|
||||
description: 'When a pipeline stage changes on an interest assigned to you.',
|
||||
},
|
||||
{
|
||||
key: 'system_alert',
|
||||
label: 'System Alerts',
|
||||
description: 'Background job failures and maintenance notices.',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationPreferencesForm() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<Pref[]>({
|
||||
queryKey: ['notifications', 'preferences'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data),
|
||||
});
|
||||
|
||||
const [prefs, setPrefs] = useState<Map<string, Pref>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const map = new Map<string, Pref>();
|
||||
for (const t of KNOWN_TYPES) {
|
||||
map.set(t.key, { notificationType: t.key, inApp: true, email: true });
|
||||
}
|
||||
if (data) {
|
||||
for (const p of data) {
|
||||
map.set(p.notificationType, p);
|
||||
}
|
||||
}
|
||||
setPrefs(map);
|
||||
}, [data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = { preferences: Array.from(prefs.values()) };
|
||||
return apiFetch('/api/v1/notifications/preferences', {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Preferences saved');
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
|
||||
});
|
||||
|
||||
function update(type: string, field: 'inApp' | 'email', value: boolean) {
|
||||
setPrefs((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(type) ?? { notificationType: type, inApp: true, email: true };
|
||||
next.set(type, { ...existing, [field]: value });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading preferences…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border divide-y">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
<div>Type</div>
|
||||
<div className="w-16 text-center">In-app</div>
|
||||
<div className="w-16 text-center">Email</div>
|
||||
</div>
|
||||
{KNOWN_TYPES.map((t) => {
|
||||
const pref = prefs.get(t.key) ?? {
|
||||
notificationType: t.key,
|
||||
inApp: true,
|
||||
email: true,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 items-center"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{t.description}</div>
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<Switch checked={pref.inApp} onCheckedChange={(v) => update(t.key, 'inApp', v)} />
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<Switch checked={pref.email} onCheckedChange={(v) => update(t.key, 'email', v)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Saving…' : 'Save preferences'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
interface PasswordSetFormProps {
|
||||
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
|
||||
@@ -75,7 +75,7 @@ export function PasswordSetForm({
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<PortalAuthShell>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -89,13 +89,13 @@ export function PasswordSetForm({
|
||||
Request a new link
|
||||
</Link>
|
||||
</div>
|
||||
</PortalAuthShell>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<PortalAuthShell>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
||||
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||
@@ -109,12 +109,12 @@ export function PasswordSetForm({
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</PortalAuthShell>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalAuthShell>
|
||||
<BrandedAuthShell>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
||||
@@ -173,6 +173,6 @@ export function PasswordSetForm({
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</PortalAuthShell>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,13 +195,18 @@ export function ReservationList({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.contractFileId ? (
|
||||
// TODO: Confirm final file-download endpoint URL when available
|
||||
<a
|
||||
href={`/api/v1/files/${r.contractFileId}/download`}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={async () => {
|
||||
const res = await apiFetch<{ data: { url: string } }>(
|
||||
`/api/v1/files/${r.contractFileId}/download`,
|
||||
);
|
||||
window.open(res.data.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
View contract
|
||||
</a>
|
||||
</button>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
|
||||
299
src/components/residential/residential-client-detail.tsx
Normal file
299
src/components/residential/residential-client-detail.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ResidentialInterestSummary {
|
||||
id: string;
|
||||
pipelineStage: string;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ResidentialClientDetail {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
placeOfResidence: string | null;
|
||||
preferredContactMethod: string | null;
|
||||
status: string;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
interests: ResidentialInterestSummary[];
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'prospect', label: 'Prospect' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const CONTACT_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
];
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
];
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
new: 'New',
|
||||
contacted: 'Contacted',
|
||||
viewing_scheduled: 'Viewing scheduled',
|
||||
offer_made: 'Offer made',
|
||||
offer_accepted: 'Offer accepted',
|
||||
closed_won: 'Closed — won',
|
||||
closed_lost: 'Closed — lost',
|
||||
};
|
||||
|
||||
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [newInterestOpen, setNewInterestOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ResidentialClientDetail }>({
|
||||
queryKey: ['residential-client', clientId],
|
||||
queryFn: () => apiFetch(`/api/v1/residential/clients/${clientId}`),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'residential_client:updated': [['residential-client', clientId]],
|
||||
'residential_interest:created': [['residential-client', clientId]],
|
||||
'residential_interest:updated': [['residential-client', clientId]],
|
||||
'residential_interest:archived': [['residential-client', clientId]],
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) =>
|
||||
apiFetch(`/api/v1/residential/clients/${clientId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-client', clientId] }),
|
||||
});
|
||||
|
||||
const save = (field: string) => async (next: string | null) => {
|
||||
await update.mutateAsync({ [field]: next });
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
const client = data.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/clients` as any}
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> All residential clients
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<Row label="Email">
|
||||
<InlineEditableField value={client.email} onSave={save('email')} />
|
||||
</Row>
|
||||
<Row label="Phone">
|
||||
<InlineEditableField value={client.phone} onSave={save('phone')} />
|
||||
</Row>
|
||||
<Row label="Preferred contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Place of residence">
|
||||
<InlineEditableField
|
||||
value={client.placeOfResidence}
|
||||
onSave={save('placeOfResidence')}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
||||
<Row label="Status">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STATUS_OPTIONS}
|
||||
value={client.status}
|
||||
onSave={save('status')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Notes">
|
||||
<InlineEditableField value={client.notes} onSave={save('notes')} />
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Interests</h2>
|
||||
<Button size="sm" onClick={() => setNewInterestOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New interest
|
||||
</Button>
|
||||
</div>
|
||||
{client.interests.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No interests yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{client.interests.map((i) => (
|
||||
<li key={i.id} className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
|
||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '—'}</span>
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewInterestSheet
|
||||
clientId={clientId}
|
||||
open={newInterestOpen}
|
||||
onOpenChange={setNewInterestOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewInterestSheet({
|
||||
clientId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
clientId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [preferences, setPreferences] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/residential/interests', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
residentialClientId: clientId,
|
||||
preferences: preferences || undefined,
|
||||
notes: notes || undefined,
|
||||
source: 'manual',
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['residential-client', clientId] });
|
||||
onOpenChange(false);
|
||||
setPreferences('');
|
||||
setNotes('');
|
||||
toast.success('Interest added');
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to add'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New interest</SheetTitle>
|
||||
</SheetHeader>
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ri-prefs">Preferences</Label>
|
||||
<Input
|
||||
id="ri-prefs"
|
||||
value={preferences}
|
||||
onChange={(e) => setPreferences(e.target.value)}
|
||||
placeholder="Unit type, size, budget…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ri-notes">Notes</Label>
|
||||
<Input id="ri-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'Saving…' : 'Create'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
246
src/components/residential/residential-clients-list.tsx
Normal file
246
src/components/residential/residential-clients-list.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ResidentialClientRow {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
placeOfResidence: string | null;
|
||||
status: string;
|
||||
source: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: ResidentialClientRow[];
|
||||
pagination: { total: number; page: number; pageSize: number };
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
prospect: 'Prospect',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
};
|
||||
|
||||
export function ResidentialClientsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['residential-clients', { search }],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams({ search, limit: '50' });
|
||||
return apiFetch(`/api/v1/residential/clients?${qs.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'residential_client:created': [['residential-clients']],
|
||||
'residential_client:updated': [['residential-clients']],
|
||||
'residential_client:archived': [['residential-clients']],
|
||||
'residential_client:restored': [['residential-clients']],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Residential Clients"
|
||||
description="Inquiries and clients for the residential side"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, email, phone, residence…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-2">Name</th>
|
||||
<th className="text-left font-medium px-3 py-2">Email</th>
|
||||
<th className="text-left font-medium px-3 py-2">Phone</th>
|
||||
<th className="text-left font-medium px-3 py-2">Residence</th>
|
||||
<th className="text-left font-medium px-3 py-2">Status</th>
|
||||
<th className="text-left font-medium px-3 py-2">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
|
||||
No residential clients yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.data.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/clients/${c.id}` as any}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{c.fullName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{c.email ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{c.phone ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '—'}</td>
|
||||
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
|
||||
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewResidentialClientSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [placeOfResidence, setPlaceOfResidence] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/residential/clients', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fullName,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
placeOfResidence: placeOfResidence || undefined,
|
||||
notes: notes || undefined,
|
||||
source: 'manual',
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['residential-clients'] });
|
||||
onOpenChange(false);
|
||||
setFullName('');
|
||||
setEmail('');
|
||||
setPhone('');
|
||||
setPlaceOfResidence('');
|
||||
setNotes('');
|
||||
toast.success('Residential client added');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New residential client</SheetTitle>
|
||||
</SheetHeader>
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-name">Full name *</Label>
|
||||
<Input
|
||||
id="rc-name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-email">Email</Label>
|
||||
<Input
|
||||
id="rc-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-phone">Phone</Label>
|
||||
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-residence">Place of residence</Label>
|
||||
<Input
|
||||
id="rc-residence"
|
||||
value={placeOfResidence}
|
||||
onChange={(e) => setPlaceOfResidence(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-notes">Notes</Label>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!fullName.trim() || create.isPending}>
|
||||
{create.isPending ? 'Saving…' : 'Create'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
158
src/components/residential/residential-interest-detail.tsx
Normal file
158
src/components/residential/residential-interest-detail.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PIPELINE_STAGES } from '@/lib/validators/residential';
|
||||
|
||||
interface ResidentialInterestDetail {
|
||||
id: string;
|
||||
residentialClientId: string;
|
||||
pipelineStage: string;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
assignedTo: string | null;
|
||||
client: { id: string; fullName: string } | null;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
new: 'New',
|
||||
contacted: 'Contacted',
|
||||
viewing_scheduled: 'Viewing scheduled',
|
||||
offer_made: 'Offer made',
|
||||
offer_accepted: 'Offer accepted',
|
||||
closed_won: 'Closed — won',
|
||||
closed_lost: 'Closed — lost',
|
||||
};
|
||||
|
||||
const STAGE_OPTIONS = PIPELINE_STAGES.map((s) => ({
|
||||
value: s,
|
||||
label: STAGE_LABELS[s] ?? s,
|
||||
}));
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
];
|
||||
|
||||
export function ResidentialInterestDetail({ interestId }: { interestId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ResidentialInterestDetail }>({
|
||||
queryKey: ['residential-interest', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/residential/interests/${interestId}`),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'residential_interest:updated': [['residential-interest', interestId]],
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) =>
|
||||
apiFetch(`/api/v1/residential/interests/${interestId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-interest', interestId] }),
|
||||
});
|
||||
|
||||
const save = (field: string) => async (next: string | null) => {
|
||||
await update.mutateAsync({ [field]: next });
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
const interest = data.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/interests` as any}
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> All residential interests
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground tracking-wider mb-1">
|
||||
Residential interest
|
||||
</p>
|
||||
{interest.client && (
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/clients/${interest.client.id}` as any}
|
||||
className="hover:underline"
|
||||
>
|
||||
{interest.client.fullName}
|
||||
</Link>
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Pipeline</h3>
|
||||
<Row label="Stage">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STAGE_OPTIONS}
|
||||
value={interest.pipelineStage}
|
||||
onSave={save('pipelineStage')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={interest.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Assigned to">
|
||||
<InlineEditableField
|
||||
value={interest.assignedTo}
|
||||
onSave={save('assignedTo')}
|
||||
placeholder="user id"
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Details</h3>
|
||||
<Row label="Preferences">
|
||||
<InlineEditableField value={interest.preferences} onSave={save('preferences')} />
|
||||
</Row>
|
||||
<Row label="Notes">
|
||||
<InlineEditableField value={interest.notes} onSave={save('notes')} />
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/residential/residential-interests-list.tsx
Normal file
154
src/components/residential/residential-interests-list.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PIPELINE_STAGES } from '@/lib/validators/residential';
|
||||
|
||||
interface ResidentialInterestRow {
|
||||
id: string;
|
||||
residentialClientId: string;
|
||||
pipelineStage: string;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
assignedTo: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: ResidentialInterestRow[];
|
||||
pagination: { total: number };
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
new: 'New',
|
||||
contacted: 'Contacted',
|
||||
viewing_scheduled: 'Viewing scheduled',
|
||||
offer_made: 'Offer made',
|
||||
offer_accepted: 'Offer accepted',
|
||||
closed_won: 'Closed — won',
|
||||
closed_lost: 'Closed — lost',
|
||||
};
|
||||
|
||||
export function ResidentialInterestsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
const [stage, setStage] = useState<string>('all');
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['residential-interests', { search, stage }],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams({ search, limit: '50' });
|
||||
if (stage !== 'all') qs.set('pipelineStage', stage);
|
||||
return apiFetch(`/api/v1/residential/interests?${qs.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'residential_interest:created': [['residential-interests']],
|
||||
'residential_interest:updated': [['residential-interests']],
|
||||
'residential_interest:archived': [['residential-interests']],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Residential Interests"
|
||||
description="Inquiries flowing through the residential pipeline"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search notes / preferences…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Select value={stage} onValueChange={setStage}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="All stages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All stages</SelectItem>
|
||||
{PIPELINE_STAGES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STAGE_LABELS[s] ?? s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-2">Stage</th>
|
||||
<th className="text-left font-medium px-3 py-2">Preferences</th>
|
||||
<th className="text-left font-medium px-3 py-2">Notes</th>
|
||||
<th className="text-left font-medium px-3 py-2">Source</th>
|
||||
<th className="text-left font-medium px-3 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
|
||||
No interests match.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.data.map((i) => (
|
||||
<tr
|
||||
key={i.id}
|
||||
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
|
||||
{i.preferences ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
|
||||
{i.notes ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 capitalize text-muted-foreground">{i.source ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{new Date(i.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,13 @@ const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
export function PortalAuthShell({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Branded shell shared by every auth/form surface — CRM login, portal login,
|
||||
* password set/reset/activate, forgot-password. Renders the blurred Port
|
||||
* Nimara overhead background, the circular logo, and a centered white card
|
||||
* that consumers populate with their own form/content.
|
||||
*/
|
||||
export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-8"
|
||||
272
src/components/shared/inline-editable-field.tsx
Normal file
272
src/components/shared/inline-editable-field.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
value: string | null | undefined;
|
||||
onSave: (next: string | null) => Promise<void>;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TextProps extends BaseProps {
|
||||
variant?: 'text';
|
||||
}
|
||||
|
||||
interface SelectFieldProps extends BaseProps {
|
||||
variant: 'select';
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
interface TextareaProps extends BaseProps {
|
||||
variant: 'textarea';
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
|
||||
|
||||
/**
|
||||
* Click-to-edit field used in detail panels. Shows the value as plain text
|
||||
* with a pencil affordance on hover; clicking swaps to an input that saves on
|
||||
* Enter/blur and cancels on Escape.
|
||||
*/
|
||||
export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
const { value, onSave, placeholder, emptyText = '—', className, disabled } = props;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value ?? '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
} else if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
async function commit(nextRaw: string) {
|
||||
const trimmed = nextRaw.trim();
|
||||
if (trimmed === (value ?? '')) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(trimmed === '' ? null : trimmed);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save');
|
||||
setDraft(value ?? '');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (props.variant === 'select') {
|
||||
const labelFor = (v: string | null | undefined) =>
|
||||
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={labelFor(value)}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Select
|
||||
value={draft}
|
||||
onValueChange={(v) => void commit(v)}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !saving) setEditing(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-sm w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === 'textarea') {
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
multiline
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit(draft);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!saving) void commit(draft);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={saving}
|
||||
rows={props.rows ?? 4}
|
||||
className="text-sm"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void commit(draft);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!saving) void commit(draft);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={saving}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadButton({
|
||||
value,
|
||||
emptyText,
|
||||
disabled,
|
||||
onClick,
|
||||
multiline,
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
multiline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
|
||||
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
|
||||
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1',
|
||||
multiline && 'whitespace-pre-wrap',
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{value ?? emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Pencil
|
||||
className={cn(
|
||||
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
|
||||
multiline && 'mt-1 shrink-0',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
132
src/components/shared/inline-tag-editor.tsx
Normal file
132
src/components/shared/inline-tag-editor.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface InlineTagEditorProps {
|
||||
/** PUT endpoint for replacing the entity's tag list — body shape `{ tagIds: string[] }`. */
|
||||
endpoint: string;
|
||||
currentTags: Tag[];
|
||||
/** TanStack Query key to invalidate after a successful change. */
|
||||
invalidateKey: readonly unknown[];
|
||||
/** Hide the "+ Add tag" button (read-only mode). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function InlineTagEditor({
|
||||
endpoint,
|
||||
currentTags,
|
||||
invalidateKey,
|
||||
readOnly,
|
||||
}: InlineTagEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: allTags } = useQuery<{ data: Tag[] }>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => apiFetch('/api/v1/tags'),
|
||||
staleTime: 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const setTags = useMutation({
|
||||
mutationFn: (tagIds: string[]) => apiFetch(endpoint, { method: 'PUT', body: { tagIds } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: invalidateKey }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to update tags'),
|
||||
});
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const has = currentTags.some((t) => t.id === tagId);
|
||||
const nextIds = has
|
||||
? currentTags.filter((t) => t.id !== tagId).map((t) => t.id)
|
||||
: [...currentTags.map((t) => t.id), tagId];
|
||||
setTags.mutate(nextIds);
|
||||
}
|
||||
|
||||
function removeTag(tagId: string) {
|
||||
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{currentTags.map((t) => (
|
||||
<span
|
||||
key={t.id}
|
||||
className="group inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${t.color}20`, color: t.color }}
|
||||
>
|
||||
{t.name}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(t.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity hover:opacity-100"
|
||||
aria-label={`Remove tag ${t.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!readOnly && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{currentTags.length === 0 ? 'Add tag' : 'Add'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>}
|
||||
{allTags?.data.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No tags defined yet. Create some in Admin → Tags.
|
||||
</div>
|
||||
)}
|
||||
{allTags?.data.map((t) => {
|
||||
const checked = currentTags.some((c) => c.id === t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(t.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: t.color }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{t.name}</span>
|
||||
{checked && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface Note {
|
||||
}
|
||||
|
||||
interface NotesListProps {
|
||||
entityType: 'clients' | 'interests';
|
||||
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
|
||||
entityId: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
@@ -43,8 +43,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setNewNote('');
|
||||
@@ -61,8 +60,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (noteId: string) =>
|
||||
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
@@ -127,13 +125,9 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
{note.isLocked && (
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||
{canEdit(note) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getTimeRemaining(note)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
||||
)}
|
||||
</div>
|
||||
{editingId === note.id ? (
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type YachtPatchField =
|
||||
| 'name'
|
||||
| 'hullNumber'
|
||||
| 'registration'
|
||||
| 'flag'
|
||||
| 'yearBuilt'
|
||||
| 'builder'
|
||||
| 'model'
|
||||
| 'hullMaterial'
|
||||
| 'lengthFt'
|
||||
| 'widthFt'
|
||||
| 'draftFt'
|
||||
| 'lengthM'
|
||||
| 'widthM'
|
||||
| 'draftM'
|
||||
| 'status'
|
||||
| 'notes';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'retired', label: 'Retired' },
|
||||
{ value: 'sold_away', label: 'Sold away' },
|
||||
];
|
||||
|
||||
interface YachtTabsYacht {
|
||||
id: string;
|
||||
@@ -22,6 +52,7 @@ interface YachtTabsYacht {
|
||||
draftM: string | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
interface YachtTabsOptions {
|
||||
@@ -30,25 +61,43 @@ interface YachtTabsOptions {
|
||||
yacht: YachtTabsYacht;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
function useYachtPatch(yachtId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<Record<YachtPatchField, string | number | null>>) =>
|
||||
apiFetch(`/api/v1/yachts/${yachtId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['yachts', yachtId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
retired: 'Retired',
|
||||
sold_away: 'Sold away',
|
||||
};
|
||||
|
||||
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
|
||||
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
|
||||
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
|
||||
function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYacht }) {
|
||||
const mutation = useYachtPatch(yachtId);
|
||||
const save =
|
||||
(field: YachtPatchField, transform?: (v: string | null) => string | number | null) =>
|
||||
async (next: string | null) => {
|
||||
const value = transform ? transform(next) : next;
|
||||
await mutation.mutateAsync({ [field]: value });
|
||||
};
|
||||
const numericString = (next: string | null) => (next === null ? null : next);
|
||||
const yearTransform = (next: string | null) => {
|
||||
if (next === null) return null;
|
||||
const n = Number.parseInt(next, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -56,77 +105,113 @@ function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
<dl>
|
||||
<InfoRow label="Name" value={yacht.name} />
|
||||
<InfoRow label="Hull Number" value={yacht.hullNumber} />
|
||||
<InfoRow label="Registration" value={yacht.registration} />
|
||||
<InfoRow label="Flag" value={yacht.flag} />
|
||||
<InfoRow label="Year Built" value={yacht.yearBuilt} />
|
||||
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
|
||||
<EditableRow label="Name">
|
||||
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Number">
|
||||
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Registration">
|
||||
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Flag">
|
||||
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Year Built">
|
||||
<InlineEditableField
|
||||
value={yacht.yearBuilt?.toString() ?? null}
|
||||
onSave={save('yearBuilt', yearTransform)}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Status">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STATUS_OPTIONS}
|
||||
value={yacht.status}
|
||||
onSave={save('status')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Build */}
|
||||
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||
<dl>
|
||||
<InfoRow label="Builder" value={yacht.builder} />
|
||||
<InfoRow label="Model" value={yacht.model} />
|
||||
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||
<dl>
|
||||
<EditableRow label="Builder">
|
||||
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Model">
|
||||
<InlineEditableField value={yacht.model} onSave={save('model')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Material">
|
||||
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Dimensions (ft) */}
|
||||
{hasFtDimensions && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||
<dl>
|
||||
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
|
||||
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
|
||||
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (ft)">
|
||||
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (ft)">
|
||||
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (ft)">
|
||||
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Dimensions (m) */}
|
||||
{hasMDimensions && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||
<dl>
|
||||
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
|
||||
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
|
||||
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (m)">
|
||||
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (m)">
|
||||
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (m)">
|
||||
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{yacht.notes && (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||
{yacht.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={yacht.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes — click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/yachts/${yachtId}/tags`}
|
||||
currentTags={yacht.tags ?? []}
|
||||
invalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getYachtTabs({
|
||||
yachtId,
|
||||
// currentUserId reserved for when NotesList supports entityType='yachts'.
|
||||
currentUserId: _currentUserId,
|
||||
yacht,
|
||||
}: YachtTabsOptions): DetailTab[] {
|
||||
void _currentUserId;
|
||||
|
||||
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab yacht={yacht} />,
|
||||
content: <OverviewTab yachtId={yachtId} yacht={yacht} />,
|
||||
},
|
||||
{
|
||||
id: 'ownership-history',
|
||||
@@ -146,23 +231,7 @@ export function getYachtTabs({
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||
// Extend NotesList (or swap to a yacht-notes endpoint) in a follow-up.
|
||||
content: (
|
||||
<EmptyState
|
||||
title="Notes"
|
||||
description="Yacht notes coming soon — the notes endpoint is pending wiring."
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
// TODO: replace with an inline tag editor once one exists; yacht tags
|
||||
// can be edited via the Edit form in the meantime.
|
||||
content: (
|
||||
<EmptyState title="Tags" description="Manage tags from the Edit yacht form for now." />
|
||||
),
|
||||
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,15 +6,44 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`.
|
||||
* Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand
|
||||
* store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */
|
||||
const slugToIdCache = new Map<string, string>();
|
||||
|
||||
async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
|
||||
const cached = slugToIdCache.get(slug);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch('/api/v1/admin/ports', { credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
|
||||
const port = body.data?.find((p) => p.slug === slug);
|
||||
if (!port) return null;
|
||||
slugToIdCache.set(slug, port.id);
|
||||
return port.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side fetch wrapper that attaches the `X-Port-Id` header from the
|
||||
* UI store to every request. Used by all queryFn/mutationFn callbacks.
|
||||
*
|
||||
* Falls back to extracting the port slug from `window.location.pathname` and
|
||||
* resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been
|
||||
* populated yet (fresh page load before `PortProvider`'s effect has fired).
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(
|
||||
url: string,
|
||||
opts: ApiFetchOptions = {},
|
||||
): Promise<T> {
|
||||
const portId = useUIStore.getState().currentPortId;
|
||||
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
|
||||
let portId = useUIStore.getState().currentPortId;
|
||||
|
||||
if (!portId && typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
portId = await resolvePortIdFromSlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(opts.headers);
|
||||
if (portId) {
|
||||
|
||||
@@ -156,6 +156,23 @@ export function withAuth(
|
||||
override.permissionOverrides as Record<string, unknown>,
|
||||
) as RolePermissions;
|
||||
}
|
||||
|
||||
// Per-user residential toggle — flips the residential domain on
|
||||
// top of whatever the role grants. We never use it to *revoke*
|
||||
// residential access from a role that already grants it.
|
||||
if (portRole.residentialAccess && permissions) {
|
||||
permissions = {
|
||||
...permissions,
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (profile.isSuperAdmin && portId) {
|
||||
// Super admin still needs portSlug for response context.
|
||||
const port = await db.query.ports.findFirst({
|
||||
|
||||
@@ -16,7 +16,7 @@ export const auth = betterAuth({
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 12,
|
||||
minPasswordLength: 9,
|
||||
// Accounts are admin-created only — no self-service email verification flow.
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
|
||||
13
src/lib/db/migrations/0010_brave_joshua_kane.sql
Normal file
13
src/lib/db/migrations/0010_brave_joshua_kane.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "crm_user_invites" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"name" text,
|
||||
"token_hash" text NOT NULL,
|
||||
"is_super_admin" boolean DEFAULT false NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_crm_invites_token_hash" ON "crm_user_invites" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX "idx_crm_invites_email" ON "crm_user_invites" USING btree ("email");
|
||||
43
src/lib/db/migrations/0011_red_cargill.sql
Normal file
43
src/lib/db/migrations/0011_red_cargill.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "residential_clients" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"email" text,
|
||||
"phone" text,
|
||||
"place_of_residence" text,
|
||||
"preferred_contact_method" text,
|
||||
"status" text DEFAULT 'prospect' NOT NULL,
|
||||
"source" text,
|
||||
"notes" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "residential_interests" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"residential_client_id" text NOT NULL,
|
||||
"pipeline_stage" text DEFAULT 'new' NOT NULL,
|
||||
"source" text,
|
||||
"notes" text,
|
||||
"preferences" text,
|
||||
"assigned_to" text,
|
||||
"date_first_contact" timestamp with time zone,
|
||||
"date_last_contact" timestamp with time zone,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "residential_clients" ADD CONSTRAINT "residential_clients_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_residential_client_id_residential_clients_id_fk" FOREIGN KEY ("residential_client_id") REFERENCES "public"."residential_clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_port" ON "residential_clients" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_email" ON "residential_clients" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_archived" ON "residential_clients" USING btree ("port_id","archived_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_port" ON "residential_interests" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_client" ON "residential_interests" USING btree ("residential_client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_stage" ON "residential_interests" USING btree ("port_id","pipeline_stage");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_assigned" ON "residential_interests" USING btree ("assigned_to");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_archived" ON "residential_interests" USING btree ("port_id","archived_at");
|
||||
1
src/lib/db/migrations/0012_large_zarda.sql
Normal file
1
src/lib/db/migrations/0012_large_zarda.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_port_roles" ADD COLUMN "residential_access" boolean DEFAULT false NOT NULL;
|
||||
8868
src/lib/db/migrations/meta/0010_snapshot.json
Normal file
8868
src/lib/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9225
src/lib/db/migrations/meta/0011_snapshot.json
Normal file
9225
src/lib/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9232
src/lib/db/migrations/meta/0012_snapshot.json
Normal file
9232
src/lib/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,27 @@
|
||||
"when": 1777210206070,
|
||||
"tag": "0009_outgoing_rumiko_fujikawa",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1777303428222,
|
||||
"tag": "0010_brave_joshua_kane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1777307410311,
|
||||
"tag": "0011_red_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1777308900666,
|
||||
"tag": "0012_large_zarda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
32
src/lib/db/schema/crm-invites.ts
Normal file
32
src/lib/db/schema/crm-invites.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { pgTable, text, boolean, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Single-use admin-issued invites for CRM users (better-auth realm).
|
||||
*
|
||||
* `tokenHash` is a SHA-256 hash of the raw token sent in the email. Lookups
|
||||
* happen by hash so a DB compromise never leaks active tokens. The invite
|
||||
* is consumed at /set-password — the route creates the better-auth `user`
|
||||
* row + `account` credential and the matching `user_profiles` extension.
|
||||
*/
|
||||
export const crmUserInvites = pgTable(
|
||||
'crm_user_invites',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_crm_invites_token_hash').on(table.tokenHash),
|
||||
index('idx_crm_invites_email').on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export type CrmUserInvite = typeof crmUserInvites.$inferSelect;
|
||||
export type NewCrmUserInvite = typeof crmUserInvites.$inferInsert;
|
||||
@@ -34,6 +34,13 @@ export * from './email';
|
||||
// Portal (client-portal auth)
|
||||
export * from './portal';
|
||||
|
||||
// CRM admin invites (better-auth realm)
|
||||
export * from './crm-invites';
|
||||
|
||||
// Residential (parallel domain — separate clients & interests for the
|
||||
// external residential team)
|
||||
export * from './residential';
|
||||
|
||||
// Operations
|
||||
export * from './operations';
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
customFieldDefinitions,
|
||||
customFieldValues,
|
||||
} from './system';
|
||||
import { residentialClients, residentialInterests } from './residential';
|
||||
|
||||
// ─── Ports ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,6 +117,8 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
||||
savedViews: many(savedViews),
|
||||
userNotificationPreferences: many(userNotificationPreferences),
|
||||
customFieldDefinitions: many(customFieldDefinitions),
|
||||
residentialClients: many(residentialClients),
|
||||
residentialInterests: many(residentialInterests),
|
||||
berthMaintenanceLogs: many(berthMaintenanceLog),
|
||||
clientMergeLogs: many(clientMergeLog),
|
||||
clientRelationships: many(clientRelationships),
|
||||
@@ -819,3 +822,24 @@ export const customFieldValuesRelations = relations(customFieldValues, ({ one })
|
||||
references: [customFieldDefinitions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Residential ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const residentialClientsRelations = relations(residentialClients, ({ one, many }) => ({
|
||||
port: one(ports, {
|
||||
fields: [residentialClients.portId],
|
||||
references: [ports.id],
|
||||
}),
|
||||
interests: many(residentialInterests),
|
||||
}));
|
||||
|
||||
export const residentialInterestsRelations = relations(residentialInterests, ({ one }) => ({
|
||||
port: one(ports, {
|
||||
fields: [residentialInterests.portId],
|
||||
references: [ports.id],
|
||||
}),
|
||||
client: one(residentialClients, {
|
||||
fields: [residentialInterests.residentialClientId],
|
||||
references: [residentialClients.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
94
src/lib/db/schema/residential.ts
Normal file
94
src/lib/db/schema/residential.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
|
||||
/**
|
||||
* Residential clients — physically separated from `clients` because the
|
||||
* residential side is handled by an external team that should never see
|
||||
* marina-side data, and vice versa. The two domains share a port but no
|
||||
* tables, so the access boundary is enforced at the schema level.
|
||||
*/
|
||||
export const residentialClients = pgTable(
|
||||
'residential_clients',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
fullName: text('full_name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
placeOfResidence: text('place_of_residence'),
|
||||
preferredContactMethod: text('preferred_contact_method'), // email | phone
|
||||
/**
|
||||
* Lifecycle: prospect | active | inactive. Distinct from
|
||||
* pipeline_stage on residential_interests (which is per-inquiry).
|
||||
*/
|
||||
status: text('status').notNull().default('prospect'),
|
||||
source: text('source'), // website | manual | referral | broker
|
||||
notes: text('notes'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_residential_clients_port').on(table.portId),
|
||||
index('idx_residential_clients_email').on(table.email),
|
||||
index('idx_residential_clients_archived').on(table.portId, table.archivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Residential interests — one per inquiry/lead. A residential_client can
|
||||
* have multiple interests over time (e.g. inquired about a unit in 2025,
|
||||
* came back about a different unit in 2026).
|
||||
*
|
||||
* Pipeline stages: new | contacted | viewing_scheduled | offer_made |
|
||||
* offer_accepted | closed_won | closed_lost.
|
||||
*/
|
||||
export const residentialInterests = pgTable(
|
||||
'residential_interests',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
residentialClientId: text('residential_client_id')
|
||||
.notNull()
|
||||
.references(() => residentialClients.id, { onDelete: 'cascade' }),
|
||||
pipelineStage: text('pipeline_stage').notNull().default('new'),
|
||||
source: text('source'), // website | manual | referral | broker
|
||||
notes: text('notes'),
|
||||
/**
|
||||
* Free-text capture of unit-type / size / floor / budget preferences —
|
||||
* residential leads are exploratory and the external team uses notes
|
||||
* heavily. Schema can grow into structured columns later if needed.
|
||||
*/
|
||||
preferences: text('preferences'),
|
||||
/**
|
||||
* better-auth user id of the residential team member working this lead.
|
||||
*/
|
||||
assignedTo: text('assigned_to'),
|
||||
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
||||
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_residential_interests_port').on(table.portId),
|
||||
index('idx_residential_interests_client').on(table.residentialClientId),
|
||||
index('idx_residential_interests_stage').on(table.portId, table.pipelineStage),
|
||||
index('idx_residential_interests_assigned').on(table.assignedTo),
|
||||
index('idx_residential_interests_archived').on(table.portId, table.archivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type ResidentialClient = typeof residentialClients.$inferSelect;
|
||||
export type NewResidentialClient = typeof residentialClients.$inferInsert;
|
||||
export type ResidentialInterest = typeof residentialInterests.$inferSelect;
|
||||
export type NewResidentialInterest = typeof residentialInterests.$inferInsert;
|
||||
@@ -118,6 +118,19 @@ export type RolePermissions = {
|
||||
manage_tags: boolean;
|
||||
system_backup: boolean;
|
||||
};
|
||||
residential_clients: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
residential_interests: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
change_stage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
@@ -251,6 +264,13 @@ export const userPortRoles = pgTable(
|
||||
roleId: text('role_id')
|
||||
.notNull()
|
||||
.references(() => roles.id, { onDelete: 'cascade' }),
|
||||
/**
|
||||
* Per-user per-port toggle that grants full residential domain access
|
||||
* (residential_clients.* and residential_interests.*) on top of the
|
||||
* user's primary role. Lets admins flip residential access for sales
|
||||
* staff individually without minting a second role.
|
||||
*/
|
||||
residentialAccess: boolean('residential_access').notNull().default(false),
|
||||
assignedBy: text('assigned_by'), // user ID of who assigned this
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -91,6 +91,14 @@ const ALL_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: true,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
@@ -157,6 +165,14 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
@@ -223,6 +239,14 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
@@ -289,6 +313,14 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
@@ -355,6 +387,14 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: false,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Port Definitions ────────────────────────────────────────────────────────
|
||||
|
||||
101
src/lib/email/templates/crm-invite.ts
Normal file
101
src/lib/email/templates/crm-invite.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
interface InviteData {
|
||||
link: string;
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function crmInviteEmail(data: InviteData): {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
} {
|
||||
const subject = `You're invited to the Port Nimara CRM`;
|
||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
Welcome to the Port Nimara CRM
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||
You've been invited to the Port Nimara CRM as a ${role}. Click the
|
||||
button below to set your password and activate your account. The
|
||||
link expires in ${data.ttlHours} hours.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Set up your account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
<strong>Port Nimara CRM</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Welcome to the Port Nimara CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`Port Nimara CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
107
src/lib/email/templates/residential-inquiry.ts
Normal file
107
src/lib/email/templates/residential-inquiry.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
}
|
||||
|
||||
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
|
||||
const subject = 'Thank You for Your Interest — Port Nimara Residences';
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
Welcome to Port Nimara
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
Dear ${escapeHtml(data.firstName)},
|
||||
</p>
|
||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||
Thank you for expressing interest in Port Nimara residences. Our residential
|
||||
sales team has received your inquiry and will reach out to you shortly with
|
||||
more information.
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
If you have any questions in the meantime, please reach us at
|
||||
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Best regards,<br />
|
||||
<strong>The Port Nimara Residential Team</strong>
|
||||
</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
}
|
||||
|
||||
export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
crmDeepLink?: string;
|
||||
}
|
||||
|
||||
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
||||
const subject = `New Residential Inquiry — ${data.fullName}`;
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
New residential inquiry
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
|
||||
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
|
||||
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
|
||||
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
|
||||
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
|
||||
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
|
||||
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
|
||||
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
||||
<p style="font-size:14px; color:#666;">— Port Nimara CRM</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
|
||||
@@ -8,6 +8,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
@@ -59,7 +60,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
if (sort === 'fullName') sortColumn = clients.fullName;
|
||||
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
||||
|
||||
const result = await buildListQuery({
|
||||
const result = await buildListQuery<typeof clients.$inferSelect>({
|
||||
table: clients,
|
||||
portIdColumn: clients.portId,
|
||||
portId,
|
||||
@@ -75,7 +76,41 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
archivedAtColumn: clients.archivedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
if (result.data.length === 0) return result;
|
||||
|
||||
const ids = result.data.map((r) => r.id);
|
||||
|
||||
const [yachtCounts, companyCounts] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||
.from(yachts)
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
inArray(yachts.currentOwnerId, ids),
|
||||
isNull(yachts.archivedAt),
|
||||
),
|
||||
)
|
||||
.groupBy(yachts.currentOwnerId),
|
||||
db
|
||||
.select({ clientId: companyMemberships.clientId, count: count() })
|
||||
.from(companyMemberships)
|
||||
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
||||
.groupBy(companyMemberships.clientId),
|
||||
]);
|
||||
|
||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((row) => ({
|
||||
...row,
|
||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||
companyCount: companyCountMap.get(row.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||
@@ -157,6 +192,8 @@ export async function getClientById(id: string, portId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
const portalEnabled = await isPortalEnabledForPort(portId);
|
||||
|
||||
return {
|
||||
...client,
|
||||
contacts,
|
||||
@@ -164,6 +201,7 @@ export async function getClientById(id: string, portId: string) {
|
||||
yachts: yachtRows,
|
||||
companies: membershipRows,
|
||||
activeReservations,
|
||||
clientPortalEnabled: portalEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user