@hed-hog/operations 0.0.285 → 0.0.291
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/hedhog/frontend/app/_components/kanban-board.tsx.ejs +604 -61
- package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +398 -3
- package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +29 -0
- package/hedhog/frontend/app/_lib/types/operations.ts.ejs +67 -6
- package/hedhog/frontend/app/allocations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/certifications/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/contracts/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/goals/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +857 -107
- package/hedhog/frontend/app/projects/page.tsx.ejs +1044 -81
- package/hedhog/frontend/app/rewards/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/tasks/page.tsx.ejs +968 -16
- package/hedhog/frontend/messages/en.json +306 -4
- package/hedhog/frontend/messages/pt.json +306 -4
- package/package.json +5 -5
|
@@ -1,34 +1,427 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
EmptyState,
|
|
5
|
+
Page,
|
|
6
|
+
PaginationFooter,
|
|
7
|
+
SearchBar,
|
|
8
|
+
StatsCards,
|
|
9
|
+
type StatCardConfig,
|
|
10
|
+
} from '@/components/entity-list';
|
|
11
|
+
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
14
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
15
|
+
import {
|
|
16
|
+
Form,
|
|
17
|
+
FormControl,
|
|
18
|
+
FormDescription,
|
|
19
|
+
FormField,
|
|
20
|
+
FormItem,
|
|
21
|
+
FormLabel,
|
|
22
|
+
FormMessage,
|
|
23
|
+
} from '@/components/ui/form';
|
|
4
24
|
import { Input } from '@/components/ui/input';
|
|
5
25
|
import { Progress } from '@/components/ui/progress';
|
|
26
|
+
import {
|
|
27
|
+
Select,
|
|
28
|
+
SelectContent,
|
|
29
|
+
SelectItem,
|
|
30
|
+
SelectTrigger,
|
|
31
|
+
SelectValue,
|
|
32
|
+
} from '@/components/ui/select';
|
|
33
|
+
import {
|
|
34
|
+
Sheet,
|
|
35
|
+
SheetContent,
|
|
36
|
+
SheetDescription,
|
|
37
|
+
SheetFooter,
|
|
38
|
+
SheetHeader,
|
|
39
|
+
SheetTitle,
|
|
40
|
+
} from '@/components/ui/sheet';
|
|
41
|
+
import {
|
|
42
|
+
Table,
|
|
43
|
+
TableBody,
|
|
44
|
+
TableCell,
|
|
45
|
+
TableHead,
|
|
46
|
+
TableHeader,
|
|
47
|
+
TableRow,
|
|
48
|
+
} from '@/components/ui/table';
|
|
49
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
50
|
+
import { useApp } from '@hed-hog/next-app-provider';
|
|
51
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
52
|
+
import {
|
|
53
|
+
BarChart3,
|
|
54
|
+
CalendarDays,
|
|
55
|
+
ChevronRight,
|
|
56
|
+
Clock3,
|
|
57
|
+
FolderKanban,
|
|
58
|
+
Grid3X3,
|
|
59
|
+
List,
|
|
60
|
+
Plus,
|
|
61
|
+
SearchX,
|
|
62
|
+
SquarePen,
|
|
63
|
+
Users,
|
|
64
|
+
} from 'lucide-react';
|
|
6
65
|
import { useTranslations } from 'next-intl';
|
|
7
66
|
import Link from 'next/link';
|
|
8
|
-
import { useMemo, useState } from 'react';
|
|
67
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
68
|
+
import { useForm } from 'react-hook-form';
|
|
69
|
+
import { z } from 'zod';
|
|
9
70
|
import { OperationsHeader } from '../_components/operations-header';
|
|
10
|
-
import { SectionCard } from '../_components/section-card';
|
|
11
71
|
import { StatusBadge } from '../_components/status-badge';
|
|
12
72
|
import { useOperationsData } from '../_lib/hooks/use-operations-data';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
73
|
+
import type { Project, ProjectStatus } from '../_lib/types/operations';
|
|
74
|
+
import { formatCurrency, formatDate, formatHours } from '../_lib/utils/format';
|
|
75
|
+
import { getProjectBadgeClasses } from '../_lib/utils/status';
|
|
76
|
+
|
|
77
|
+
type ProjectViewMode = 'table' | 'grid';
|
|
78
|
+
|
|
79
|
+
const PAGE_SIZE_OPTIONS = [8, 12, 24, 48];
|
|
80
|
+
const DEFAULT_PAGE_SIZE = PAGE_SIZE_OPTIONS[0] ?? 8;
|
|
81
|
+
|
|
82
|
+
const PROJECT_STATUS_KEYS: Record<ProjectStatus, string> = {
|
|
83
|
+
planning: 'planning',
|
|
84
|
+
active: 'active',
|
|
85
|
+
'at-risk': 'atRisk',
|
|
86
|
+
paused: 'paused',
|
|
87
|
+
completed: 'completed',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const PROJECT_STATUS_ACCENTS: Record<ProjectStatus, string> = {
|
|
91
|
+
planning: 'from-slate-500 via-slate-400 to-slate-300',
|
|
92
|
+
active: 'from-blue-600 via-cyan-500 to-sky-400',
|
|
93
|
+
'at-risk': 'from-orange-600 via-amber-500 to-yellow-400',
|
|
94
|
+
paused: 'from-zinc-500 via-neutral-400 to-stone-300',
|
|
95
|
+
completed: 'from-emerald-600 via-green-500 to-lime-400',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const progressRanges = [
|
|
99
|
+
{ value: 'all', min: 0, max: 100 },
|
|
100
|
+
{ value: '0-25', min: 0, max: 25 },
|
|
101
|
+
{ value: '26-50', min: 26, max: 50 },
|
|
102
|
+
{ value: '51-75', min: 51, max: 75 },
|
|
103
|
+
{ value: '76-100', min: 76, max: 100 },
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
const projectFormSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
name: z.string().trim().min(2),
|
|
109
|
+
client: z.string().trim().min(2),
|
|
110
|
+
status: z.enum(['planning', 'active', 'at-risk', 'paused', 'completed']),
|
|
111
|
+
progress: z.coerce.number().min(0).max(100),
|
|
112
|
+
hoursLogged: z.coerce.number().min(0),
|
|
113
|
+
budget: z.coerce.number().min(0),
|
|
114
|
+
contractId: z.string().trim().min(1),
|
|
115
|
+
startDate: z.string().min(1),
|
|
116
|
+
endDate: z.string().min(1),
|
|
117
|
+
teamMemberIds: z.array(z.string()).min(1),
|
|
118
|
+
description: z.string().trim().min(10),
|
|
119
|
+
})
|
|
120
|
+
.refine((value) => value.endDate >= value.startDate, {
|
|
121
|
+
message: 'invalidDateRange',
|
|
122
|
+
path: ['endDate'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
|
126
|
+
|
|
127
|
+
function stripHtml(value: string) {
|
|
128
|
+
return value
|
|
129
|
+
.replace(/<[^>]*>/g, ' ')
|
|
130
|
+
.replace(/\s+/g, ' ')
|
|
131
|
+
.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getPreviewText(value: string, maxLength = 120) {
|
|
135
|
+
const sanitized = stripHtml(value);
|
|
136
|
+
|
|
137
|
+
if (sanitized.length <= maxLength) {
|
|
138
|
+
return sanitized;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${sanitized.slice(0, maxLength).trimEnd()}...`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getInitials(value: string) {
|
|
145
|
+
return value
|
|
146
|
+
.split(' ')
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.slice(0, 2)
|
|
149
|
+
.map((part) => part[0]?.toUpperCase())
|
|
150
|
+
.join('');
|
|
151
|
+
}
|
|
18
152
|
|
|
19
153
|
export default function ProjectsPage() {
|
|
20
154
|
const t = useTranslations('operations.ProjectsPage');
|
|
21
|
-
const {
|
|
155
|
+
const { showToastHandler } = useApp();
|
|
156
|
+
const { projects, users, contracts } = useOperationsData();
|
|
157
|
+
|
|
158
|
+
const [projectsList, setProjectsList] = useState<Project[]>(projects);
|
|
159
|
+
const [viewMode, setViewMode] = useState<ProjectViewMode>('table');
|
|
160
|
+
const [searchInput, setSearchInput] = useState('');
|
|
22
161
|
const [search, setSearch] = useState('');
|
|
162
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
163
|
+
const [progressFilter, setProgressFilter] = useState('all');
|
|
164
|
+
const [startDateFrom, setStartDateFrom] = useState('');
|
|
165
|
+
const [endDateTo, setEndDateTo] = useState('');
|
|
166
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
167
|
+
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
|
168
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
169
|
+
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
|
170
|
+
const [nextProjectId, setNextProjectId] = useState(projects.length + 1);
|
|
171
|
+
|
|
172
|
+
const form = useForm<ProjectFormValues>({
|
|
173
|
+
resolver: zodResolver(projectFormSchema),
|
|
174
|
+
defaultValues: {
|
|
175
|
+
name: '',
|
|
176
|
+
client: '',
|
|
177
|
+
status: 'planning',
|
|
178
|
+
progress: 0,
|
|
179
|
+
hoursLogged: 0,
|
|
180
|
+
budget: 0,
|
|
181
|
+
contractId: contracts[0]?.id ?? '',
|
|
182
|
+
startDate: new Date().toISOString().slice(0, 10),
|
|
183
|
+
endDate: new Date().toISOString().slice(0, 10),
|
|
184
|
+
teamMemberIds: [],
|
|
185
|
+
description: '',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
setProjectsList(projects);
|
|
191
|
+
}, [projects]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
setNextProjectId(projects.length + 1);
|
|
195
|
+
}, [projects]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!sheetOpen) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (editingProject) {
|
|
203
|
+
form.reset({
|
|
204
|
+
name: editingProject.name,
|
|
205
|
+
client: editingProject.client,
|
|
206
|
+
status: editingProject.status,
|
|
207
|
+
progress: editingProject.progress,
|
|
208
|
+
hoursLogged: editingProject.hoursLogged,
|
|
209
|
+
budget: editingProject.budget,
|
|
210
|
+
contractId: editingProject.contractId,
|
|
211
|
+
startDate: editingProject.startDate,
|
|
212
|
+
endDate: editingProject.endDate,
|
|
213
|
+
teamMemberIds: editingProject.teamMemberIds,
|
|
214
|
+
description: editingProject.description,
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
form.reset({
|
|
220
|
+
name: '',
|
|
221
|
+
client: '',
|
|
222
|
+
status: 'planning',
|
|
223
|
+
progress: 0,
|
|
224
|
+
hoursLogged: 0,
|
|
225
|
+
budget: 0,
|
|
226
|
+
contractId: contracts[0]?.id ?? '',
|
|
227
|
+
startDate: new Date().toISOString().slice(0, 10),
|
|
228
|
+
endDate: new Date().toISOString().slice(0, 10),
|
|
229
|
+
teamMemberIds: [],
|
|
230
|
+
description: '',
|
|
231
|
+
});
|
|
232
|
+
}, [contracts, editingProject, form, sheetOpen]);
|
|
233
|
+
|
|
234
|
+
const getStatusLabel = (status: ProjectStatus) =>
|
|
235
|
+
t(`statusOptions.${PROJECT_STATUS_KEYS[status]}`);
|
|
23
236
|
|
|
24
|
-
const filteredProjects = useMemo(
|
|
25
|
-
(
|
|
26
|
-
|
|
27
|
-
|
|
237
|
+
const filteredProjects = useMemo(() => {
|
|
238
|
+
const selectedRange = progressRanges.find(
|
|
239
|
+
(range) => range.value === progressFilter
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return projectsList
|
|
243
|
+
.filter((project) => {
|
|
244
|
+
const matchesSearch = `${project.name} ${project.client}`
|
|
28
245
|
.toLowerCase()
|
|
29
|
-
.includes(search.toLowerCase())
|
|
30
|
-
|
|
31
|
-
|
|
246
|
+
.includes(search.toLowerCase());
|
|
247
|
+
const matchesStatus =
|
|
248
|
+
statusFilter === 'all' || project.status === statusFilter;
|
|
249
|
+
const matchesProgress = selectedRange
|
|
250
|
+
? project.progress >= selectedRange.min &&
|
|
251
|
+
project.progress <= selectedRange.max
|
|
252
|
+
: true;
|
|
253
|
+
const matchesStart =
|
|
254
|
+
!startDateFrom || project.startDate >= startDateFrom;
|
|
255
|
+
const matchesEnd = !endDateTo || project.endDate <= endDateTo;
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
matchesSearch &&
|
|
259
|
+
matchesStatus &&
|
|
260
|
+
matchesProgress &&
|
|
261
|
+
matchesStart &&
|
|
262
|
+
matchesEnd
|
|
263
|
+
);
|
|
264
|
+
})
|
|
265
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
266
|
+
}, [
|
|
267
|
+
endDateTo,
|
|
268
|
+
progressFilter,
|
|
269
|
+
projectsList,
|
|
270
|
+
search,
|
|
271
|
+
startDateFrom,
|
|
272
|
+
statusFilter,
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
setCurrentPage(1);
|
|
277
|
+
}, [search, statusFilter, progressFilter, startDateFrom, endDateTo]);
|
|
278
|
+
|
|
279
|
+
const totalPages = Math.max(1, Math.ceil(filteredProjects.length / pageSize));
|
|
280
|
+
const safePage = Math.min(Math.max(currentPage, 1), totalPages);
|
|
281
|
+
|
|
282
|
+
const paginatedProjects = useMemo(() => {
|
|
283
|
+
const start = (safePage - 1) * pageSize;
|
|
284
|
+
return filteredProjects.slice(start, start + pageSize);
|
|
285
|
+
}, [filteredProjects, pageSize, safePage]);
|
|
286
|
+
|
|
287
|
+
const stats = useMemo<StatCardConfig[]>(() => {
|
|
288
|
+
const totalProjects = filteredProjects.length;
|
|
289
|
+
const activeProjects = filteredProjects.filter(
|
|
290
|
+
(project) => project.status === 'active'
|
|
291
|
+
).length;
|
|
292
|
+
const totalHours = filteredProjects.reduce(
|
|
293
|
+
(sum, project) => sum + project.hoursLogged,
|
|
294
|
+
0
|
|
295
|
+
);
|
|
296
|
+
const avgProgress =
|
|
297
|
+
totalProjects > 0
|
|
298
|
+
? Math.round(
|
|
299
|
+
filteredProjects.reduce(
|
|
300
|
+
(sum, project) => sum + project.progress,
|
|
301
|
+
0
|
|
302
|
+
) / totalProjects
|
|
303
|
+
)
|
|
304
|
+
: 0;
|
|
305
|
+
|
|
306
|
+
return [
|
|
307
|
+
{
|
|
308
|
+
title: t('stats.totalProjects'),
|
|
309
|
+
value: totalProjects,
|
|
310
|
+
icon: <FolderKanban className="size-5" />,
|
|
311
|
+
iconBgColor: 'bg-blue-50',
|
|
312
|
+
iconColor: 'text-blue-600',
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
title: t('stats.activeProjects'),
|
|
316
|
+
value: activeProjects,
|
|
317
|
+
icon: <Users className="size-5" />,
|
|
318
|
+
iconBgColor: 'bg-emerald-50',
|
|
319
|
+
iconColor: 'text-emerald-600',
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
title: t('stats.avgProgress'),
|
|
323
|
+
value: `${avgProgress}%`,
|
|
324
|
+
icon: <BarChart3 className="size-5" />,
|
|
325
|
+
iconBgColor: 'bg-amber-50',
|
|
326
|
+
iconColor: 'text-amber-600',
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
title: t('stats.totalHours'),
|
|
330
|
+
value: formatHours(totalHours),
|
|
331
|
+
icon: <List className="size-5" />,
|
|
332
|
+
iconBgColor: 'bg-violet-50',
|
|
333
|
+
iconColor: 'text-violet-600',
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
}, [filteredProjects, t]);
|
|
337
|
+
|
|
338
|
+
const contractNameById = useMemo(
|
|
339
|
+
() => new Map(contracts.map((contract) => [contract.id, contract.name])),
|
|
340
|
+
[contracts]
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const userNameById = useMemo(
|
|
344
|
+
() => new Map(users.map((user) => [user.id, user.name])),
|
|
345
|
+
[users]
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const clearFilters = () => {
|
|
349
|
+
setSearch('');
|
|
350
|
+
setSearchInput('');
|
|
351
|
+
setStatusFilter('all');
|
|
352
|
+
setProgressFilter('all');
|
|
353
|
+
setStartDateFrom('');
|
|
354
|
+
setEndDateTo('');
|
|
355
|
+
setCurrentPage(1);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const openCreateSheet = () => {
|
|
359
|
+
setEditingProject(null);
|
|
360
|
+
setSheetOpen(true);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const openEditSheet = (project: Project) => {
|
|
364
|
+
setEditingProject(project);
|
|
365
|
+
setSheetOpen(true);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const onSubmit = (values: ProjectFormValues) => {
|
|
369
|
+
const payload: Project = {
|
|
370
|
+
id: editingProject ? editingProject.id : `prj-custom-${nextProjectId}`,
|
|
371
|
+
name: values.name.trim(),
|
|
372
|
+
client: values.client.trim(),
|
|
373
|
+
status: values.status,
|
|
374
|
+
progress: Number(values.progress),
|
|
375
|
+
hoursLogged: Number(values.hoursLogged),
|
|
376
|
+
budget: Number(values.budget),
|
|
377
|
+
startDate: values.startDate,
|
|
378
|
+
endDate: values.endDate,
|
|
379
|
+
contractId: values.contractId,
|
|
380
|
+
teamMemberIds: values.teamMemberIds,
|
|
381
|
+
description: values.description,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (editingProject) {
|
|
385
|
+
setProjectsList((current) =>
|
|
386
|
+
current.map((project) =>
|
|
387
|
+
project.id === editingProject.id ? payload : project
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
showToastHandler('success', t('toasts.updated'));
|
|
391
|
+
} else {
|
|
392
|
+
setProjectsList((current) => [payload, ...current]);
|
|
393
|
+
setNextProjectId((current) => current + 1);
|
|
394
|
+
showToastHandler('success', t('toasts.created'));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
setSheetOpen(false);
|
|
398
|
+
setEditingProject(null);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const headerActions = (
|
|
402
|
+
<div className="flex items-center gap-2">
|
|
403
|
+
<ToggleGroup
|
|
404
|
+
type="single"
|
|
405
|
+
value={viewMode}
|
|
406
|
+
onValueChange={(value) => {
|
|
407
|
+
if (value === 'table' || value === 'grid') {
|
|
408
|
+
setViewMode(value);
|
|
409
|
+
}
|
|
410
|
+
}}
|
|
411
|
+
variant="outline"
|
|
412
|
+
>
|
|
413
|
+
<ToggleGroupItem value="table" aria-label={t('view.table')}>
|
|
414
|
+
<List className="h-4 w-4" />
|
|
415
|
+
</ToggleGroupItem>
|
|
416
|
+
<ToggleGroupItem value="grid" aria-label={t('view.grid')}>
|
|
417
|
+
<Grid3X3 className="h-4 w-4" />
|
|
418
|
+
</ToggleGroupItem>
|
|
419
|
+
</ToggleGroup>
|
|
420
|
+
<Button size="sm" onClick={openCreateSheet}>
|
|
421
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
422
|
+
{t('actions.create')}
|
|
423
|
+
</Button>
|
|
424
|
+
</div>
|
|
32
425
|
);
|
|
33
426
|
|
|
34
427
|
return (
|
|
@@ -37,75 +430,645 @@ export default function ProjectsPage() {
|
|
|
37
430
|
title={t('title')}
|
|
38
431
|
description={t('description')}
|
|
39
432
|
current={t('breadcrumb')}
|
|
433
|
+
actions={headerActions}
|
|
40
434
|
/>
|
|
41
435
|
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
436
|
+
<StatsCards stats={stats} />
|
|
437
|
+
|
|
438
|
+
<SearchBar
|
|
439
|
+
searchQuery={searchInput}
|
|
440
|
+
onSearchChange={setSearchInput}
|
|
441
|
+
onSearch={() => {
|
|
442
|
+
setSearch(searchInput);
|
|
443
|
+
setCurrentPage(1);
|
|
444
|
+
}}
|
|
445
|
+
placeholder={t('searchPlaceholder')}
|
|
446
|
+
controls={[
|
|
447
|
+
{
|
|
448
|
+
id: 'status-filter',
|
|
449
|
+
type: 'select',
|
|
450
|
+
value: statusFilter,
|
|
451
|
+
onChange: (value) => {
|
|
452
|
+
setStatusFilter(value);
|
|
453
|
+
},
|
|
454
|
+
placeholder: t('filters.statusAll'),
|
|
455
|
+
options: [
|
|
456
|
+
{ value: 'all', label: t('filters.statusAll') },
|
|
457
|
+
{ value: 'planning', label: t('statusOptions.planning') },
|
|
458
|
+
{ value: 'active', label: t('statusOptions.active') },
|
|
459
|
+
{ value: 'at-risk', label: t('statusOptions.atRisk') },
|
|
460
|
+
{ value: 'paused', label: t('statusOptions.paused') },
|
|
461
|
+
{ value: 'completed', label: t('statusOptions.completed') },
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
id: 'progress-filter',
|
|
466
|
+
type: 'select',
|
|
467
|
+
value: progressFilter,
|
|
468
|
+
onChange: (value) => {
|
|
469
|
+
setProgressFilter(value);
|
|
470
|
+
},
|
|
471
|
+
placeholder: t('filters.progressAll'),
|
|
472
|
+
options: [
|
|
473
|
+
{ value: 'all', label: t('filters.progressAll') },
|
|
474
|
+
{ value: '0-25', label: t('filters.progress0To25') },
|
|
475
|
+
{ value: '26-50', label: t('filters.progress26To50') },
|
|
476
|
+
{ value: '51-75', label: t('filters.progress51To75') },
|
|
477
|
+
{ value: '76-100', label: t('filters.progress76To100') },
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
id: 'start-date-from',
|
|
482
|
+
type: 'date',
|
|
483
|
+
value: startDateFrom,
|
|
484
|
+
onChange: (value) => {
|
|
485
|
+
setStartDateFrom(value);
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
id: 'end-date-to',
|
|
490
|
+
type: 'date',
|
|
491
|
+
value: endDateTo,
|
|
492
|
+
onChange: (value) => {
|
|
493
|
+
setEndDateTo(value);
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
]}
|
|
497
|
+
/>
|
|
498
|
+
|
|
499
|
+
{filteredProjects.length === 0 ? (
|
|
500
|
+
<EmptyState
|
|
501
|
+
icon={<SearchX className="h-12 w-12" />}
|
|
502
|
+
title={t('emptyState.title')}
|
|
503
|
+
description={t('emptyState.description')}
|
|
504
|
+
actionLabel={t('emptyState.action')}
|
|
505
|
+
onAction={clearFilters}
|
|
506
|
+
/>
|
|
507
|
+
) : (
|
|
508
|
+
<div className="space-y-4">
|
|
509
|
+
{viewMode === 'table' ? (
|
|
510
|
+
<Table>
|
|
511
|
+
<TableHeader>
|
|
512
|
+
<TableRow>
|
|
513
|
+
<TableHead>{t('columns.project')}</TableHead>
|
|
514
|
+
<TableHead>{t('columns.client')}</TableHead>
|
|
515
|
+
<TableHead>{t('columns.status')}</TableHead>
|
|
516
|
+
<TableHead>{t('columns.progress')}</TableHead>
|
|
517
|
+
<TableHead>{t('columns.team')}</TableHead>
|
|
518
|
+
<TableHead>{t('columns.hours')}</TableHead>
|
|
519
|
+
<TableHead>{t('columns.dates')}</TableHead>
|
|
520
|
+
<TableHead>{t('columns.budget')}</TableHead>
|
|
521
|
+
<TableHead className="text-right">
|
|
522
|
+
{t('columns.actions')}
|
|
523
|
+
</TableHead>
|
|
524
|
+
</TableRow>
|
|
525
|
+
</TableHeader>
|
|
526
|
+
<TableBody>
|
|
527
|
+
{paginatedProjects.map((project) => {
|
|
528
|
+
const teamSize = project.teamMemberIds.length;
|
|
529
|
+
const previewText = getPreviewText(project.description, 84);
|
|
530
|
+
const contractName = contractNameById.get(project.contractId);
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<TableRow key={project.id} className="hover:bg-muted/30">
|
|
534
|
+
<TableCell>
|
|
535
|
+
<div className="flex items-start gap-3">
|
|
536
|
+
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-sm font-semibold text-primary">
|
|
537
|
+
{getInitials(project.name)}
|
|
538
|
+
</div>
|
|
539
|
+
<div className="space-y-1.5">
|
|
540
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
541
|
+
<Link
|
|
542
|
+
href={`/operations/projects/${project.id}`}
|
|
543
|
+
className="font-medium text-foreground transition hover:text-primary"
|
|
544
|
+
>
|
|
545
|
+
{project.name}
|
|
546
|
+
</Link>
|
|
547
|
+
{contractName ? (
|
|
548
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
549
|
+
{contractName}
|
|
550
|
+
</span>
|
|
551
|
+
) : null}
|
|
552
|
+
</div>
|
|
553
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
554
|
+
{project.client}
|
|
555
|
+
</p>
|
|
556
|
+
<p className="max-w-[320px] text-xs leading-relaxed text-muted-foreground">
|
|
557
|
+
{previewText}
|
|
558
|
+
</p>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</TableCell>
|
|
562
|
+
<TableCell>{project.client}</TableCell>
|
|
563
|
+
<TableCell>
|
|
564
|
+
<StatusBadge
|
|
565
|
+
label={getStatusLabel(project.status)}
|
|
566
|
+
className={getProjectBadgeClasses(project.status)}
|
|
567
|
+
/>
|
|
568
|
+
</TableCell>
|
|
569
|
+
<TableCell className="min-w-[180px]">
|
|
570
|
+
<div className="space-y-2">
|
|
571
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
572
|
+
<span>{project.progress}%</span>
|
|
573
|
+
</div>
|
|
574
|
+
<Progress value={project.progress} />
|
|
575
|
+
</div>
|
|
576
|
+
</TableCell>
|
|
577
|
+
<TableCell>{teamSize}</TableCell>
|
|
578
|
+
<TableCell>{formatHours(project.hoursLogged)}</TableCell>
|
|
579
|
+
<TableCell>
|
|
580
|
+
<div className="text-xs text-muted-foreground">
|
|
581
|
+
<p>{formatDate(project.startDate)}</p>
|
|
582
|
+
<p>{formatDate(project.endDate)}</p>
|
|
583
|
+
</div>
|
|
584
|
+
</TableCell>
|
|
585
|
+
<TableCell>{formatCurrency(project.budget)}</TableCell>
|
|
586
|
+
<TableCell>
|
|
587
|
+
<div className="flex justify-end gap-2">
|
|
588
|
+
<Button
|
|
589
|
+
variant="outline"
|
|
590
|
+
size="sm"
|
|
591
|
+
onClick={() => openEditSheet(project)}
|
|
592
|
+
>
|
|
593
|
+
<SquarePen className="h-4 w-4" />
|
|
594
|
+
</Button>
|
|
595
|
+
</div>
|
|
596
|
+
</TableCell>
|
|
597
|
+
</TableRow>
|
|
598
|
+
);
|
|
599
|
+
})}
|
|
600
|
+
</TableBody>
|
|
601
|
+
</Table>
|
|
602
|
+
) : (
|
|
603
|
+
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
604
|
+
{paginatedProjects.map((project) => {
|
|
605
|
+
const memberNames = project.teamMemberIds
|
|
606
|
+
.map((id) => userNameById.get(id))
|
|
607
|
+
.filter((value): value is string => Boolean(value));
|
|
608
|
+
const previewText = getPreviewText(project.description, 150);
|
|
609
|
+
const contractName = contractNameById.get(project.contractId);
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<Card
|
|
613
|
+
key={project.id}
|
|
614
|
+
className="group overflow-hidden border-border/70 bg-background shadow-none transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5"
|
|
615
|
+
>
|
|
616
|
+
<div
|
|
617
|
+
className={`h-1.5 w-full bg-linear-to-r ${PROJECT_STATUS_ACCENTS[project.status]}`}
|
|
74
618
|
/>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
619
|
+
<CardContent className="space-y-5 p-5">
|
|
620
|
+
<div className="flex items-start justify-between gap-4">
|
|
621
|
+
<div className="flex items-start gap-3">
|
|
622
|
+
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-sm font-semibold text-primary shadow-sm">
|
|
623
|
+
{getInitials(project.name)}
|
|
624
|
+
</div>
|
|
625
|
+
<div className="space-y-1">
|
|
626
|
+
<div className="flex items-center gap-2">
|
|
627
|
+
<Link
|
|
628
|
+
href={`/operations/projects/${project.id}`}
|
|
629
|
+
className="text-base font-semibold leading-tight transition group-hover:text-primary"
|
|
630
|
+
>
|
|
631
|
+
{project.name}
|
|
632
|
+
</Link>
|
|
633
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-primary" />
|
|
634
|
+
</div>
|
|
635
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
636
|
+
{project.client}
|
|
637
|
+
</p>
|
|
638
|
+
{contractName ? (
|
|
639
|
+
<p className="text-xs text-muted-foreground/90">
|
|
640
|
+
{contractName}
|
|
641
|
+
</p>
|
|
642
|
+
) : null}
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
<StatusBadge
|
|
646
|
+
label={getStatusLabel(project.status)}
|
|
647
|
+
className={getProjectBadgeClasses(project.status)}
|
|
648
|
+
/>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
<p className="min-h-10 text-sm leading-relaxed text-muted-foreground">
|
|
652
|
+
{previewText}
|
|
653
|
+
</p>
|
|
654
|
+
|
|
655
|
+
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
656
|
+
<div className="mb-2 flex items-center justify-between text-sm">
|
|
657
|
+
<span className="font-medium text-foreground">
|
|
658
|
+
{t('progress')}
|
|
659
|
+
</span>
|
|
660
|
+
<span className="text-sm font-semibold text-foreground">
|
|
661
|
+
{project.progress}%
|
|
662
|
+
</span>
|
|
663
|
+
</div>
|
|
664
|
+
<Progress value={project.progress} />
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
668
|
+
<div className="rounded-xl border bg-background px-3 py-3">
|
|
669
|
+
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
670
|
+
{t('hoursLogged')}
|
|
671
|
+
</p>
|
|
672
|
+
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
673
|
+
{formatHours(project.hoursLogged)}
|
|
674
|
+
</p>
|
|
675
|
+
</div>
|
|
676
|
+
<div className="rounded-xl border bg-background px-3 py-3">
|
|
677
|
+
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
678
|
+
{t('budget')}
|
|
679
|
+
</p>
|
|
680
|
+
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
681
|
+
{formatCurrency(project.budget)}
|
|
682
|
+
</p>
|
|
683
|
+
</div>
|
|
684
|
+
<div className="rounded-xl border bg-background px-3 py-3">
|
|
685
|
+
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
686
|
+
<Users className="h-3.5 w-3.5" />
|
|
687
|
+
<span>{t('teamMembers')}</span>
|
|
688
|
+
</div>
|
|
689
|
+
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
690
|
+
{memberNames.length}
|
|
691
|
+
</p>
|
|
692
|
+
</div>
|
|
693
|
+
<div className="rounded-xl border bg-background px-3 py-3">
|
|
694
|
+
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
695
|
+
<CalendarDays className="h-3.5 w-3.5" />
|
|
696
|
+
<span>{t('dateRange')}</span>
|
|
697
|
+
</div>
|
|
698
|
+
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
699
|
+
{formatDate(project.endDate)}
|
|
700
|
+
</p>
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
<div className="flex items-center justify-between gap-3 border-t pt-4">
|
|
705
|
+
<div className="min-w-0 flex-1">
|
|
706
|
+
<div className="flex items-center -space-x-2">
|
|
707
|
+
{memberNames.slice(0, 3).map((name) => (
|
|
708
|
+
<div
|
|
709
|
+
key={name}
|
|
710
|
+
className="flex size-8 items-center justify-center rounded-full border-2 border-background bg-primary/10 text-[11px] font-semibold text-primary"
|
|
711
|
+
title={name}
|
|
712
|
+
>
|
|
713
|
+
{getInitials(name)}
|
|
714
|
+
</div>
|
|
715
|
+
))}
|
|
716
|
+
{memberNames.length > 3 ? (
|
|
717
|
+
<div className="flex size-8 items-center justify-center rounded-full border-2 border-background bg-muted text-[11px] font-semibold text-muted-foreground">
|
|
718
|
+
+{memberNames.length - 3}
|
|
719
|
+
</div>
|
|
720
|
+
) : null}
|
|
721
|
+
</div>
|
|
722
|
+
<p className="mt-2 truncate text-xs text-muted-foreground">
|
|
723
|
+
{memberNames.join(', ') || '-'}
|
|
724
|
+
</p>
|
|
725
|
+
</div>
|
|
726
|
+
<div className="flex items-center gap-2">
|
|
727
|
+
<div className="hidden items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground md:flex">
|
|
728
|
+
<Clock3 className="h-3 w-3" />
|
|
729
|
+
{formatDate(project.startDate)}
|
|
730
|
+
</div>
|
|
731
|
+
<Button
|
|
732
|
+
variant="outline"
|
|
733
|
+
size="sm"
|
|
734
|
+
onClick={() => openEditSheet(project)}
|
|
735
|
+
>
|
|
736
|
+
<SquarePen className="mr-2 h-4 w-4" />
|
|
737
|
+
{t('actions.edit')}
|
|
738
|
+
</Button>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
</CardContent>
|
|
742
|
+
</Card>
|
|
743
|
+
);
|
|
744
|
+
})}
|
|
745
|
+
</div>
|
|
746
|
+
)}
|
|
747
|
+
|
|
748
|
+
<PaginationFooter
|
|
749
|
+
currentPage={safePage}
|
|
750
|
+
pageSize={pageSize}
|
|
751
|
+
totalItems={filteredProjects.length}
|
|
752
|
+
onPageChange={setCurrentPage}
|
|
753
|
+
onPageSizeChange={(nextSize) => {
|
|
754
|
+
setPageSize(nextSize);
|
|
755
|
+
setCurrentPage(1);
|
|
756
|
+
}}
|
|
757
|
+
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
758
|
+
/>
|
|
107
759
|
</div>
|
|
108
|
-
|
|
760
|
+
)}
|
|
761
|
+
|
|
762
|
+
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
763
|
+
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
|
764
|
+
<SheetHeader>
|
|
765
|
+
<SheetTitle>
|
|
766
|
+
{editingProject ? t('sheet.editTitle') : t('sheet.createTitle')}
|
|
767
|
+
</SheetTitle>
|
|
768
|
+
<SheetDescription>
|
|
769
|
+
{editingProject
|
|
770
|
+
? t('sheet.editDescription')
|
|
771
|
+
: t('sheet.createDescription')}
|
|
772
|
+
</SheetDescription>
|
|
773
|
+
</SheetHeader>
|
|
774
|
+
|
|
775
|
+
<Form {...form}>
|
|
776
|
+
<form
|
|
777
|
+
className="space-y-4 px-4"
|
|
778
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
779
|
+
>
|
|
780
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
781
|
+
<FormField
|
|
782
|
+
control={form.control}
|
|
783
|
+
name="name"
|
|
784
|
+
render={({ field }) => (
|
|
785
|
+
<FormItem className="md:col-span-2">
|
|
786
|
+
<FormLabel>{t('form.nameLabel')}</FormLabel>
|
|
787
|
+
<FormControl>
|
|
788
|
+
<Input
|
|
789
|
+
{...field}
|
|
790
|
+
placeholder={t('form.namePlaceholder')}
|
|
791
|
+
/>
|
|
792
|
+
</FormControl>
|
|
793
|
+
<FormMessage />
|
|
794
|
+
</FormItem>
|
|
795
|
+
)}
|
|
796
|
+
/>
|
|
797
|
+
|
|
798
|
+
<FormField
|
|
799
|
+
control={form.control}
|
|
800
|
+
name="client"
|
|
801
|
+
render={({ field }) => (
|
|
802
|
+
<FormItem>
|
|
803
|
+
<FormLabel>{t('form.clientLabel')}</FormLabel>
|
|
804
|
+
<FormControl>
|
|
805
|
+
<Input
|
|
806
|
+
{...field}
|
|
807
|
+
placeholder={t('form.clientPlaceholder')}
|
|
808
|
+
/>
|
|
809
|
+
</FormControl>
|
|
810
|
+
<FormMessage />
|
|
811
|
+
</FormItem>
|
|
812
|
+
)}
|
|
813
|
+
/>
|
|
814
|
+
|
|
815
|
+
<FormField
|
|
816
|
+
control={form.control}
|
|
817
|
+
name="contractId"
|
|
818
|
+
render={({ field }) => (
|
|
819
|
+
<FormItem>
|
|
820
|
+
<FormLabel>{t('form.contractLabel')}</FormLabel>
|
|
821
|
+
<Select
|
|
822
|
+
value={field.value}
|
|
823
|
+
onValueChange={(value) => field.onChange(value)}
|
|
824
|
+
>
|
|
825
|
+
<FormControl>
|
|
826
|
+
<SelectTrigger className="w-full">
|
|
827
|
+
<SelectValue
|
|
828
|
+
placeholder={t('form.contractPlaceholder')}
|
|
829
|
+
/>
|
|
830
|
+
</SelectTrigger>
|
|
831
|
+
</FormControl>
|
|
832
|
+
<SelectContent>
|
|
833
|
+
{contracts.map((contract) => (
|
|
834
|
+
<SelectItem key={contract.id} value={contract.id}>
|
|
835
|
+
{contract.name}
|
|
836
|
+
</SelectItem>
|
|
837
|
+
))}
|
|
838
|
+
</SelectContent>
|
|
839
|
+
</Select>
|
|
840
|
+
<FormMessage />
|
|
841
|
+
</FormItem>
|
|
842
|
+
)}
|
|
843
|
+
/>
|
|
844
|
+
|
|
845
|
+
<FormField
|
|
846
|
+
control={form.control}
|
|
847
|
+
name="status"
|
|
848
|
+
render={({ field }) => (
|
|
849
|
+
<FormItem>
|
|
850
|
+
<FormLabel>{t('form.statusLabel')}</FormLabel>
|
|
851
|
+
<Select
|
|
852
|
+
value={field.value}
|
|
853
|
+
onValueChange={(value) => field.onChange(value)}
|
|
854
|
+
>
|
|
855
|
+
<FormControl>
|
|
856
|
+
<SelectTrigger className="w-full">
|
|
857
|
+
<SelectValue
|
|
858
|
+
placeholder={t('form.statusPlaceholder')}
|
|
859
|
+
/>
|
|
860
|
+
</SelectTrigger>
|
|
861
|
+
</FormControl>
|
|
862
|
+
<SelectContent>
|
|
863
|
+
<SelectItem value="planning">
|
|
864
|
+
{t('statusOptions.planning')}
|
|
865
|
+
</SelectItem>
|
|
866
|
+
<SelectItem value="active">
|
|
867
|
+
{t('statusOptions.active')}
|
|
868
|
+
</SelectItem>
|
|
869
|
+
<SelectItem value="at-risk">
|
|
870
|
+
{t('statusOptions.atRisk')}
|
|
871
|
+
</SelectItem>
|
|
872
|
+
<SelectItem value="paused">
|
|
873
|
+
{t('statusOptions.paused')}
|
|
874
|
+
</SelectItem>
|
|
875
|
+
<SelectItem value="completed">
|
|
876
|
+
{t('statusOptions.completed')}
|
|
877
|
+
</SelectItem>
|
|
878
|
+
</SelectContent>
|
|
879
|
+
</Select>
|
|
880
|
+
<FormMessage />
|
|
881
|
+
</FormItem>
|
|
882
|
+
)}
|
|
883
|
+
/>
|
|
884
|
+
|
|
885
|
+
<FormField
|
|
886
|
+
control={form.control}
|
|
887
|
+
name="progress"
|
|
888
|
+
render={({ field }) => (
|
|
889
|
+
<FormItem>
|
|
890
|
+
<FormLabel>{t('form.progressLabel')}</FormLabel>
|
|
891
|
+
<FormControl>
|
|
892
|
+
<Input
|
|
893
|
+
{...field}
|
|
894
|
+
type="number"
|
|
895
|
+
min={0}
|
|
896
|
+
max={100}
|
|
897
|
+
placeholder={t('form.progressPlaceholder')}
|
|
898
|
+
value={field.value}
|
|
899
|
+
onChange={(event) =>
|
|
900
|
+
field.onChange(Number(event.target.value))
|
|
901
|
+
}
|
|
902
|
+
/>
|
|
903
|
+
</FormControl>
|
|
904
|
+
<FormMessage />
|
|
905
|
+
</FormItem>
|
|
906
|
+
)}
|
|
907
|
+
/>
|
|
908
|
+
|
|
909
|
+
<FormField
|
|
910
|
+
control={form.control}
|
|
911
|
+
name="hoursLogged"
|
|
912
|
+
render={({ field }) => (
|
|
913
|
+
<FormItem>
|
|
914
|
+
<FormLabel>{t('form.hoursLabel')}</FormLabel>
|
|
915
|
+
<FormControl>
|
|
916
|
+
<Input
|
|
917
|
+
{...field}
|
|
918
|
+
type="number"
|
|
919
|
+
min={0}
|
|
920
|
+
step={1}
|
|
921
|
+
placeholder={t('form.hoursPlaceholder')}
|
|
922
|
+
value={field.value}
|
|
923
|
+
onChange={(event) =>
|
|
924
|
+
field.onChange(Number(event.target.value))
|
|
925
|
+
}
|
|
926
|
+
/>
|
|
927
|
+
</FormControl>
|
|
928
|
+
<FormMessage />
|
|
929
|
+
</FormItem>
|
|
930
|
+
)}
|
|
931
|
+
/>
|
|
932
|
+
|
|
933
|
+
<FormField
|
|
934
|
+
control={form.control}
|
|
935
|
+
name="budget"
|
|
936
|
+
render={({ field }) => (
|
|
937
|
+
<FormItem>
|
|
938
|
+
<FormLabel>{t('form.budgetLabel')}</FormLabel>
|
|
939
|
+
<FormControl>
|
|
940
|
+
<Input
|
|
941
|
+
{...field}
|
|
942
|
+
type="number"
|
|
943
|
+
min={0}
|
|
944
|
+
step={500}
|
|
945
|
+
placeholder={t('form.budgetPlaceholder')}
|
|
946
|
+
value={field.value}
|
|
947
|
+
onChange={(event) =>
|
|
948
|
+
field.onChange(Number(event.target.value))
|
|
949
|
+
}
|
|
950
|
+
/>
|
|
951
|
+
</FormControl>
|
|
952
|
+
<FormMessage />
|
|
953
|
+
</FormItem>
|
|
954
|
+
)}
|
|
955
|
+
/>
|
|
956
|
+
|
|
957
|
+
<FormField
|
|
958
|
+
control={form.control}
|
|
959
|
+
name="startDate"
|
|
960
|
+
render={({ field }) => (
|
|
961
|
+
<FormItem>
|
|
962
|
+
<FormLabel>{t('form.startDateLabel')}</FormLabel>
|
|
963
|
+
<FormControl>
|
|
964
|
+
<Input
|
|
965
|
+
{...field}
|
|
966
|
+
type="date"
|
|
967
|
+
placeholder={t('form.startDatePlaceholder')}
|
|
968
|
+
/>
|
|
969
|
+
</FormControl>
|
|
970
|
+
<FormMessage />
|
|
971
|
+
</FormItem>
|
|
972
|
+
)}
|
|
973
|
+
/>
|
|
974
|
+
|
|
975
|
+
<FormField
|
|
976
|
+
control={form.control}
|
|
977
|
+
name="endDate"
|
|
978
|
+
render={({ field }) => (
|
|
979
|
+
<FormItem>
|
|
980
|
+
<FormLabel>{t('form.endDateLabel')}</FormLabel>
|
|
981
|
+
<FormControl>
|
|
982
|
+
<Input
|
|
983
|
+
{...field}
|
|
984
|
+
type="date"
|
|
985
|
+
placeholder={t('form.endDatePlaceholder')}
|
|
986
|
+
/>
|
|
987
|
+
</FormControl>
|
|
988
|
+
<FormMessage>
|
|
989
|
+
{form.formState.errors.endDate?.message ===
|
|
990
|
+
'invalidDateRange'
|
|
991
|
+
? t('form.invalidDateRange')
|
|
992
|
+
: undefined}
|
|
993
|
+
</FormMessage>
|
|
994
|
+
</FormItem>
|
|
995
|
+
)}
|
|
996
|
+
/>
|
|
997
|
+
|
|
998
|
+
<FormField
|
|
999
|
+
control={form.control}
|
|
1000
|
+
name="teamMemberIds"
|
|
1001
|
+
render={({ field }) => (
|
|
1002
|
+
<FormItem className="md:col-span-2">
|
|
1003
|
+
<FormLabel>{t('form.teamMembersLabel')}</FormLabel>
|
|
1004
|
+
<div className="grid gap-2 rounded-md border p-3 sm:grid-cols-2">
|
|
1005
|
+
{users.map((user) => {
|
|
1006
|
+
const checked = field.value.includes(user.id);
|
|
1007
|
+
|
|
1008
|
+
return (
|
|
1009
|
+
<label
|
|
1010
|
+
key={user.id}
|
|
1011
|
+
className="flex cursor-pointer items-center gap-2 rounded-sm p-1 hover:bg-muted/40"
|
|
1012
|
+
>
|
|
1013
|
+
<Checkbox
|
|
1014
|
+
checked={checked}
|
|
1015
|
+
onCheckedChange={(nextState) => {
|
|
1016
|
+
if (nextState === true) {
|
|
1017
|
+
field.onChange([...field.value, user.id]);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
field.onChange(
|
|
1022
|
+
field.value.filter((id) => id !== user.id)
|
|
1023
|
+
);
|
|
1024
|
+
}}
|
|
1025
|
+
/>
|
|
1026
|
+
<span className="text-sm">{user.name}</span>
|
|
1027
|
+
</label>
|
|
1028
|
+
);
|
|
1029
|
+
})}
|
|
1030
|
+
</div>
|
|
1031
|
+
<FormDescription>
|
|
1032
|
+
{t('form.teamMembersDescription')}
|
|
1033
|
+
</FormDescription>
|
|
1034
|
+
<FormMessage />
|
|
1035
|
+
</FormItem>
|
|
1036
|
+
)}
|
|
1037
|
+
/>
|
|
1038
|
+
|
|
1039
|
+
<FormField
|
|
1040
|
+
control={form.control}
|
|
1041
|
+
name="description"
|
|
1042
|
+
render={({ field }) => (
|
|
1043
|
+
<FormItem className="md:col-span-2">
|
|
1044
|
+
<FormLabel>{t('form.descriptionLabel')}</FormLabel>
|
|
1045
|
+
<FormControl>
|
|
1046
|
+
<div className="w-full max-w-full overflow-hidden rounded-md border">
|
|
1047
|
+
<RichTextEditor
|
|
1048
|
+
value={field.value}
|
|
1049
|
+
onChange={field.onChange}
|
|
1050
|
+
className="w-full"
|
|
1051
|
+
/>
|
|
1052
|
+
</div>
|
|
1053
|
+
</FormControl>
|
|
1054
|
+
<FormDescription>
|
|
1055
|
+
{t('form.descriptionHint')}
|
|
1056
|
+
</FormDescription>
|
|
1057
|
+
<FormMessage />
|
|
1058
|
+
</FormItem>
|
|
1059
|
+
)}
|
|
1060
|
+
/>
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<SheetFooter className="px-0 pb-0">
|
|
1064
|
+
<Button type="submit" className="w-full">
|
|
1065
|
+
{editingProject ? t('actions.save') : t('actions.create')}
|
|
1066
|
+
</Button>
|
|
1067
|
+
</SheetFooter>
|
|
1068
|
+
</form>
|
|
1069
|
+
</Form>
|
|
1070
|
+
</SheetContent>
|
|
1071
|
+
</Sheet>
|
|
109
1072
|
</Page>
|
|
110
1073
|
);
|
|
111
1074
|
}
|