@hed-hog/operations 0.0.294 → 0.0.296

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 (126) hide show
  1. package/dist/operations.controller.d.ts +415 -0
  2. package/dist/operations.controller.d.ts.map +1 -0
  3. package/dist/operations.controller.js +333 -0
  4. package/dist/operations.controller.js.map +1 -0
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +4 -3
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.service.d.ts +589 -153
  9. package/dist/operations.service.d.ts.map +1 -1
  10. package/dist/operations.service.js +2229 -100
  11. package/dist/operations.service.js.map +1 -1
  12. package/hedhog/data/menu.yaml +198 -251
  13. package/hedhog/data/role.yaml +23 -14
  14. package/hedhog/data/route.yaml +317 -143
  15. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -0
  16. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -0
  17. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -0
  18. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -0
  19. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -0
  20. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -0
  21. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -0
  22. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -0
  24. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -25
  25. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -0
  26. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -147
  27. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -0
  28. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -0
  29. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -0
  30. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -0
  31. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -108
  33. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -0
  34. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -181
  35. package/hedhog/frontend/app/page.tsx.ejs +319 -177
  36. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -0
  37. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -936
  38. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -0
  39. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -1074
  40. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -0
  41. package/hedhog/frontend/app/team/page.tsx.ejs +339 -0
  42. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -0
  43. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -126
  44. package/hedhog/frontend/messages/en.json +648 -454
  45. package/hedhog/frontend/messages/pt.json +647 -454
  46. package/hedhog/table/operations_approval.yaml +49 -0
  47. package/hedhog/table/operations_approval_history.yaml +29 -0
  48. package/hedhog/table/{operations_employee.yaml → operations_collaborator.yaml} +67 -64
  49. package/hedhog/table/operations_collaborator_schedule_day.yaml +34 -0
  50. package/hedhog/table/operations_contract.yaml +100 -48
  51. package/hedhog/table/operations_contract_document.yaml +39 -0
  52. package/hedhog/table/operations_contract_financial_term.yaml +40 -0
  53. package/hedhog/table/operations_contract_history.yaml +27 -0
  54. package/hedhog/table/operations_contract_party.yaml +46 -0
  55. package/hedhog/table/operations_contract_revision.yaml +38 -0
  56. package/hedhog/table/operations_contract_signature.yaml +38 -0
  57. package/hedhog/table/operations_project.yaml +54 -50
  58. package/hedhog/table/{operations_allocation.yaml → operations_project_assignment.yaml} +55 -52
  59. package/hedhog/table/operations_schedule_adjustment_day.yaml +34 -0
  60. package/hedhog/table/operations_schedule_adjustment_request.yaml +53 -0
  61. package/hedhog/table/operations_time_off_request.yaml +57 -0
  62. package/hedhog/table/operations_timesheet.yaml +41 -36
  63. package/hedhog/table/operations_timesheet_entry.yaml +40 -50
  64. package/package.json +7 -6
  65. package/src/operations.controller.ts +182 -0
  66. package/src/operations.module.ts +22 -21
  67. package/src/operations.service.ts +3595 -137
  68. package/hedhog/data/operations_career_level.yaml +0 -102
  69. package/hedhog/data/operations_career_track.yaml +0 -8
  70. package/hedhog/data/operations_certification.yaml +0 -38
  71. package/hedhog/data/operations_evaluation_cycle.yaml +0 -18
  72. package/hedhog/data/operations_performance_criterion.yaml +0 -48
  73. package/hedhog/frontend/app/_components/allocation-calendar.tsx.ejs +0 -56
  74. package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +0 -626
  75. package/hedhog/frontend/app/_components/timesheet-entry-dialog.tsx.ejs +0 -142
  76. package/hedhog/frontend/app/_lib/hooks/use-operations-data.ts.ejs +0 -41
  77. package/hedhog/frontend/app/_lib/hooks/use-operations-growth-data.ts.ejs +0 -63
  78. package/hedhog/frontend/app/_lib/mocks/allocations.mock.ts.ejs +0 -74
  79. package/hedhog/frontend/app/_lib/mocks/contracts.mock.ts.ejs +0 -74
  80. package/hedhog/frontend/app/_lib/mocks/operations-growth.mock.ts.ejs +0 -824
  81. package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +0 -455
  82. package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +0 -117
  83. package/hedhog/frontend/app/_lib/mocks/timesheets.mock.ts.ejs +0 -84
  84. package/hedhog/frontend/app/_lib/mocks/users.mock.ts.ejs +0 -67
  85. package/hedhog/frontend/app/_lib/services/contracts.service.ts.ejs +0 -10
  86. package/hedhog/frontend/app/_lib/services/operations-growth.service.ts.ejs +0 -31
  87. package/hedhog/frontend/app/_lib/services/projects.service.ts.ejs +0 -10
  88. package/hedhog/frontend/app/_lib/services/tasks.service.ts.ejs +0 -10
  89. package/hedhog/frontend/app/_lib/services/timesheets.service.ts.ejs +0 -10
  90. package/hedhog/frontend/app/_lib/types/operations-growth.ts.ejs +0 -209
  91. package/hedhog/frontend/app/_lib/types/operations.ts.ejs +0 -156
  92. package/hedhog/frontend/app/_lib/utils/growth.ts.ejs +0 -62
  93. package/hedhog/frontend/app/_lib/utils/metrics.ts.ejs +0 -103
  94. package/hedhog/frontend/app/_lib/utils/status.ts.ejs +0 -80
  95. package/hedhog/frontend/app/allocations/page.tsx.ejs +0 -155
  96. package/hedhog/frontend/app/career/page.tsx.ejs +0 -143
  97. package/hedhog/frontend/app/certifications/page.tsx.ejs +0 -202
  98. package/hedhog/frontend/app/evaluations/page.tsx.ejs +0 -278
  99. package/hedhog/frontend/app/goals/page.tsx.ejs +0 -171
  100. package/hedhog/frontend/app/growth/page.tsx.ejs +0 -288
  101. package/hedhog/frontend/app/manager/page.tsx.ejs +0 -175
  102. package/hedhog/frontend/app/rewards/page.tsx.ejs +0 -196
  103. package/hedhog/frontend/app/tasks/page.tsx.ejs +0 -999
  104. package/hedhog/table/operations_calibration_item.yaml +0 -61
  105. package/hedhog/table/operations_calibration_session.yaml +0 -25
  106. package/hedhog/table/operations_career_level.yaml +0 -75
  107. package/hedhog/table/operations_career_track.yaml +0 -21
  108. package/hedhog/table/operations_certification.yaml +0 -48
  109. package/hedhog/table/operations_employee_certification.yaml +0 -43
  110. package/hedhog/table/operations_employee_connect.yaml +0 -61
  111. package/hedhog/table/operations_employee_evaluation.yaml +0 -113
  112. package/hedhog/table/operations_employee_evaluation_item.yaml +0 -39
  113. package/hedhog/table/operations_employee_profile.yaml +0 -80
  114. package/hedhog/table/operations_employee_skill_matrix.yaml +0 -30
  115. package/hedhog/table/operations_evaluation_cycle.yaml +0 -31
  116. package/hedhog/table/operations_goal.yaml +0 -67
  117. package/hedhog/table/operations_goal_progress.yaml +0 -31
  118. package/hedhog/table/operations_performance_criterion.yaml +0 -29
  119. package/hedhog/table/operations_promotion_readiness.yaml +0 -49
  120. package/hedhog/table/operations_promotion_recommendation.yaml +0 -63
  121. package/hedhog/table/operations_public_recognition.yaml +0 -46
  122. package/hedhog/table/operations_reward.yaml +0 -100
  123. package/hedhog/table/operations_score_event.yaml +0 -81
  124. package/hedhog/table/operations_task.yaml +0 -60
  125. package/src/operations-data.controller.ts +0 -54
  126. package/src/operations-growth.controller.ts +0 -44
