QuotePageClient.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  1. "use client";
  2. import { useState, useCallback, useEffect, useRef } from "react";
  3. import { useRouter } from "next/navigation";
  4. import Link from "next/link";
  5. import { toast } from "sonner";
  6. import { useTranslations } from "next-intl";
  7. import { Badge } from "@/components/ui/badge";
  8. import { Button } from "@/components/ui/button";
  9. import { ButtonGroup } from "@/components/ui/button-group";
  10. import { Input } from "@/components/ui/input";
  11. import { Label } from "@/components/ui/label";
  12. import {
  13. Select,
  14. SelectContent,
  15. SelectItem,
  16. SelectTrigger,
  17. SelectValue,
  18. } from "@/components/ui/select";
  19. import {
  20. Dialog,
  21. DialogContent,
  22. DialogHeader,
  23. DialogTitle,
  24. } from "@/components/ui/dialog";
  25. import {
  26. ResizablePanelGroup,
  27. ResizablePanel,
  28. ResizableHandle,
  29. } from "@/components/ui/resizable";
  30. import { useGlassModal } from "@/components/glass-modal";
  31. import { useConfirm } from "@/components/confirm-dialog";
  32. import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
  33. import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
  34. import { revokeQuotePublicLink } from "@/features/quotes/Actions/quoteShareActions";
  35. import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
  36. import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
  37. import { QuoteShareDialog } from "@/features/quotes/Components/QuoteShareDialog";
  38. import { RichTextEditor } from "@/features/vehicles/Components/service-edit/RichTextEditor";
  39. import type { QuotePartInput, QuoteLaborInput } from "@/features/quotes/Schema/quoteSchema";
  40. import { QuoteImagesManager } from "@/features/quotes/Components/QuoteImagesManager";
  41. import { QuoteDocumentsManager } from "@/features/quotes/Components/QuoteDocumentsManager";
  42. import {
  43. ArrowLeft,
  44. ArrowRight,
  45. Camera,
  46. Car,
  47. Check,
  48. ClipboardCheck,
  49. Download,
  50. FileText,
  51. Globe,
  52. Loader2,
  53. Mail,
  54. MessageSquare,
  55. Paperclip,
  56. Plus,
  57. Save,
  58. Trash2,
  59. Users,
  60. X,
  61. } from "lucide-react";
  62. import { SharedLinkCard } from "@/components/shared-link-card";
  63. import { formatCurrency, getCurrencySymbol } from "@/lib/format";
  64. const statusColors: Record<string, string> = {
  65. draft: "bg-gray-500/10 text-gray-500 border-gray-500/20",
  66. sent: "bg-blue-500/10 text-blue-500 border-blue-500/20",
  67. accepted: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
  68. rejected: "bg-red-500/10 text-red-500 border-red-500/20",
  69. expired: "bg-amber-500/10 text-amber-600 border-amber-500/20",
  70. converted: "bg-purple-500/10 text-purple-500 border-purple-500/20",
  71. changes_requested: "bg-orange-500/10 text-orange-600 border-orange-500/20",
  72. };
  73. interface CustomerOption {
  74. id: string;
  75. name: string;
  76. email: string | null;
  77. company: string | null;
  78. }
  79. interface VehicleOption {
  80. id: string;
  81. make: string;
  82. model: string;
  83. year: number;
  84. licensePlate: string | null;
  85. customerId: string | null;
  86. customerName: string | null;
  87. }
  88. interface QuoteAttachment {
  89. id: string;
  90. fileName: string;
  91. fileUrl: string;
  92. fileType: string;
  93. fileSize: number;
  94. category: string;
  95. description: string | null;
  96. includeInInvoice: boolean;
  97. }
  98. type TabType = "details" | "images" | "documents";
  99. interface QuoteRecord {
  100. id: string;
  101. quoteNumber: string | null;
  102. title: string;
  103. description: string | null;
  104. status: string;
  105. validUntil: Date | null;
  106. subtotal: number;
  107. taxRate: number;
  108. taxAmount: number;
  109. discountType: string | null;
  110. discountValue: number;
  111. discountAmount: number;
  112. totalAmount: number;
  113. notes: string | null;
  114. customerMessage: string | null;
  115. publicToken: string | null;
  116. sharedAt: Date | null;
  117. viewCount: number;
  118. lastViewedAt: Date | null;
  119. convertedToId: string | null;
  120. inspectionId: string | null;
  121. createdAt: Date;
  122. updatedAt: Date;
  123. partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean }[];
  124. laborItems: { id: string; description: string; hours: number; rate: number; total: number; excluded?: boolean }[];
  125. customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
  126. vehicle: { id: string; make: string; model: string; year: number; vin: string | null; licensePlate: string | null; mileage: number } | null;
  127. }
  128. const emptyPart = (): QuotePartInput => ({
  129. partNumber: "",
  130. name: "",
  131. quantity: 1,
  132. unitPrice: 0,
  133. total: 0,
  134. excluded: false,
  135. });
  136. const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
  137. description: "",
  138. hours: 0,
  139. rate: defaultRate,
  140. total: 0,
  141. excluded: false,
  142. });
  143. const LG_BREAKPOINT = 1024;
  144. function useIsLargeScreen() {
  145. const [isLarge, setIsLarge] = useState(false);
  146. useEffect(() => {
  147. const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
  148. const onChange = () => setIsLarge(mql.matches);
  149. mql.addEventListener("change", onChange);
  150. setIsLarge(mql.matches);
  151. return () => mql.removeEventListener("change", onChange);
  152. }, []);
  153. return isLarge;
  154. }
  155. export function QuotePageClient({
  156. quote,
  157. organizationId,
  158. currencyCode = "USD",
  159. defaultTaxRate = 0,
  160. taxEnabled = true,
  161. defaultLaborRate = 0,
  162. customers = [],
  163. vehicles = [],
  164. smsEnabled = false,
  165. emailEnabled = false,
  166. imageAttachments = [],
  167. documentAttachments = [],
  168. maxImages,
  169. maxDocuments,
  170. }: {
  171. quote: QuoteRecord;
  172. organizationId: string;
  173. currencyCode?: string;
  174. defaultTaxRate?: number;
  175. taxEnabled?: boolean;
  176. defaultLaborRate?: number;
  177. customers?: CustomerOption[];
  178. vehicles?: VehicleOption[];
  179. smsEnabled?: boolean;
  180. emailEnabled?: boolean;
  181. imageAttachments?: QuoteAttachment[];
  182. documentAttachments?: QuoteAttachment[];
  183. maxImages?: number;
  184. maxDocuments?: number;
  185. }) {
  186. const cs = getCurrencySymbol(currencyCode);
  187. const router = useRouter();
  188. const modal = useGlassModal();
  189. const confirm = useConfirm();
  190. const isLarge = useIsLargeScreen();
  191. const t = useTranslations("quotes");
  192. // Tab state
  193. const [activeTab, setActiveTab] = useState<TabType>("details");
  194. // Form state
  195. const [saving, setSaving] = useState(false);
  196. const [downloading, setDownloading] = useState(false);
  197. const [status, setStatus] = useState(quote.status);
  198. const [customerId, setCustomerId] = useState(quote.customer?.id || "");
  199. const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
  200. const [partItems, setPartItems] = useState<QuotePartInput[]>(
  201. quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false }))
  202. );
  203. const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
  204. quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, excluded: l.excluded ?? false }))
  205. );
  206. const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate);
  207. const [discountType, setDiscountType] = useState<string>(quote.discountType || "none");
  208. const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0);
  209. const [noteType, setNoteType] = useState<"public" | "internal">("public");
  210. const [description, setDescription] = useState(quote.description || "");
  211. const [notes, setNotes] = useState(quote.notes || "");
  212. // Dialog state
  213. const [showEmailDialog, setShowEmailDialog] = useState(false);
  214. const [showShareDialog, setShowShareDialog] = useState(false);
  215. const [showConvertDialog, setShowConvertDialog] = useState(false);
  216. const [convertVehicleId, setConvertVehicleId] = useState(quote.vehicle?.id || "");
  217. const [converting, setConverting] = useState(false);
  218. const [defaultValidDate] = useState(() =>
  219. quote.validUntil ? new Date(quote.validUntil).toISOString().split("T")[0] : ""
  220. );
  221. // Unsaved changes tracking & autosave
  222. const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
  223. const [showSaved, setShowSaved] = useState(false);
  224. const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  225. const formRef = useRef<HTMLFormElement>(null);
  226. const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  227. const isSavingRef = useRef(false);
  228. const markDirty = useCallback(() => {
  229. setHasUnsavedChanges(true);
  230. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  231. autosaveTimer.current = setTimeout(() => {
  232. if (!isSavingRef.current && formRef.current) {
  233. formRef.current.requestSubmit();
  234. }
  235. }, 5000);
  236. }, []);
  237. useEffect(() => {
  238. return () => {
  239. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  240. };
  241. }, []);
  242. useEffect(() => {
  243. if (!hasUnsavedChanges) return;
  244. const handler = (e: BeforeUnloadEvent) => {
  245. e.preventDefault();
  246. };
  247. window.addEventListener("beforeunload", handler);
  248. return () => window.removeEventListener("beforeunload", handler);
  249. }, [hasUnsavedChanges]);
  250. // Calculations
  251. const partsSubtotal = partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
  252. const laborSubtotal = laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
  253. const subtotal = partsSubtotal + laborSubtotal;
  254. const discountAmount = discountType === "percentage"
  255. ? subtotal * (discountValue / 100)
  256. : discountType === "fixed"
  257. ? Math.min(discountValue, subtotal)
  258. : 0;
  259. const taxAmount = (subtotal - discountAmount) * (taxRate / 100);
  260. const totalAmount = subtotal - discountAmount + taxAmount;
  261. const selectedVehicle = vehicles.find((v) => v.id === vehicleId);
  262. const selectedCustomer = customers.find((c) => c.id === customerId);
  263. const handleVehicleChange = (v: string) => {
  264. const vid = v === "none" ? "" : v;
  265. setVehicleId(vid);
  266. if (vid) {
  267. const vehicle = vehicles.find((veh) => veh.id === vid);
  268. if (vehicle?.customerId) setCustomerId(vehicle.customerId);
  269. }
  270. markDirty();
  271. };
  272. const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
  273. setPartItems((prev) => {
  274. const updated = [...prev];
  275. const part = { ...updated[index], [field]: value };
  276. if (field === "quantity" || field === "unitPrice") part.total = Number(part.quantity) * Number(part.unitPrice);
  277. updated[index] = part;
  278. return updated;
  279. });
  280. markDirty();
  281. }, [markDirty]);
  282. const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
  283. setLaborItems((prev) => {
  284. const updated = [...prev];
  285. const labor = { ...updated[index], [field]: value };
  286. if (field === "hours" || field === "rate") labor.total = Number(labor.hours) * Number(labor.rate);
  287. updated[index] = labor;
  288. return updated;
  289. });
  290. markDirty();
  291. }, [markDirty]);
  292. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  293. e.preventDefault();
  294. if (isSavingRef.current) return;
  295. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  296. isSavingRef.current = true;
  297. setSaving(true);
  298. const formData = new FormData(e.currentTarget);
  299. const result = await updateQuote({
  300. id: quote.id,
  301. title: formData.get("title") as string,
  302. description: description || undefined,
  303. status,
  304. validUntil: (formData.get("validUntil") as string) || undefined,
  305. customerId: customerId || undefined,
  306. vehicleId: vehicleId || undefined,
  307. notes: notes || undefined,
  308. partItems: partItems.filter((p) => p.name),
  309. laborItems: laborItems.filter((l) => l.description),
  310. subtotal,
  311. taxRate,
  312. taxAmount,
  313. discountType: discountType === "none" ? undefined : discountType,
  314. discountValue,
  315. discountAmount,
  316. totalAmount,
  317. });
  318. if (result.success) {
  319. setHasUnsavedChanges(false);
  320. if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
  321. setShowSaved(true);
  322. savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000);
  323. router.refresh();
  324. } else {
  325. modal.open("error", "Error", result.error || t("page.failedSave"));
  326. }
  327. isSavingRef.current = false;
  328. setSaving(false);
  329. };
  330. const handleDelete = async () => {
  331. const ok = await confirm({ title: t("page.deleteTitle"), description: t("page.deleteDescription"), confirmLabel: t("page.delete"), destructive: true });
  332. if (!ok) return;
  333. const result = await deleteQuote(quote.id);
  334. if (result.success) {
  335. router.push("/quotes");
  336. router.refresh();
  337. } else {
  338. modal.open("error", "Error", result.error || t("page.failedDelete"));
  339. }
  340. };
  341. const saveNow = async () => {
  342. if (!formRef.current || isSavingRef.current) return;
  343. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  344. formRef.current.requestSubmit();
  345. // Wait for save to complete
  346. await new Promise<void>((resolve) => {
  347. const check = () => {
  348. if (!isSavingRef.current) return resolve();
  349. setTimeout(check, 50);
  350. };
  351. setTimeout(check, 50);
  352. });
  353. };
  354. const handleDownloadPDF = async () => {
  355. if (hasUnsavedChanges) await saveNow();
  356. setDownloading(true);
  357. try {
  358. const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`);
  359. if (!res.ok) throw new Error(t("page.failedPdf"));
  360. const blob = await res.blob();
  361. const url = URL.createObjectURL(blob);
  362. const a = document.createElement("a");
  363. a.href = url;
  364. a.download = `quote-${quote.quoteNumber || quote.id}.pdf`;
  365. a.click();
  366. URL.revokeObjectURL(url);
  367. } catch {
  368. modal.open("error", "Error", t("page.failedPdf"));
  369. }
  370. setDownloading(false);
  371. };
  372. const handleConvert = async () => {
  373. if (hasUnsavedChanges) await saveNow();
  374. if (!convertVehicleId) {
  375. modal.open("error", "Error", t("page.selectVehicle"));
  376. return;
  377. }
  378. setConverting(true);
  379. const result = await convertQuoteToServiceRecord(quote.id, convertVehicleId);
  380. if (result.success && result.data) {
  381. router.push(`/vehicles/${convertVehicleId}/service/${result.data.id}`);
  382. router.refresh();
  383. } else {
  384. modal.open("error", "Error", result.error || t("page.failedConvert"));
  385. }
  386. setConverting(false);
  387. };
  388. const [resolving, setResolving] = useState(false);
  389. const handleResolveResponse = async () => {
  390. setResolving(true);
  391. const result = await acknowledgeQuoteResponse(quote.id);
  392. if (result.success) {
  393. setStatus("draft");
  394. toast.success(t("page.responseResolved"));
  395. router.refresh();
  396. }
  397. setResolving(false);
  398. };
  399. // --- Left column: Parts, Labor, Notes ---
  400. const leftColumn = (
  401. <div className="space-y-3">
  402. {/* Parts */}
  403. <div className="rounded-lg border p-3 space-y-2">
  404. <div className="flex items-center justify-between">
  405. <h3 className="text-sm font-semibold">{t("parts.title")}</h3>
  406. <Button type="button" variant="outline" size="sm" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}>
  407. <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
  408. </Button>
  409. </div>
  410. {partItems.length > 0 && (
  411. <>
  412. <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
  413. <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 />
  414. </div>
  415. {partItems.map((part, i) => (
  416. <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" : ""}`}>
  417. <Input placeholder={t("parts.partNumber")} value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
  418. <Input placeholder={t("parts.namePlaceholder")} value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
  419. <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
  420. <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
  421. <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
  422. <div className="flex items-center gap-1">
  423. <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" />
  424. <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>
  425. </div>
  426. </div>
  427. ))}
  428. <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>
  429. <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
  430. </>
  431. )}
  432. {partItems.length === 0 && (
  433. <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(); }}>
  434. <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("parts.addPart")}</span>
  435. </button>
  436. )}
  437. </div>
  438. {/* Labor */}
  439. <div className="rounded-lg border p-3 space-y-2">
  440. <div className="flex items-center justify-between">
  441. <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
  442. <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>
  443. </div>
  444. {laborItems.length > 0 && (
  445. <>
  446. <div className="hidden grid-cols-[2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
  447. <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
  448. </div>
  449. {laborItems.map((labor, i) => (
  450. <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
  451. <Input placeholder={t("labor.descriptionPlaceholder")} value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
  452. <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
  453. <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
  454. <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
  455. <div className="flex items-center gap-1">
  456. <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" />
  457. <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>
  458. </div>
  459. </div>
  460. ))}
  461. <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>
  462. <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("labor.subtotal", { amount: formatCurrency(laborSubtotal, currencyCode) })}</span></div>
  463. </>
  464. )}
  465. {laborItems.length === 0 && (
  466. <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(); }}>
  467. <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("labor.addLabor")}</span>
  468. </button>
  469. )}
  470. </div>
  471. {/* Notes */}
  472. <div className="rounded-lg border p-3 space-y-3">
  473. <div className="flex items-center justify-between">
  474. <h3 className="flex items-center gap-2 text-sm font-semibold"><FileText className="h-3.5 w-3.5" />{t("notes.title")}</h3>
  475. <Select value={noteType} onValueChange={(v) => setNoteType(v as "public" | "internal")}>
  476. <SelectTrigger className="h-7 w-[120px] text-xs"><SelectValue /></SelectTrigger>
  477. <SelectContent>
  478. <SelectItem value="public">{t("notes.public")}</SelectItem>
  479. <SelectItem value="internal">{t("notes.internal")}</SelectItem>
  480. </SelectContent>
  481. </Select>
  482. </div>
  483. {noteType === "public" && (
  484. <div className="space-y-1">
  485. <RichTextEditor content={description} onChange={(v) => { setDescription(v); markDirty(); }} placeholder={t("notes.publicPlaceholder")} />
  486. <p className="text-xs text-muted-foreground">{t("notes.publicHelper")}</p>
  487. </div>
  488. )}
  489. {noteType === "internal" && (
  490. <div className="space-y-1">
  491. <RichTextEditor content={notes} onChange={(v) => { setNotes(v); markDirty(); }} placeholder={t("notes.internalPlaceholder")} />
  492. <p className="text-xs text-muted-foreground">{t("notes.internalHelper")}</p>
  493. </div>
  494. )}
  495. </div>
  496. </div>
  497. );
  498. // --- Right column: Vehicle & Customer, Quote Details, Totals ---
  499. const rightColumn = (
  500. <div className="space-y-3">
  501. {/* Convert to Work Order */}
  502. {quote.status !== "converted" && (
  503. <div className="rounded-lg border p-3">
  504. <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setShowConvertDialog(true)}>
  505. <ArrowRight className="mr-1 h-3.5 w-3.5" /> {t("page.convertToWorkOrder")}
  506. </Button>
  507. </div>
  508. )}
  509. {/* Shared Link */}
  510. {quote.publicToken && (
  511. <SharedLinkCard
  512. publicToken={quote.publicToken}
  513. organizationId={organizationId}
  514. type="quote"
  515. sharedAt={quote.sharedAt}
  516. viewCount={quote.viewCount}
  517. lastViewedAt={quote.lastViewedAt}
  518. onRevoke={async () => {
  519. await revokeQuotePublicLink(quote.id);
  520. router.refresh();
  521. }}
  522. />
  523. )}
  524. {/* Vehicle & Customer */}
  525. <div className="rounded-lg border p-3 space-y-3">
  526. <div className="space-y-1">
  527. <Label className="text-xs">{t("details.vehicle")}</Label>
  528. <Select value={vehicleId || "none"} onValueChange={handleVehicleChange}>
  529. <SelectTrigger><SelectValue placeholder={t("details.selectVehicle")} /></SelectTrigger>
  530. <SelectContent>
  531. <SelectItem value="none">{t("details.none")}</SelectItem>
  532. {vehicles.map((v) => (
  533. <SelectItem key={v.id} value={v.id}>{v.year} {v.make} {v.model}{v.licensePlate ? ` (${v.licensePlate})` : ""}</SelectItem>
  534. ))}
  535. </SelectContent>
  536. </Select>
  537. </div>
  538. {selectedVehicle && (
  539. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  540. <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  541. <Link href={`/vehicles/${selectedVehicle.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
  542. <span className="font-medium">{selectedVehicle.year} {selectedVehicle.make} {selectedVehicle.model}</span>
  543. {selectedVehicle.licensePlate && <span className="ml-1.5 text-muted-foreground">{selectedVehicle.licensePlate}</span>}
  544. </Link>
  545. <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>
  546. </div>
  547. )}
  548. <div className="space-y-1">
  549. <Label className="text-xs">{t("details.customer")}</Label>
  550. <Select value={customerId || "none"} onValueChange={(v) => { setCustomerId(v === "none" ? "" : v); markDirty(); }}>
  551. <SelectTrigger><SelectValue placeholder={t("details.selectCustomer")} /></SelectTrigger>
  552. <SelectContent>
  553. <SelectItem value="none">{t("details.none")}</SelectItem>
  554. {customers.map((c) => (
  555. <SelectItem key={c.id} value={c.id}>{c.name}{c.company ? ` (${c.company})` : ""}</SelectItem>
  556. ))}
  557. </SelectContent>
  558. </Select>
  559. </div>
  560. {selectedCustomer && (
  561. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  562. <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  563. <Link href={`/customers/${selectedCustomer.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
  564. <span className="font-medium">{selectedCustomer.name}</span>
  565. {selectedCustomer.company && <span className="ml-1.5 text-muted-foreground">{selectedCustomer.company}</span>}
  566. </Link>
  567. <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>
  568. </div>
  569. )}
  570. </div>
  571. {/* Quote Details */}
  572. <div className="rounded-lg border p-3 space-y-3">
  573. <h3 className="text-sm font-semibold">{t("details.title")}</h3>
  574. <div className="space-y-1">
  575. <Label htmlFor="title" className="text-xs">{t("details.titleLabel")}</Label>
  576. <Input id="title" name="title" placeholder={t("details.titlePlaceholder")} defaultValue={quote.title} required onChange={markDirty} />
  577. </div>
  578. <div className="grid grid-cols-2 gap-2">
  579. <div className="space-y-1">
  580. <Label className="text-xs">{t("details.status")}</Label>
  581. <Select value={status} onValueChange={(v) => { setStatus(v); markDirty(); }}>
  582. <SelectTrigger><SelectValue /></SelectTrigger>
  583. <SelectContent>
  584. <SelectItem value="draft">{t("details.statusDraft")}</SelectItem>
  585. <SelectItem value="sent">{t("details.statusSent")}</SelectItem>
  586. <SelectItem value="accepted">{t("details.statusAccepted")}</SelectItem>
  587. <SelectItem value="rejected">{t("details.statusRejected")}</SelectItem>
  588. </SelectContent>
  589. </Select>
  590. </div>
  591. <div className="space-y-1">
  592. <Label htmlFor="validUntil" className="text-xs">{t("details.validUntil")}</Label>
  593. <Input id="validUntil" name="validUntil" type="date" defaultValue={defaultValidDate} onChange={markDirty} />
  594. </div>
  595. </div>
  596. {quote.inspectionId && (
  597. <Link
  598. href={`/inspections/${quote.inspectionId}`}
  599. 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"
  600. >
  601. <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
  602. <span>{t("details.viewInspection")}</span>
  603. </Link>
  604. )}
  605. </div>
  606. {/* Customer Response */}
  607. {quote.customerMessage && (status === "changes_requested" || status === "accepted") && (
  608. <div className={`rounded-lg border p-3 space-y-2 ${
  609. status === "changes_requested"
  610. ? "border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
  611. : "border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20"
  612. }`}>
  613. <div className="flex items-center justify-between">
  614. <h3 className={`flex items-center gap-1.5 text-sm font-semibold ${
  615. status === "changes_requested" ? "text-orange-700 dark:text-orange-400" : "text-emerald-700 dark:text-emerald-400"
  616. }`}>
  617. <MessageSquare className="h-3.5 w-3.5" />
  618. {status === "changes_requested" ? t("page.changesRequested") : t("page.quoteAccepted")}
  619. </h3>
  620. <span className={`text-[10px] ${
  621. status === "changes_requested" ? "text-orange-500 dark:text-orange-500" : "text-emerald-500 dark:text-emerald-500"
  622. }`}>
  623. {new Date(quote.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}{" "}
  624. {new Date(quote.updatedAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
  625. </span>
  626. </div>
  627. <p className={`text-sm ${
  628. status === "changes_requested" ? "text-orange-600 dark:text-orange-400" : "text-emerald-600 dark:text-emerald-400"
  629. }`}>
  630. &ldquo;{quote.customerMessage}&rdquo;
  631. </p>
  632. <Button
  633. type="button"
  634. variant="outline"
  635. size="sm"
  636. className="w-full"
  637. disabled={resolving}
  638. onClick={handleResolveResponse}
  639. >
  640. {resolving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
  641. {t("page.markResolved")}
  642. </Button>
  643. </div>
  644. )}
  645. {/* Totals */}
  646. <div className="rounded-lg border p-3 space-y-2">
  647. <h3 className="text-sm font-semibold">{t("totals.title")}</h3>
  648. <div className="space-y-2">
  649. <div className="flex items-center justify-between text-sm"><span className="text-muted-foreground">{t("totals.parts")}</span><span>{formatCurrency(partsSubtotal, currencyCode)}</span></div>
  650. <div className="flex items-center justify-between text-sm"><span className="text-muted-foreground">{t("totals.labor")}</span><span>{formatCurrency(laborSubtotal, currencyCode)}</span></div>
  651. <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>
  652. <div className="flex items-center justify-between text-sm">
  653. <div className="flex items-center gap-2">
  654. <span className="text-muted-foreground">{t("totals.discount")}</span>
  655. <Select value={discountType} onValueChange={(v) => { setDiscountType(v); markDirty(); }}>
  656. <SelectTrigger className="h-7 w-28 text-xs"><SelectValue /></SelectTrigger>
  657. <SelectContent>
  658. <SelectItem value="none">{t("totals.discountNone")}</SelectItem>
  659. <SelectItem value="percentage">{t("totals.discountPercentage")}</SelectItem>
  660. <SelectItem value="fixed">{t("totals.discountFixed")}</SelectItem>
  661. </SelectContent>
  662. </Select>
  663. {discountType !== "none" && (
  664. <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" />
  665. )}
  666. {discountType === "percentage" && <span className="text-muted-foreground">%</span>}
  667. </div>
  668. {discountAmount > 0 && <span className="text-destructive">{formatCurrency(-discountAmount, currencyCode)}</span>}
  669. </div>
  670. {taxEnabled && (
  671. <div className="flex items-center justify-between text-sm">
  672. <div className="flex items-center gap-2">
  673. <span className="text-muted-foreground">{t("totals.tax")}</span>
  674. <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" />
  675. <span className="text-muted-foreground">%</span>
  676. </div>
  677. <span>{formatCurrency(taxAmount, currencyCode)}</span>
  678. </div>
  679. )}
  680. <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>
  681. </div>
  682. </div>
  683. </div>
  684. );
  685. return (
  686. <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
  687. {/* Header */}
  688. <div className="shrink-0 border-b bg-background px-4 py-2">
  689. <div className="flex items-center justify-between">
  690. <Link
  691. href="/quotes"
  692. className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
  693. >
  694. <ArrowLeft className="h-4 w-4 shrink-0" />
  695. <div className="min-w-0">
  696. <div className="flex items-center gap-2">
  697. <Badge variant="outline" className={`shrink-0 text-xs capitalize ${statusColors[status] || ""}`}>
  698. {status}
  699. </Badge>
  700. <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
  701. </div>
  702. <p className="truncate text-xs text-muted-foreground">
  703. {quote.quoteNumber || t("page.quote")}
  704. {quote.customer ? ` · ${quote.customer.name}` : ""}
  705. </p>
  706. </div>
  707. </Link>
  708. <div className="flex shrink-0 items-center gap-2">
  709. {hasUnsavedChanges && (
  710. <span className="text-xs font-medium text-amber-600 dark:text-amber-400">{t("page.unsavedChanges")}</span>
  711. )}
  712. {showSaved && !hasUnsavedChanges && (
  713. <span className="text-xs font-medium text-green-600 dark:text-green-400">{t("page.saved")}</span>
  714. )}
  715. <Button type="submit" form="quote-form" size="sm" disabled={saving} variant={hasUnsavedChanges ? "default" : "outline"} className={hasUnsavedChanges ? "animate-pulse" : ""}>
  716. {saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Save className="mr-1 h-3.5 w-3.5" />}
  717. {t("page.save")}
  718. </Button>
  719. <ButtonGroup>
  720. <Button variant="outline" size="sm" onClick={handleDownloadPDF} disabled={downloading}>
  721. {downloading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
  722. {t("page.pdf")}
  723. </Button>
  724. <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowEmailDialog(true); }}>
  725. <Mail className="mr-1 h-3.5 w-3.5" />
  726. {t("page.email")}
  727. </Button>
  728. <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowShareDialog(true); }}>
  729. <Globe className="mr-1 h-3.5 w-3.5" />
  730. {t("page.share")}
  731. </Button>
  732. <Button variant="outline" size="sm" className="text-destructive hover:bg-destructive/10" onClick={handleDelete}>
  733. <Trash2 className="mr-1 h-3.5 w-3.5" />
  734. {t("page.delete")}
  735. </Button>
  736. </ButtonGroup>
  737. </div>
  738. </div>
  739. </div>
  740. {/* Tabs */}
  741. <div className="shrink-0 border-b bg-background px-4">
  742. <div className="flex gap-1">
  743. {([
  744. { key: "details" as TabType, label: t("page.tabs.details"), icon: FileText },
  745. { key: "images" as TabType, label: t("page.tabs.images"), icon: Camera },
  746. { key: "documents" as TabType, label: t("page.tabs.documents"), icon: Paperclip },
  747. ]).map(({ key, label, icon: Icon }) => (
  748. <button
  749. key={key}
  750. type="button"
  751. onClick={() => setActiveTab(key)}
  752. className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
  753. activeTab === key
  754. ? "border-primary text-primary"
  755. : "border-transparent text-muted-foreground hover:text-foreground"
  756. }`}
  757. >
  758. <Icon className="h-3.5 w-3.5" />
  759. {label}
  760. </button>
  761. ))}
  762. </div>
  763. </div>
  764. {/* Tab Content */}
  765. {activeTab === "details" && (
  766. <form id="quote-form" ref={formRef} onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
  767. {isLarge ? (
  768. <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
  769. <ResizablePanel defaultSize={75} minSize={40}>
  770. <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
  771. <div className="space-y-3 pb-40">{leftColumn}</div>
  772. </div>
  773. </ResizablePanel>
  774. <ResizableHandle withHandle />
  775. <ResizablePanel defaultSize={25} minSize={15}>
  776. <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
  777. <div className="space-y-3 pb-40">{rightColumn}</div>
  778. </div>
  779. </ResizablePanel>
  780. </ResizablePanelGroup>
  781. ) : (
  782. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  783. <div className="space-y-3 pb-40">
  784. {leftColumn}
  785. {rightColumn}
  786. </div>
  787. </div>
  788. )}
  789. </form>
  790. )}
  791. {activeTab === "images" && (
  792. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  793. <div className="mx-auto max-w-4xl pb-40">
  794. <QuoteImagesManager
  795. quoteId={quote.id}
  796. initialImages={imageAttachments}
  797. maxImages={maxImages}
  798. />
  799. </div>
  800. </div>
  801. )}
  802. {activeTab === "documents" && (
  803. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  804. <div className="mx-auto max-w-4xl pb-40">
  805. <QuoteDocumentsManager
  806. quoteId={quote.id}
  807. initialDocuments={documentAttachments}
  808. maxDocuments={maxDocuments}
  809. />
  810. </div>
  811. </div>
  812. )}
  813. {/* Dialogs */}
  814. <SendEmailDialog
  815. open={showEmailDialog}
  816. onOpenChange={setShowEmailDialog}
  817. defaultEmail={quote.customer?.email || ""}
  818. entityLabel={t("page.entityLabel")}
  819. onSend={async (email, message) => sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message })}
  820. />
  821. <QuoteShareDialog
  822. open={showShareDialog}
  823. onOpenChange={setShowShareDialog}
  824. quoteId={quote.id}
  825. organizationId={organizationId}
  826. initialToken={quote.publicToken}
  827. customer={quote.customer}
  828. smsEnabled={smsEnabled}
  829. emailEnabled={emailEnabled}
  830. />
  831. <Dialog open={showConvertDialog} onOpenChange={setShowConvertDialog}>
  832. <DialogContent className="max-w-md">
  833. <DialogHeader><DialogTitle>{t("page.convertTitle")}</DialogTitle></DialogHeader>
  834. <div className="space-y-4">
  835. <p className="text-sm text-muted-foreground">{t("page.convertDescription")}</p>
  836. <Select value={convertVehicleId} onValueChange={setConvertVehicleId}>
  837. <SelectTrigger><SelectValue placeholder={t("details.selectVehicle")} /></SelectTrigger>
  838. <SelectContent>
  839. {vehicles.map((v) => (
  840. <SelectItem key={v.id} value={v.id}>{v.year} {v.make} {v.model}{v.licensePlate ? ` (${v.licensePlate})` : ""}</SelectItem>
  841. ))}
  842. </SelectContent>
  843. </Select>
  844. <div className="flex gap-2">
  845. <Button type="button" onClick={handleConvert} disabled={converting || !convertVehicleId}>
  846. {converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
  847. {t("page.convert")}
  848. </Button>
  849. <Button type="button" variant="ghost" onClick={() => setShowConvertDialog(false)}>{t("page.cancel")}</Button>
  850. </div>
  851. </div>
  852. </DialogContent>
  853. </Dialog>
  854. </div>
  855. );
  856. }