QuoteDocumentsManager.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. "use client";
  2. import { interactiveRow } from "@/lib/interactive-row";
  3. import { useState, useCallback, useRef } from "react";
  4. import { useTranslations } from "next-intl";
  5. import { Button } from "@/components/ui/button";
  6. import { Switch } from "@/components/ui/switch";
  7. import { AppCard } from "@/components/app-card";
  8. import { toast } from "sonner";
  9. import {
  10. FileText,
  11. Image as ImageIcon,
  12. Loader2,
  13. Paperclip,
  14. Upload,
  15. X,
  16. } from "lucide-react";
  17. import { addQuoteAttachment } from "@/features/quotes/Actions/addQuoteAttachment";
  18. import { updateQuoteAttachment } from "@/features/quotes/Actions/updateQuoteAttachment";
  19. import { deleteQuoteAttachment } from "@/features/quotes/Actions/deleteQuoteAttachment";
  20. interface Attachment {
  21. id: string;
  22. fileName: string;
  23. fileUrl: string;
  24. fileType: string;
  25. fileSize: number;
  26. category: string;
  27. description: string | null;
  28. includeInInvoice: boolean;
  29. }
  30. interface QuoteDocumentsManagerProps {
  31. quoteId: string;
  32. initialDocuments: Attachment[];
  33. maxDocuments?: number;
  34. }
  35. function formatFileSize(bytes: number) {
  36. if (bytes < 1024) return `${bytes} B`;
  37. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  38. return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  39. }
  40. function getFileIcon(type: string) {
  41. if (type === "application/pdf")
  42. return <FileText className="h-4 w-4 text-red-500" />;
  43. if (type.startsWith("image/"))
  44. return <ImageIcon className="h-4 w-4 text-blue-500" />;
  45. return <Paperclip className="h-4 w-4 text-muted-foreground" />;
  46. }
  47. export function QuoteDocumentsManager({
  48. quoteId,
  49. initialDocuments,
  50. maxDocuments,
  51. }: QuoteDocumentsManagerProps) {
  52. const [files, setFiles] = useState<Attachment[]>(initialDocuments);
  53. const [uploading, setUploading] = useState(false);
  54. const inputRef = useRef<HTMLInputElement>(null);
  55. const atLimit = maxDocuments !== undefined && files.length >= maxDocuments;
  56. const t = useTranslations("quotes");
  57. const handleUpload = useCallback(
  58. async (fileList: FileList | File[]) => {
  59. let fileArr = Array.from(fileList);
  60. if (maxDocuments !== undefined) {
  61. const remaining = maxDocuments - files.length;
  62. if (remaining <= 0) {
  63. toast.error(t("documents.limitReached", { count: files.length, max: maxDocuments }));
  64. return;
  65. }
  66. if (fileArr.length > remaining) {
  67. fileArr = fileArr.slice(0, remaining);
  68. toast.warning(t("documents.onlyUploading", { count: remaining }));
  69. }
  70. }
  71. setUploading(true);
  72. const toastId = toast.loading(
  73. t("documents.uploadingCount", { count: fileArr.length })
  74. );
  75. let successCount = 0;
  76. for (const file of fileArr) {
  77. const formData = new FormData();
  78. formData.append("file", file);
  79. try {
  80. const res = await fetch("/api/protected/upload/quote-files", {
  81. method: "POST",
  82. body: formData,
  83. });
  84. if (!res.ok) {
  85. const err = await res.json();
  86. toast.error(err.error || t("documents.failedUploadFile", { name: file.name }));
  87. continue;
  88. }
  89. const data = await res.json();
  90. const result = await addQuoteAttachment({
  91. quoteId,
  92. attachment: {
  93. fileName: data.fileName,
  94. fileUrl: data.url,
  95. fileType: data.fileType,
  96. fileSize: data.fileSize,
  97. category: "document",
  98. includeInInvoice: true,
  99. },
  100. });
  101. if (result.success && result.data) {
  102. setFiles((prev) => [...prev, result.data as Attachment]);
  103. successCount++;
  104. } else {
  105. toast.error(result.error || t("documents.failedSaveFile", { name: file.name }));
  106. }
  107. } catch {
  108. toast.error(t("documents.failedUploadFile", { name: file.name }));
  109. }
  110. }
  111. if (successCount > 0) {
  112. toast.success(
  113. t("documents.uploadedCount", { count: successCount }),
  114. { id: toastId }
  115. );
  116. } else {
  117. toast.error(t("documents.uploadFailed"), { id: toastId });
  118. }
  119. setUploading(false);
  120. },
  121. [quoteId, maxDocuments, files.length, t]
  122. );
  123. const handleDelete = useCallback(async (attachmentId: string) => {
  124. const result = await deleteQuoteAttachment(attachmentId);
  125. if (result.success) {
  126. setFiles((prev) => prev.filter((f) => f.id !== attachmentId));
  127. toast.success(t("documents.deleted"));
  128. } else {
  129. toast.error(result.error || t("documents.failedDelete"));
  130. }
  131. }, [t]);
  132. const handleToggleInvoice = useCallback(
  133. async (attachmentId: string, checked: boolean) => {
  134. setFiles((prev) =>
  135. prev.map((f) =>
  136. f.id === attachmentId ? { ...f, includeInInvoice: checked } : f
  137. )
  138. );
  139. const result = await updateQuoteAttachment({
  140. id: attachmentId,
  141. includeInInvoice: checked,
  142. });
  143. if (!result.success) {
  144. setFiles((prev) =>
  145. prev.map((f) =>
  146. f.id === attachmentId
  147. ? { ...f, includeInInvoice: !checked }
  148. : f
  149. )
  150. );
  151. toast.error(result.error || t("documents.failedUpdate"));
  152. }
  153. },
  154. [t]
  155. );
  156. return (
  157. <AppCard
  158. icon={Paperclip}
  159. title={t("documents.title")}
  160. badge={maxDocuments !== undefined ? `${files.length} / ${maxDocuments}` : undefined}
  161. contentClassName="space-y-4"
  162. >
  163. {atLimit ? (
  164. <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
  165. <p className="text-sm font-medium text-muted-foreground">
  166. {t("documents.limitReached", { count: files.length, max: maxDocuments })}
  167. </p>
  168. <p className="mt-1 text-xs text-muted-foreground">
  169. {t("documents.upgradePrompt")}
  170. </p>
  171. </div>
  172. ) : (
  173. <div
  174. onDrop={(e) => {
  175. e.preventDefault();
  176. if (e.dataTransfer.files.length > 0)
  177. handleUpload(e.dataTransfer.files);
  178. }}
  179. onDragOver={(e) => e.preventDefault()}
  180. {...interactiveRow(() => inputRef.current?.click())}
  181. className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
  182. >
  183. <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
  184. <p className="text-sm font-medium">
  185. {uploading
  186. ? t("documents.uploading")
  187. : t("documents.dropzone")}
  188. </p>
  189. <p className="mt-1 text-xs text-muted-foreground">
  190. {t("documents.formats")}
  191. </p>
  192. <input
  193. ref={inputRef}
  194. type="file"
  195. multiple
  196. accept=".pdf,.csv,.txt"
  197. className="hidden"
  198. onChange={(e) => {
  199. if (e.target.files && e.target.files.length > 0) {
  200. handleUpload(e.target.files);
  201. e.target.value = "";
  202. }
  203. }}
  204. />
  205. </div>
  206. )}
  207. {uploading && (
  208. <div className="flex items-center gap-2 text-sm text-muted-foreground">
  209. <Loader2 className="h-4 w-4 animate-spin" />
  210. {t("documents.uploadingDocuments")}
  211. </div>
  212. )}
  213. {files.length > 0 && (
  214. <div className="space-y-2">
  215. {files.map((file) => (
  216. <div
  217. key={file.id}
  218. className="flex items-center gap-3 rounded-md border p-2.5"
  219. >
  220. {getFileIcon(file.fileType)}
  221. <div className="min-w-0 flex-1">
  222. <p className="truncate text-sm font-medium">{file.fileName}</p>
  223. <p className="text-xs text-muted-foreground">
  224. {formatFileSize(file.fileSize)}
  225. </p>
  226. </div>
  227. <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
  228. <Switch
  229. checked={file.includeInInvoice}
  230. onCheckedChange={(checked) =>
  231. handleToggleInvoice(file.id, checked)
  232. }
  233. className="scale-75"
  234. />
  235. {t("documents.pdf")}
  236. </label>
  237. <Button
  238. type="button"
  239. variant="ghost"
  240. size="icon"
  241. className="h-7 w-7 text-muted-foreground hover:text-destructive"
  242. onClick={() => handleDelete(file.id)}
  243. aria-label={t("page.delete")}
  244. >
  245. <X className="h-3.5 w-3.5" />
  246. </Button>
  247. </div>
  248. ))}
  249. </div>
  250. )}
  251. </AppCard>
  252. );
  253. }