@hed-hog/operations 0.0.319 → 0.0.322

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/controllers/operations-tasks.controller.d.ts +22 -0
  2. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.js +37 -0
  4. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  5. package/dist/dto/create-task.dto.d.ts.map +1 -1
  6. package/dist/dto/create-task.dto.js +0 -1
  7. package/dist/dto/create-task.dto.js.map +1 -1
  8. package/dist/dto/update-task.dto.d.ts.map +1 -1
  9. package/dist/dto/update-task.dto.js +0 -1
  10. package/dist/dto/update-task.dto.js.map +1 -1
  11. package/dist/operations.service.d.ts +22 -0
  12. package/dist/operations.service.d.ts.map +1 -1
  13. package/dist/operations.service.js +187 -132
  14. package/dist/operations.service.js.map +1 -1
  15. package/hedhog/data/operations_cost_type.yaml +95 -95
  16. package/hedhog/data/route.yaml +39 -0
  17. package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
  18. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
  19. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
  20. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
  21. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
  22. package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +179 -178
  24. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
  25. package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
  26. package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
  27. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
  28. package/hedhog/frontend/messages/en.json +143 -2
  29. package/hedhog/frontend/messages/pt.json +143 -2
  30. package/hedhog/table/operations_task_file.yaml +23 -0
  31. package/package.json +5 -5
  32. package/src/controllers/operations-reports.controller.ts +32 -32
  33. package/src/controllers/operations-tasks.controller.ts +43 -9
  34. package/src/dto/create-task.dto.ts +0 -1
  35. package/src/dto/list-reports.dto.ts +51 -51
  36. package/src/dto/update-task.dto.ts +0 -1
  37. package/src/operations.module.ts +5 -5
  38. package/src/operations.service.ts +754 -632
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
5
  import { Button } from '@/components/ui/button';
5
6
  import { Checkbox } from '@/components/ui/checkbox';
