@hed-hog/operations 0.0.321 → 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.
- package/dist/controllers/operations-contracts.controller.d.ts +9 -9
- package/dist/controllers/operations-tasks.controller.d.ts +22 -0
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +37 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +0 -1
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +0 -1
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +22 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +77 -22
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/route.yaml +39 -0
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
- package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
- package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
- package/hedhog/frontend/messages/en.json +143 -2
- package/hedhog/frontend/messages/pt.json +143 -2
- package/hedhog/table/operations_task_file.yaml +23 -0
- package/package.json +5 -5
- package/src/controllers/operations-tasks.controller.ts +43 -9
- package/src/dto/create-task.dto.ts +0 -1
- package/src/dto/update-task.dto.ts +0 -1
- package/src/operations.service.ts +144 -22
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
+
}
|
|
@@ -365,6 +365,7 @@ export type OperationsProject = {
|
|
|
365
365
|
contractStatus?: string | null;
|
|
366
366
|
contractCategory?: string | null;
|
|
367
367
|
managerName?: string | null;
|
|
368
|
+
managerAvatarId?: number | null;
|
|
368
369
|
myAssignmentId?: number | null;
|
|
369
370
|
myRoleLabel?: string | null;
|
|
370
371
|
teamSize?: number;
|