| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906 |
- "use client";
- import { useState, useCallback, useEffect, useRef } from "react";
- import { useRouter } from "next/navigation";
- import Link from "next/link";
- import { toast } from "sonner";
- import { useTranslations } from "next-intl";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { ButtonGroup } from "@/components/ui/button-group";
- import { Input } from "@/components/ui/input";
- import { Label } from "@/components/ui/label";
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from "@/components/ui/select";
- import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- } from "@/components/ui/dialog";
- import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
- } from "@/components/ui/resizable";
- import { useGlassModal } from "@/components/glass-modal";
- import { useConfirm } from "@/components/confirm-dialog";
- import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
- import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
- import { revokeQuotePublicLink } from "@/features/quotes/Actions/quoteShareActions";
- import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
- import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
- import { QuoteShareDialog } from "@/features/quotes/Components/QuoteShareDialog";
- import { RichTextEditor } from "@/features/vehicles/Components/service-edit/RichTextEditor";
- import type { QuotePartInput, QuoteLaborInput } from "@/features/quotes/Schema/quoteSchema";
- import { QuoteImagesManager } from "@/features/quotes/Components/QuoteImagesManager";
- import { QuoteDocumentsManager } from "@/features/quotes/Components/QuoteDocumentsManager";
- import {
- ArrowLeft,
- ArrowRight,
- Camera,
- Car,
- Check,
- ClipboardCheck,
- Download,
- FileText,
- Globe,
- Loader2,
- Mail,
- MessageSquare,
- Paperclip,
- Plus,
- Save,
- Trash2,
- Users,
- X,
- } from "lucide-react";
- import { SharedLinkCard } from "@/components/shared-link-card";
- import { formatCurrency, getCurrencySymbol } from "@/lib/format";
- const statusColors: Record<string, string> = {
- draft: "bg-gray-500/10 text-gray-500 border-gray-500/20",
- sent: "bg-blue-500/10 text-blue-500 border-blue-500/20",
- accepted: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
- rejected: "bg-red-500/10 text-red-500 border-red-500/20",
- expired: "bg-amber-500/10 text-amber-600 border-amber-500/20",
- converted: "bg-purple-500/10 text-purple-500 border-purple-500/20",
- changes_requested: "bg-orange-500/10 text-orange-600 border-orange-500/20",
- };
- interface CustomerOption {
- id: string;
- name: string;
- email: string | null;
- company: string | null;
- }
- interface VehicleOption {
- id: string;
- make: string;
- model: string;
- year: number;
- licensePlate: string | null;
- customerId: string | null;
- customerName: string | null;
- }
- interface QuoteAttachment {
- id: string;
- fileName: string;
- fileUrl: string;
- fileType: string;
- fileSize: number;
- category: string;
- description: string | null;
- includeInInvoice: boolean;
- }
- type TabType = "details" | "images" | "documents";
- interface QuoteRecord {
- id: string;
- quoteNumber: string | null;
- title: string;
- description: string | null;
- status: string;
- validUntil: Date | null;
- subtotal: number;
- taxRate: number;
- taxAmount: number;
- discountType: string | null;
- discountValue: number;
- discountAmount: number;
- totalAmount: number;
- notes: string | null;
- customerMessage: string | null;
- publicToken: string | null;
- sharedAt: Date | null;
- viewCount: number;
- lastViewedAt: Date | null;
- convertedToId: string | null;
- inspectionId: string | null;
- createdAt: Date;
- updatedAt: Date;
- partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean }[];
- laborItems: { id: string; description: string; hours: number; rate: number; total: number; excluded?: boolean }[];
- customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
- vehicle: { id: string; make: string; model: string; year: number; vin: string | null; licensePlate: string | null; mileage: number } | null;
- }
- const emptyPart = (): QuotePartInput => ({
- partNumber: "",
- name: "",
- quantity: 1,
- unitPrice: 0,
- total: 0,
- excluded: false,
- });
- const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
- description: "",
- hours: 0,
- rate: defaultRate,
- total: 0,
- excluded: false,
- });
- const LG_BREAKPOINT = 1024;
- function useIsLargeScreen() {
- const [isLarge, setIsLarge] = useState(false);
- useEffect(() => {
- const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
- const onChange = () => setIsLarge(mql.matches);
- mql.addEventListener("change", onChange);
- setIsLarge(mql.matches);
- return () => mql.removeEventListener("change", onChange);
- }, []);
- return isLarge;
- }
- export function QuotePageClient({
- quote,
- organizationId,
- currencyCode = "USD",
- defaultTaxRate = 0,
- taxEnabled = true,
- defaultLaborRate = 0,
- customers = [],
- vehicles = [],
- smsEnabled = false,
- emailEnabled = false,
- imageAttachments = [],
- documentAttachments = [],
- maxImages,
- maxDocuments,
- }: {
- quote: QuoteRecord;
- organizationId: string;
- currencyCode?: string;
- defaultTaxRate?: number;
- taxEnabled?: boolean;
- defaultLaborRate?: number;
- customers?: CustomerOption[];
- vehicles?: VehicleOption[];
- smsEnabled?: boolean;
- emailEnabled?: boolean;
- imageAttachments?: QuoteAttachment[];
- documentAttachments?: QuoteAttachment[];
- maxImages?: number;
- maxDocuments?: number;
- }) {
- const cs = getCurrencySymbol(currencyCode);
- const router = useRouter();
- const modal = useGlassModal();
- const confirm = useConfirm();
- const isLarge = useIsLargeScreen();
- const t = useTranslations("quotes");
- // Tab state
- const [activeTab, setActiveTab] = useState<TabType>("details");
- // Form state
- const [saving, setSaving] = useState(false);
- const [downloading, setDownloading] = useState(false);
- const [status, setStatus] = useState(quote.status);
- const [customerId, setCustomerId] = useState(quote.customer?.id || "");
- const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
- const [partItems, setPartItems] = useState<QuotePartInput[]>(
- quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false }))
- );
- const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
- quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, excluded: l.excluded ?? false }))
- );
- const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate);
- const [discountType, setDiscountType] = useState<string>(quote.discountType || "none");
- const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0);
- const [noteType, setNoteType] = useState<"public" | "internal">("public");
- const [description, setDescription] = useState(quote.description || "");
- const [notes, setNotes] = useState(quote.notes || "");
- // Dialog state
- const [showEmailDialog, setShowEmailDialog] = useState(false);
- const [showShareDialog, setShowShareDialog] = useState(false);
- const [showConvertDialog, setShowConvertDialog] = useState(false);
- const [convertVehicleId, setConvertVehicleId] = useState(quote.vehicle?.id || "");
- const [converting, setConverting] = useState(false);
- const [defaultValidDate] = useState(() =>
- quote.validUntil ? new Date(quote.validUntil).toISOString().split("T")[0] : ""
- );
- // Unsaved changes tracking & autosave
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const [showSaved, setShowSaved] = useState(false);
- const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- const formRef = useRef<HTMLFormElement>(null);
- const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
- const isSavingRef = useRef(false);
- const markDirty = useCallback(() => {
- setHasUnsavedChanges(true);
- if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
- autosaveTimer.current = setTimeout(() => {
- if (!isSavingRef.current && formRef.current) {
- formRef.current.requestSubmit();
- }
- }, 5000);
- }, []);
- useEffect(() => {
- return () => {
- if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
- };
- }, []);
- useEffect(() => {
- if (!hasUnsavedChanges) return;
- const handler = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- };
- window.addEventListener("beforeunload", handler);
- return () => window.removeEventListener("beforeunload", handler);
- }, [hasUnsavedChanges]);
- // Calculations
- const partsSubtotal = partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
- const laborSubtotal = laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
- const subtotal = partsSubtotal + laborSubtotal;
- const discountAmount = discountType === "percentage"
- ? subtotal * (discountValue / 100)
- : discountType === "fixed"
- ? Math.min(discountValue, subtotal)
- : 0;
- const taxAmount = (subtotal - discountAmount) * (taxRate / 100);
- const totalAmount = subtotal - discountAmount + taxAmount;
- const selectedVehicle = vehicles.find((v) => v.id === vehicleId);
- const selectedCustomer = customers.find((c) => c.id === customerId);
- const handleVehicleChange = (v: string) => {
- const vid = v === "none" ? "" : v;
- setVehicleId(vid);
- if (vid) {
- const vehicle = vehicles.find((veh) => veh.id === vid);
- if (vehicle?.customerId) setCustomerId(vehicle.customerId);
- }
- markDirty();
- };
- const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
- setPartItems((prev) => {
- const updated = [...prev];
- const part = { ...updated[index], [field]: value };
- if (field === "quantity" || field === "unitPrice") part.total = Number(part.quantity) * Number(part.unitPrice);
- updated[index] = part;
- return updated;
- });
- markDirty();
- }, [markDirty]);
- const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
- setLaborItems((prev) => {
- const updated = [...prev];
- const labor = { ...updated[index], [field]: value };
- if (field === "hours" || field === "rate") labor.total = Number(labor.hours) * Number(labor.rate);
- updated[index] = labor;
- return updated;
- });
- markDirty();
- }, [markDirty]);
- const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
- e.preventDefault();
- if (isSavingRef.current) return;
- if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
- isSavingRef.current = true;
- setSaving(true);
- const formData = new FormData(e.currentTarget);
- const result = await updateQuote({
- id: quote.id,
- title: formData.get("title") as string,
- description: description || undefined,
- status,
- validUntil: (formData.get("validUntil") as string) || undefined,
- customerId: customerId || undefined,
- vehicleId: vehicleId || undefined,
- notes: notes || undefined,
- partItems: partItems.filter((p) => p.name),
- laborItems: laborItems.filter((l) => l.description),
- subtotal,
- taxRate,
- taxAmount,
- discountType: discountType === "none" ? undefined : discountType,
- discountValue,
- discountAmount,
- totalAmount,
- });
- if (result.success) {
- setHasUnsavedChanges(false);
- if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
- setShowSaved(true);
- savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000);
- router.refresh();
- } else {
- modal.open("error", "Error", result.error || t("page.failedSave"));
- }
- isSavingRef.current = false;
- setSaving(false);
- };
- const handleDelete = async () => {
- const ok = await confirm({ title: t("page.deleteTitle"), description: t("page.deleteDescription"), confirmLabel: t("page.delete"), destructive: true });
- if (!ok) return;
- const result = await deleteQuote(quote.id);
- if (result.success) {
- router.push("/quotes");
- router.refresh();
- } else {
- modal.open("error", "Error", result.error || t("page.failedDelete"));
- }
- };
- const saveNow = async () => {
- if (!formRef.current || isSavingRef.current) return;
- if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
- formRef.current.requestSubmit();
- // Wait for save to complete
- await new Promise<void>((resolve) => {
- const check = () => {
- if (!isSavingRef.current) return resolve();
- setTimeout(check, 50);
- };
- setTimeout(check, 50);
- });
- };
- const handleDownloadPDF = async () => {
- if (hasUnsavedChanges) await saveNow();
- setDownloading(true);
- try {
- const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`);
- if (!res.ok) throw new Error(t("page.failedPdf"));
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `quote-${quote.quoteNumber || quote.id}.pdf`;
- a.click();
- URL.revokeObjectURL(url);
- } catch {
- modal.open("error", "Error", t("page.failedPdf"));
- }
- setDownloading(false);
- };
- const handleConvert = async () => {
- if (hasUnsavedChanges) await saveNow();
- if (!convertVehicleId) {
- modal.open("error", "Error", t("page.selectVehicle"));
- return;
- }
- setConverting(true);
- const result = await convertQuoteToServiceRecord(quote.id, convertVehicleId);
- if (result.success && result.data) {
- router.push(`/vehicles/${convertVehicleId}/service/${result.data.id}`);
- router.refresh();
- } else {
- modal.open("error", "Error", result.error || t("page.failedConvert"));
- }
- setConverting(false);
- };
- const [resolving, setResolving] = useState(false);
- const handleResolveResponse = async () => {
- setResolving(true);
- const result = await acknowledgeQuoteResponse(quote.id);
- if (result.success) {
- setStatus("draft");
- toast.success(t("page.responseResolved"));
- router.refresh();
- }
- setResolving(false);
- };
- // --- Left column: Parts, Labor, Notes ---
- const leftColumn = (
- <div className="space-y-3">
- {/* Parts */}
- <div className="rounded-lg border p-3 space-y-2">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-semibold">{t("parts.title")}</h3>
- <Button type="button" variant="outline" size="sm" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}>
- <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
- </Button>
- </div>
- {partItems.length > 0 && (
- <>
- <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
- <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
- </div>
- {partItems.map((part, i) => (
- <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
- <Input placeholder={t("parts.partNumber")} value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
- <Input placeholder={t("parts.namePlaceholder")} value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
- <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
- <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
- <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
- <div className="flex items-center gap-1">
- <input type="checkbox" checked={part.excluded ?? false} onChange={(e) => updatePart(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
- <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => { setPartItems(partItems.filter((_, j) => j !== i)); markDirty(); }}><Trash2 className="h-4 w-4" /></Button>
- </div>
- </div>
- ))}
- <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}><Plus className="h-4 w-4" /></button>
- <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
- </>
- )}
- {partItems.length === 0 && (
- <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}>
- <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("parts.addPart")}</span>
- </button>
- )}
- </div>
- {/* Labor */}
- <div className="rounded-lg border p-3 space-y-2">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
- <Button type="button" variant="outline" size="sm" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
- </div>
- {laborItems.length > 0 && (
- <>
- <div className="hidden grid-cols-[2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
- <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
- </div>
- {laborItems.map((labor, i) => (
- <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
- <Input placeholder={t("labor.descriptionPlaceholder")} value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
- <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
- <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
- <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
- <div className="flex items-center gap-1">
- <input type="checkbox" checked={labor.excluded ?? false} onChange={(e) => updateLabor(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
- <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => { setLaborItems(laborItems.filter((_, j) => j !== i)); markDirty(); }}><Trash2 className="h-4 w-4" /></Button>
- </div>
- </div>
- ))}
- <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}><Plus className="h-4 w-4" /></button>
- <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("labor.subtotal", { amount: formatCurrency(laborSubtotal, currencyCode) })}</span></div>
- </>
- )}
- {laborItems.length === 0 && (
- <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}>
- <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("labor.addLabor")}</span>
- </button>
- )}
- </div>
- {/* Notes */}
- <div className="rounded-lg border p-3 space-y-3">
- <div className="flex items-center justify-between">
- <h3 className="flex items-center gap-2 text-sm font-semibold"><FileText className="h-3.5 w-3.5" />{t("notes.title")}</h3>
- <Select value={noteType} onValueChange={(v) => setNoteType(v as "public" | "internal")}>
- <SelectTrigger className="h-7 w-[120px] text-xs"><SelectValue /></SelectTrigger>
- <SelectContent>
- <SelectItem value="public">{t("notes.public")}</SelectItem>
- <SelectItem value="internal">{t("notes.internal")}</SelectItem>
- </SelectContent>
- </Select>
- </div>
- {noteType === "public" && (
- <div className="space-y-1">
- <RichTextEditor content={description} onChange={(v) => { setDescription(v); markDirty(); }} placeholder={t("notes.publicPlaceholder")} />
- <p className="text-xs text-muted-foreground">{t("notes.publicHelper")}</p>
- </div>
- )}
- {noteType === "internal" && (
- <div className="space-y-1">
- <RichTextEditor content={notes} onChange={(v) => { setNotes(v); markDirty(); }} placeholder={t("notes.internalPlaceholder")} />
- <p className="text-xs text-muted-foreground">{t("notes.internalHelper")}</p>
- </div>
- )}
- </div>
- </div>
- );
- // --- Right column: Vehicle & Customer, Quote Details, Totals ---
- const rightColumn = (
- <div className="space-y-3">
- {/* Convert to Work Order */}
- {quote.status !== "converted" && (
- <div className="rounded-lg border p-3">
- <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setShowConvertDialog(true)}>
- <ArrowRight className="mr-1 h-3.5 w-3.5" /> {t("page.convertToWorkOrder")}
- </Button>
- </div>
- )}
- {/* Shared Link */}
- {quote.publicToken && (
- <SharedLinkCard
- publicToken={quote.publicToken}
- organizationId={organizationId}
- type="quote"
- sharedAt={quote.sharedAt}
- viewCount={quote.viewCount}
- lastViewedAt={quote.lastViewedAt}
- onRevoke={async () => {
- await revokeQuotePublicLink(quote.id);
- router.refresh();
- }}
- />
- )}
- {/* Vehicle & Customer */}
- <div className="rounded-lg border p-3 space-y-3">
- <div className="space-y-1">
- <Label className="text-xs">{t("details.vehicle")}</Label>
- <Select value={vehicleId || "none"} onValueChange={handleVehicleChange}>
- <SelectTrigger><SelectValue placeholder={t("details.selectVehicle")} /></SelectTrigger>
- <SelectContent>
- <SelectItem value="none">{t("details.none")}</SelectItem>
- {vehicles.map((v) => (
- <SelectItem key={v.id} value={v.id}>{v.year} {v.make} {v.model}{v.licensePlate ? ` (${v.licensePlate})` : ""}</SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- {selectedVehicle && (
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
- <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- <Link href={`/vehicles/${selectedVehicle.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
- <span className="font-medium">{selectedVehicle.year} {selectedVehicle.make} {selectedVehicle.model}</span>
- {selectedVehicle.licensePlate && <span className="ml-1.5 text-muted-foreground">{selectedVehicle.licensePlate}</span>}
- </Link>
- <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setVehicleId(""); markDirty(); }}><X className="h-3 w-3" /></Button>
- </div>
- )}
- <div className="space-y-1">
- <Label className="text-xs">{t("details.customer")}</Label>
- <Select value={customerId || "none"} onValueChange={(v) => { setCustomerId(v === "none" ? "" : v); markDirty(); }}>
- <SelectTrigger><SelectValue placeholder={t("details.selectCustomer")} /></SelectTrigger>
- <SelectContent>
- <SelectItem value="none">{t("details.none")}</SelectItem>
- {customers.map((c) => (
- <SelectItem key={c.id} value={c.id}>{c.name}{c.company ? ` (${c.company})` : ""}</SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- {selectedCustomer && (
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
- <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- <Link href={`/customers/${selectedCustomer.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
- <span className="font-medium">{selectedCustomer.name}</span>
- {selectedCustomer.company && <span className="ml-1.5 text-muted-foreground">{selectedCustomer.company}</span>}
- </Link>
- <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setCustomerId(""); markDirty(); }}><X className="h-3 w-3" /></Button>
- </div>
- )}
- </div>
- {/* Quote Details */}
- <div className="rounded-lg border p-3 space-y-3">
- <h3 className="text-sm font-semibold">{t("details.title")}</h3>
- <div className="space-y-1">
- <Label htmlFor="title" className="text-xs">{t("details.titleLabel")}</Label>
- <Input id="title" name="title" placeholder={t("details.titlePlaceholder")} defaultValue={quote.title} required onChange={markDirty} />
- </div>
- <div className="grid grid-cols-2 gap-2">
- <div className="space-y-1">
- <Label className="text-xs">{t("details.status")}</Label>
- <Select value={status} onValueChange={(v) => { setStatus(v); markDirty(); }}>
- <SelectTrigger><SelectValue /></SelectTrigger>
- <SelectContent>
- <SelectItem value="draft">{t("details.statusDraft")}</SelectItem>
- <SelectItem value="sent">{t("details.statusSent")}</SelectItem>
- <SelectItem value="accepted">{t("details.statusAccepted")}</SelectItem>
- <SelectItem value="rejected">{t("details.statusRejected")}</SelectItem>
- </SelectContent>
- </Select>
- </div>
- <div className="space-y-1">
- <Label htmlFor="validUntil" className="text-xs">{t("details.validUntil")}</Label>
- <Input id="validUntil" name="validUntil" type="date" defaultValue={defaultValidDate} onChange={markDirty} />
- </div>
- </div>
- {quote.inspectionId && (
- <Link
- href={`/inspections/${quote.inspectionId}`}
- className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
- >
- <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
- <span>{t("details.viewInspection")}</span>
- </Link>
- )}
- </div>
- {/* Customer Response */}
- {quote.customerMessage && (status === "changes_requested" || status === "accepted") && (
- <div className={`rounded-lg border p-3 space-y-2 ${
- status === "changes_requested"
- ? "border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
- : "border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20"
- }`}>
- <div className="flex items-center justify-between">
- <h3 className={`flex items-center gap-1.5 text-sm font-semibold ${
- status === "changes_requested" ? "text-orange-700 dark:text-orange-400" : "text-emerald-700 dark:text-emerald-400"
- }`}>
- <MessageSquare className="h-3.5 w-3.5" />
- {status === "changes_requested" ? t("page.changesRequested") : t("page.quoteAccepted")}
- </h3>
- <span className={`text-[10px] ${
- status === "changes_requested" ? "text-orange-500 dark:text-orange-500" : "text-emerald-500 dark:text-emerald-500"
- }`}>
- {new Date(quote.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}{" "}
- {new Date(quote.updatedAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
- </span>
- </div>
- <p className={`text-sm ${
- status === "changes_requested" ? "text-orange-600 dark:text-orange-400" : "text-emerald-600 dark:text-emerald-400"
- }`}>
- “{quote.customerMessage}”
- </p>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="w-full"
- disabled={resolving}
- onClick={handleResolveResponse}
- >
- {resolving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
- {t("page.markResolved")}
- </Button>
- </div>
- )}
- {/* Totals */}
- <div className="rounded-lg border p-3 space-y-2">
- <h3 className="text-sm font-semibold">{t("totals.title")}</h3>
- <div className="space-y-2">
- <div className="flex items-center justify-between text-sm"><span className="text-muted-foreground">{t("totals.parts")}</span><span>{formatCurrency(partsSubtotal, currencyCode)}</span></div>
- <div className="flex items-center justify-between text-sm"><span className="text-muted-foreground">{t("totals.labor")}</span><span>{formatCurrency(laborSubtotal, currencyCode)}</span></div>
- <div className="flex items-center justify-between text-sm"><span className="text-muted-foreground">{t("totals.subtotal")}</span><span className="font-medium">{formatCurrency(subtotal, currencyCode)}</span></div>
- <div className="flex items-center justify-between text-sm">
- <div className="flex items-center gap-2">
- <span className="text-muted-foreground">{t("totals.discount")}</span>
- <Select value={discountType} onValueChange={(v) => { setDiscountType(v); markDirty(); }}>
- <SelectTrigger className="h-7 w-28 text-xs"><SelectValue /></SelectTrigger>
- <SelectContent>
- <SelectItem value="none">{t("totals.discountNone")}</SelectItem>
- <SelectItem value="percentage">{t("totals.discountPercentage")}</SelectItem>
- <SelectItem value="fixed">{t("totals.discountFixed")}</SelectItem>
- </SelectContent>
- </Select>
- {discountType !== "none" && (
- <Input type="number" min="0" step="0.01" value={discountValue} onChange={(e) => { setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value)); markDirty(); }} className="h-7 w-20 text-right text-xs" />
- )}
- {discountType === "percentage" && <span className="text-muted-foreground">%</span>}
- </div>
- {discountAmount > 0 && <span className="text-destructive">{formatCurrency(-discountAmount, currencyCode)}</span>}
- </div>
- {taxEnabled && (
- <div className="flex items-center justify-between text-sm">
- <div className="flex items-center gap-2">
- <span className="text-muted-foreground">{t("totals.tax")}</span>
- <Input type="number" min="0" step="0.1" value={taxRate} onChange={(e) => { setTaxRate(e.target.value === "" ? 0 : Number(e.target.value)); markDirty(); }} className="h-7 w-20 text-right text-xs" />
- <span className="text-muted-foreground">%</span>
- </div>
- <span>{formatCurrency(taxAmount, currencyCode)}</span>
- </div>
- )}
- <div className="flex items-center justify-between border-t pt-2 text-lg font-bold"><span>{t("totals.total")}</span><span>{formatCurrency(totalAmount, currencyCode)}</span></div>
- </div>
- </div>
- </div>
- );
- return (
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
- {/* Header */}
- <div className="shrink-0 border-b bg-background px-4 py-2">
- <div className="flex items-center justify-between">
- <Link
- href="/quotes"
- className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
- >
- <ArrowLeft className="h-4 w-4 shrink-0" />
- <div className="min-w-0">
- <div className="flex items-center gap-2">
- <Badge variant="outline" className={`shrink-0 text-xs capitalize ${statusColors[status] || ""}`}>
- {status}
- </Badge>
- <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
- </div>
- <p className="truncate text-xs text-muted-foreground">
- {quote.quoteNumber || t("page.quote")}
- {quote.customer ? ` · ${quote.customer.name}` : ""}
- </p>
- </div>
- </Link>
- <div className="flex shrink-0 items-center gap-2">
- {hasUnsavedChanges && (
- <span className="text-xs font-medium text-amber-600 dark:text-amber-400">{t("page.unsavedChanges")}</span>
- )}
- {showSaved && !hasUnsavedChanges && (
- <span className="text-xs font-medium text-green-600 dark:text-green-400">{t("page.saved")}</span>
- )}
- <Button type="submit" form="quote-form" size="sm" disabled={saving} variant={hasUnsavedChanges ? "default" : "outline"} className={hasUnsavedChanges ? "animate-pulse" : ""}>
- {saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Save className="mr-1 h-3.5 w-3.5" />}
- {t("page.save")}
- </Button>
- <ButtonGroup>
- <Button variant="outline" size="sm" onClick={handleDownloadPDF} disabled={downloading}>
- {downloading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
- {t("page.pdf")}
- </Button>
- <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowEmailDialog(true); }}>
- <Mail className="mr-1 h-3.5 w-3.5" />
- {t("page.email")}
- </Button>
- <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowShareDialog(true); }}>
- <Globe className="mr-1 h-3.5 w-3.5" />
- {t("page.share")}
- </Button>
- <Button variant="outline" size="sm" className="text-destructive hover:bg-destructive/10" onClick={handleDelete}>
- <Trash2 className="mr-1 h-3.5 w-3.5" />
- {t("page.delete")}
- </Button>
- </ButtonGroup>
- </div>
- </div>
- </div>
- {/* Tabs */}
- <div className="shrink-0 border-b bg-background px-4">
- <div className="flex gap-1">
- {([
- { key: "details" as TabType, label: t("page.tabs.details"), icon: FileText },
- { key: "images" as TabType, label: t("page.tabs.images"), icon: Camera },
- { key: "documents" as TabType, label: t("page.tabs.documents"), icon: Paperclip },
- ]).map(({ key, label, icon: Icon }) => (
- <button
- key={key}
- type="button"
- onClick={() => setActiveTab(key)}
- className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
- activeTab === key
- ? "border-primary text-primary"
- : "border-transparent text-muted-foreground hover:text-foreground"
- }`}
- >
- <Icon className="h-3.5 w-3.5" />
- {label}
- </button>
- ))}
- </div>
- </div>
- {/* Tab Content */}
- {activeTab === "details" && (
- <form id="quote-form" ref={formRef} onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
- {isLarge ? (
- <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
- <ResizablePanel defaultSize={75} minSize={40}>
- <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
- <div className="space-y-3 pb-40">{leftColumn}</div>
- </div>
- </ResizablePanel>
- <ResizableHandle withHandle />
- <ResizablePanel defaultSize={25} minSize={15}>
- <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
- <div className="space-y-3 pb-40">{rightColumn}</div>
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="space-y-3 pb-40">
- {leftColumn}
- {rightColumn}
- </div>
- </div>
- )}
- </form>
- )}
- {activeTab === "images" && (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="mx-auto max-w-4xl pb-40">
- <QuoteImagesManager
- quoteId={quote.id}
- initialImages={imageAttachments}
- maxImages={maxImages}
- />
- </div>
- </div>
- )}
- {activeTab === "documents" && (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="mx-auto max-w-4xl pb-40">
- <QuoteDocumentsManager
- quoteId={quote.id}
- initialDocuments={documentAttachments}
- maxDocuments={maxDocuments}
- />
- </div>
- </div>
- )}
- {/* Dialogs */}
- <SendEmailDialog
- open={showEmailDialog}
- onOpenChange={setShowEmailDialog}
- defaultEmail={quote.customer?.email || ""}
- entityLabel={t("page.entityLabel")}
- onSend={async (email, message) => sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message })}
- />
- <QuoteShareDialog
- open={showShareDialog}
- onOpenChange={setShowShareDialog}
- quoteId={quote.id}
- organizationId={organizationId}
- initialToken={quote.publicToken}
- customer={quote.customer}
- smsEnabled={smsEnabled}
- emailEnabled={emailEnabled}
- />
- <Dialog open={showConvertDialog} onOpenChange={setShowConvertDialog}>
- <DialogContent className="max-w-md">
- <DialogHeader><DialogTitle>{t("page.convertTitle")}</DialogTitle></DialogHeader>
- <div className="space-y-4">
- <p className="text-sm text-muted-foreground">{t("page.convertDescription")}</p>
- <Select value={convertVehicleId} onValueChange={setConvertVehicleId}>
- <SelectTrigger><SelectValue placeholder={t("details.selectVehicle")} /></SelectTrigger>
- <SelectContent>
- {vehicles.map((v) => (
- <SelectItem key={v.id} value={v.id}>{v.year} {v.make} {v.model}{v.licensePlate ? ` (${v.licensePlate})` : ""}</SelectItem>
- ))}
- </SelectContent>
- </Select>
- <div className="flex gap-2">
- <Button type="button" onClick={handleConvert} disabled={converting || !convertVehicleId}>
- {converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {t("page.convert")}
- </Button>
- <Button type="button" variant="ghost" onClick={() => setShowConvertDialog(false)}>{t("page.cancel")}</Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- </div>
- );
- }
|