@hed-hog/operations 0.0.338 → 0.0.349

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 (61) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +73 -0
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-collaborators.controller.js +100 -0
  4. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  5. package/dist/controllers/operations-contracts.controller.d.ts +15 -15
  6. package/dist/controllers/operations-projects.controller.d.ts +3 -0
  7. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  8. package/dist/dto/create-collaborator-invoice.dto.d.ts +11 -0
  9. package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
  10. package/dist/dto/create-collaborator-invoice.dto.js +55 -0
  11. package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
  12. package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
  13. package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
  14. package/dist/dto/create-collaborator-payment.dto.js +50 -0
  15. package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
  16. package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
  17. package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
  18. package/dist/dto/list-collaborator-invoice.dto.js +8 -0
  19. package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
  20. package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
  21. package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
  22. package/dist/dto/list-collaborator-payment.dto.js +8 -0
  23. package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
  24. package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
  25. package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
  26. package/dist/dto/update-collaborator-invoice.dto.js +9 -0
  27. package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
  28. package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
  29. package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
  30. package/dist/dto/update-collaborator-payment.dto.js +9 -0
  31. package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
  32. package/dist/operations.service.d.ts +98 -0
  33. package/dist/operations.service.d.ts.map +1 -1
  34. package/dist/operations.service.js +226 -3
  35. package/dist/operations.service.js.map +1 -1
  36. package/hedhog/data/menu.yaml +32 -11
  37. package/hedhog/data/route.yaml +72 -0
  38. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +38 -0
  39. package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
  40. package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
  41. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +212 -10
  42. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +668 -11
  43. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +182 -28
  44. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +28 -7
  45. package/hedhog/frontend/app/_lib/api.ts.ejs +151 -0
  46. package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
  47. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
  48. package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
  49. package/hedhog/frontend/messages/en.json +96 -2
  50. package/hedhog/frontend/messages/pt.json +96 -2
  51. package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
  52. package/hedhog/table/operations_collaborator_payment.yaml +32 -0
  53. package/package.json +4 -4
  54. package/src/controllers/operations-collaborators.controller.ts +109 -0
  55. package/src/dto/create-collaborator-invoice.dto.ts +39 -0
  56. package/src/dto/create-collaborator-payment.dto.ts +35 -0
  57. package/src/dto/list-collaborator-invoice.dto.ts +3 -0
  58. package/src/dto/list-collaborator-payment.dto.ts +3 -0
  59. package/src/dto/update-collaborator-invoice.dto.ts +6 -0
  60. package/src/dto/update-collaborator-payment.dto.ts +6 -0
  61. package/src/operations.service.ts +318 -4
@@ -18,6 +18,7 @@ import {
18
18
  DialogHeader,
19
19
  DialogTitle,
20
20
  } from '@/components/ui/dialog';
21
+ import { EntityPicker } from '@/components/ui/entity-picker';
21
22
  import { Input } from '@/components/ui/input';
22
23
  import { Label } from '@/components/ui/label';
23
24
  import { Progress } from '@/components/ui/progress';
