chore: merge 02-03 executor worktree
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: "03"
|
||||
subsystem: "admin-workspace"
|
||||
tags: [admin, tabs, server-actions, phases, tasks, payments, documents, comments]
|
||||
dependency_graph:
|
||||
requires: ["02-02"]
|
||||
provides: ["admin-client-workspace", "getClientFullDetail", "client-detail-mutations"]
|
||||
affects: ["admin-area", "client-data-management"]
|
||||
tech_stack:
|
||||
added:
|
||||
- "@radix-ui/react-tabs ^1.1.13 — tab primitive for client workspace"
|
||||
- "shadcn/ui tabs component — src/components/ui/tabs.tsx"
|
||||
patterns:
|
||||
- "Inline Server Action closures in RSC props (action={async (fd) => { 'use server'; ... }})"
|
||||
- "getClientFullDetail() — waterfall DB query assembling full client data in one call"
|
||||
- "Server-side allowlist validation on all status mutations before db.update()"
|
||||
key_files:
|
||||
created:
|
||||
- src/app/admin/clients/[id]/page.tsx
|
||||
- src/app/admin/clients/[id]/actions.ts
|
||||
- src/components/admin/tabs/PhasesTab.tsx
|
||||
- src/components/admin/tabs/PaymentsTab.tsx
|
||||
- src/components/admin/tabs/DocumentsTab.tsx
|
||||
- src/components/admin/tabs/CommentsTab.tsx
|
||||
- src/components/ui/tabs.tsx
|
||||
modified:
|
||||
- src/lib/admin-queries.ts
|
||||
- package.json
|
||||
- package-lock.json
|
||||
decisions:
|
||||
- "Inline Server Action closures capture clientId/phaseId/taskId from RSC scope — no cross-client pollution (T-02-14)"
|
||||
- "approved_at immutability enforced by omission in addDeliverable — field not set in insert, never updated"
|
||||
- "quote_items never queried in getClientFullDetail — accepted_total is the only price surface returned"
|
||||
- "params in Next.js 16 App Router must be awaited (Promise<{ id: string }>) — applied as deviation fix"
|
||||
metrics:
|
||||
duration_minutes: 25
|
||||
completed_date: "2026-05-15"
|
||||
tasks_completed: 2
|
||||
tasks_total: 2
|
||||
files_created: 7
|
||||
files_modified: 3
|
||||
---
|
||||
|
||||
# Phase 2 Plan 03: Admin Client Workspace (Tabs) Summary
|
||||
|
||||
**One-liner:** Full-featured admin client workspace with Radix Tabs covering phases/tasks, payments, documents, and comments — all mutations via inline Server Actions with server-side validation.
|
||||
|
||||
## What Was Built
|
||||
|
||||
The `/admin/clients/[id]` route delivers a complete project management workspace for the admin. Four tabs cover every concern of a client's lifecycle:
|
||||
|
||||
- **Fasi & Task** — Add phases and nested tasks, update phase/task status with select dropdowns
|
||||
- **Pagamenti** — Edit `accepted_total` (auto-splits to 50% per payment row), update payment status (sets `paid_at` on saldato)
|
||||
- **Documenti** — Add document links (label + external URL) and delete them
|
||||
- **Commenti** — Read all client/admin comments chronologically; post admin replies against any task or deliverable
|
||||
|
||||
All mutations are Server Actions in `src/app/admin/clients/[id]/actions.ts`. The page calls `getClientFullDetail()` which assembles client + phases + tasks + deliverables + payments + documents + notes + comments in a single waterfall query sequence.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Key Files |
|
||||
|------|------|--------|-----------|
|
||||
| 1 | Install tabs, add getClientFullDetail, create Server Actions | 7733566 | package.json, admin-queries.ts, [id]/actions.ts, ui/tabs.tsx |
|
||||
| 2 | Build detail page and all four tab components | 59a46d3 | [id]/page.tsx, PhasesTab, PaymentsTab, DocumentsTab, CommentsTab |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Next.js 16 params must be awaited**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** In Next.js 16 (project uses 16.2.6), dynamic route `params` is a `Promise<{ id: string }>` not a plain object. The plan's template used `params.id` directly which would fail at runtime.
|
||||
- **Fix:** Changed page signature to `params: Promise<{ id: string }>` and added `const { id } = await params;` before use.
|
||||
- **Files modified:** src/app/admin/clients/[id]/page.tsx
|
||||
|
||||
**2. [Rule 2 - Missing critical functionality] Explicit TypeScript types on Server Action closure params**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Inline closures `async (fd) => { "use server"; ... }` lacked explicit `FormData` type annotation, which could cause TypeScript inference issues.
|
||||
- **Fix:** Added explicit `: FormData` type annotation on all closure parameters.
|
||||
- **Files modified:** PhasesTab.tsx, PaymentsTab.tsx, DocumentsTab.tsx, CommentsTab.tsx
|
||||
|
||||
## Architecture Constraints Respected
|
||||
|
||||
- `clients.token` — Read-only in this plan. Never used as primary key.
|
||||
- `quote_items` — Not queried anywhere in this plan. `accepted_total` is the only price value exposed.
|
||||
- `deliverables.approved_at` — Enforced by omission: `addDeliverable` inserts `status: "pending"` with no `approved_at` field.
|
||||
- Two independent auth paths — Admin workspace is under `/admin/*`, protected by middleware session from Phase 2 Plan 01.
|
||||
|
||||
## Security Notes (Threat Register Mitigations Applied)
|
||||
|
||||
- **T-02-10:** `updateTaskStatus` and `updatePaymentStatus` both validate status against an allowlist before any `db.update()` call.
|
||||
- **T-02-11:** `deleteDocument` is only reachable through admin-protected routes; no client can reach it without a valid Auth.js session.
|
||||
- **T-02-13:** `postAdminComment` parses the composite `"type:id"` entity value and validates `entity_type` is exactly `"task"` or `"deliverable"` before insert.
|
||||
- **T-02-14:** Inline closures capture `clientId`, `phaseId`, `taskId` from the Server Component's own scope, preventing cross-client data pollution.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all data is live from the database via `getClientFullDetail()`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no new network endpoints, auth paths, or schema changes introduced beyond what the plan's threat model covers.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- [x] src/app/admin/clients/[id]/page.tsx exists
|
||||
- [x] src/app/admin/clients/[id]/actions.ts exists
|
||||
- [x] src/components/admin/tabs/PhasesTab.tsx exists (153 lines > 60 min)
|
||||
- [x] src/components/admin/tabs/PaymentsTab.tsx exists (96 lines > 40 min)
|
||||
- [x] src/components/admin/tabs/DocumentsTab.tsx exists
|
||||
- [x] src/components/admin/tabs/CommentsTab.tsx exists
|
||||
- [x] Commit 7733566 exists (Task 1)
|
||||
- [x] Commit 59a46d3 exists (Task 2)
|
||||
- [x] npm run build passes cleanly
|
||||
|
||||
## Self-Check: PASSED
|
||||
Generated
+198
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -2816,6 +2817,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
@@ -2863,6 +2888,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
@@ -3003,6 +3115,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
phases,
|
||||
tasks,
|
||||
deliverables,
|
||||
documents,
|
||||
payments,
|
||||
clients,
|
||||
comments,
|
||||
} from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
// ── PHASES ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addPhase(clientId: string, formData: FormData) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
if (!title) throw new Error("Titolo fase richiesto");
|
||||
|
||||
const existingPhases = await db
|
||||
.select({ sort_order: phases.sort_order })
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, clientId));
|
||||
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
|
||||
|
||||
await db.insert(phases).values({
|
||||
client_id: clientId,
|
||||
title,
|
||||
sort_order: maxOrder + 1,
|
||||
status: "upcoming",
|
||||
});
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updatePhaseStatus(
|
||||
phaseId: string,
|
||||
clientId: string,
|
||||
status: string
|
||||
) {
|
||||
const allowed = ["upcoming", "active", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addTask(
|
||||
phaseId: string,
|
||||
clientId: string,
|
||||
formData: FormData
|
||||
) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
if (!title) throw new Error("Titolo task richiesto");
|
||||
|
||||
const existingTasks = await db
|
||||
.select({ sort_order: tasks.sort_order })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.phase_id, phaseId));
|
||||
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
|
||||
|
||||
await db.insert(tasks).values({
|
||||
phase_id: phaseId,
|
||||
title,
|
||||
description: (formData.get("description") as string)?.trim() || null,
|
||||
sort_order: maxOrder + 1,
|
||||
status: "todo",
|
||||
});
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(
|
||||
taskId: string,
|
||||
clientId: string,
|
||||
status: string
|
||||
) {
|
||||
const allowed = ["todo", "in_progress", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addDeliverable(
|
||||
taskId: string,
|
||||
clientId: string,
|
||||
formData: FormData
|
||||
) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
const url = (formData.get("url") as string)?.trim() || null;
|
||||
if (!title) throw new Error("Titolo deliverable richiesto");
|
||||
// approved_at is intentionally omitted — immutable constraint: never set by admin here
|
||||
await db
|
||||
.insert(deliverables)
|
||||
.values({ task_id: taskId, title, url, status: "pending" });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── DOCUMENTS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const docSchema = z.object({
|
||||
label: z.string().min(1, "Etichetta richiesta"),
|
||||
url: z.string().url("URL non valido"),
|
||||
});
|
||||
|
||||
export async function addDocument(clientId: string, formData: FormData) {
|
||||
const parsed = docSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
url: formData.get("url"),
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(documents).values({ client_id: clientId, ...parsed.data });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteDocument(documentId: string, clientId: string) {
|
||||
await db.delete(documents).where(eq(documents.id, documentId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updatePaymentStatus(
|
||||
paymentId: string,
|
||||
clientId: string,
|
||||
status: string
|
||||
) {
|
||||
const allowed = ["da_saldare", "inviata", "saldato"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
||||
const paid_at = status === "saldato" ? new Date() : null;
|
||||
await db
|
||||
.update(payments)
|
||||
.set({ status, paid_at })
|
||||
.where(eq(payments.id, paymentId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
const val = parseFloat(raw);
|
||||
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||
// Update accepted_total on client row — denormalized field, quote_items never exposed
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ accepted_total: val.toFixed(2) })
|
||||
.where(eq(clients.id, clientId));
|
||||
// Split evenly between two payment rows (Acconto 50% + Saldo 50%)
|
||||
const half = (val / 2).toFixed(2);
|
||||
const paymentsRows = await db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.client_id, clientId));
|
||||
for (const p of paymentsRows) {
|
||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
||||
}
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
|
||||
|
||||
export async function postAdminComment(clientId: string, formData: FormData) {
|
||||
const entity = formData.get("entity") as string;
|
||||
const body = (formData.get("body") as string)?.trim();
|
||||
if (!body || !entity) throw new Error("Dati mancanti");
|
||||
const [entity_type, entity_id] = entity.split(":");
|
||||
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
||||
const allowedTypes = ["task", "deliverable"];
|
||||
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClientFullDetail } from "@/lib/admin-queries";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import Link from "next/link";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ClientDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const detail = await getClientFullDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
const { client, phases, payments, documents, notes, comments } = detail;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
← Clienti
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||
<p className="text-sm text-gray-500">{client.brand_name}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/c/${client.token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
|
||||
>
|
||||
Link cliente →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="phases">
|
||||
<PhasesTab phases={phases} clientId={client.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="payments">
|
||||
<PaymentsTab
|
||||
payments={payments}
|
||||
acceptedTotal={client.accepted_total ?? "0"}
|
||||
clientId={client.id}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="documents">
|
||||
<DocumentsTab documents={documents} clientId={client.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="comments">
|
||||
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { Comment } from "@/db/schema";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
comments: Comment[];
|
||||
phases: ClientFullDetail["phases"];
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
// Build entity label map for display
|
||||
const entityLabels: Record<string, string> = {};
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entityLabels[task.id] = `Task: ${task.title}`;
|
||||
for (const d of task.deliverables) {
|
||||
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build list of entities the admin can reply on
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||
for (const d of task.deliverables) {
|
||||
entities.push({
|
||||
id: d.id,
|
||||
type: "deliverable",
|
||||
label: `Deliverable: ${d.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
{/* Comment list */}
|
||||
{comments.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
||||
c.author === "admin"
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-white border border-gray-200 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-medium mb-1 opacity-60">
|
||||
{c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "}
|
||||
{entityLabels[c.entity_id] ?? c.entity_id}
|
||||
</p>
|
||||
<p>{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Admin reply form */}
|
||||
{entities.length > 0 && (
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await postAdminComment(clientId, fd);
|
||||
}}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 text-sm">
|
||||
Rispondi come admin
|
||||
</h3>
|
||||
<select
|
||||
name="entity"
|
||||
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
|
||||
required
|
||||
>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={`${e.type}:${e.id}`}>
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
name="body"
|
||||
placeholder="Scrivi un commento..."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Invia risposta
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Document } from "@/db/schema";
|
||||
|
||||
type Props = { documents: Document[]; clientId: string };
|
||||
|
||||
export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await addDocument(clientId, fd);
|
||||
}}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
||||
<Input
|
||||
id="doc-label"
|
||||
name="label"
|
||||
placeholder="es. Brief progetto"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
|
||||
<Input
|
||||
id="doc-url"
|
||||
name="url"
|
||||
type="url"
|
||||
placeholder="https://drive.google.com/..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Aggiungi
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3"
|
||||
>
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{doc.label}
|
||||
</a>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await deleteDocument(doc.id, clientId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
Rimuovi
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
updatePaymentStatus,
|
||||
updateAcceptedTotal,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Payment } from "@/db/schema";
|
||||
|
||||
type Props = {
|
||||
payments: Payment[];
|
||||
acceptedTotal: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
da_saldare: "Da saldare",
|
||||
inviata: "Inviata",
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Accepted total */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
}}
|
||||
className="flex items-end gap-3"
|
||||
>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment rows */}
|
||||
{payments.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
<span className="text-sm text-gray-600">
|
||||
€{" "}
|
||||
{parseFloat(p.amount).toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={p.status}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
Aggiorna
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
addPhase,
|
||||
addTask,
|
||||
updateTaskStatus,
|
||||
updatePhaseStatus,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
phases: ClientFullDetail["phases"];
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
const taskStatusOptions = [
|
||||
{ value: "todo", label: "Da fare" },
|
||||
{ value: "in_progress", label: "In corso" },
|
||||
{ value: "done", label: "Fatto" },
|
||||
];
|
||||
|
||||
const phaseStatusOptions = [
|
||||
{ value: "upcoming", label: "In arrivo" },
|
||||
{ value: "active", label: "Attiva" },
|
||||
{ value: "done", label: "Completata" },
|
||||
];
|
||||
|
||||
export async function PhasesTab({ phases, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add phase form */}
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await addPhase(clientId, fd);
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<Input
|
||||
name="title"
|
||||
placeholder="Nome nuova fase..."
|
||||
className="max-w-xs"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
+ Fase
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Phases list */}
|
||||
{phases.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
|
||||
)}
|
||||
{phases.map((phase) => (
|
||||
<div
|
||||
key={phase.id}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePhaseStatus(
|
||||
phase.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={phase.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{phaseStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{phase.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-800">{task.title}</span>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateTaskStatus(
|
||||
task.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={task.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{taskStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs px-1"
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add task form */}
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await addTask(phase.id, clientId, fd);
|
||||
}}
|
||||
className="flex gap-2 mt-2"
|
||||
>
|
||||
<Input
|
||||
name="title"
|
||||
placeholder="Nuovo task..."
|
||||
className="text-sm max-w-xs"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||
+ Task
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
+112
-2
@@ -1,6 +1,25 @@
|
||||
import { db } from "@/db";
|
||||
import { clients, payments } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
clients,
|
||||
payments,
|
||||
phases,
|
||||
tasks,
|
||||
deliverables,
|
||||
comments,
|
||||
documents,
|
||||
notes,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc } from "drizzle-orm";
|
||||
import type {
|
||||
Client,
|
||||
Phase,
|
||||
Task,
|
||||
Deliverable,
|
||||
Payment,
|
||||
Document,
|
||||
Note,
|
||||
Comment,
|
||||
} from "@/db/schema";
|
||||
|
||||
export type ClientWithPayments = {
|
||||
id: string;
|
||||
@@ -55,3 +74,94 @@ export async function getClientById(id: string) {
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
|
||||
|
||||
export type ClientFullDetail = {
|
||||
client: Client;
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
||||
const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
||||
if (clientRows.length === 0) return null;
|
||||
const client = clientRows[0];
|
||||
|
||||
const phasesRows = await db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, id))
|
||||
.orderBy(asc(phases.sort_order));
|
||||
|
||||
const phaseIds = phasesRows.map((p) => p.id);
|
||||
|
||||
const tasksRows =
|
||||
phaseIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds))
|
||||
.orderBy(asc(tasks.sort_order));
|
||||
|
||||
const taskIds = tasksRows.map((t) => t.id);
|
||||
|
||||
const deliverablesRows =
|
||||
taskIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds));
|
||||
|
||||
const paymentsRows = await db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.client_id, id));
|
||||
|
||||
const documentsRows = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.client_id, id))
|
||||
.orderBy(asc(documents.created_at));
|
||||
|
||||
const notesRows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.client_id, id))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Fetch all comments for tasks and deliverables belonging to this client
|
||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
const commentsRows =
|
||||
allEntityIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
.orderBy(asc(comments.created_at));
|
||||
|
||||
const phasesWithTasks = phasesRows.map((phase) => {
|
||||
const phaseTasks = tasksRows
|
||||
.filter((t) => t.phase_id === phase.id)
|
||||
.map((task) => ({
|
||||
...task,
|
||||
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||
}));
|
||||
return { ...phase, tasks: phaseTasks };
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
phases: phasesWithTasks,
|
||||
payments: paymentsRows,
|
||||
documents: documentsRows,
|
||||
notes: notesRows,
|
||||
comments: commentsRows,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user