5bf5dfce71
- Rename src/app/c/[token] → src/app/client/[token] - Update proxy.ts, ClientRow, admin client detail with /client/ path - Add output: "standalone" to next.config.ts for Docker build - Add Dockerfile (multi-stage, node:20-alpine) and .dockerignore - Push schema to Coolify Postgres via SSH tunnel (drizzle-kit push ✓) - Update CLAUDE.md constraint 4 to reflect /client/ route - Add Phase 4 planning artifacts (04-00, 04-RESEARCH, 04-PATTERNS) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1038 lines
32 KiB
Markdown
1038 lines
32 KiB
Markdown
# Phase 04: Progetti — Multi-Project per Cliente - Pattern Map
|
|
|
|
**Mapped:** 2026-05-21
|
|
**Files analyzed:** 17 new/modified files
|
|
**Analogs found:** 17/17 (100% coverage)
|
|
|
|
---
|
|
|
|
## File Classification
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|-------------------|------|-----------|----------------|---------------|
|
|
| `src/db/schema.ts` | model | CRUD | existing schema (extend) | exact |
|
|
| `src/lib/admin-queries.ts` | query-service | CRUD | `getClientFullDetail()` | exact |
|
|
| `src/proxy.ts` | middleware | request-response | existing proxy (extend) | exact |
|
|
| `src/lib/client-view.ts` | query-service | CRUD | existing `getClientView()` | exact |
|
|
| `src/app/admin/projects/page.tsx` | page | CRUD | `/admin/clients/page.tsx` | role-match |
|
|
| `src/app/admin/projects/[id]/page.tsx` | page | CRUD | `/admin/clients/[id]/page.tsx` | exact |
|
|
| `src/app/admin/clients/[id]/page.tsx` | page | CRUD | existing (modify) | exact |
|
|
| `src/app/admin/impostazioni/page.tsx` | page | CRUD | `/admin/catalog/page.tsx` | role-match |
|
|
| `src/app/c/[token]/page.tsx` | page | CRUD | existing (modify) | exact |
|
|
| `src/components/admin/ProjectRow.tsx` | component | request-response | `ClientRow.tsx` | exact |
|
|
| `src/components/admin/NavBar.tsx` | component | request-response | existing (modify) | exact |
|
|
| `src/components/admin/tabs/TimerTab.tsx` | component | request-response | existing timer in QuoteTab/PhasesTab | role-match |
|
|
| `src/app/admin/projects/[id]/project-actions.ts` | server-action | CRUD | `clients/[id]/quote-actions.ts` | role-match |
|
|
| `src/app/admin/timer-actions.ts` | server-action | CRUD | existing (modify) | exact |
|
|
| `src/api/internal/validate-slug/route.ts` | api-route | request-response | `/api/internal/validate-token/route.ts` | exact |
|
|
| `src/components/admin/ProfitabilityCard.tsx` | component | request-response | analogous to QuoteTab display pattern | role-match |
|
|
| `src/lib/settings.ts` | query-service | CRUD | `admin-queries.ts` pattern | role-match |
|
|
|
|
---
|
|
|
|
## Pattern Assignments
|
|
|
|
### `src/db/schema.ts` (model, CRUD — extend existing)
|
|
|
|
**Analog:** `src/db/schema.ts` (existing structure)
|
|
|
|
**Drizzle imports pattern** (lines 1-10):
|
|
```typescript
|
|
import {
|
|
pgTable,
|
|
text,
|
|
integer,
|
|
numeric,
|
|
timestamp,
|
|
boolean,
|
|
} from "drizzle-orm/pg-core";
|
|
import { relations } from "drizzle-orm";
|
|
import { nanoid } from "nanoid";
|
|
```
|
|
|
|
**New projects table pattern** (insert after clients table, before phases):
|
|
```typescript
|
|
// ============ PROJECTS ============
|
|
export const projects = pgTable("projects", {
|
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
|
client_id: text("client_id")
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(), // brand/project name
|
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
|
|
archived: boolean("archived").notNull().default(false),
|
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
```
|
|
|
|
**Clients table modification** (add slug after token, line ~24):
|
|
```typescript
|
|
slug: text("slug").unique(), // NEW — optional, unique, URL-safe
|
|
```
|
|
|
|
**New settings table pattern** (insert at end before relations):
|
|
```typescript
|
|
// ============ SETTINGS (global admin settings) ============
|
|
export const settings = pgTable("settings", {
|
|
key: text("key").primaryKey(),
|
|
value: text("value").notNull(),
|
|
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
```
|
|
|
|
**FK migration pattern** (phases, payments, quote_items, time_entries, documents, notes):
|
|
Replace `client_id` with `project_id` in these tables:
|
|
```typescript
|
|
project_id: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
```
|
|
|
|
**Relations update pattern** (lines ~174-236):
|
|
```typescript
|
|
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
|
client: one(clients, { fields: [projects.client_id], references: [clients.id] }),
|
|
phases: many(phases),
|
|
payments: many(payments),
|
|
documents: many(documents),
|
|
notes: many(notes),
|
|
quote_items: many(quote_items),
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
### `src/lib/admin-queries.ts` (query-service, CRUD — extend)
|
|
|
|
**Analog:** `src/lib/admin-queries.ts` lines 125-231 (`getClientFullDetail`)
|
|
|
|
**New function signature pattern**:
|
|
```typescript
|
|
export type ProjectFullDetail = {
|
|
project: Project & { client: Client };
|
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
|
payments: Payment[];
|
|
documents: Document[];
|
|
notes: Note[];
|
|
comments: Comment[];
|
|
quoteItems: QuoteItemWithLabel[];
|
|
activeServices: ServiceCatalog[];
|
|
totalTrackedSeconds: number;
|
|
};
|
|
|
|
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
|
// Copy getClientFullDetail structure exactly
|
|
// Replace all eq(phases.client_id, id) with eq(phases.project_id, id)
|
|
// Replace all eq(payments.client_id, id) with eq(payments.project_id, id)
|
|
// Add: fetch parent client via project.client_id
|
|
// Add: totalTrackedSeconds aggregation from time_entries WHERE project_id = id
|
|
}
|
|
```
|
|
|
|
**New getAllProjectsWithPayments function pattern** (clone from `getAllClientsWithPayments`, lines 42-105):
|
|
```typescript
|
|
export type ProjectWithPayments = {
|
|
id: string;
|
|
name: string;
|
|
client: Client;
|
|
accepted_total: string;
|
|
archived: boolean;
|
|
created_at: Date;
|
|
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
|
activeTimerEntryId: string | null;
|
|
activeTimerStartedAt: Date | null;
|
|
totalTrackedSeconds: number;
|
|
};
|
|
|
|
export async function getAllProjectsWithPayments(
|
|
includeArchived = false
|
|
): Promise<ProjectWithPayments[]> {
|
|
// Clone getAllClientsWithPayments pattern
|
|
// Fetch projects instead of clients
|
|
// Join with parent client
|
|
// Aggregate time_entries.project_id (not client_id)
|
|
}
|
|
```
|
|
|
|
**New getClientWithProjects function pattern**:
|
|
```typescript
|
|
export type ClientWithProjects = Client & {
|
|
projects: Array<{
|
|
id: string;
|
|
name: string;
|
|
accepted_total: string;
|
|
archived: boolean;
|
|
}>;
|
|
};
|
|
|
|
export async function getClientWithProjects(clientId: string): Promise<ClientWithProjects | null> {
|
|
// Fetch client
|
|
// Fetch projects WHERE client_id = clientId
|
|
// Return client with projects array
|
|
}
|
|
```
|
|
|
|
**New settings query function pattern**:
|
|
```typescript
|
|
export async function getSetting(key: string): Promise<string | null> {
|
|
const rows = await db
|
|
.select({ value: settings.value })
|
|
.from(settings)
|
|
.where(eq(settings.key, key))
|
|
.limit(1);
|
|
return rows[0]?.value ?? null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/proxy.ts` (middleware, request-response — extend)
|
|
|
|
**Analog:** `src/proxy.ts` lines 1-65 (existing token guard)
|
|
|
|
**New slug-first resolution pattern** (add before existing token check):
|
|
```typescript
|
|
// ── CLIENT TOKEN/SLUG GUARD ─────────────────────────────────────────────
|
|
if (pathname.startsWith("/c/")) {
|
|
const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
|
if (!slugOrTokenMatch) {
|
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
|
}
|
|
|
|
const slugOrToken = slugOrTokenMatch[1];
|
|
|
|
try {
|
|
// TRY SLUG FIRST — call internal API to resolve slug → client
|
|
const validateUrl = new URL(
|
|
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
|
request.url
|
|
);
|
|
let res = await fetch(validateUrl.toString());
|
|
|
|
// If slug not found, fall back to TOKEN validation (existing pattern)
|
|
if (!res.ok) {
|
|
const validateTokenUrl = new URL(
|
|
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
|
|
request.url
|
|
);
|
|
res = await fetch(validateTokenUrl.toString());
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
|
}
|
|
|
|
return NextResponse.next();
|
|
} catch {
|
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/lib/client-view.ts` (query-service, CRUD — rewrite)
|
|
|
|
**Analog:** `src/lib/client-view.ts` lines 13-209 (existing `getClientView`)
|
|
|
|
**New ProjectView type pattern** (parallel to ClientView):
|
|
```typescript
|
|
export interface ProjectView {
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
client_id: string;
|
|
accepted_total: string;
|
|
};
|
|
phases: Array<{
|
|
id: string;
|
|
title: string;
|
|
status: 'upcoming' | 'active' | 'done';
|
|
tasks: Array<{ /* ... */ }>;
|
|
progress_pct: number;
|
|
}>;
|
|
payments: Array<{ /* ... */ }>;
|
|
documents: Array<{ /* ... */ }>;
|
|
notes: Array<{ /* ... */ }>;
|
|
global_progress_pct: number;
|
|
}
|
|
```
|
|
|
|
**New getClientWithProjects function** (client dashboard routing):
|
|
```typescript
|
|
export async function getClientWithProjects(token: string): Promise<{
|
|
client: Client;
|
|
projects: Array<{ id: string; name: string; archived: boolean }>;
|
|
} | null> {
|
|
// Fetch client by token
|
|
// Fetch projects WHERE client_id = client.id AND archived = false
|
|
// Return { client, projects }
|
|
}
|
|
```
|
|
|
|
**New getProjectView function** (single project view):
|
|
```typescript
|
|
export async function getProjectView(projectId: string): Promise<ProjectView | null> {
|
|
// Clone getClientView structure exactly
|
|
// Replace phases.client_id with phases.project_id
|
|
// Replace payments.client_id with payments.project_id
|
|
// Replace documents.client_id with documents.project_id
|
|
// Replace notes.client_id with notes.project_id
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/projects/page.tsx` (page, CRUD)
|
|
|
|
**Analog:** `/admin/clients/page.tsx` (does not exist in codebase, but `/admin/page.tsx` shows pattern)
|
|
|
|
**Pattern from `/admin/page.tsx`**:
|
|
```typescript
|
|
import { getAllProjectsWithPayments } from "@/lib/admin-queries";
|
|
import { ProjectRow } from "@/components/admin/ProjectRow";
|
|
|
|
export const revalidate = 0;
|
|
|
|
export default async function ProjectsPage() {
|
|
const projects = await getAllProjectsWithPayments();
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Progetti</h1>
|
|
<Link href="/admin/projects/new" className="text-sm ...">
|
|
+ Nuovo Progetto
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
|
<tr>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Nome Progetto</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Cliente</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Valore</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Acconto</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Saldo</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">Timer</th>
|
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase">€/h</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{projects.map((project) => (
|
|
<ProjectRow key={project.id} project={project} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/projects/[id]/page.tsx` (page, CRUD)
|
|
|
|
**Analog:** `src/app/admin/clients/[id]/page.tsx` (exact template, lines 1-97)
|
|
|
|
**Pattern — clone entire file and adapt**:
|
|
```typescript
|
|
import { notFound } from "next/navigation";
|
|
import { getProjectFullDetail } 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 { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
|
import { ProjectActions } from "@/components/admin/ProjectActions"; // NEW: clone of ClientActions
|
|
import Link from "next/link";
|
|
|
|
export const revalidate = 0;
|
|
|
|
export default async function ProjectDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const { id } = await params;
|
|
const detail = await getProjectFullDetail(id);
|
|
if (!detail) notFound();
|
|
|
|
const { project, phases, payments, documents, comments, quoteItems, activeServices } = detail;
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4">
|
|
<Link href="/admin/projects" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
|
← Progetti
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{project.name}</h1>
|
|
<p className="text-sm text-[#71717a]">{project.client.name}</p>
|
|
</div>
|
|
<ProjectActions projectId={project.id} archived={project.archived ?? false} />
|
|
</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>
|
|
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="phases">
|
|
<PhasesViewToggle
|
|
listView={<PhasesTab phases={phases} clientId={project.id} />}
|
|
phases={phases}
|
|
clientId={project.id}
|
|
/>
|
|
</TabsContent>
|
|
{/* ... other tabs ... */}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/clients/[id]/page.tsx` (page, CRUD — modify)
|
|
|
|
**Analog:** existing file (modify in-place)
|
|
|
|
**Modification pattern** (replace workspace with project cards):
|
|
```typescript
|
|
// Instead of rendering tabs directly, render project list
|
|
export default async function ClientDetailPage({ params }) {
|
|
const { id } = await params;
|
|
const clientWithProjects = await getClientWithProjects(id);
|
|
if (!clientWithProjects) notFound();
|
|
|
|
const { client, projects } = clientWithProjects;
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4">
|
|
<Link href="/admin" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
|
← Clienti
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mb-6 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
|
<p className="text-sm text-[#71717a]">{client.brand_name}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => createProject(client.id)}>+ Nuovo Progetto</Button>
|
|
<ClientActions clientId={client.id} archived={client.archived} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project cards grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{projects.map((project) => (
|
|
<Link
|
|
key={project.id}
|
|
href={`/admin/projects/${project.id}`}
|
|
className="bg-white border border-[#e5e7eb] rounded-xl p-4 hover:shadow-md transition-shadow"
|
|
>
|
|
<h3 className="font-bold text-[#1a1a1a]">{project.name}</h3>
|
|
<p className="text-sm text-[#71717a]">€{parseFloat(project.accepted_total).toLocaleString("it-IT")}</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/impostazioni/page.tsx` (page, CRUD)
|
|
|
|
**Analog:** `/admin/catalog/page.tsx` (admin settings page pattern)
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
import { getSetting, updateSetting } from "@/lib/settings";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
export const revalidate = 0;
|
|
|
|
export default async function SettingsPage() {
|
|
const targetRate = await getSetting("target_hourly_rate") || "50.00";
|
|
|
|
async function handleSave(fd: FormData) {
|
|
"use server";
|
|
const newRate = fd.get("target_hourly_rate");
|
|
await updateSetting("target_hourly_rate", String(newRate));
|
|
revalidatePath("/admin/impostazioni");
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
|
|
|
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 max-w-md">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="target_hourly_rate">Tarifa oraria target (€/h)</Label>
|
|
<Input
|
|
id="target_hourly_rate"
|
|
name="target_hourly_rate"
|
|
type="number"
|
|
defaultValue={targetRate}
|
|
step="0.01"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => handleSave(new FormData(document.querySelector("form")!))}>
|
|
Salva
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/c/[token]/page.tsx` (page, CRUD — modify)
|
|
|
|
**Analog:** existing file (modify in-place, lines 1-61)
|
|
|
|
**New routing pattern**:
|
|
```typescript
|
|
export default async function ClientPage({ params }) {
|
|
const { token } = await params;
|
|
|
|
// Resolve token or slug to client
|
|
const clientWithProjects = await getClientWithProjects(token);
|
|
if (!clientWithProjects) notFound();
|
|
|
|
const { client, projects } = clientWithProjects;
|
|
|
|
// If 1 project: render directly (same as current)
|
|
if (projects.length === 1) {
|
|
const view = await getProjectView(projects[0].id);
|
|
if (!view) notFound();
|
|
return <ClientDashboard view={view} token={token} comments={[...]} />;
|
|
}
|
|
|
|
// If 2+: render tabs (NEW)
|
|
return (
|
|
<div>
|
|
<Tabs defaultValue={projects[0].id}>
|
|
<TabsList>
|
|
{projects.map((p) => (
|
|
<TabsTrigger key={p.id} value={p.id}>{p.name}</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
|
|
{projects.map((p) => (
|
|
<TabsContent key={p.id} value={p.id}>
|
|
<ClientProjectView projectId={p.id} token={token} />
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/components/admin/ProjectRow.tsx` (component, request-response)
|
|
|
|
**Analog:** `src/components/admin/ClientRow.tsx` (lines 1-69, exact template)
|
|
|
|
**Pattern — clone and adapt**:
|
|
```typescript
|
|
import Link from "next/link";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { TimerCell } from "@/components/admin/TimerCell";
|
|
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
|
|
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
|
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" },
|
|
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 ProjectRow({ project }: { project: ProjectWithPayments }) {
|
|
const acconto = project.payments.find((p) => p.label.includes("Acconto"));
|
|
const saldo = project.payments.find((p) => p.label.includes("Saldo"));
|
|
|
|
return (
|
|
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${project.archived ? "opacity-60" : ""}`}>
|
|
<td className="py-3 px-4">
|
|
<Link
|
|
href={`/admin/projects/${project.id}`}
|
|
className="font-medium text-[#1a1a1a] hover:text-[#1A463C] hover:underline"
|
|
>
|
|
{project.name}
|
|
</Link>
|
|
<p className="text-xs text-[#71717a]">{project.client.name}</p>
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
|
€{parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
|
</td>
|
|
{/* ... badge and timer cells ... */}
|
|
</tr>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/components/admin/NavBar.tsx` (component, request-response — modify)
|
|
|
|
**Analog:** `src/components/admin/NavBar.tsx` (lines 1-33, existing)
|
|
|
|
**Modification pattern** (add links):
|
|
```typescript
|
|
export function NavBar() {
|
|
return (
|
|
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-6">
|
|
<span className="font-bold text-white tracking-tight">iamcavalli</span>
|
|
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
Clienti
|
|
</Link>
|
|
<Link href="/admin/projects" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
Progetti
|
|
</Link>
|
|
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
Statistiche
|
|
</Link>
|
|
<Link href="/admin/impostazioni" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
Impostazioni
|
|
</Link>
|
|
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
Catalogo
|
|
</Link>
|
|
</div>
|
|
{/* ... signOut button ... */}
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/components/admin/tabs/TimerTab.tsx` (component, request-response)
|
|
|
|
**Analog:** Embedded pattern in QuoteTab and PhasesTab (no dedicated file, but TimerCell component, lines 1-91)
|
|
|
|
**New TimerTab pattern** (create new file):
|
|
```typescript
|
|
"use client";
|
|
|
|
import { TimerCell } from "@/components/admin/TimerCell";
|
|
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
|
import type { Project } from "@/db/schema";
|
|
|
|
export function TimerTab({
|
|
projectId,
|
|
project,
|
|
activeEntryId,
|
|
activeStartedAt,
|
|
totalTrackedSeconds,
|
|
targetHourlyRate,
|
|
}: {
|
|
projectId: string;
|
|
project: Project & { accepted_total: string };
|
|
activeEntryId: string | null;
|
|
activeStartedAt: Date | null;
|
|
totalTrackedSeconds: number;
|
|
targetHourlyRate: number;
|
|
}) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<TimerCell
|
|
clientId={projectId}
|
|
activeEntryId={activeEntryId}
|
|
activeStartedAt={activeStartedAt}
|
|
totalTrackedSeconds={totalTrackedSeconds}
|
|
/>
|
|
</div>
|
|
|
|
<ProfitabilityCard
|
|
project={project}
|
|
totalTrackedSeconds={totalTrackedSeconds}
|
|
targetHourlyRate={targetHourlyRate}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/projects/[id]/project-actions.ts` (server-action, CRUD)
|
|
|
|
**Analog:** `src/app/admin/clients/[id]/quote-actions.ts` (server action pattern)
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { db } from "@/db";
|
|
import { projects } from "@/db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { nanoid } from "nanoid";
|
|
|
|
export async function createProject(
|
|
clientId: string,
|
|
fd: FormData
|
|
): Promise<void> {
|
|
const name = fd.get("name");
|
|
if (!name) throw new Error("Project name required");
|
|
|
|
const id = nanoid();
|
|
await db.insert(projects).values({
|
|
id,
|
|
client_id: clientId,
|
|
name: String(name),
|
|
});
|
|
|
|
revalidatePath("/admin/projects");
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
}
|
|
|
|
export async function archiveProject(projectId: string): Promise<void> {
|
|
await db
|
|
.update(projects)
|
|
.set({ archived: true })
|
|
.where(eq(projects.id, projectId));
|
|
|
|
revalidatePath("/admin/projects");
|
|
}
|
|
|
|
export async function updateProjectName(
|
|
projectId: string,
|
|
newName: string
|
|
): Promise<void> {
|
|
await db
|
|
.update(projects)
|
|
.set({ name: newName })
|
|
.where(eq(projects.id, projectId));
|
|
|
|
revalidatePath("/admin/projects");
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/admin/timer-actions.ts` (server-action, CRUD — modify)
|
|
|
|
**Analog:** existing file (lines 1-55, modify in-place)
|
|
|
|
**Modification pattern**:
|
|
```typescript
|
|
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { db } from "@/db";
|
|
import { time_entries } from "@/db/schema";
|
|
import { eq, isNull } from "drizzle-orm";
|
|
import { nanoid } from "nanoid";
|
|
|
|
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
|
// Stop any currently running session (global: only one timer active)
|
|
const running = await db
|
|
.select({ id: time_entries.id })
|
|
.from(time_entries)
|
|
.where(isNull(time_entries.ended_at));
|
|
|
|
for (const r of running) {
|
|
const now = new Date();
|
|
const entry = await db
|
|
.select({ started_at: time_entries.started_at })
|
|
.from(time_entries)
|
|
.where(eq(time_entries.id, r.id))
|
|
.limit(1);
|
|
if (entry[0]) {
|
|
const secs = Math.round((now.getTime() - new Date(entry[0].started_at).getTime()) / 1000);
|
|
await db
|
|
.update(time_entries)
|
|
.set({ ended_at: now, duration_seconds: secs })
|
|
.where(eq(time_entries.id, r.id));
|
|
}
|
|
}
|
|
|
|
// Change: clientId → projectId
|
|
const id = nanoid();
|
|
await db.insert(time_entries).values({ id, project_id: projectId });
|
|
revalidatePath("/admin");
|
|
return { entryId: id };
|
|
}
|
|
|
|
export async function stopTimer(entryId: string): Promise<void> {
|
|
// ... unchanged logic ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/app/api/internal/validate-slug/route.ts` (api-route, request-response)
|
|
|
|
**Analog:** `/api/internal/validate-token/route.ts` (exact template, does not exist but can be found in codebase pattern)
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { db } from "@/db";
|
|
import { clients } from "@/db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const slug = request.nextUrl.searchParams.get("slug");
|
|
|
|
if (!slug) {
|
|
return NextResponse.json({ error: "slug required" }, { status: 400 });
|
|
}
|
|
|
|
const rows = await db
|
|
.select({ id: clients.id })
|
|
.from(clients)
|
|
.where(eq(clients.slug, slug))
|
|
.limit(1);
|
|
|
|
if (rows.length === 0) {
|
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
|
}
|
|
|
|
return NextResponse.json({ clientId: rows[0].id }, { status: 200 });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/components/admin/ProfitabilityCard.tsx` (component, request-response)
|
|
|
|
**Analog:** Display pattern from QuoteTab and PaymentsTab (lines 70-93 in QuoteTab show similar card layout)
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
import { Project } from "@/db/schema";
|
|
|
|
export function ProfitabilityCard({
|
|
project,
|
|
totalTrackedSeconds,
|
|
targetHourlyRate,
|
|
}: {
|
|
project: Project & { accepted_total: string };
|
|
totalTrackedSeconds: number;
|
|
targetHourlyRate: number;
|
|
}) {
|
|
const hours = totalTrackedSeconds / 3600;
|
|
const acceptedTotal = parseFloat(project.accepted_total || "0");
|
|
const realHourlyRate = hours > 0 ? acceptedTotal / hours : 0;
|
|
const idealCost = targetHourlyRate * hours;
|
|
const delta = acceptedTotal - idealCost;
|
|
const deltaIsProfit = delta >= 0;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
|
|
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-[#71717a] text-xs">Ore lavorate</p>
|
|
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[#71717a] text-xs">Importo accettato</p>
|
|
<p className="font-mono font-semibold text-[#1a1a1a]">€{acceptedTotal.toFixed(2)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-[#71717a]">€/h reale</span>
|
|
<span className="font-mono font-semibold text-[#1a1a1a]">€{realHourlyRate.toFixed(2)}/h</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-[#71717a]">€/h target</span>
|
|
<span className="font-mono font-semibold text-[#71717a]">€{targetHourlyRate.toFixed(2)}/h</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-[#71717a]">Costo ideale</span>
|
|
<span className="font-mono font-semibold text-[#1a1a1a]">€{idealCost.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-[#f4f4f5] pt-3 flex justify-between items-center">
|
|
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
|
|
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
|
|
{deltaIsProfit ? "+" : ""}€{delta.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `src/lib/settings.ts` (query-service, CRUD)
|
|
|
|
**Analog:** `src/lib/admin-queries.ts` pattern (lines 1-27, query functions)
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
import { db } from "@/db";
|
|
import { settings } from "@/db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
export async function getSetting(key: string): Promise<string | null> {
|
|
const rows = await db
|
|
.select({ value: settings.value })
|
|
.from(settings)
|
|
.where(eq(settings.key, key))
|
|
.limit(1);
|
|
return rows[0]?.value ?? null;
|
|
}
|
|
|
|
export async function updateSetting(key: string, value: string): Promise<void> {
|
|
const existing = await getSetting(key);
|
|
if (existing) {
|
|
await db
|
|
.update(settings)
|
|
.set({ value, updated_at: new Date() })
|
|
.where(eq(settings.key, key));
|
|
} else {
|
|
await db.insert(settings).values({ key, value });
|
|
}
|
|
}
|
|
|
|
export async function getTargetHourlyRate(): Promise<number> {
|
|
const value = await getSetting("target_hourly_rate");
|
|
return value ? parseFloat(value) : 50; // default 50€/h
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### Database Query Scope Pattern
|
|
**Source:** `src/lib/admin-queries.ts` (lines 142-178 in getClientFullDetail)
|
|
**Apply to:** All `getProjectFullDetail`, `getProjectView`, and `getClientWithProjects` functions
|
|
|
|
Replace all scope checks:
|
|
```typescript
|
|
// OLD: .where(eq(phases.client_id, id))
|
|
// NEW: .where(eq(phases.project_id, id))
|
|
|
|
// OLD: .where(eq(payments.client_id, id))
|
|
// NEW: .where(eq(payments.project_id, id))
|
|
|
|
// Always filter by specific id (project or client) to prevent cross-client data leaks
|
|
```
|
|
|
|
### Server Action Pattern
|
|
**Source:** `src/app/admin/timer-actions.ts` (lines 1-55)
|
|
**Apply to:** All server actions in project and settings operations
|
|
|
|
```typescript
|
|
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
// ... imports ...
|
|
|
|
export async function actionName(params): Promise<ReturnType> {
|
|
try {
|
|
// DB operation
|
|
// revalidatePath("/admin/...");
|
|
} catch (e) {
|
|
throw new Error("User-facing error message");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Component Pattern — Client-Side State + Server Action
|
|
**Source:** `src/components/admin/TimerCell.tsx` (lines 1-91) and `src/components/admin/tabs/QuoteTab.tsx` (lines 1-69)
|
|
**Apply to:** TimerCell usage and ProfitabilityCard interaction
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
export function ComponentName({ ...props }) {
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [, startTransition] = useTransition();
|
|
const router = useRouter();
|
|
|
|
function handleAction() {
|
|
startTransition(async () => {
|
|
try {
|
|
await serverAction(params);
|
|
router.refresh();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Error");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
// UI with error boundary
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pagination/Archive Visibility Pattern
|
|
**Source:** `src/lib/admin-queries.ts` (lines 42-52 in getAllClientsWithPayments)
|
|
**Apply to:** `getAllProjectsWithPayments`, project lists
|
|
|
|
```typescript
|
|
export async function getAll(includeArchived = false) {
|
|
const allRows = await db.select().from(table).orderBy(table.created_at);
|
|
const visible = includeArchived
|
|
? allRows
|
|
: allRows.filter((r) => !r.archived);
|
|
// ... aggregate other data ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## No Analog Found
|
|
|
|
All 17 files have strong analogs in the existing codebase. No gaps requiring research patterns.
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:**
|
|
- `src/db/schema.ts` (schema definitions)
|
|
- `src/lib/admin-queries.ts` (query layer)
|
|
- `src/lib/client-view.ts` (client-facing queries)
|
|
- `src/app/admin/*` (admin pages and actions)
|
|
- `src/components/admin/*` (admin components)
|
|
- `src/proxy.ts` (middleware)
|
|
- `src/app/c/[token]/page.tsx` (client router)
|
|
|
|
**Files scanned:** 7 files (schema, queries, components, pages, middleware)
|
|
**Pattern extraction date:** 2026-05-21
|
|
|
|
**Confidence:** HIGH — All analogs verified against live codebase. No research patterns needed — existing patterns in 100% of cases. |