fix: resolve merge conflict in admin-queries.ts — keep both quote_items and service_catalog imports
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
phase: "03"
|
||||||
|
plan: "03"
|
||||||
|
subsystem: "quote-builder"
|
||||||
|
tags: [quote, admin, server-actions, drizzle, security]
|
||||||
|
dependency_graph:
|
||||||
|
requires: ["03-01"]
|
||||||
|
provides: ["quote-tab-ui", "quote-actions", "admin-quote-queries"]
|
||||||
|
affects: ["src/lib/admin-queries.ts", "src/app/admin/clients/[id]/page.tsx"]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: ["Server Actions with requireAdmin guard", "useTransition for optimistic UI", "COALESCE SQL for label resolution", "leftJoin for optional catalog ref"]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- src/app/admin/clients/[id]/quote-actions.ts
|
||||||
|
- src/components/admin/tabs/QuoteTab.tsx
|
||||||
|
modified:
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
- src/app/admin/clients/[id]/page.tsx
|
||||||
|
decisions:
|
||||||
|
- "QuoteTab is a Client Component (useTransition + useRouter) — actions called via startTransition, router.refresh() for revalidation"
|
||||||
|
- "updateAcceptedTotal in quote-actions.ts is separate from the one in actions.ts — scoped to quote tab, adds requireAdmin guard"
|
||||||
|
- "Service price pre-filled in catalog mode but editable — allows overriding price at quote time (snapshot semantics)"
|
||||||
|
metrics:
|
||||||
|
duration: "~15 min"
|
||||||
|
completed_date: "2026-05-17T09:45:11Z"
|
||||||
|
tasks_completed: 2
|
||||||
|
tasks_total: 2
|
||||||
|
files_created: 2
|
||||||
|
files_modified: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 03 Plan 03: Quote Builder Tab Summary
|
||||||
|
|
||||||
|
**One-liner:** Admin quote builder tab with catalog dropdown, freeform toggle, items table with calculated total, and accepted_total editor — all backed by Zod-validated Server Actions with requireAdmin guard.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 | Server Actions + extend getClientFullDetail | db81829 | quote-actions.ts, admin-queries.ts |
|
||||||
|
| 2 | QuoteTab component + wire into client detail page | 48f81e7 | QuoteTab.tsx, page.tsx |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
**Task 1 — Server Actions + Query Layer**
|
||||||
|
|
||||||
|
- `src/app/admin/clients/[id]/quote-actions.ts`: Three Server Actions exported:
|
||||||
|
- `addQuoteItem(clientId, formData)` — Zod validates service_id/custom_label, quantity, unit_price; computes subtotal; inserts into `quote_items`
|
||||||
|
- `removeQuoteItem(quoteItemId, clientId)` — deletes by item ID
|
||||||
|
- `updateAcceptedTotal(clientId, formData)` — writes to `clients.accepted_total` only (no payment row splitting — that stays in `actions.ts`)
|
||||||
|
- All three call `requireAdmin()` (getServerSession check) before any DB operation
|
||||||
|
- `src/lib/admin-queries.ts`:
|
||||||
|
- Added `QuoteItemWithLabel` type (COALESCE resolved label, snapshotted unit_price)
|
||||||
|
- Extended `ClientFullDetail` with `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]`
|
||||||
|
- Added two queries in `getClientFullDetail()`: leftJoin for quote items with COALESCE label; active services ordered by name
|
||||||
|
- Security comment enforces that `client-view.ts` must never query `quote_items` (verified: 0 functional references)
|
||||||
|
|
||||||
|
**Task 2 — UI Component + Page Wiring**
|
||||||
|
|
||||||
|
- `src/components/admin/tabs/QuoteTab.tsx` (`"use client"`) — three sections:
|
||||||
|
- **Add items**: catalog dropdown (pre-fills unit_price on selection, editable) + freeform toggle (custom_label + price + qty)
|
||||||
|
- **Items table**: label, qty, unit_price, subtotal columns; "Rimuovi" button per row; "Totale calcolato" in bold footer
|
||||||
|
- **Accepted total**: editable numeric input with "Salva" button + helper text clarifying client sees only this value
|
||||||
|
- `src/app/admin/clients/[id]/page.tsx`:
|
||||||
|
- Import `QuoteTab`
|
||||||
|
- Destructure `quoteItems`, `activeServices` from `getClientFullDetail` result
|
||||||
|
- Added 5th `TabsTrigger value="quote"` with label "Preventivo"
|
||||||
|
- Added 5th `TabsContent value="quote"` rendering `<QuoteTab>`
|
||||||
|
|
||||||
|
## Security Verification
|
||||||
|
|
||||||
|
| Constraint | Status |
|
||||||
|
|------------|--------|
|
||||||
|
| T-03-03-01: requireAdmin on all Server Actions | Done — all three actions call `await requireAdmin()` first |
|
||||||
|
| T-03-03-02: Zod validation on formData numbers | Done — `quoteItemSchema` validates quantity + unit_price as `z.coerce.number().min(0.01)` |
|
||||||
|
| T-03-03-03: quote_items not in client-facing routes | Done — client-view.ts has 0 functional references to quote_items (only comments) |
|
||||||
|
| T-03-03-04: IDOR on removeQuoteItem | Mitigated by requireAdmin; future multi-admin scenario noted for future hardening |
|
||||||
|
| T-03-03-05: XSS in custom_label | Accepted — React JSX auto-escapes, no dangerouslySetInnerHTML used |
|
||||||
|
| T-03-03-06: calculated_total vs accepted_total confusion | Accepted — visual design enforces separation |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Build required DATABASE_URL env var not present in worktree**
|
||||||
|
- **Found during:** Task 2 build verification
|
||||||
|
- **Issue:** Worktree has no `.env.local`; build fails with "DATABASE_URL env var is required" at runtime collection phase
|
||||||
|
- **Fix:** Ran build with `DATABASE_URL=$(grep DATABASE_URL /path/.env.local ...)` from main repo — build passed clean
|
||||||
|
- **Impact:** None on code quality; worktree environment limitation only
|
||||||
|
|
||||||
|
**2. [Rule 1 - Architecture] updateAcceptedTotal in quote-actions.ts does NOT update payment rows**
|
||||||
|
- **Found during:** Task 1 implementation
|
||||||
|
- **Rationale:** The existing `updateAcceptedTotal` in `actions.ts` splits the total 50/50 between payment rows. The quote tab version intentionally only writes to `clients.accepted_total` — this is the quote builder's domain. Payment row updates remain in the payments tab action. This preserves clean separation of concerns.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all three sections are fully wired to real Server Actions and real DB queries.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — all new surface is admin-only, guarded by `requireAdmin()`, and consistent with the plan's threat model.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `src/app/admin/clients/[id]/quote-actions.ts` exists and exports 3 Server Actions
|
||||||
|
- `src/components/admin/tabs/QuoteTab.tsx` exists and exports QuoteTab
|
||||||
|
- `src/lib/admin-queries.ts` modified with QuoteItemWithLabel type + quoteItems/activeServices in return
|
||||||
|
- `src/app/admin/clients/[id]/page.tsx` modified with Preventivo tab
|
||||||
|
- Commits db81829 and 48f81e7 verified in git log
|
||||||
|
- TypeScript: no errors
|
||||||
|
- Build: passes (with DATABASE_URL)
|
||||||
|
- client-view.ts: 0 functional references to quote_items
|
||||||
@@ -5,6 +5,7 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
|||||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
|
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
import { ClientActions } from "@/components/admin/ClientActions";
|
import { ClientActions } from "@/components/admin/ClientActions";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -20,7 +21,7 @@ export default async function ClientDetailPage({
|
|||||||
const detail = await getClientFullDetail(id);
|
const detail = await getClientFullDetail(id);
|
||||||
if (!detail) notFound();
|
if (!detail) notFound();
|
||||||
|
|
||||||
const { client, phases, payments, documents, comments } = detail;
|
const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -59,6 +60,7 @@ export default async function ClientDetailPage({
|
|||||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||||
|
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="phases">
|
<TabsContent value="phases">
|
||||||
@@ -81,6 +83,14 @@ export default async function ClientDetailPage({
|
|||||||
<TabsContent value="comments">
|
<TabsContent value="comments">
|
||||||
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="quote">
|
||||||
|
<QuoteTab
|
||||||
|
clientId={client.id}
|
||||||
|
items={quoteItems}
|
||||||
|
activeServices={activeServices}
|
||||||
|
acceptedTotal={client.accepted_total ?? "0"}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
|
||||||
|
// Only clients.accepted_total is visible to client-facing routes
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { quote_items, clients, service_catalog } from "@/db/schema";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteItemSchema = z.object({
|
||||||
|
service_id: z.string().nullable(),
|
||||||
|
custom_label: z.string().nullable(),
|
||||||
|
quantity: z.coerce.number().min(0.01, "Quantità deve essere > 0"),
|
||||||
|
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addQuoteItem(clientId: string, formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const rawServiceId = formData.get("service_id") as string | null;
|
||||||
|
const rawCustomLabel = formData.get("custom_label") as string | null;
|
||||||
|
|
||||||
|
const parsed = quoteItemSchema.safeParse({
|
||||||
|
service_id: rawServiceId && rawServiceId !== "" ? rawServiceId : null,
|
||||||
|
custom_label: rawCustomLabel && rawCustomLabel !== "" ? rawCustomLabel : null,
|
||||||
|
quantity: formData.get("quantity"),
|
||||||
|
unit_price: formData.get("unit_price"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
|
||||||
|
// Validate: either service_id or custom_label must be present
|
||||||
|
if (!parsed.data.service_id && !parsed.data.custom_label) {
|
||||||
|
throw new Error(
|
||||||
|
"Seleziona un servizio dal catalogo o inserisci il nome di una voce libera"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { service_id, custom_label, quantity, unit_price } = parsed.data;
|
||||||
|
const subtotal = (quantity * unit_price).toFixed(2);
|
||||||
|
|
||||||
|
await db.insert(quote_items).values({
|
||||||
|
client_id: clientId,
|
||||||
|
service_id: service_id ?? null,
|
||||||
|
custom_label: custom_label ?? null,
|
||||||
|
quantity: quantity.toFixed(2),
|
||||||
|
unit_price: unit_price.toFixed(2),
|
||||||
|
subtotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeQuoteItem(quoteItemId: string, clientId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({ accepted_total: val.toFixed(2) })
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
addQuoteItem,
|
||||||
|
removeQuoteItem,
|
||||||
|
updateAcceptedTotal,
|
||||||
|
} from "@/app/admin/clients/[id]/quote-actions";
|
||||||
|
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
|
||||||
|
import type { ServiceCatalog } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
clientId: string;
|
||||||
|
items: QuoteItemWithLabel[];
|
||||||
|
activeServices: ServiceCatalog[];
|
||||||
|
acceptedTotal: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
|
||||||
|
const [showCustom, setShowCustom] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [totalError, setTotalError] = useState<string | null>(null);
|
||||||
|
// For catalog mode: pre-fill unit_price when service is selected
|
||||||
|
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const calculatedTotal = items.reduce(
|
||||||
|
(sum, item) => sum + parseFloat(item.subtotal),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleAddItem(fd: FormData) {
|
||||||
|
setAddError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await addQuoteItem(clientId, fd);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(quoteItemId: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeQuoteItem(quoteItemId, clientId);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveTotal(fd: FormData) {
|
||||||
|
setTotalError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateAcceptedTotal(clientId, fd);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setTotalError(
|
||||||
|
e instanceof Error ? e.message : "Errore nel salvataggio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
|
||||||
|
{/* Section 1: Add items */}
|
||||||
|
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
|
||||||
|
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
||||||
|
Aggiungi voci
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!showCustom ? (
|
||||||
|
/* Catalog mode */
|
||||||
|
<form action={handleAddItem} className="space-y-3">
|
||||||
|
<input type="hidden" name="custom_label" value="" />
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-[180px] space-y-1">
|
||||||
|
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
|
||||||
|
<select
|
||||||
|
name="service_id"
|
||||||
|
id="service_id"
|
||||||
|
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||||
|
onChange={(e) => {
|
||||||
|
const svc = activeServices.find((s) => s.id === e.target.value);
|
||||||
|
setSelectedServicePrice(
|
||||||
|
svc ? parseFloat(svc.unit_price).toFixed(2) : ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">— Scegli servizio —</option>
|
||||||
|
{activeServices.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} (€
|
||||||
|
{parseFloat(s.unit_price).toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-28 space-y-1">
|
||||||
|
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
|
||||||
|
<Input
|
||||||
|
id="unit_price_catalog"
|
||||||
|
name="unit_price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={selectedServicePrice}
|
||||||
|
onChange={(e) => setSelectedServicePrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 space-y-1">
|
||||||
|
<Label htmlFor="quantity_catalog">Qty</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity_catalog"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
defaultValue="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||||
|
>
|
||||||
|
Aggiungi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustom(true);
|
||||||
|
setAddError(null);
|
||||||
|
}}
|
||||||
|
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
|
||||||
|
>
|
||||||
|
Oppure aggiungi voce libera →
|
||||||
|
</button>
|
||||||
|
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* Freeform mode */
|
||||||
|
<form action={handleAddItem} className="space-y-3">
|
||||||
|
<input type="hidden" name="service_id" value="" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="custom_label">Nome voce</Label>
|
||||||
|
<Input
|
||||||
|
id="custom_label"
|
||||||
|
name="custom_label"
|
||||||
|
placeholder="es. Consulenza extra, Spese viaggi"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-[120px] space-y-1">
|
||||||
|
<Label htmlFor="unit_price_custom">Prezzo unitario (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="unit_price_custom"
|
||||||
|
name="unit_price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 space-y-1">
|
||||||
|
<Label htmlFor="quantity_custom">Qty</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity_custom"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
defaultValue="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||||
|
>
|
||||||
|
Aggiungi voce libera
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustom(false);
|
||||||
|
setAddError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Torna al catalogo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Quote items table + calculated total */}
|
||||||
|
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
|
||||||
|
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
||||||
|
Voci preventivo
|
||||||
|
</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#71717a] py-4 text-center">
|
||||||
|
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-[#e5e7eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium text-[#71717a]">
|
||||||
|
Voce
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||||
|
Qty
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||||
|
Prezzo unit.
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||||
|
Subtotale
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]"
|
||||||
|
>
|
||||||
|
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums">
|
||||||
|
{parseFloat(item.quantity).toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums font-mono">
|
||||||
|
€
|
||||||
|
{parseFloat(item.unit_price).toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
|
||||||
|
€
|
||||||
|
{parseFloat(item.subtotal).toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(item.id)}
|
||||||
|
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Rimuovi
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
|
||||||
|
<p className="font-bold text-[#1a1a1a] tabular-nums">
|
||||||
|
Totale calcolato: €
|
||||||
|
{calculatedTotal.toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Accepted total */}
|
||||||
|
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
|
||||||
|
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
||||||
|
Totale accettato dal cliente
|
||||||
|
</h3>
|
||||||
|
<form action={handleSaveTotal} className="flex items-end gap-3">
|
||||||
|
<div className="flex-1 max-w-[200px] space-y-1">
|
||||||
|
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="accepted_total"
|
||||||
|
name="accepted_total"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||||
|
>
|
||||||
|
Salva
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Il cliente vede solo questo importo, non le singole voci del preventivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
documents,
|
documents,
|
||||||
notes,
|
notes,
|
||||||
time_entries,
|
time_entries,
|
||||||
|
quote_items,
|
||||||
service_catalog,
|
service_catalog,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
||||||
@@ -110,6 +111,17 @@ export async function getClientById(id: string) {
|
|||||||
|
|
||||||
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
|
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
|
||||||
|
|
||||||
|
// quote_items NEVER exposed via client API — admin workspace query only
|
||||||
|
export type QuoteItemWithLabel = {
|
||||||
|
id: string;
|
||||||
|
label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
|
||||||
|
custom_label: string | null;
|
||||||
|
service_id: string | null;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
|
||||||
|
subtotal: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientFullDetail = {
|
export type ClientFullDetail = {
|
||||||
client: Client;
|
client: Client;
|
||||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
@@ -117,6 +129,8 @@ export type ClientFullDetail = {
|
|||||||
documents: Document[];
|
documents: Document[];
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
quoteItems: QuoteItemWithLabel[];
|
||||||
|
activeServices: ServiceCatalog[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
||||||
@@ -182,6 +196,28 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// quote_items NEVER exposed via client API — admin workspace query only
|
||||||
|
const quoteItemRows: QuoteItemWithLabel[] = await db
|
||||||
|
.select({
|
||||||
|
id: quote_items.id,
|
||||||
|
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
|
||||||
|
custom_label: quote_items.custom_label,
|
||||||
|
service_id: quote_items.service_id,
|
||||||
|
quantity: quote_items.quantity,
|
||||||
|
unit_price: quote_items.unit_price,
|
||||||
|
subtotal: quote_items.subtotal,
|
||||||
|
})
|
||||||
|
.from(quote_items)
|
||||||
|
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
|
||||||
|
.where(eq(quote_items.client_id, id))
|
||||||
|
.orderBy(asc(quote_items.id));
|
||||||
|
|
||||||
|
const activeServiceRows = await db
|
||||||
|
.select()
|
||||||
|
.from(service_catalog)
|
||||||
|
.where(eq(service_catalog.active, true))
|
||||||
|
.orderBy(asc(service_catalog.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
phases: phasesWithTasks,
|
phases: phasesWithTasks,
|
||||||
@@ -189,6 +225,8 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
documents: documentsRows,
|
documents: documentsRows,
|
||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
comments: commentsRows,
|
||||||
|
quoteItems: quoteItemRows,
|
||||||
|
activeServices: activeServiceRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user