Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6a9774cfe | |||
| 4d8678b1a3 |
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
plan: "04-04"
|
||||||
|
phase: "04-progetti-multi-project"
|
||||||
|
status: complete
|
||||||
|
completed_at: "2026-05-22"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: 04-04 — Slug Resolution + Multi-Project Dashboard + Slug Edit
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
**`/api/internal/validate-slug/route.ts`** — Internal API route for Edge middleware. Queries `clients.slug`, returns 200 `{ valid: true }` if found, 404 otherwise. Same pattern as `validate-token`.
|
||||||
|
|
||||||
|
**`src/proxy.ts`** — Updated client guard with slug-first resolution (D-06): tries `/api/internal/validate-slug` first, then falls back to `/api/internal/validate-token`. Route path is `/client/[token]` (not `/c/` as referenced in the plan — adapted accordingly).
|
||||||
|
|
||||||
|
**`src/lib/client-view.ts`** — Complete rewrite for multi-project model. Exports:
|
||||||
|
- `getClientWithProjectsByToken(tokenOrSlug)` — resolves slug → client (slug first, then token fallback), returns `ClientProjectSummary` with active (non-archived) projects only
|
||||||
|
- `getProjectView(projectId)` — returns full `ProjectView` scoped to one project; **payments select excludes amount** (DASH-07 + CLAUDE.md); **no quote_items** anywhere in the file
|
||||||
|
|
||||||
|
**`src/app/client/[token]/page.tsx`** — Rewritten for multi-project logic:
|
||||||
|
- 0 active projects → placeholder screen
|
||||||
|
- 1 project → `ClientDashboard` directly (no selector), using `projectViewToClientView` adapter
|
||||||
|
- 2+ projects → shared header + shadcn `Tabs` (project names as triggers) + `ClientDashboard` per tab
|
||||||
|
|
||||||
|
**`src/app/admin/clients/[id]/edit/page.tsx`** — Added slug field with:
|
||||||
|
- Pattern hint: `[a-z0-9-]{3,50}`
|
||||||
|
- Link preview showing `/client/{slug || token}`
|
||||||
|
- Error banner when redirected back with `?error=slug_taken`
|
||||||
|
|
||||||
|
**`src/app/admin/clients/[id]/actions.ts`** — Updated `clientSchema` to include `slug` (Zod regex + `.transform(v => v === "" ? null : v)` to convert empty string to null). `updateClient` now:
|
||||||
|
1. Parses slug from form data
|
||||||
|
2. Persists to `clients.slug` via db.update
|
||||||
|
3. Catches unique constraint violation → redirects to edit page with `?error=slug_taken`
|
||||||
|
4. On success: redirects to `/admin/clients/[id]` (previously no redirect)
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
|
||||||
|
**Adapter pattern `projectViewToClientView`**: `ClientDashboard` was written for `ClientView` shape. Rather than modifying it, an adapter function in the page converts `ProjectView` + `ClientProjectSummary.client` → `ClientView`. Key mappings:
|
||||||
|
- `view.project.accepted_total` → `clientView.client.accepted_total` (project-level, not client-level)
|
||||||
|
- `view.project.client_id` → `clientView.client.id` (used by ChatSection for comment threading)
|
||||||
|
- `deliverables[].approved_at: Date | null` → `.toISOString() | null` (ClientView expects string)
|
||||||
|
- `documents[].created_at` stripped (ClientView.documents only has id/label/url)
|
||||||
|
|
||||||
|
**Multi-project tabs with full ClientDashboard**: Each tab content renders a complete `ClientDashboard` (including its header/footer). shadcn Tabs shows only the active one via CSS. This is MVP-acceptable: the outer page provides the tab navigation, and within each tab the full dashboard renders.
|
||||||
|
|
||||||
|
**Slug empty string → null**: Zod schema transforms `""` to `null` so saving with empty slug removes it. This lets admins clear a slug without special UI.
|
||||||
|
|
||||||
|
**Security invariants verified** (grep confirmed):
|
||||||
|
- `grep "quote_items" src/lib/client-view.ts` → only in comments, not in queries
|
||||||
|
- `grep "amount" src/lib/client-view.ts` → only in comments documenting the exclusion
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `src/app/api/internal/validate-slug/route.ts` | Created |
|
||||||
|
| `src/proxy.ts` | Updated — slug-first resolution in client guard |
|
||||||
|
| `src/lib/client-view.ts` | Complete rewrite — getClientWithProjectsByToken + getProjectView |
|
||||||
|
| `src/app/client/[token]/page.tsx` | Complete rewrite — multi-project dashboard |
|
||||||
|
| `src/app/admin/clients/[id]/edit/page.tsx` | Updated — slug field + error banner |
|
||||||
|
| `src/app/admin/clients/[id]/actions.ts` | Updated — clientSchema + slug in updateClient |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The plan referenced `/c/[token]` path but actual route has always been `/client/[token]`. Proxy and all references use `/client/` consistently.
|
||||||
|
- `ClientDashboard` component unchanged — the adapter absorbs all type differences.
|
||||||
|
- `getClientWithProjectsByToken` filters out archived projects; archived projects are invisible to the client dashboard.
|
||||||
@@ -49,9 +49,8 @@ export default async function AdminDashboard({
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">LTV</th>
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">LTV</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Acconto</th>
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo incassato</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Saldo</th>
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo da saldare</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Timer</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default async function ClientPage({
|
|||||||
return (
|
return (
|
||||||
<ClientDashboard
|
<ClientDashboard
|
||||||
view={projectViewToClientView(client, view)}
|
view={projectViewToClientView(client, view)}
|
||||||
token={token}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
comments={view.comments as unknown as Comment[]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -155,7 +155,7 @@ export default async function ClientPage({
|
|||||||
{view ? (
|
{view ? (
|
||||||
<ClientDashboard
|
<ClientDashboard
|
||||||
view={projectViewToClientView(client, view)}
|
view={projectViewToClientView(client, view)}
|
||||||
token={token}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
comments={view.comments as unknown as Comment[]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { TimerCell } from "@/components/admin/TimerCell";
|
|
||||||
import type { ClientWithPayments } from "@/lib/admin-queries";
|
import type { ClientWithPayments } from "@/lib/admin-queries";
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
function fmt(amount: number) {
|
||||||
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" },
|
return `€${amount.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
||||||
inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" },
|
}
|
||||||
saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||||
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
|
const incassato = client.payments
|
||||||
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
|
.filter((p) => p.status === "saldato")
|
||||||
|
.reduce((sum, p) => sum + parseFloat(p.amount || "0"), 0);
|
||||||
|
|
||||||
|
const daSaldare = client.payments
|
||||||
|
.filter((p) => p.status !== "saldato")
|
||||||
|
.reduce((sum, p) => sum + parseFloat(p.amount || "0"), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${client.archived ? "opacity-60" : ""}`}>
|
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${client.archived ? "opacity-60" : ""}`}>
|
||||||
@@ -34,30 +35,22 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
||||||
€{parseFloat(client.ltv).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
{fmt(parseFloat(client.ltv))}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
||||||
{acconto && (
|
{incassato > 0 ? (
|
||||||
<Badge className={statusConfig[acconto.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
|
<span className="text-[#16a34a] font-medium">{fmt(incassato)}</span>
|
||||||
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
|
) : (
|
||||||
</Badge>
|
<span className="text-[#71717a]">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4 text-sm">
|
||||||
{saldo && (
|
{daSaldare > 0 ? (
|
||||||
<Badge className={statusConfig[saldo.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
|
<span className="text-[#1a1a1a]">{fmt(daSaldare)}</span>
|
||||||
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
|
) : (
|
||||||
</Badge>
|
<span className="text-[#71717a]">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
|
||||||
<TimerCell
|
|
||||||
clientId={client.id}
|
|
||||||
activeEntryId={client.activeTimerEntryId}
|
|
||||||
activeStartedAt={client.activeTimerStartedAt}
|
|
||||||
totalTrackedSeconds={client.totalTrackedSeconds}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<a
|
<a
|
||||||
href={`/client/${client.token}`}
|
href={`/client/${client.token}`}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useTransition } from "react";
|
import { useState, useEffect, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTimerForClient, stopTimer } from "@/app/admin/timer-actions";
|
import { startTimer, startTimerForClient, stopTimer } from "@/app/admin/timer-actions";
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -14,11 +14,13 @@ function formatDuration(seconds: number): string {
|
|||||||
|
|
||||||
export function TimerCell({
|
export function TimerCell({
|
||||||
clientId,
|
clientId,
|
||||||
|
projectId,
|
||||||
activeEntryId,
|
activeEntryId,
|
||||||
activeStartedAt,
|
activeStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
}: {
|
}: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
projectId?: string;
|
||||||
activeEntryId: string | null;
|
activeEntryId: string | null;
|
||||||
activeStartedAt: Date | null;
|
activeStartedAt: Date | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
@@ -46,6 +48,8 @@ export function TimerCell({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (isRunning && activeEntryId) {
|
if (isRunning && activeEntryId) {
|
||||||
await stopTimer(activeEntryId);
|
await stopTimer(activeEntryId);
|
||||||
|
} else if (projectId) {
|
||||||
|
await startTimer(projectId);
|
||||||
} else {
|
} else {
|
||||||
await startTimerForClient(clientId);
|
await startTimerForClient(clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function TimerTab({
|
|||||||
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
||||||
<TimerCell
|
<TimerCell
|
||||||
clientId={projectId}
|
clientId={projectId}
|
||||||
|
projectId={projectId}
|
||||||
activeEntryId={activeTimerEntryId}
|
activeEntryId={activeTimerEntryId}
|
||||||
activeStartedAt={activeTimerStartedAt}
|
activeStartedAt={activeTimerStartedAt}
|
||||||
totalTrackedSeconds={totalTrackedSeconds}
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
|||||||
Reference in New Issue
Block a user