6
7
  import {
@@ -80,7 +81,7 @@ import type {
80
81
  OperationsProjectDetails,
81
82
  OperationsProjectRole,
82
83
  } from '../_lib/types';
83
- import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
84
+ import { formatEnumLabel } from '../_lib/utils/format';
84
85
  import {
85
86
  normalizePercentInput,
86
87
  parseNumberInput,
@@ -126,6 +127,16 @@ type ProjectFormState = {
126
127
 
127
128
  type ProjectFormValues = ProjectFormState;
128
129
 
130
+ function generateProjectCode(name: string): string {
131
+ const words = name.trim().split(/\s+/).filter(Boolean);
132
+ if (words.length === 0) return '';
133
+ if (words.length === 1) return words[0]!.slice(0, 5).toUpperCase();
134
+ return words
135
+ .map((w) => w[0]!.toUpperCase())
136
+ .join('')
137
+ .slice(0, 6);
138
+ }
139
+
129
140
  function normalizeDateInputValue(value?: string | null) {
130
141
  if (!value) {
131
142
  return '';
@@ -272,6 +283,7 @@ type SearchableSelectProps = {
272
283
  id: number;
273
284
  title: string;
274
285
  description?: string | null;
286
+ avatarUrl?: string | null;
275
287
  }>;
276
288
  placeholder: string;
277
289
  searchPlaceholder: string;
@@ -388,6 +400,25 @@ function SearchableSelect({
388
400
  ) : (
389
401
  <span className="mr-2 h-4 w-4" />
390
402
  )}
403
+ {option.avatarUrl !== undefined ? (
404
+ <Avatar className="mr-2 h-6 w-6 shrink-0">
405
+ {option.avatarUrl ? (
406
+ <AvatarImage
407
+ src={option.avatarUrl}
408
+ alt={option.title}
409
+ />
410
+ ) : null}
411
+ <AvatarFallback className="text-[9px] font-medium">
412
+ {option.title
413
+ .trim()
414
+ .split(' ')
415
+ .filter(Boolean)
416
+ .slice(0, 2)
417
+ .map((w) => w[0]!.toUpperCase())
418
+ .join('')}
419
+ </AvatarFallback>
420
+ </Avatar>
421
+ ) : null}
391
422
  <div className="min-w-0">
392
423
  <div className="truncate">{option.title}</div>
393
424
  {option.description ? (
@@ -602,6 +633,7 @@ export function ProjectFormScreen({
602
633
  const [assignmentSearch, setAssignmentSearch] = useState('');
603
634
  const isSheetMode = Boolean(onCancel);
604
635
  const isCreateMode = !projectId;
636
+ const [codeAutoMode, setCodeAutoMode] = useState(isCreateMode);
605
637
 
606
638
  const projectFormSchema = useMemo(
607
639
  () =>
@@ -682,7 +714,7 @@ export function ProjectFormScreen({
682
714
  });
683
715
  };
684
716
 
685
- const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
717
+ const { data: rawCollaborators } = useQuery<OperationsCollaborator[]>({
686
718
  queryKey: ['operations-project-form-collaborators', currentLocaleCode],
687
719
  enabled: access.isDirector,
688
720
  queryFn: () =>
@@ -691,6 +723,12 @@ export function ProjectFormScreen({
691
723
  '/operations/collaborators'
692
724
  ),
693
725
  });
726
+ // Stable reference prevents the useEffect(reset, [collaborators, project]) from
727
+ // re-running on every render while the query is still loading (undefined → [] new ref).
728
+ const collaborators = useMemo(
729
+ () => rawCollaborators ?? [],
730
+ [rawCollaborators]
731
+ );
694
732
 
695
733
  const { data: contracts = [], refetch: refetchContracts } = useQuery<
696
734
  OperationsContract[]
@@ -811,6 +849,11 @@ export function ProjectFormScreen({
811
849
  description: [collaborator.department, collaborator.title]
812
850
  .filter(Boolean)
813
851
  .join(' • '),
852
+ avatarUrl:
853
+ typeof collaborator.personAvatarId === 'number' &&
854
+ collaborator.personAvatarId > 0
855
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${collaborator.personAvatarId}`
856
+ : null,
814
857
  })),
815
858
  [availableCollaborators]
816
859
  );
@@ -1041,6 +1084,15 @@ export function ProjectFormScreen({
1041
1084
  {...field}
1042
1085
  placeholder={t('placeholders.name')}
1043
1086
  autoFocus
1087
+ onChange={(e) => {
1088
+ field.onChange(e);
1089
+ if (codeAutoMode) {
1090
+ formMethods.setValue(
1091
+ 'code',
1092
+ generateProjectCode(e.target.value)
1093
+ );
1094
+ }
1095
+ }}
1044
1096
  />
1045
1097
  </FormControl>
1046
1098
  <FormMessage />
@@ -1055,7 +1107,14 @@ export function ProjectFormScreen({
1055
1107
  <FormItem className="min-w-0 lg:col-span-1">
1056
1108
  <FormLabel>{t('fields.code')}</FormLabel>
1057
1109
  <FormControl>
1058
- <Input {...field} placeholder={t('placeholders.code')} />
1110
+ <Input
1111
+ {...field}
1112
+ placeholder={t('placeholders.code')}
1113
+ onChange={(e) => {
1114
+ setCodeAutoMode(false);
1115
+ field.onChange(e);
1116
+ }}
1117
+ />
1059
1118
  </FormControl>
1060
1119
  <FormMessage />
1061
1120
  </FormItem>
@@ -1388,71 +1447,6 @@ export function ProjectFormScreen({
1388
1447
  </div>
1389
1448
  </section>
1390
1449
 
1391
- <section className="space-y-3">
1392
- <div className="space-y-0.5">
1393
- <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1394
- {t('sections.contract')}
1395
- </h3>
1396
- <p className="text-[11px] text-muted-foreground/80">
1397
- {t('sections.contractDescription')}
1398
- </p>
1399
- </div>
1400
-
1401
- <div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
1402
- <div className="rounded-lg border px-3 py-3">
1403
- <div className="flex items-start justify-between gap-3">
1404
- <div className="min-w-0">
1405
- <div className="text-sm font-medium text-foreground">
1406
- {selectedContract?.name ||
1407
- commonT('labels.notAssigned')}
1408
- </div>
1409
- <div className="mt-1 text-xs text-muted-foreground">
1410
- {selectedContract
1411
- ? [
1412
- selectedContract.code,
1413
- selectedContract.clientName,
1414
- formatEnumLabel(selectedContract.contractType),
1415
- ]
1416
- .filter(Boolean)
1417
- .join(' • ')
1418
- : commonT('labels.notAssigned')}
1419
- </div>
1420
- </div>
1421
- {selectedContract?.status ? (
1422
- <span className="shrink-0">
1423
- <span
1424
- className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${getStatusBadgeClass(
1425
- selectedContract.status
1426
- )}`}
1427
- >
1428
- {contractT.has(
1429
- `options.statuses.${selectedContract.status}`
1430
- )
1431
- ? contractT(
1432
- `options.statuses.${selectedContract.status}`
1433
- )
1434
- : formatEnumLabel(selectedContract.status)}
1435
- </span>
1436
- </span>
1437
- ) : null}
1438
- </div>
1439
-
1440
- <div className="mt-3 flex flex-wrap gap-2">
1441
- {selectedContract ? (
1442
- <Button type="button" variant="outline" size="sm" asChild>
1443
- <Link
1444
- href={`/operations/contracts?edit=${selectedContract.id}`}
1445
- >
1446
- <FileText className="size-4" />
1447
- {commonT('actions.openContract')}
1448
- </Link>
1449
- </Button>
1450
- ) : null}
1451
- </div>
1452
- </div>
1453
- </div>
1454
- </section>
1455
-
1456
1450
  <section className="space-y-3">
1457
1451
  <div className="space-y-0.5">
1458
1452
  <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@@ -0,0 +1,388 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { useApp } from '@hed-hog/next-app-provider';
5
+ import {
6
+ Archive,
7
+ ExternalLink,
8
+ File,
9
+ FileAudio,
10
+ FileCode,
11
+ FileImage,
12
+ FileSpreadsheet,
13
+ FileText,
14
+ FileVideo,
15
+ Loader2,
16
+ Paperclip,
17
+ Trash2,
18
+ UploadCloud,
19
+ } from 'lucide-react';
20
+ import { useCallback, useEffect, useRef, useState } from 'react';
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ type TaskFile = {
25
+ id: number;
26
+ file_id: number;
27
+ filename: string;
28
+ size: number;
29
+ mimetype: string;
30
+ created_at: string;
31
+ };
32
+
33
+ type UploadingFile = {
34
+ key: string;
35
+ filename: string;
36
+ progress: number;
37
+ error?: string;
38
+ };
39
+
40
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
41
+
42
+ function formatBytes(bytes: number) {
43
+ if (bytes === 0) return '0 B';
44
+ const k = 1024;
45
+ const sizes = ['B', 'KB', 'MB', 'GB'];
46
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
47
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
48
+ }
49
+
50
+ function getFileIcon(mimetype: string, filename: string) {
51
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
52
+
53
+ if (mimetype.startsWith('image/')) return FileImage;
54
+ if (mimetype.startsWith('video/')) return FileVideo;
55
+ if (mimetype.startsWith('audio/')) return FileAudio;
56
+ if (
57
+ mimetype === 'application/pdf' ||
58
+ mimetype.includes('word') ||
59
+ mimetype.includes('document')
60
+ )
61
+ return FileText;
62
+ if (
63
+ mimetype.includes('spreadsheet') ||
64
+ mimetype.includes('excel') ||
65
+ ext === 'xlsx' ||
66
+ ext === 'xls'
67
+ )
68
+ return FileSpreadsheet;
69
+ if (ext === 'csv') return FileSpreadsheet;
70
+ if (
71
+ mimetype.includes('zip') ||
72
+ mimetype.includes('compressed') ||
73
+ mimetype.includes('archive') ||
74
+ ['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)
75
+ )
76
+ return Archive;
77
+ if (
78
+ [
79
+ 'js',
80
+ 'ts',
81
+ 'tsx',
82
+ 'jsx',
83
+ 'py',
84
+ 'java',
85
+ 'cs',
86
+ 'cpp',
87
+ 'c',
88
+ 'go',
89
+ 'rs',
90
+ 'json',
91
+ 'xml',
92
+ 'html',
93
+ 'css',
94
+ ].includes(ext) ||
95
+ mimetype.includes('javascript') ||
96
+ mimetype.includes('json') ||
97
+ mimetype.includes('xml') ||
98
+ mimetype.includes('html')
99
+ )
100
+ return FileCode;
101
+
102
+ return File;
103
+ }
104
+
105
+ function getFileOpenUrl(fileId: number) {
106
+ const base =
107
+ typeof process !== 'undefined'
108
+ ? (process.env.NEXT_PUBLIC_API_BASE_URL?.trim().replace(/\/$/, '') ?? '')
109
+ : '';
110
+ return `${base}/file/open/${fileId}`;
111
+ }
112
+
113
+ // ─── Component ────────────────────────────────────────────────────────────────
114
+
115
+ type Props = {
116
+ taskId: number | null;
117
+ };
118
+
119
+ export function TaskFileAttachments({ taskId }: Props) {
120
+ const { request } = useApp();
121
+
122
+ const [files, setFiles] = useState<TaskFile[]>([]);
123
+ const [uploading, setUploading] = useState<UploadingFile[]>([]);
124
+ const [loadingFiles, setLoadingFiles] = useState(false);
125
+ const [deletingId, setDeletingId] = useState<number | null>(null);
126
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
127
+
128
+ const inputRef = useRef<HTMLInputElement>(null);
129
+
130
+ // ── Fetch existing files ──────────────────────────────────────────────────
131
+
132
+ const fetchFiles = useCallback(async () => {
133
+ if (!taskId) return;
134
+ setLoadingFiles(true);
135
+ try {
136
+ const res = await request<TaskFile[]>({
137
+ url: `/operations/tasks/${taskId}/files`,
138
+ method: 'GET',
139
+ });
140
+ setFiles((res.data as TaskFile[]) ?? []);
141
+ } catch {
142
+ // ignore
143
+ } finally {
144
+ setLoadingFiles(false);
145
+ }
146
+ }, [taskId, request]);
147
+
148
+ useEffect(() => {
149
+ void fetchFiles();
150
+ }, [fetchFiles]);
151
+
152
+ // ── Upload handler ────────────────────────────────────────────────────────
153
+
154
+ const uploadFile = useCallback(
155
+ async (file: File) => {
156
+ if (!taskId) return;
157
+
158
+ const key = `${file.name}-${Date.now()}`;
159
+ setUploading((prev) => [
160
+ ...prev,
161
+ { key, filename: file.name, progress: 0 },
162
+ ]);
163
+
164
+ try {
165
+ const formData = new FormData();
166
+ formData.append('file', file);
167
+
168
+ const res = await (request as any)({
169
+ url: `/operations/tasks/${taskId}/files`,
170
+ method: 'POST',
171
+ data: formData,
172
+ headers: { 'Content-Type': 'multipart/form-data' },
173
+ onUploadProgress: (e: { loaded: number; total?: number }) => {
174
+ if (!e.total) return;
175
+ const pct = Math.round((e.loaded * 100) / e.total);
176
+ setUploading((prev) =>
177
+ prev.map((u) => (u.key === key ? { ...u, progress: pct } : u))
178
+ );
179
+ },
180
+ });
181
+
182
+ if (res?.data) {
183
+ await fetchFiles();
184
+ }
185
+ } catch {
186
+ setUploading((prev) =>
187
+ prev.map((u) =>
188
+ u.key === key ? { ...u, error: 'Erro no upload' } : u
189
+ )
190
+ );
191
+ setTimeout(() => {
192
+ setUploading((prev) => prev.filter((u) => u.key !== key));
193
+ }, 3000);
194
+ return;
195
+ }
196
+
197
+ setUploading((prev) => prev.filter((u) => u.key !== key));
198
+ },
199
+ [taskId, request, fetchFiles]
200
+ );
201
+
202
+ const handleFiles = useCallback(
203
+ (fileList: FileList | null) => {
204
+ if (!fileList) return;
205
+ Array.from(fileList).forEach((f) => void uploadFile(f));
206
+ if (inputRef.current) inputRef.current.value = '';
207
+ },
208
+ [uploadFile]
209
+ );
210
+
211
+ // ── Delete handler ────────────────────────────────────────────────────────
212
+
213
+ const handleDelete = useCallback(
214
+ async (relationId: number) => {
215
+ if (!taskId) return;
216
+ setDeletingId(relationId);
217
+ try {
218
+ await request({
219
+ url: `/operations/tasks/${taskId}/files/${relationId}`,
220
+ method: 'DELETE',
221
+ });
222
+ setFiles((prev) => prev.filter((f) => f.id !== relationId));
223
+ } catch {
224
+ // ignore
225
+ } finally {
226
+ setDeletingId(null);
227
+ }
228
+ },
229
+ [taskId, request]
230
+ );
231
+
232
+ // ── Drag and drop ─────────────────────────────────────────────────────────
233
+
234
+ const handleDragOver = (e: React.DragEvent) => {
235
+ e.preventDefault();
236
+ setIsDraggingOver(true);
237
+ };
238
+
239
+ const handleDragLeave = () => setIsDraggingOver(false);
240
+
241
+ const handleDrop = (e: React.DragEvent) => {
242
+ e.preventDefault();
243
+ setIsDraggingOver(false);
244
+ handleFiles(e.dataTransfer.files);
245
+ };
246
+
247
+ // ── Render ────────────────────────────────────────────────────────────────
248
+
249
+ const isEmpty = files.length === 0 && uploading.length === 0;
250
+
251
+ return (
252
+ <div className="space-y-3">
253
+ {/* Drop zone */}
254
+ <div
255
+ role="button"
256
+ tabIndex={0}
257
+ aria-label="Clique ou solte arquivos para anexar"
258
+ className={[
259
+ 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-5 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
260
+ isDraggingOver
261
+ ? 'border-primary bg-primary/5 text-primary'
262
+ : 'border-border bg-muted/30 text-muted-foreground hover:border-primary/50 hover:bg-muted/50',
263
+ ].join(' ')}
264
+ onClick={() => inputRef.current?.click()}
265
+ onKeyDown={(e) => {
266
+ if (e.key === 'Enter' || e.key === ' ') {
267
+ e.preventDefault();
268
+ inputRef.current?.click();
269
+ }
270
+ }}
271
+ onDragOver={handleDragOver}
272
+ onDragLeave={handleDragLeave}
273
+ onDrop={handleDrop}
274
+ >
275
+ <UploadCloud className="size-6 shrink-0 opacity-60" />
276
+ <span className="text-center text-xs leading-relaxed">
277
+ {isDraggingOver
278
+ ? 'Solte os arquivos aqui'
279
+ : 'Clique ou arraste arquivos para anexar'}
280
+ </span>
281
+ <input
282
+ ref={inputRef}
283
+ type="file"
284
+ multiple
285
+ className="hidden"
286
+ onChange={(e) => handleFiles(e.target.files)}
287
+ />
288
+ </div>
289
+
290
+ {/* Upload progress items */}
291
+ {uploading.map((u) => (
292
+ <div
293
+ key={u.key}
294
+ className="flex items-center gap-3 rounded-xl border bg-muted/30 px-3 py-2"
295
+ >
296
+ <Loader2 className="size-4 shrink-0 animate-spin text-primary" />
297
+ <div className="min-w-0 flex-1">
298
+ <p className="truncate text-xs font-medium">{u.filename}</p>
299
+ {u.error ? (
300
+ <p className="text-[10px] text-destructive">{u.error}</p>
301
+ ) : (
302
+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
303
+ <div
304
+ className="h-full rounded-full bg-primary transition-all duration-200"
305
+ style={{ width: `${u.progress}%` }}
306
+ />
307
+ </div>
308
+ )}
309
+ </div>
310
+ <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
311
+ {u.error ? 'Erro' : `${u.progress}%`}
312
+ </span>
313
+ </div>
314
+ ))}
315
+
316
+ {/* Loading skeleton */}
317
+ {loadingFiles && files.length === 0 && uploading.length === 0 ? (
318
+ <div className="space-y-2">
319
+ {[1, 2].map((i) => (
320
+ <div
321
+ key={i}
322
+ className="h-12 animate-pulse rounded-xl border bg-muted/30"
323
+ />
324
+ ))}
325
+ </div>
326
+ ) : null}
327
+
328
+ {/* Empty state */}
329
+ {!loadingFiles && isEmpty ? (
330
+ <div className="flex items-center gap-2 rounded-xl border border-dashed bg-muted/10 px-3 py-3 text-xs text-muted-foreground">
331
+ <Paperclip className="size-3.5 shrink-0" />
332
+ Nenhum arquivo anexado
333
+ </div>
334
+ ) : null}
335
+
336
+ {/* File list */}
337
+ {files.map((file) => {
338
+ const Icon = getFileIcon(file.mimetype, file.filename);
339
+ const isDeleting = deletingId === file.id;
340
+ return (
341
+ <div
342
+ key={file.id}
343
+ className="group flex items-center gap-3 rounded-xl border bg-card px-3 py-2 transition hover:border-primary/30 hover:bg-muted/30"
344
+ >
345
+ <Icon className="size-5 shrink-0 text-muted-foreground" />
346
+ <div className="min-w-0 flex-1">
347
+ <p className="truncate text-xs font-medium leading-tight">
348
+ {file.filename}
349
+ </p>
350
+ <p className="text-[10px] text-muted-foreground">
351
+ {formatBytes(file.size)}
352
+ </p>
353
+ </div>
354
+ <div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
355
+ <Button
356
+ type="button"
357
+ variant="ghost"
358
+ size="icon"
359
+ className="size-7 rounded-lg"
360
+ title="Abrir em nova aba"
361
+ onClick={() =>
362
+ window.open(getFileOpenUrl(file.file_id), '_blank')
363
+ }
364
+ >
365
+ <ExternalLink className="size-3.5" />
366
+ </Button>
367
+ <Button
368
+ type="button"
369
+ variant="ghost"
370
+ size="icon"
371
+ className="size-7 rounded-lg text-destructive hover:bg-destructive/10 hover:text-destructive"
372
+ title="Remover arquivo"
373
+ disabled={isDeleting}
374
+ onClick={() => void handleDelete(file.id)}
375
+ >
376
+ {isDeleting ? (
377
+ <Loader2 className="size-3.5 animate-spin" />
378
+ ) : (
379
+ <Trash2 className="size-3.5" />
380
+ )}
381
+ </Button>
382
+ </div>
383
+ </div>
384
+ );
385
+ })}
386
+ </div>
387
+ );
388
+ }