@@ -87,7 +88,6 @@ import {
87
88
  Gauge,
88
89
  GitCommitHorizontal,
89
90
  HeartPulse,
90
- History,
91
91
  LineChart as LineChartIcon,
92
92
  Loader2,
93
93
  MessageSquare,
@@ -101,6 +101,7 @@ import {
101
101
  Timer,
102
102
  Trash2,
103
103
  TrendingUp,
104
+ UserPlus,
104
105
  Users,
105
106
  type LucideIcon,
106
107
  } from 'lucide-react';
@@ -133,8 +134,11 @@ import {
133
134
  useValuesVisibility,
134
135
  } from '../_lib/hooks/use-values-visibility';
135
136
  import type {
137
+ OperationsCollaborator,
138
+ OperationsCollaboratorDetails,
136
139
  OperationsProjectDetails,
137
140
  OperationsTaskOption,
141
+ PaginatedResponse,
138
142
  } from '../_lib/types';
139
143
  import {
140
144
  formatCurrency,
@@ -145,17 +149,14 @@ import {
145
149
  formatPercent,
146
150
  getStatusBadgeClass,
147
151
  } from '../_lib/utils/format';
152
+ import { parseNumberInput } from '../_lib/utils/forms';
148
153
  import { OperationsHeader } from './operations-header';
149
154
  import { ProjectCostsSection } from './project-costs-section';
155
+ import { ProjectFileAttachments } from './project-file-attachments';
150
156
  import { ProjectFormScreen } from './project-form-screen';
151
157
  import { SectionCard } from './section-card';
152
158
  import { StatusBadge } from './status-badge';
153
- import {
154
- TaskDetailSheet,
155
- type TaskDetailSheetData,
156
- } from './task-detail-sheet';
157
- import { ProjectFileAttachments } from './project-file-attachments';
158
- import { TaskFileAttachments } from './task-file-attachments';
159
+ import { TaskDetailSheet, type TaskDetailSheetData } from './task-detail-sheet';
159
160
  import { TaskFormSheet } from './task-form-sheet';
160
161
  import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
161
162
 
@@ -204,6 +205,15 @@ type TimesheetEntryPrefill = {
204
205
  taskLabel: string;
205
206
  };
206
207
 
208
+ function formatAssignmentNumericValue(value: number) {
209
+ if (!Number.isFinite(value)) {
210
+ return '';
211
+ }
212
+
213
+ const roundedValue = Math.round(value * 100) / 100;
214
+ return String(roundedValue);
215
+ }
216
+
207
217
  const KANBAN_COLUMNS: Array<{ id: BoardColumnId; label: string }> = [
208
218
  { id: 'todo', label: 'Backlog' },
209
219
  { id: 'doing', label: 'Em execução' },
@@ -1092,6 +1102,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1092
1102
  const commonT = useTranslations('operations.Common');
1093
1103
  const formT = useTranslations('operations.ProjectFormPage');
1094
1104
  const contractT = useTranslations('operations.ContractFormPage');
1105
+ const collaboratorFormT = useTranslations('operations.CollaboratorFormPage');
1095
1106
  const { request, currentLocaleCode, getSettingValue } = useApp();
1096
1107
  const access = useOperationsAccess();
1097
1108
  const isLimitedView = !access.isDirector && !access.isSupervisor;
@@ -1298,6 +1309,24 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1298
1309
  useState(false);
1299
1310
  const [timesheetPrefill, setTimesheetPrefill] =
1300
1311
  useState<TimesheetEntryPrefill | null>(null);
1312
+ const [assignmentSheetOpen, setAssignmentSheetOpen] = useState(false);
1313
+ const [editingAssignment, setEditingAssignment] = useState<
1314
+ OperationsProjectDetails['assignments'][0] | null
1315
+ >(null);
1316
+ const [assignmentFormData, setAssignmentFormData] = useState({
1317
+ collaboratorId: '',
1318
+ weeklyHours: '',
1319
+ allocationPercent: '',
1320
+ status: 'active',
1321
+ startDate: '',
1322
+ endDate: '',
1323
+ });
1324
+ const [selectedAssignmentCollaborator, setSelectedAssignmentCollaborator] =
1325
+ useState<OperationsCollaborator | null>(null);
1326
+ const [savingAssignment, setSavingAssignment] = useState(false);
1327
+ const [removingAssignmentId, setRemovingAssignmentId] = useState<
1328
+ number | null
1329
+ >(null);
1301
1330
 
1302
1331
  const apiTasks = useMemo(() => rawTasks.map(apiTaskToBoardTask), [rawTasks]);
1303
1332
  const archivedTasks = useMemo(
@@ -1506,7 +1535,11 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1506
1535
  const handleDeleteProject = useCallback(async () => {
1507
1536
  setDeletingProject(true);
1508
1537
  try {
1509
- await mutateOperations(request, `/operations/projects/${projectId}`, 'DELETE');
1538
+ await mutateOperations(
1539
+ request,
1540
+ `/operations/projects/${projectId}`,
1541
+ 'DELETE'
1542
+ );
1510
1543
  router.push('/operations/projects');
1511
1544
  } catch {
1512
1545
  // ignore
@@ -1540,6 +1573,245 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1540
1573
  [commonT, project, projectId]
1541
1574
  );
1542
1575
 
1576
+ const loadCollaboratorOptions = useCallback(
1577
+ async ({
1578
+ page,
1579
+ pageSize,
1580
+ search,
1581
+ }: {
1582
+ page: number;
1583
+ pageSize: number;
1584
+ search: string;
1585
+ }) => {
1586
+ const params = new URLSearchParams({
1587
+ page: String(page),
1588
+ pageSize: String(pageSize),
1589
+ sortField: 'displayName',
1590
+ sortOrder: 'asc',
1591
+ });
1592
+ if (search.trim()) params.set('search', search.trim());
1593
+ const result = await fetchOperations<
1594
+ PaginatedResponse<OperationsCollaborator>
1595
+ >(request, `/operations/collaborators?${params.toString()}`);
1596
+ const items = result?.data ?? [];
1597
+ const total = result?.total ?? 0;
1598
+ return { items, hasMore: page * pageSize < total };
1599
+ },
1600
+ [request]
1601
+ );
1602
+
1603
+ const loadAssignmentCollaboratorById = useCallback(
1604
+ async (collaboratorId: number) => {
1605
+ const collaborator = await fetchOperations<OperationsCollaboratorDetails>(
1606
+ request,
1607
+ `/operations/collaborators/${collaboratorId}`
1608
+ );
1609
+ setSelectedAssignmentCollaborator(collaborator);
1610
+ return collaborator;
1611
+ },
1612
+ [request]
1613
+ );
1614
+
1615
+ const createAssignmentCollaborator = useCallback(
1616
+ async (values: Record<string, string>) => {
1617
+ const displayName = values.displayName?.trim() ?? '';
1618
+ const weeklyCapacityHours = parseNumberInput(
1619
+ values.weeklyCapacityHours ?? ''
1620
+ );
1621
+
1622
+ if (!displayName) {
1623
+ return null;
1624
+ }
1625
+
1626
+ return mutateOperations<OperationsCollaborator>(
1627
+ request,
1628
+ '/operations/collaborators',
1629
+ 'POST',
1630
+ {
1631
+ displayName,
1632
+ weeklyCapacityHours,
1633
+ status: 'active',
1634
+ autoGenerateContractDraft: false,
1635
+ }
1636
+ );
1637
+ },
1638
+ [request]
1639
+ );
1640
+
1641
+ const syncAssignmentFromWeeklyHours = useCallback(
1642
+ (
1643
+ weeklyHours: string,
1644
+ currentFormData: typeof assignmentFormData,
1645
+ collaborator?: OperationsCollaborator | null
1646
+ ) => {
1647
+ const capacity = collaborator?.weeklyCapacityHours;
1648
+ if (!capacity || capacity <= 0) {
1649
+ return {
1650
+ weeklyHours,
1651
+ allocationPercent: currentFormData.allocationPercent,
1652
+ };
1653
+ }
1654
+
1655
+ const parsedHours = parseNumberInput(weeklyHours);
1656
+ if (parsedHours == null) {
1657
+ return { weeklyHours, allocationPercent: '' };
1658
+ }
1659
+
1660
+ return {
1661
+ weeklyHours,
1662
+ allocationPercent: formatAssignmentNumericValue(
1663
+ (parsedHours / capacity) * 100
1664
+ ),
1665
+ };
1666
+ },
1667
+ []
1668
+ );
1669
+
1670
+ const syncAssignmentFromAllocation = useCallback(
1671
+ (
1672
+ allocationPercent: string,
1673
+ currentFormData: typeof assignmentFormData,
1674
+ collaborator?: OperationsCollaborator | null
1675
+ ) => {
1676
+ const capacity = collaborator?.weeklyCapacityHours;
1677
+ if (!capacity || capacity <= 0) {
1678
+ return {
1679
+ weeklyHours: currentFormData.weeklyHours,
1680
+ allocationPercent,
1681
+ };
1682
+ }
1683
+
1684
+ const parsedPercent = parseNumberInput(allocationPercent);
1685
+ if (parsedPercent == null) {
1686
+ return { weeklyHours: '', allocationPercent };
1687
+ }
1688
+
1689
+ return {
1690
+ weeklyHours: formatAssignmentNumericValue(
1691
+ (parsedPercent / 100) * capacity
1692
+ ),
1693
+ allocationPercent,
1694
+ };
1695
+ },
1696
+ []
1697
+ );
1698
+
1699
+ const openAddAssignment = useCallback(() => {
1700
+ setEditingAssignment(null);
1701
+ setSelectedAssignmentCollaborator(null);
1702
+ setAssignmentFormData({
1703
+ collaboratorId: '',
1704
+ weeklyHours: '',
1705
+ allocationPercent: '',
1706
+ status: 'active',
1707
+ startDate: '',
1708
+ endDate: '',
1709
+ });
1710
+ setAssignmentSheetOpen(true);
1711
+ }, []);
1712
+
1713
+ const openEditAssignment = useCallback(
1714
+ (assignment: OperationsProjectDetails['assignments'][0]) => {
1715
+ setEditingAssignment(assignment);
1716
+ setSelectedAssignmentCollaborator(null);
1717
+ setAssignmentFormData({
1718
+ collaboratorId: String(assignment.collaboratorId),
1719
+ weeklyHours:
1720
+ assignment.weeklyHours != null ? String(assignment.weeklyHours) : '',
1721
+ allocationPercent:
1722
+ assignment.allocationPercent != null
1723
+ ? String(assignment.allocationPercent)
1724
+ : '',
1725
+ status: assignment.status,
1726
+ startDate: assignment.startDate?.slice(0, 10) ?? '',
1727
+ endDate: assignment.endDate?.slice(0, 10) ?? '',
1728
+ });
1729
+ void loadAssignmentCollaboratorById(assignment.collaboratorId);
1730
+ setAssignmentSheetOpen(true);
1731
+ },
1732
+ [loadAssignmentCollaboratorById]
1733
+ );
1734
+
1735
+ const handleSaveAssignment = useCallback(async () => {
1736
+ if (!project) return;
1737
+ const collabId = Number(assignmentFormData.collaboratorId);
1738
+ if (!collabId) return;
1739
+ setSavingAssignment(true);
1740
+ const newEntry = {
1741
+ collaboratorId: collabId,
1742
+ weeklyHours: assignmentFormData.weeklyHours
1743
+ ? Number(assignmentFormData.weeklyHours)
1744
+ : null,
1745
+ allocationPercent: assignmentFormData.allocationPercent
1746
+ ? Number(assignmentFormData.allocationPercent)
1747
+ : null,
1748
+ status: assignmentFormData.status,
1749
+ startDate: assignmentFormData.startDate || null,
1750
+ endDate: assignmentFormData.endDate || null,
1751
+ };
1752
+ const existingMapped = project.assignments.map((a) => ({
1753
+ collaboratorId: a.collaboratorId,
1754
+ weeklyHours: a.weeklyHours ?? null,
1755
+ allocationPercent: a.allocationPercent ?? null,
1756
+ status: a.status,
1757
+ startDate: a.startDate ?? null,
1758
+ endDate: a.endDate ?? null,
1759
+ }));
1760
+ const updatedAssignments = editingAssignment
1761
+ ? existingMapped.map((a) =>
1762
+ a.collaboratorId === editingAssignment.collaboratorId ? newEntry : a
1763
+ )
1764
+ : [...existingMapped, newEntry];
1765
+ try {
1766
+ await mutateOperations(
1767
+ request,
1768
+ `/operations/projects/${projectId}`,
1769
+ 'PATCH',
1770
+ { teamAssignments: updatedAssignments }
1771
+ );
1772
+ await refetch();
1773
+ setAssignmentSheetOpen(false);
1774
+ } catch {
1775
+ // ignore
1776
+ } finally {
1777
+ setSavingAssignment(false);
1778
+ }
1779
+ }, [
1780
+ project,
1781
+ assignmentFormData,
1782
+ editingAssignment,
1783
+ request,
1784
+ projectId,
1785
+ refetch,
1786
+ ]);
1787
+
1788
+ const handleConfirmRemoveAssignment = useCallback(async () => {
1789
+ if (!project || removingAssignmentId === null) return;
1790
+ const updatedAssignments = project.assignments
1791
+ .filter((a) => a.collaboratorId !== removingAssignmentId)
1792
+ .map((a) => ({
1793
+ collaboratorId: a.collaboratorId,
1794
+ weeklyHours: a.weeklyHours ?? null,
1795
+ allocationPercent: a.allocationPercent ?? null,
1796
+ status: a.status,
1797
+ startDate: a.startDate ?? null,
1798
+ endDate: a.endDate ?? null,
1799
+ }));
1800
+ try {
1801
+ await mutateOperations(
1802
+ request,
1803
+ `/operations/projects/${projectId}`,
1804
+ 'PATCH',
1805
+ { teamAssignments: updatedAssignments }
1806
+ );
1807
+ await refetch();
1808
+ } catch {
1809
+ // ignore
1810
+ } finally {
1811
+ setRemovingAssignmentId(null);
1812
+ }
1813
+ }, [project, removingAssignmentId, request, projectId, refetch]);
1814
+
1543
1815
  const allocationChartData = useMemo(() => {
1544
1816
  if (projectStats?.allocationByCollaborator?.length) {
1545
1817
  return projectStats.allocationByCollaborator;
@@ -2141,7 +2413,10 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
2141
2413
  <div className="flex cursor-default items-center gap-1.5 border-r px-3 py-2 transition hover:bg-muted/30">
2142
2414
  <Avatar className="size-5 shrink-0 border bg-muted">
2143
2415
  <AvatarImage
2144
- src={getPersonAvatarUrl(project.clientAvatarId)}
2416
+ src={
2417
+ getUserPhotoUrl(project.clientUserPhotoId) ||
2418
+ getPersonAvatarUrl(project.clientAvatarId)
2419
+ }
2145
2420
  alt={project.clientName || ''}
2146
2421
  />
2147
2422
  <AvatarFallback className="text-[9px]">
@@ -2246,7 +2521,6 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
2246
2521
  <div className="flex items-center gap-1.5">
2247
2522
  <div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
2248
2523
  <motion.div
2249
- initial={{ width: 0 }}
2250
2524
  animate={{ width: `${displayedProgress}%` }}
2251
2525
  transition={{ duration: 0.7, ease: 'easeOut' }}
2252
2526
  className="h-full rounded-full bg-primary"
@@ -3853,6 +4127,19 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3853
4127
  'rounded-2xl border bg-card p-4 shadow-sm',
3854
4128
  isLimitedView ? 'xl:col-span-12' : 'xl:col-span-8',
3855
4129
  ].join(' ')}
4130
+ actions={
4131
+ !isLimitedView ? (
4132
+ <Button
4133
+ size="sm"
4134
+ variant="outline"
4135
+ className="cursor-pointer"
4136
+ onClick={openAddAssignment}
4137
+ >
4138
+ <UserPlus className="mr-1.5 size-4" />
4139
+ {t('teamPanel.addCollaborator')}
4140
+ </Button>
4141
+ ) : undefined
4142
+ }
3856
4143
  >
3857
4144
  {project.assignments.length > 0 ? (
3858
4145
  <div className="space-y-4">
@@ -3960,6 +4247,34 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3960
4247
  <ToneIcon className="size-3" />
3961
4248
  {t(`teamPanel.status.${tone.labelKey}`)}
3962
4249
  </span>
4250
+ {!isLimitedView ? (
4251
+ <div className="flex items-center gap-1">
4252
+ <Button
4253
+ type="button"
4254
+ variant="ghost"
4255
+ size="icon"
4256
+ className="size-7 cursor-pointer"
4257
+ onClick={() =>
4258
+ openEditAssignment(assignment)
4259
+ }
4260
+ >
4261
+ <Pencil className="size-3.5" />
4262
+ </Button>
4263
+ <Button
4264
+ type="button"
4265
+ variant="ghost"
4266
+ size="icon"
4267
+ className="size-7 cursor-pointer text-destructive hover:text-destructive"
4268
+ onClick={() =>
4269
+ setRemovingAssignmentId(
4270
+ assignment.collaboratorId
4271
+ )
4272
+ }
4273
+ >
4274
+ <Trash2 className="size-3.5" />
4275
+ </Button>
4276
+ </div>
4277
+ ) : null}
3963
4278
  </div>
3964
4279
  </div>
3965
4280
  </div>
@@ -4260,6 +4575,345 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
4260
4575
  }
4261
4576
  />
4262
4577
 
4578
+ <Sheet
4579
+ open={assignmentSheetOpen}
4580
+ onOpenChange={(open) => {
4581
+ if (!open) {
4582
+ setAssignmentSheetOpen(false);
4583
+ setSelectedAssignmentCollaborator(null);
4584
+ }
4585
+ }}
4586
+ >
4587
+ <SheetContent className="w-full overflow-y-auto sm:max-w-[min(92vw,28rem)]">
4588
+ <SheetHeader>
4589
+ <SheetTitle>
4590
+ {editingAssignment
4591
+ ? t('teamPanel.editTitle')
4592
+ : t('teamPanel.addTitle')}
4593
+ </SheetTitle>
4594
+ <SheetDescription>
4595
+ {t('teamPanel.formDescription')}
4596
+ </SheetDescription>
4597
+ </SheetHeader>
4598
+ <div className="mt-6 space-y-4 px-1 sm:px-4">
4599
+ {!editingAssignment ? (
4600
+ <div className="space-y-2">
4601
+ <Label className="text-sm font-medium">
4602
+ {commonT('labels.collaborator')}
4603
+ </Label>
4604
+ <EntityPicker<OperationsCollaborator>
4605
+ value={
4606
+ assignmentFormData.collaboratorId
4607
+ ? Number(assignmentFormData.collaboratorId)
4608
+ : null
4609
+ }
4610
+ onChange={(val, option) => {
4611
+ const collaborator = option ?? null;
4612
+ setSelectedAssignmentCollaborator(collaborator);
4613
+ setAssignmentFormData((prev) => {
4614
+ const collaboratorId = val ? String(val) : '';
4615
+ if (!collaboratorId) {
4616
+ return {
4617
+ ...prev,
4618
+ collaboratorId: '',
4619
+ weeklyHours: '',
4620
+ allocationPercent: '',
4621
+ };
4622
+ }
4623
+
4624
+ const capacity = collaborator?.weeklyCapacityHours;
4625
+ if (!capacity || capacity <= 0) {
4626
+ return {
4627
+ ...prev,
4628
+ collaboratorId,
4629
+ };
4630
+ }
4631
+
4632
+ const parsedHours = parseNumberInput(prev.weeklyHours);
4633
+ const parsedPercent = parseNumberInput(
4634
+ prev.allocationPercent
4635
+ );
4636
+
4637
+ if (parsedHours != null) {
4638
+ return {
4639
+ ...prev,
4640
+ collaboratorId,
4641
+ ...syncAssignmentFromWeeklyHours(
4642
+ prev.weeklyHours,
4643
+ prev,
4644
+ collaborator
4645
+ ),
4646
+ };
4647
+ }
4648
+
4649
+ if (parsedPercent != null) {
4650
+ return {
4651
+ ...prev,
4652
+ collaboratorId,
4653
+ ...syncAssignmentFromAllocation(
4654
+ prev.allocationPercent,
4655
+ prev,
4656
+ collaborator
4657
+ ),
4658
+ };
4659
+ }
4660
+
4661
+ return {
4662
+ ...prev,
4663
+ collaboratorId,
4664
+ weeklyHours: formatAssignmentNumericValue(capacity),
4665
+ allocationPercent: '100',
4666
+ };
4667
+ });
4668
+ }}
4669
+ placeholder={commonT('labels.collaborator')}
4670
+ searchPlaceholder={commonT('labels.collaborator')}
4671
+ loadOptions={loadCollaboratorOptions}
4672
+ getOptionValue={(opt) => opt.id}
4673
+ getOptionLabel={(opt) => opt.displayName}
4674
+ renderOption={({ option }) => (
4675
+ <div className="flex min-w-0 items-center gap-2.5">
4676
+ <Avatar className="size-6 shrink-0">
4677
+ <AvatarImage
4678
+ src={
4679
+ getUserPhotoUrl(option.userPhotoId) ||
4680
+ getPersonAvatarUrl(option.personAvatarId)
4681
+ }
4682
+ alt={option.displayName}
4683
+ />
4684
+ <AvatarFallback className="text-[9px]">
4685
+ {getInitials(option.displayName)}
4686
+ </AvatarFallback>
4687
+ </Avatar>
4688
+ <div className="min-w-0">
4689
+ <div className="truncate text-sm">
4690
+ {option.displayName}
4691
+ </div>
4692
+ {option.department ? (
4693
+ <div className="truncate text-xs text-muted-foreground">
4694
+ {option.department}
4695
+ </div>
4696
+ ) : null}
4697
+ </div>
4698
+ </div>
4699
+ )}
4700
+ valueType="number"
4701
+ clearable
4702
+ allowEmptySelection
4703
+ showCreateButton
4704
+ entityLabel={commonT('labels.collaborator').toLowerCase()}
4705
+ createActionLabel={`${commonT('actions.create')} ${commonT(
4706
+ 'labels.collaborator'
4707
+ ).toLowerCase()}`}
4708
+ createTitle={`${commonT('actions.create')} ${commonT(
4709
+ 'labels.collaborator'
4710
+ ).toLowerCase()}`}
4711
+ createDescription={t('teamPanel.formDescription')}
4712
+ createFields={[
4713
+ {
4714
+ name: 'displayName',
4715
+ label: collaboratorFormT('fields.displayName'),
4716
+ placeholder: collaboratorFormT('fields.displayName'),
4717
+ required: true,
4718
+ },
4719
+ {
4720
+ name: 'weeklyCapacityHours',
4721
+ label: collaboratorFormT('fields.weeklyCapacityHours'),
4722
+ placeholder: '40',
4723
+ type: 'number',
4724
+ },
4725
+ ]}
4726
+ mapSearchToCreateValues={(search) => ({
4727
+ displayName: search,
4728
+ weeklyCapacityHours: '40',
4729
+ })}
4730
+ onCreate={createAssignmentCollaborator}
4731
+ />
4732
+ </div>
4733
+ ) : (
4734
+ <div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
4735
+ <Avatar className="size-10 border bg-muted">
4736
+ <AvatarImage
4737
+ src={
4738
+ getUserPhotoUrl(editingAssignment.userPhotoId) ||
4739
+ getPersonAvatarUrl(editingAssignment.personAvatarId)
4740
+ }
4741
+ alt={editingAssignment.collaboratorName}
4742
+ />
4743
+ <AvatarFallback className="text-xs">
4744
+ {getInitials(editingAssignment.collaboratorName)}
4745
+ </AvatarFallback>
4746
+ </Avatar>
4747
+ <div className="min-w-0">
4748
+ <div className="truncate text-sm font-semibold">
4749
+ {editingAssignment.collaboratorName}
4750
+ </div>
4751
+ {editingAssignment.roleLabel ? (
4752
+ <div className="truncate text-xs text-muted-foreground">
4753
+ {editingAssignment.roleLabel}
4754
+ </div>
4755
+ ) : null}
4756
+ </div>
4757
+ </div>
4758
+ )}
4759
+ <div className="space-y-2">
4760
+ <Label className="text-sm font-medium">
4761
+ {commonT('labels.allocationPercent')}
4762
+ </Label>
4763
+ <Input
4764
+ type="number"
4765
+ min="0"
4766
+ max="200"
4767
+ value={assignmentFormData.allocationPercent}
4768
+ onChange={(e) =>
4769
+ setAssignmentFormData((prev) => ({
4770
+ ...prev,
4771
+ ...syncAssignmentFromAllocation(
4772
+ e.target.value,
4773
+ prev,
4774
+ selectedAssignmentCollaborator
4775
+ ),
4776
+ }))
4777
+ }
4778
+ placeholder="100"
4779
+ />
4780
+ </div>
4781
+ <div className="space-y-2">
4782
+ <Label className="text-sm font-medium">
4783
+ {commonT('labels.weeklyCapacity')}
4784
+ </Label>
4785
+ <Input
4786
+ type="number"
4787
+ min="0"
4788
+ value={assignmentFormData.weeklyHours}
4789
+ onChange={(e) =>
4790
+ setAssignmentFormData((prev) => ({
4791
+ ...prev,
4792
+ ...syncAssignmentFromWeeklyHours(
4793
+ e.target.value,
4794
+ prev,
4795
+ selectedAssignmentCollaborator
4796
+ ),
4797
+ }))
4798
+ }
4799
+ placeholder="40"
4800
+ />
4801
+ </div>
4802
+ <div className="space-y-2">
4803
+ <Label className="text-sm font-medium">
4804
+ {commonT('labels.status')}
4805
+ </Label>
4806
+ <Select
4807
+ value={assignmentFormData.status}
4808
+ onValueChange={(val) =>
4809
+ setAssignmentFormData((prev) => ({ ...prev, status: val }))
4810
+ }
4811
+ >
4812
+ <SelectTrigger className="cursor-pointer">
4813
+ <SelectValue />
4814
+ </SelectTrigger>
4815
+ <SelectContent>
4816
+ <SelectItem value="active">
4817
+ {formatEnumLabel('active')}
4818
+ </SelectItem>
4819
+ <SelectItem value="inactive">
4820
+ {formatEnumLabel('inactive')}
4821
+ </SelectItem>
4822
+ <SelectItem value="completed">
4823
+ {formatEnumLabel('completed')}
4824
+ </SelectItem>
4825
+ </SelectContent>
4826
+ </Select>
4827
+ </div>
4828
+ <div className="grid grid-cols-2 gap-3">
4829
+ <div className="space-y-2">
4830
+ <Label className="text-sm font-medium">
4831
+ {commonT('labels.startDate')}
4832
+ </Label>
4833
+ <Input
4834
+ type="date"
4835
+ value={assignmentFormData.startDate}
4836
+ onChange={(e) =>
4837
+ setAssignmentFormData((prev) => ({
4838
+ ...prev,
4839
+ startDate: e.target.value,
4840
+ }))
4841
+ }
4842
+ />
4843
+ </div>
4844
+ <div className="space-y-2">
4845
+ <Label className="text-sm font-medium">
4846
+ {commonT('labels.endDate')}
4847
+ </Label>
4848
+ <Input
4849
+ type="date"
4850
+ value={assignmentFormData.endDate}
4851
+ onChange={(e) =>
4852
+ setAssignmentFormData((prev) => ({
4853
+ ...prev,
4854
+ endDate: e.target.value,
4855
+ }))
4856
+ }
4857
+ />
4858
+ </div>
4859
+ </div>
4860
+ <div className="flex gap-2 pt-2">
4861
+ <Button
4862
+ variant="outline"
4863
+ className="flex-1 cursor-pointer"
4864
+ onClick={() => setAssignmentSheetOpen(false)}
4865
+ disabled={savingAssignment}
4866
+ >
4867
+ {commonT('actions.cancel')}
4868
+ </Button>
4869
+ <Button
4870
+ className="flex-1 cursor-pointer"
4871
+ disabled={
4872
+ savingAssignment ||
4873
+ (!editingAssignment && !assignmentFormData.collaboratorId)
4874
+ }
4875
+ onClick={() => void handleSaveAssignment()}
4876
+ >
4877
+ {savingAssignment ? (
4878
+ <Loader2 className="mr-2 size-4 animate-spin" />
4879
+ ) : null}
4880
+ {commonT('actions.save')}
4881
+ </Button>
4882
+ </div>
4883
+ </div>
4884
+ </SheetContent>
4885
+ </Sheet>
4886
+
4887
+ <Dialog
4888
+ open={removingAssignmentId !== null}
4889
+ onOpenChange={(open) => {
4890
+ if (!open) setRemovingAssignmentId(null);
4891
+ }}
4892
+ >
4893
+ <DialogContent className="sm:max-w-sm">
4894
+ <DialogHeader>
4895
+ <DialogTitle>{t('teamPanel.removeTitle')}</DialogTitle>
4896
+ <DialogDescription>
4897
+ {t('teamPanel.removeDescription')}
4898
+ </DialogDescription>
4899
+ </DialogHeader>
4900
+ <DialogFooter className="mt-4">
4901
+ <Button
4902
+ variant="outline"
4903
+ onClick={() => setRemovingAssignmentId(null)}
4904
+ >
4905
+ {commonT('actions.cancel')}
4906
+ </Button>
4907
+ <Button
4908
+ variant="destructive"
4909
+ onClick={() => void handleConfirmRemoveAssignment()}
4910
+ >
4911
+ {commonT('actions.delete')}
4912
+ </Button>
4913
+ </DialogFooter>
4914
+ </DialogContent>
4915
+ </Dialog>
4916
+
4263
4917
  <TimesheetEntryCreateSheet
4264
4918
  open={isTimesheetEntrySheetOpen}
4265
4919
  onOpenChange={(open) => {
@@ -4341,7 +4995,10 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
4341
4995
  )
4342
4996
  : undefined
4343
4997
  }
4344
- onCountChanged={() => { setBoardState(null); void refetchTasks(); }}
4998
+ onCountChanged={() => {
4999
+ setBoardState(null);
5000
+ void refetchTasks();
5001
+ }}
4345
5002
  onSaved={() => {
4346
5003
  setBoardState(null);
4347
5004
  void refetchTasks();