@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.
@@ -1,34 +1,427 @@
1
1
  'use client';
2
2
 
3
- import { Page } from '@/components/entity-list';
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 { formatDate, formatHours } from '../_lib/utils/format';
14
- import {
15
- getProjectBadgeClasses,
16
- getProjectStatusLabel,
17
- } from '../_lib/utils/status';
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 { projects, users } = useOperationsData();
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
- projects.filter((project) =>
27
- `${project.name} ${project.client}`
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
- [projects, search]
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
- <SectionCard title={t('gridTitle')} description={t('gridDescription')}>
43
- <div className="mb-4">
44
- <Input
45
- value={search}
46
- onChange={(event) => setSearch(event.target.value)}
47
- placeholder={t('searchPlaceholder')}
48
- />
49
- </div>
50
- <div className="grid gap-4 xl:grid-cols-2">
51
- {filteredProjects.map((project) => {
52
- const memberNames = users
53
- .filter((user) => project.teamMemberIds.includes(user.id))
54
- .map((user) => user.name)
55
- .join(', ');
56
-
57
- return (
58
- <Link
59
- key={project.id}
60
- href={`/operations/projects/${project.id}`}
61
- className="rounded-xl border p-5 transition hover:border-primary/40 hover:shadow-md"
62
- >
63
- <div className="space-y-4">
64
- <div className="flex items-start justify-between gap-4">
65
- <div>
66
- <p className="text-lg font-semibold">{project.name}</p>
67
- <p className="text-sm text-muted-foreground">
68
- {project.client}
69
- </p>
70
- </div>
71
- <StatusBadge
72
- label={getProjectStatusLabel(project.status)}
73
- className={getProjectBadgeClasses(project.status)}
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
- </div>
76
-
77
- <div>
78
- <div className="mb-2 flex items-center justify-between text-sm">
79
- <span>{t('progress')}</span>
80
- <span>{project.progress}%</span>
81
- </div>
82
- <Progress value={project.progress} />
83
- </div>
84
-
85
- <div className="grid gap-3 text-sm sm:grid-cols-2">
86
- <p>
87
- <span className="font-medium">{t('teamMembers')}:</span>{' '}
88
- {memberNames}
89
- </p>
90
- <p>
91
- <span className="font-medium">{t('hoursLogged')}:</span>{' '}
92
- {formatHours(project.hoursLogged)}
93
- </p>
94
- <p>
95
- <span className="font-medium">{t('startDate')}:</span>{' '}
96
- {formatDate(project.startDate)}
97
- </p>
98
- <p>
99
- <span className="font-medium">{t('endDate')}:</span>{' '}
100
- {formatDate(project.endDate)}
101
- </p>
102
- </div>
103
- </div>
104
- </Link>
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
- </SectionCard>
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
  }