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/
|
.remember/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
eoi/
|
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 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.
|
- **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).
|
- **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.
|
- **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.
|
- **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
|
## 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).
|
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 }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <>{children}</>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { toast } from 'sonner';
|
|||||||
import { authClient } from '@/lib/auth/client';
|
import { authClient } from '@/lib/auth/client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Please enter a valid email address'),
|
email: z.string().email('Please enter a valid email address'),
|
||||||
@@ -55,18 +55,14 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<BrandedAuthShell>
|
||||||
className="min-h-screen flex items-center justify-center px-4"
|
<div className="text-center mb-6">
|
||||||
style={{ backgroundColor: '#1e2844' }}
|
<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>
|
||||||
<Card className="w-full max-w-md">
|
</div>
|
||||||
<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>
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -77,18 +73,13 @@ export default function LoginPage() {
|
|||||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Link
|
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
|
||||||
href="/reset-password"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,22 +88,20 @@ export default function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
|
||||||
)}
|
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</BrandedAuthShell>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const resetSchema = z.object({
|
const resetSchema = z.object({
|
||||||
@@ -49,35 +49,26 @@ export default function ResetPasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<BrandedAuthShell>
|
||||||
className="min-h-screen flex items-center justify-center px-4"
|
<div className="text-center mb-6">
|
||||||
style={{ backgroundColor: '#1e2844' }}
|
<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>
|
||||||
<Card className="w-full max-w-md">
|
</div>
|
||||||
<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 ? (
|
{submitted ? (
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<div className="space-y-2">
|
<p className="font-medium text-gray-900">Check your email</p>
|
||||||
<p className="font-medium text-foreground">Check your email</p>
|
<p className="text-sm text-gray-500">
|
||||||
<p className="text-sm text-muted-foreground">
|
If an account exists for that email address, we have sent a password reset link. Please
|
||||||
If an account exists for that email address, we have sent a password reset link.
|
check your inbox and spam folder.
|
||||||
Please check your inbox and spam folder.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -85,33 +76,28 @@ export default function ResetPasswordPage() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||||
errors.email && 'border-destructive focus-visible:ring-destructive',
|
|
||||||
)}
|
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-gray-500">
|
||||||
Remember your password?{' '}
|
Remember your password?{' '}
|
||||||
<Link
|
<Link href="/login" className="text-[#007bff] hover:underline">
|
||||||
href="/login"
|
|
||||||
className="text-foreground underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</BrandedAuthShell>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CheckCircle2, Circle } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
|
||||||
|
const MIN_LENGTH = 9;
|
||||||
|
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
password: z
|
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
|
||||||
.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'),
|
|
||||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
@@ -31,25 +27,11 @@ const passwordSchema = z
|
|||||||
|
|
||||||
type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
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() {
|
function SetPasswordInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [passwordValue, setPasswordValue] = useState('');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -61,7 +43,7 @@ function SetPasswordInner() {
|
|||||||
|
|
||||||
async function onSubmit(data: SetPasswordFormData) {
|
async function onSubmit(data: SetPasswordFormData) {
|
||||||
if (!token) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +57,7 @@ function SetPasswordInner() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,66 +70,47 @@ function SetPasswordInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div
|
<BrandedAuthShell>
|
||||||
className="min-h-screen flex items-center justify-center px-4"
|
<div className="text-center space-y-3">
|
||||||
style={{ backgroundColor: '#1e2844' }}
|
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||||
>
|
<p className="text-sm text-gray-500">
|
||||||
<Card className="w-full max-w-md">
|
Please use the link from the email we sent you. If the link is broken, ask your
|
||||||
<CardHeader className="space-y-1 text-center pb-6">
|
administrator for a new one.
|
||||||
<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>
|
</p>
|
||||||
) : (
|
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</BrandedAuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">New Password</Label>
|
<Label htmlFor="password">New password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
{...register('password')}
|
||||||
)}
|
|
||||||
{...register('password', {
|
|
||||||
onChange: (e) => setPasswordValue(e.target.value),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -163,27 +126,21 @@ function SetPasswordInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{isLoading ? 'Setting password…' : 'Set password'}
|
{isLoading ? 'Setting password…' : 'Set password'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
</BrandedAuthShell>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SetPasswordPage() {
|
export default function SetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
|
||||||
fallback={
|
|
||||||
<div
|
|
||||||
className="min-h-screen flex items-center justify-center px-4"
|
|
||||||
style={{ backgroundColor: '#1e2844' }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SetPasswordInner />
|
<SetPasswordInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
|
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
|
||||||
|
|
||||||
export default function FormTemplatesPage() {
|
export default function FormTemplatesPage() {
|
||||||
return (
|
return <FormTemplateList />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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() {
|
export default function EmailPage() {
|
||||||
|
const [tab, setTab] = useState('threads');
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
||||||
<p className="text-muted-foreground">Send and manage client communications</p>
|
<p className="text-muted-foreground">Send and manage client communications</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
<Button onClick={() => setComposeOpen(true)}>
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
<p className="text-sm text-muted-foreground">
|
Compose
|
||||||
This feature will be implemented in the next phase.
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
</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() {
|
export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||||
return <DashboardShell />;
|
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 { auth } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
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 { QueryProvider } from '@/providers/query-provider';
|
||||||
import { SocketProvider } from '@/providers/socket-provider';
|
import { SocketProvider } from '@/providers/socket-provider';
|
||||||
import { PortProvider } from '@/providers/port-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() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session?.user) redirect('/login');
|
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({
|
const portRoles = await db.query.userPortRoles.findMany({
|
||||||
where: eq(userPortRoles.userId, session.user.id),
|
where: eq(userPortRoles.userId, session.user.id),
|
||||||
with: { port: true, role: true },
|
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 (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<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">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
<Topbar ports={ports} />
|
<Topbar ports={ports} />
|
||||||
<main className="flex-1 overflow-y-auto bg-background p-6">
|
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, Mail } from 'lucide-react';
|
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
|
||||||
export default function PortalForgotPasswordPage() {
|
export default function PortalForgotPasswordPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -31,31 +32,29 @@ export default function PortalForgotPasswordPage() {
|
|||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
<BrandedAuthShell>
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
<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>
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
|
<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
|
If <strong>{email}</strong> matches a portal account, we've sent a reset link. The
|
||||||
link expires in 30 minutes.
|
link expires in 30 minutes.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/portal/login"
|
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
|
Back to sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BrandedAuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
<BrandedAuthShell>
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
@@ -74,13 +73,14 @@ export default function PortalForgotPasswordPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
disabled={loading || !email}
|
disabled={loading || !email}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -92,16 +92,14 @@ export default function PortalForgotPasswordPage() {
|
|||||||
'Send reset link'
|
'Send reset link'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<Link
|
<p className="text-center text-sm text-gray-500">
|
||||||
href="/portal/login"
|
Remember your password?{' '}
|
||||||
className="block mt-4 text-center text-xs text-gray-500 hover:underline"
|
<Link href="/portal/login" className="text-[#007bff] hover:underline">
|
||||||
>
|
Sign in
|
||||||
Back to sign in
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</BrandedAuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Loader2 } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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() {
|
export default function PortalLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -49,7 +49,7 @@ export default function PortalLoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalAuthShell>
|
<BrandedAuthShell>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
<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>
|
<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">
|
<p className="text-center text-xs text-gray-400 mt-6">
|
||||||
This portal is for existing clients only.
|
This portal is for existing clients only.
|
||||||
</p>
|
</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 { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
import { listHandler, createHandler } from './handlers';
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||||
export const POST = withAuth(withPermission('reservations', 'create', createHandler));
|
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 { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('companies', 'view', getHandler));
|
export const GET = withAuth(withPermission('companies', 'view', getHandler));
|
||||||
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));
|
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 { listHandler, createHandler } from './handlers';
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('companies', 'view', listHandler));
|
export const GET = withAuth(withPermission('companies', 'view', listHandler));
|
||||||
export const POST = withAuth(withPermission('companies', 'create', createHandler));
|
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 { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||||
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));
|
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 { listHandler, createHandler } from './handlers';
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
|
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
|
||||||
export const POST = withAuth(withPermission('yachts', 'create', createHandler));
|
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,
|
manage_tags: false,
|
||||||
system_backup: 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> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'boolean' | 'number' | 'json' | 'string';
|
type: 'boolean' | 'number' | 'json' | 'string';
|
||||||
defaultValue: unknown;
|
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',
|
key: 'ai_interest_scoring',
|
||||||
label: 'AI Interest Scoring',
|
label: 'AI Interest Scoring',
|
||||||
@@ -89,6 +97,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'json',
|
type: 'json',
|
||||||
defaultValue: [],
|
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() {
|
export function SettingsManager() {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface UserFormProps {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
role: { id: string; name: string };
|
role: { id: string; name: string };
|
||||||
|
residentialAccess?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [roleId, setRoleId] = useState('');
|
const [roleId, setRoleId] = useState('');
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [residentialAccess, setResidentialAccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
setPhone(user.phone ?? '');
|
setPhone(user.phone ?? '');
|
||||||
setRoleId(user.role.id);
|
setRoleId(user.role.id);
|
||||||
setIsActive(user.isActive);
|
setIsActive(user.isActive);
|
||||||
|
setResidentialAccess(user.residentialAccess ?? false);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
} else {
|
} else {
|
||||||
setName('');
|
setName('');
|
||||||
@@ -71,6 +74,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
setPhone('');
|
setPhone('');
|
||||||
setRoleId('');
|
setRoleId('');
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
|
setResidentialAccess(false);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -91,6 +95,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
roleId,
|
roleId,
|
||||||
isActive,
|
isActive,
|
||||||
|
residentialAccess,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -103,6 +108,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
displayName,
|
displayName,
|
||||||
phone: phone || undefined,
|
phone: phone || undefined,
|
||||||
roleId,
|
roleId,
|
||||||
|
residentialAccess,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,6 +196,21 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 && (
|
{isEdit && (
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function BerthStatusSuggestionDialog({
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiFetch(`/api/v1/berths/${berthId}/status`, {
|
apiFetch(`/api/v1/berths/${berthId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ status: suggestedStatus, reason }),
|
body: { status: suggestedStatus, reason },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onApplied();
|
onApplied();
|
||||||
@@ -66,21 +66,14 @@ export function BerthStatusSuggestionDialog({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reason && (
|
{reason && <p className="text-sm text-muted-foreground text-center px-4">{reason}</p>}
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Dismiss
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
|
||||||
onClick={() => applyMutation.mutate()}
|
{applyMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||||
disabled={applyMutation.isPending}
|
|
||||||
>
|
|
||||||
{applyMutation.isPending && (
|
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Apply Change
|
Apply Change
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
|
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -81,9 +77,7 @@ function SortableEntry({
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm truncate">{entry.clientId}</p>
|
<p className="text-sm truncate">{entry.clientId}</p>
|
||||||
{entry.notes && (
|
{entry.notes && <p className="text-xs text-muted-foreground truncate">{entry.notes}</p>}
|
||||||
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
|
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
|
||||||
@@ -118,7 +112,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
|||||||
mutationFn: (body: { entryId: string; newPosition: number }) =>
|
mutationFn: (body: { entryId: string; newPosition: number }) =>
|
||||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||||
@@ -129,7 +123,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
|||||||
mutationFn: (entries: WaitingListEntry[]) =>
|
mutationFn: (entries: WaitingListEntry[]) =>
|
||||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ entries }),
|
body: { entries },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||||
@@ -207,10 +201,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
|||||||
value={newClientId}
|
value={newClientId}
|
||||||
onChange={(e) => setNewClientId(e.target.value)}
|
onChange={(e) => setNewClientId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select value={newPriority} onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}>
|
||||||
value={newPriority}
|
|
||||||
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -226,9 +217,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||||
{addMutation.isPending && (
|
{addMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Add to List
|
Add to List
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
|
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
|
||||||
@@ -243,22 +232,11 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
|||||||
No entries on waiting list.
|
No entries on waiting list.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
sensors={sensors}
|
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={entries.map((e) => e.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<SortableEntry
|
<SortableEntry key={entry.id} entry={entry} onRemove={handleRemove} />
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface ClientRow {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
yachtCount?: number;
|
||||||
|
companyCount?: number;
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
@@ -39,10 +41,6 @@ interface GetColumnsOptions {
|
|||||||
onArchive: (client: ClientRow) => void;
|
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({
|
export function getClientColumns({
|
||||||
portSlug,
|
portSlug,
|
||||||
onEdit,
|
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',
|
id: 'tags',
|
||||||
header: 'Tags',
|
header: 'Tags',
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { ClientForm } from '@/components/clients/client-form';
|
|
||||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -25,22 +24,10 @@ interface ClientDetailHeaderProps {
|
|||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
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> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
website: 'Website',
|
website: 'Website',
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
@@ -50,7 +37,6 @@ const SOURCE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
const isArchived = !!client.archivedAt;
|
const isArchived = !!client.archivedAt;
|
||||||
@@ -128,17 +114,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isArchived && (
|
{!isArchived && client.clientPortalEnabled !== false && (
|
||||||
<PortalInviteButton
|
<PortalInviteButton
|
||||||
clientId={client.id}
|
clientId={client.id}
|
||||||
clientName={client.fullName}
|
clientName={client.fullName}
|
||||||
defaultEmail={primaryEmail?.value}
|
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
|
<Button
|
||||||
variant={isArchived ? 'outline' : 'outline'}
|
variant={isArchived ? 'outline' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -160,12 +142,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientForm
|
|
||||||
open={editOpen}
|
|
||||||
onOpenChange={setEditOpen}
|
|
||||||
client={client as unknown as ClientFormClient}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArchiveConfirmDialog
|
<ArchiveConfirmDialog
|
||||||
open={archiveOpen}
|
open={archiveOpen}
|
||||||
onOpenChange={setArchiveOpen}
|
onOpenChange={setArchiveOpen}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ClientData {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
sourceDetails: string | null;
|
sourceDetails: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
|
clientPortalEnabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
contacts: Array<{
|
contacts: Array<{
|
||||||
|
|||||||
@@ -1,10 +1,62 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
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 { NotesList } from '@/components/shared/notes-list';
|
||||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||||
|
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 {
|
interface ClientTabsOptions {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -57,84 +109,84 @@ interface ClientTabsOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
function OverviewTab({
|
||||||
if (!value) return null;
|
clientId,
|
||||||
return (
|
client,
|
||||||
<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>
|
clientId: string;
|
||||||
<dd className="text-sm">{value}</dd>
|
client: ClientTabsOptions['client'];
|
||||||
</div>
|
}) {
|
||||||
);
|
const mutation = useClientPatch(clientId);
|
||||||
}
|
const save = (field: ClientPatchField) => async (next: string | null) => {
|
||||||
|
await mutation.mutateAsync({ [field]: next });
|
||||||
|
};
|
||||||
|
|
||||||
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Full Name" value={client.fullName} />
|
<EditableRow label="Full Name">
|
||||||
<InfoRow label="Nationality" value={client.nationality} />
|
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||||
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
</EditableRow>
|
||||||
<InfoRow label="Timezone" value={client.timezone} />
|
<EditableRow label="Nationality">
|
||||||
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contacts */}
|
{/* Contacts */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||||
{client.contacts && client.contacts.length > 0 ? (
|
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source */}
|
{/* Source */}
|
||||||
{(client.source || client.sourceDetails) && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Source" value={client.source} />
|
<EditableRow label="Source">
|
||||||
<InfoRow label="Source Details" value={client.sourceDetails} />
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{client.tags && client.tags.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-1">
|
<InlineTagEditor
|
||||||
{client.tags.map((tag) => (
|
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||||
<span
|
currentTags={client.tags ?? []}
|
||||||
key={tag.id}
|
invalidateKey={['clients', clientId]}
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +195,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab client={client} />,
|
content: <OverviewTab clientId={clientId} client={client} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'yachts',
|
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
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 { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||||
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-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 {
|
interface CompanyTabsCompany {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +38,7 @@ interface CompanyTabsCompany {
|
|||||||
status: string;
|
status: string;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompanyTabsOptions {
|
interface CompanyTabsOptions {
|
||||||
@@ -25,30 +48,34 @@ interface CompanyTabsOptions {
|
|||||||
company: CompanyTabsCompany;
|
company: CompanyTabsCompany;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
function useCompanyPatch(companyId: string) {
|
||||||
active: 'Active',
|
const qc = useQueryClient();
|
||||||
dissolved: 'Dissolved',
|
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 }) {
|
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
|
||||||
return (
|
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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null): string | null {
|
function OverviewTab({ companyId, company }: { companyId: string; company: CompanyTabsCompany }) {
|
||||||
if (!value) return null;
|
const mutation = useCompanyPatch(companyId);
|
||||||
const date = new Date(value);
|
const save = (field: CompanyPatchField) => async (next: string | null) => {
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
await mutation.mutateAsync({ [field]: next });
|
||||||
return date.toLocaleDateString();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
|
||||||
const incorporationDate = formatDate(company.incorporationDate);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Name" value={company.name} />
|
<EditableRow label="Name">
|
||||||
<InfoRow label="Legal Name" value={company.legalName} />
|
<InlineEditableField value={company.name} onSave={save('name')} />
|
||||||
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registration */}
|
{/* Registration */}
|
||||||
{(company.taxId ||
|
|
||||||
company.registrationNumber ||
|
|
||||||
company.incorporationCountry ||
|
|
||||||
incorporationDate) && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Tax ID" value={company.taxId} />
|
<EditableRow label="Tax ID">
|
||||||
<InfoRow label="Registration Number" value={company.registrationNumber} />
|
<InlineEditableField value={company.taxId} onSave={save('taxId')} />
|
||||||
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
|
</EditableRow>
|
||||||
<InfoRow label="Incorporation Date" value={incorporationDate} />
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contact */}
|
{/* Contact */}
|
||||||
{company.billingEmail && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Billing Email" value={company.billingEmail} />
|
<EditableRow label="Billing Email">
|
||||||
|
<InlineEditableField value={company.billingEmail} onSave={save('billingEmail')} />
|
||||||
|
</EditableRow>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{company.notes && (
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
<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">
|
<InlineEditableField
|
||||||
{company.notes}
|
variant="textarea"
|
||||||
</p>
|
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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,17 +166,14 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
|||||||
export function getCompanyTabs({
|
export function getCompanyTabs({
|
||||||
companyId,
|
companyId,
|
||||||
portSlug,
|
portSlug,
|
||||||
// currentUserId reserved for when NotesList supports entityType='companies'.
|
currentUserId,
|
||||||
currentUserId: _currentUserId,
|
|
||||||
company,
|
company,
|
||||||
}: CompanyTabsOptions): DetailTab[] {
|
}: CompanyTabsOptions): DetailTab[] {
|
||||||
void _currentUserId;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab company={company} />,
|
content: <OverviewTab companyId={companyId} company={company} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'members',
|
id: 'members',
|
||||||
@@ -129,7 +188,6 @@ export function getCompanyTabs({
|
|||||||
{
|
{
|
||||||
id: 'addresses',
|
id: 'addresses',
|
||||||
label: 'Addresses',
|
label: 'Addresses',
|
||||||
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
|
|
||||||
content: (
|
content: (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Addresses"
|
title="Addresses"
|
||||||
@@ -145,22 +203,8 @@ export function getCompanyTabs({
|
|||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
|
||||||
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
|
|
||||||
content: (
|
content: (
|
||||||
<EmptyState
|
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
|
||||||
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." />
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
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 { RecommendationList } from '@/components/interests/recommendation-list';
|
||||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
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 {
|
interface InterestTabsOptions {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
interest: {
|
interest: {
|
||||||
|
leadCategory: string | null;
|
||||||
|
source: string | null;
|
||||||
eoiStatus: string | null;
|
eoiStatus: string | null;
|
||||||
contractStatus: string | null;
|
contractStatus: string | null;
|
||||||
depositStatus: string | null;
|
depositStatus: string | null;
|
||||||
@@ -26,9 +40,33 @@ interface InterestTabsOptions {
|
|||||||
reminderDays: number | null;
|
reminderDays: number | null;
|
||||||
reminderLastFired: string | null;
|
reminderLastFired: string | null;
|
||||||
notes: 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 }) {
|
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
@@ -44,10 +82,39 @@ function formatDate(date: string | null) {
|
|||||||
return format(new Date(date), 'MMM d, yyyy');
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
<h3 className="text-sm font-medium mb-2">Status</h3>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -58,8 +125,8 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Dates */}
|
{/* Key Dates (read-only — set by workflow events) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
|
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
|
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
|
||||||
@@ -81,23 +148,31 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
|
|||||||
label="Reminder Days"
|
label="Reminder Days"
|
||||||
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
|
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow label="Last Fired" value={formatDate(interest.reminderLastFired)} />
|
||||||
label="Last Fired"
|
|
||||||
value={formatDate(interest.reminderLastFired)}
|
|
||||||
/>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes (editable, multiline) */}
|
||||||
{interest.notes && (
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<InlineEditableField
|
||||||
{interest.notes}
|
variant="textarea"
|
||||||
</p>
|
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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,17 +186,13 @@ export function getInterestTabs({
|
|||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab interest={interest} />,
|
content: <OverviewTab interestId={interestId} interest={interest} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
content: (
|
content: (
|
||||||
<NotesList
|
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
|
||||||
entityType="interests"
|
|
||||||
entityId={interestId}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function PipelineBoard() {
|
|||||||
let newStage = over.id as string;
|
let newStage = over.id as string;
|
||||||
|
|
||||||
// If dropped on a card (not a stage), find which stage that card belongs to
|
// 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);
|
const targetInterest = interests.find((i) => i.id === newStage);
|
||||||
if (!targetInterest) return;
|
if (!targetInterest) return;
|
||||||
newStage = targetInterest.pipelineStage;
|
newStage = targetInterest.pipelineStage;
|
||||||
@@ -85,23 +85,18 @@ export function PipelineBoard() {
|
|||||||
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
|
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
queryClient.setQueryData<{ data: InterestRow[] }>(
|
queryClient.setQueryData<{ data: InterestRow[] }>(['interests-board', portSlug], (old) => {
|
||||||
['interests-board', portSlug],
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return {
|
return {
|
||||||
...old,
|
...old,
|
||||||
data: old.data.map((i) =>
|
data: old.data.map((i) => (i.id === interestId ? { ...i, pipelineStage: newStage } : i)),
|
||||||
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ pipelineStage: newStage }),
|
body: { pipelineStage: newStage },
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sendMutation = useMutation({
|
const sendMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
|
||||||
apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
@@ -58,7 +57,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
mutationFn: (values: RecordPaymentInput) =>
|
mutationFn: (values: RecordPaymentInput) =>
|
||||||
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
|
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(values),
|
body: values,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
|
||||||
@@ -76,9 +75,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
|
|
||||||
if (error || !data?.data) {
|
if (error || !data?.data) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 text-center text-muted-foreground">
|
<div className="p-6 text-center text-muted-foreground">Failed to load invoice details.</div>
|
||||||
Failed to load invoice details.
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,9 +227,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<CardTitle className="text-sm font-medium">Notes</CardTitle>
|
<CardTitle className="text-sm font-medium">Notes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||||
{invoice.notes}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -249,9 +244,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
className="flex items-center justify-between p-3 border rounded-md text-sm"
|
className="flex items-center justify-between p-3 border rounded-md text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
|
||||||
{exp.establishmentName ?? 'Unnamed Expense'}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{exp.category ?? '—'} · {exp.expenseDate}
|
{exp.category ?? '—'} · {exp.expenseDate}
|
||||||
</p>
|
</p>
|
||||||
@@ -271,10 +264,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
|
|
||||||
{/* PDF Preview */}
|
{/* PDF Preview */}
|
||||||
<TabsContent value="pdf" className="pt-4">
|
<TabsContent value="pdf" className="pt-4">
|
||||||
<InvoicePdfPreview
|
<InvoicePdfPreview invoiceId={invoiceId} pdfFileId={invoice.pdfFileId} />
|
||||||
invoiceId={invoiceId}
|
|
||||||
pdfFileId={invoice.pdfFileId}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Payment */}
|
{/* Payment */}
|
||||||
@@ -283,10 +273,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-3 text-sm">
|
<CardContent className="pt-6 space-y-3 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
|
||||||
variant="outline"
|
|
||||||
className="bg-green-100 text-green-700 border-green-200"
|
|
||||||
>
|
|
||||||
Paid
|
Paid
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,9 +284,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Method</span>
|
<span className="text-muted-foreground">Method</span>
|
||||||
<p className="mt-0.5 capitalize">
|
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
|
||||||
{invoice.paymentMethod ?? '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Reference</span>
|
<span className="text-muted-foreground">Reference</span>
|
||||||
@@ -315,18 +300,12 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
onSubmit={paymentForm.handleSubmit((values) =>
|
onSubmit={paymentForm.handleSubmit((values) => paymentMutation.mutate(values))}
|
||||||
paymentMutation.mutate(values),
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="paymentDate">Payment Date</Label>
|
<Label htmlFor="paymentDate">Payment Date</Label>
|
||||||
<Input
|
<Input id="paymentDate" type="date" {...paymentForm.register('paymentDate')} />
|
||||||
id="paymentDate"
|
|
||||||
type="date"
|
|
||||||
{...paymentForm.register('paymentDate')}
|
|
||||||
/>
|
|
||||||
{paymentForm.formState.errors.paymentDate && (
|
{paymentForm.formState.errors.paymentDate && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{paymentForm.formState.errors.paymentDate.message}
|
{paymentForm.formState.errors.paymentDate.message}
|
||||||
@@ -349,10 +328,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
{...paymentForm.register('paymentReference')}
|
{...paymentForm.register('paymentReference')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="submit" disabled={paymentMutation.isPending}>
|
||||||
type="submit"
|
|
||||||
disabled={paymentMutation.isPending}
|
|
||||||
>
|
|
||||||
{paymentMutation.isPending ? (
|
{paymentMutation.isPending ? (
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
|
|||||||
profile: 'Profile',
|
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 {
|
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() {
|
export function Breadcrumbs() {
|
||||||
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
|
|||||||
// Split pathname and filter empty segments
|
// Split pathname and filter empty segments
|
||||||
const rawSegments = pathname.split('/').filter(Boolean);
|
const rawSegments = pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
// Remove the portSlug segment from display
|
// Remove the portSlug segment and any UUID-ish entity-id segments — the
|
||||||
const segments = currentPortSlug
|
// page H1 already shows the entity name, no need to leak the raw id.
|
||||||
? rawSegments.filter((seg) => seg !== currentPortSlug)
|
const segments = (
|
||||||
: rawSegments;
|
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||||
|
).filter((seg) => !isIdSegment(seg));
|
||||||
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
|
Home,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -51,6 +53,10 @@ interface NavSection {
|
|||||||
title: string;
|
title: string;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
adminRequired?: boolean;
|
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[] {
|
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||||
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Main',
|
title: 'Main',
|
||||||
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||||
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ 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',
|
title: 'Documents',
|
||||||
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
||||||
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
||||||
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Financial',
|
title: 'Financial',
|
||||||
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||||
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Communication',
|
title: 'Communication',
|
||||||
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
||||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||||
@@ -150,11 +176,15 @@ function SidebarContent({
|
|||||||
portSlug,
|
portSlug,
|
||||||
portRoles,
|
portRoles,
|
||||||
hasAdminAccess,
|
hasAdminAccess,
|
||||||
|
hasMarinaAccess,
|
||||||
|
hasResidentialAccess,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
portSlug: string | undefined;
|
portSlug: string | undefined;
|
||||||
portRoles: SidebarProps['portRoles'];
|
portRoles: SidebarProps['portRoles'];
|
||||||
hasAdminAccess: boolean;
|
hasAdminAccess: boolean;
|
||||||
|
hasMarinaAccess: boolean;
|
||||||
|
hasResidentialAccess: boolean;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [adminExpanded, setAdminExpanded] = useState(false);
|
const [adminExpanded, setAdminExpanded] = useState(false);
|
||||||
@@ -191,6 +221,8 @@ function SidebarContent({
|
|||||||
<nav className="px-2 space-y-4">
|
<nav className="px-2 space-y-4">
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
if (section.adminRequired && !hasAdminAccess) return null;
|
if (section.adminRequired && !hasAdminAccess) return null;
|
||||||
|
if (section.marinaRequired && !hasMarinaAccess) return null;
|
||||||
|
if (section.residentialRequired && !hasResidentialAccess) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
@@ -272,17 +304,26 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ portRoles }: SidebarProps) {
|
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
|
||||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
|
||||||
// Check for admin access based on role permissions
|
// Super admins see every section regardless of role rows.
|
||||||
const hasAdminAccess = portRoles.some(
|
const hasAdminAccess =
|
||||||
|
isSuperAdmin ||
|
||||||
|
portRoles.some(
|
||||||
(pr) =>
|
(pr) =>
|
||||||
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
|||||||
portSlug={currentPortSlug ?? undefined}
|
portSlug={currentPortSlug ?? undefined}
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
hasAdminAccess={hasAdminAccess}
|
hasAdminAccess={hasAdminAccess}
|
||||||
|
hasMarinaAccess={hasMarinaAccess}
|
||||||
|
hasResidentialAccess={hasResidentialAccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
{/* Collapse toggle */}
|
||||||
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
|||||||
portSlug={currentPortSlug ?? undefined}
|
portSlug={currentPortSlug ?? undefined}
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
hasAdminAccess={hasAdminAccess}
|
hasAdminAccess={hasAdminAccess}
|
||||||
|
hasMarinaAccess={hasMarinaAccess}
|
||||||
|
hasResidentialAccess={hasResidentialAccess}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
|
|||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
||||||
{darkMode ? (
|
{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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
|
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||||
|
|
||||||
interface PasswordSetFormProps {
|
interface PasswordSetFormProps {
|
||||||
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
|
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
|
||||||
@@ -75,7 +75,7 @@ export function PasswordSetForm({
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<PortalAuthShell>
|
<BrandedAuthShell>
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
@@ -89,13 +89,13 @@ export function PasswordSetForm({
|
|||||||
Request a new link
|
Request a new link
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</PortalAuthShell>
|
</BrandedAuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
<PortalAuthShell>
|
<BrandedAuthShell>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
<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" />
|
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||||
@@ -109,12 +109,12 @@ export function PasswordSetForm({
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</PortalAuthShell>
|
</BrandedAuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalAuthShell>
|
<BrandedAuthShell>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
||||||
@@ -173,6 +173,6 @@ export function PasswordSetForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</PortalAuthShell>
|
</BrandedAuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,13 +195,18 @@ export function ReservationList({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{r.contractFileId ? (
|
{r.contractFileId ? (
|
||||||
// TODO: Confirm final file-download endpoint URL when available
|
<button
|
||||||
<a
|
type="button"
|
||||||
href={`/api/v1/files/${r.contractFileId}/download`}
|
|
||||||
className="text-primary hover:underline"
|
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
|
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 =
|
const LOGO_URL =
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen flex items-center justify-center px-4 py-8"
|
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 {
|
interface NotesListProps {
|
||||||
entityType: 'clients' | 'interests';
|
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
|
||||||
entityId: string;
|
entityId: string;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (content: string) =>
|
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||||
apiFetch(endpoint, { method: 'POST', body: { content } }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
setNewNote('');
|
setNewNote('');
|
||||||
@@ -61,8 +60,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (noteId: string) =>
|
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||||
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,13 +125,9 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
|||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
{note.isLocked && (
|
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{canEdit(note) && (
|
{canEdit(note) && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
||||||
{getTimeRemaining(note)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editingId === note.id ? (
|
{editingId === note.id ? (
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
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 { 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 {
|
interface YachtTabsYacht {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,6 +52,7 @@ interface YachtTabsYacht {
|
|||||||
draftM: string | null;
|
draftM: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface YachtTabsOptions {
|
interface YachtTabsOptions {
|
||||||
@@ -30,25 +61,43 @@ interface YachtTabsOptions {
|
|||||||
yacht: YachtTabsYacht;
|
yacht: YachtTabsYacht;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
function useYachtPatch(yachtId: string) {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
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 (
|
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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYacht }) {
|
||||||
active: 'Active',
|
const mutation = useYachtPatch(yachtId);
|
||||||
retired: 'Retired',
|
const save =
|
||||||
sold_away: 'Sold away',
|
(field: YachtPatchField, transform?: (v: string | null) => string | number | null) =>
|
||||||
};
|
async (next: string | null) => {
|
||||||
|
const value = transform ? transform(next) : next;
|
||||||
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
|
await mutation.mutateAsync({ [field]: value });
|
||||||
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
|
};
|
||||||
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Name" value={yacht.name} />
|
<EditableRow label="Name">
|
||||||
<InfoRow label="Hull Number" value={yacht.hullNumber} />
|
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||||
<InfoRow label="Registration" value={yacht.registration} />
|
</EditableRow>
|
||||||
<InfoRow label="Flag" value={yacht.flag} />
|
<EditableRow label="Hull Number">
|
||||||
<InfoRow label="Year Built" value={yacht.yearBuilt} />
|
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
|
||||||
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Build */}
|
{/* Build */}
|
||||||
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Build</h3>
|
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Builder" value={yacht.builder} />
|
<EditableRow label="Builder">
|
||||||
<InfoRow label="Model" value={yacht.model} />
|
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
|
||||||
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dimensions (ft) */}
|
{/* Dimensions (ft) */}
|
||||||
{hasFtDimensions && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
|
<EditableRow label="Length (ft)">
|
||||||
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
|
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
|
||||||
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dimensions (m) */}
|
{/* Dimensions (m) */}
|
||||||
{hasMDimensions && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
|
<EditableRow label="Length (m)">
|
||||||
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
|
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
|
||||||
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{yacht.notes && (
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
<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">
|
<InlineEditableField
|
||||||
{yacht.notes}
|
variant="textarea"
|
||||||
</p>
|
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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getYachtTabs({
|
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
||||||
yachtId,
|
|
||||||
// currentUserId reserved for when NotesList supports entityType='yachts'.
|
|
||||||
currentUserId: _currentUserId,
|
|
||||||
yacht,
|
|
||||||
}: YachtTabsOptions): DetailTab[] {
|
|
||||||
void _currentUserId;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab yacht={yacht} />,
|
content: <OverviewTab yachtId={yachtId} yacht={yacht} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ownership-history',
|
id: 'ownership-history',
|
||||||
@@ -146,23 +231,7 @@ export function getYachtTabs({
|
|||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
|
||||||
// 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." />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,44 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
|
|||||||
body?: unknown;
|
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
|
* Client-side fetch wrapper that attaches the `X-Port-Id` header from the
|
||||||
* UI store to every request. Used by all queryFn/mutationFn callbacks.
|
* 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>(
|
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
|
||||||
url: string,
|
let portId = useUIStore.getState().currentPortId;
|
||||||
opts: ApiFetchOptions = {},
|
|
||||||
): Promise<T> {
|
if (!portId && typeof window !== 'undefined') {
|
||||||
const portId = useUIStore.getState().currentPortId;
|
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);
|
const headers = new Headers(opts.headers);
|
||||||
if (portId) {
|
if (portId) {
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ export function withAuth(
|
|||||||
override.permissionOverrides as Record<string, unknown>,
|
override.permissionOverrides as Record<string, unknown>,
|
||||||
) as RolePermissions;
|
) 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) {
|
} else if (profile.isSuperAdmin && portId) {
|
||||||
// Super admin still needs portSlug for response context.
|
// Super admin still needs portSlug for response context.
|
||||||
const port = await db.query.ports.findFirst({
|
const port = await db.query.ports.findFirst({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const auth = betterAuth({
|
|||||||
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
minPasswordLength: 12,
|
minPasswordLength: 9,
|
||||||
// Accounts are admin-created only — no self-service email verification flow.
|
// Accounts are admin-created only — no self-service email verification flow.
|
||||||
requireEmailVerification: false,
|
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,
|
"when": 1777210206070,
|
||||||
"tag": "0009_outgoing_rumiko_fujikawa",
|
"tag": "0009_outgoing_rumiko_fujikawa",
|
||||||
"breakpoints": true
|
"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)
|
// Portal (client-portal auth)
|
||||||
export * from './portal';
|
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
|
// Operations
|
||||||
export * from './operations';
|
export * from './operations';
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
customFieldDefinitions,
|
customFieldDefinitions,
|
||||||
customFieldValues,
|
customFieldValues,
|
||||||
} from './system';
|
} from './system';
|
||||||
|
import { residentialClients, residentialInterests } from './residential';
|
||||||
|
|
||||||
// ─── Ports ────────────────────────────────────────────────────────────────────
|
// ─── Ports ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -116,6 +117,8 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
|||||||
savedViews: many(savedViews),
|
savedViews: many(savedViews),
|
||||||
userNotificationPreferences: many(userNotificationPreferences),
|
userNotificationPreferences: many(userNotificationPreferences),
|
||||||
customFieldDefinitions: many(customFieldDefinitions),
|
customFieldDefinitions: many(customFieldDefinitions),
|
||||||
|
residentialClients: many(residentialClients),
|
||||||
|
residentialInterests: many(residentialInterests),
|
||||||
berthMaintenanceLogs: many(berthMaintenanceLog),
|
berthMaintenanceLogs: many(berthMaintenanceLog),
|
||||||
clientMergeLogs: many(clientMergeLog),
|
clientMergeLogs: many(clientMergeLog),
|
||||||
clientRelationships: many(clientRelationships),
|
clientRelationships: many(clientRelationships),
|
||||||
@@ -819,3 +822,24 @@ export const customFieldValuesRelations = relations(customFieldValues, ({ one })
|
|||||||
references: [customFieldDefinitions.id],
|
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;
|
manage_tags: boolean;
|
||||||
system_backup: 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 = {
|
export type UserPreferences = {
|
||||||
@@ -251,6 +264,13 @@ export const userPortRoles = pgTable(
|
|||||||
roleId: text('role_id')
|
roleId: text('role_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.id, { onDelete: 'cascade' }),
|
.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
|
assignedBy: text('assigned_by'), // user ID of who assigned this
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
manage_tags: true,
|
manage_tags: true,
|
||||||
system_backup: 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 = {
|
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||||
@@ -157,6 +165,14 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
|||||||
manage_tags: true,
|
manage_tags: true,
|
||||||
system_backup: false,
|
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 = {
|
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||||
@@ -223,6 +239,14 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
manage_tags: true,
|
manage_tags: true,
|
||||||
system_backup: 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 SALES_AGENT_PERMISSIONS: RolePermissions = {
|
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||||
@@ -289,6 +313,14 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
manage_tags: true,
|
manage_tags: true,
|
||||||
system_backup: 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 VIEWER_PERMISSIONS: RolePermissions = {
|
const VIEWER_PERMISSIONS: RolePermissions = {
|
||||||
@@ -355,6 +387,14 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
manage_tags: false,
|
manage_tags: false,
|
||||||
system_backup: 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 ────────────────────────────────────────────────────────
|
// ─── 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 { db } from '@/lib/db';
|
||||||
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
|
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 { tags } from '@/lib/db/schema/system';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
|
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -59,7 +60,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
if (sort === 'fullName') sortColumn = clients.fullName;
|
if (sort === 'fullName') sortColumn = clients.fullName;
|
||||||
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
||||||
|
|
||||||
const result = await buildListQuery({
|
const result = await buildListQuery<typeof clients.$inferSelect>({
|
||||||
table: clients,
|
table: clients,
|
||||||
portIdColumn: clients.portId,
|
portIdColumn: clients.portId,
|
||||||
portId,
|
portId,
|
||||||
@@ -75,7 +76,41 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
archivedAtColumn: clients.archivedAt,
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||||
@@ -157,6 +192,8 @@ export async function getClientById(id: string, portId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const portalEnabled = await isPortalEnabledForPort(portId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
contacts,
|
contacts,
|
||||||
@@ -164,6 +201,7 @@ export async function getClientById(id: string, portId: string) {
|
|||||||
yachts: yachtRows,
|
yachts: yachtRows,
|
||||||
companies: membershipRows,
|
companies: membershipRows,
|
||||||
activeReservations,
|
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