@@ -1,1074 +1,236 @@
1
- 'use client';
2
-
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';
24
- import { Input } from '@/components/ui/input';
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';
65
- import { useTranslations } from 'next-intl';
66
- import Link from 'next/link';
67
- import { useEffect, useMemo, useState } from 'react';
68
- import { useForm } from 'react-hook-form';
69
- import { z } from 'zod';
70
- import { OperationsHeader } from '../_components/operations-header';
71
- import { StatusBadge } from '../_components/status-badge';
72
- import { useOperationsData } from '../_lib/hooks/use-operations-data';
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
- }
152
-
153
- export default function ProjectsPage() {
154
- const t = useTranslations('operations.ProjectsPage');
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('');
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]}`);
236
-
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}`
245
- .toLowerCase()
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>
425
- );
426
-
427
- return (
428
- <Page>
429
- <OperationsHeader
430
- title={t('title')}
431
- description={t('description')}
432
- current={t('breadcrumb')}
433
- actions={headerActions}
434
- />
435
-
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]}`}
618
- />
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
- />
759
- </div>
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>
1072
- </Page>
1073
- );
1074
- }
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from '@/components/ui/table';
13
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
14
+ import { Eye, FileText, FolderKanban, Pencil } from 'lucide-react';
15
+ import Link from 'next/link';
16
+ import { useMemo, useState } from 'react';
17
+ import { useTranslations } from 'next-intl';
18
+ import { OperationsHeader } from '../_components/operations-header';
19
+ import { StatusBadge } from '../_components/status-badge';
20
+ import { fetchOperations, mutateOperations } from '../_lib/api';
21
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
22
+ import type { OperationsProject } from '../_lib/types';
23
+ import {
24
+ formatDate,
25
+ formatEnumLabel,
26
+ getStatusBadgeClass,
27
+ } from '../_lib/utils/format';
28
+
29
+ export default function OperationsProjectsPage() {
30
+ const t = useTranslations('operations.ProjectsPage');
31
+ const commonT = useTranslations('operations.Common');
32
+ const { request, showToastHandler, currentLocaleCode } = useApp();
33
+ const access = useOperationsAccess();
34
+ const [search, setSearch] = useState('');
35
+ const [statusFilter, setStatusFilter] = useState('all');
36
+
37
+ const { data: projects = [], refetch } = useQuery<OperationsProject[]>({
38
+ queryKey: ['operations-projects-list', currentLocaleCode],
39
+ queryFn: () => fetchOperations<OperationsProject[]>(request, '/operations/projects'),
40
+ });
41
+
42
+ const filteredRows = useMemo(
43
+ () =>
44
+ projects.filter((item) => {
45
+ const matchesSearch = !search.trim()
46
+ ? true
47
+ : [
48
+ item.name,
49
+ item.code,
50
+ item.clientName,
51
+ item.managerName,
52
+ item.contractName,
53
+ ]
54
+ .filter(Boolean)
55
+ .some((value) =>
56
+ String(value).toLowerCase().includes(search.trim().toLowerCase())
57
+ );
58
+
59
+ const matchesStatus =
60
+ statusFilter === 'all' ? true : item.status === statusFilter;
61
+
62
+ return matchesSearch && matchesStatus;
63
+ }),
64
+ [projects, search, statusFilter]
65
+ );
66
+
67
+ const toggleArchived = async (project: OperationsProject) => {
68
+ const nextStatus = project.status === 'archived' ? 'active' : 'archived';
69
+
70
+ try {
71
+ await mutateOperations(request, `/operations/projects/${project.id}`, 'PATCH', {
72
+ status: nextStatus,
73
+ });
74
+ showToastHandler?.('success', t('messages.statusSuccess'));
75
+ await refetch();
76
+ } catch {
77
+ showToastHandler?.('error', t('messages.statusError'));
78
+ }
79
+ };
80
+
81
+ return (
82
+ <Page>
83
+ <OperationsHeader
84
+ title={t('title')}
85
+ description={t('description')}
86
+ current={t('breadcrumb')}
87
+ actions={
88
+ access.isDirector ? (
89
+ <Button size="sm" asChild>
90
+ <Link href="/operations/projects/new">
91
+ {commonT('actions.create')}
92
+ </Link>
93
+ </Button>
94
+ ) : undefined
95
+ }
96
+ />
97
+
98
+ <SearchBar
99
+ searchQuery={search}
100
+ onSearchChange={setSearch}
101
+ onSearch={() => undefined}
102
+ placeholder={t('searchPlaceholder')}
103
+ controls={[
104
+ {
105
+ id: 'status',
106
+ type: 'select',
107
+ value: statusFilter,
108
+ onChange: setStatusFilter,
109
+ placeholder: commonT('labels.status'),
110
+ options: [
111
+ { value: 'all', label: commonT('filters.allStatuses') },
112
+ { value: 'planning', label: formatEnumLabel('planning') },
113
+ { value: 'active', label: formatEnumLabel('active') },
114
+ { value: 'at_risk', label: formatEnumLabel('at_risk') },
115
+ { value: 'paused', label: formatEnumLabel('paused') },
116
+ { value: 'completed', label: formatEnumLabel('completed') },
117
+ { value: 'archived', label: formatEnumLabel('archived') },
118
+ ],
119
+ },
120
+ ]}
121
+ />
122
+
123
+ {filteredRows.length > 0 ? (
124
+ <div className="overflow-x-auto rounded-md border">
125
+ <Table>
126
+ <TableHeader>
127
+ <TableRow>
128
+ <TableHead>{commonT('labels.project')}</TableHead>
129
+ <TableHead>{commonT('labels.client')}</TableHead>
130
+ <TableHead>{commonT('labels.status')}</TableHead>
131
+ <TableHead>{commonT('labels.manager')}</TableHead>
132
+ <TableHead>{commonT('labels.teamSize')}</TableHead>
133
+ <TableHead>{commonT('labels.startDate')}</TableHead>
134
+ <TableHead>{commonT('labels.endDate')}</TableHead>
135
+ <TableHead>{commonT('labels.contractStatus')}</TableHead>
136
+ <TableHead className="w-[260px] text-right">
137
+ {commonT('labels.actions')}
138
+ </TableHead>
139
+ </TableRow>
140
+ </TableHeader>
141
+ <TableBody>
142
+ {filteredRows.map((project) => (
143
+ <TableRow key={project.id}>
144
+ <TableCell>
145
+ <div className="font-medium">{project.name}</div>
146
+ <div className="text-xs text-muted-foreground">
147
+ {[project.code, project.myRoleLabel].filter(Boolean).join(' • ')}
148
+ </div>
149
+ </TableCell>
150
+ <TableCell>{project.clientName || commonT('labels.notAvailable')}</TableCell>
151
+ <TableCell>
152
+ <StatusBadge
153
+ label={formatEnumLabel(project.status)}
154
+ className={getStatusBadgeClass(project.status)}
155
+ />
156
+ </TableCell>
157
+ <TableCell>{project.managerName || commonT('labels.notAssigned')}</TableCell>
158
+ <TableCell>{project.teamSize ?? 0}</TableCell>
159
+ <TableCell>{formatDate(project.startDate)}</TableCell>
160
+ <TableCell>{formatDate(project.endDate)}</TableCell>
161
+ <TableCell>
162
+ {project.contractStatus ? (
163
+ <StatusBadge
164
+ label={formatEnumLabel(project.contractStatus)}
165
+ className={getStatusBadgeClass(project.contractStatus)}
166
+ />
167
+ ) : (
168
+ commonT('labels.notAssigned')
169
+ )}
170
+ </TableCell>
171
+ <TableCell>
172
+ <div className="flex justify-end gap-2">
173
+ <Button variant="outline" size="icon" asChild>
174
+ <Link href={`/operations/projects/${project.id}`}>
175
+ <Eye className="size-4" />
176
+ </Link>
177
+ </Button>
178
+ {access.isDirector ? (
179
+ <Button variant="outline" size="icon" asChild>
180
+ <Link href={`/operations/projects/${project.id}/edit`}>
181
+ <Pencil className="size-4" />
182
+ </Link>
183
+ </Button>
184
+ ) : null}
185
+ <Button
186
+ variant="outline"
187
+ size="icon"
188
+ asChild={Boolean(project.contractId)}
189
+ disabled={!project.contractId}
190
+ >
191
+ {project.contractId ? (
192
+ <Link href={`/operations/contracts?edit=${project.contractId}`}>
193
+ <FileText className="size-4" />
194
+ </Link>
195
+ ) : (
196
+ <span>
197
+ <FileText className="size-4" />
198
+ </span>
199
+ )}
200
+ </Button>
201
+ {access.isDirector ? (
202
+ <Button
203
+ variant="outline"
204
+ size="sm"
205
+ onClick={() => void toggleArchived(project)}
206
+ >
207
+ {project.status === 'archived'
208
+ ? commonT('actions.activate')
209
+ : t('actions.archive')}
210
+ </Button>
211
+ ) : null}
212
+ </div>
213
+ </TableCell>
214
+ </TableRow>
215
+ ))}
216
+ </TableBody>
217
+ </Table>
218
+ </div>
219
+ ) : (
220
+ <EmptyState
221
+ icon={<FolderKanban className="size-12" />}
222
+ title={commonT('states.emptyTitle')}
223
+ description={t('emptyDescription')}
224
+ actionLabel={access.isDirector ? commonT('actions.create') : commonT('actions.refresh')}
225
+ onAction={
226
+ access.isDirector
227
+ ? () => {
228
+ window.location.href = '/operations/projects/new';
229
+ }
230
+ : () => void refetch()
231
+ }
232
+ />
233
+ )}
234
+ </Page>
235
+ );
236
+ }