@hed-hog/operations 0.0.331 → 0.0.332

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 (62) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +54 -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/dto/create-collaborator-invoice.dto.d.ts +11 -0
  6. package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
  7. package/dist/dto/create-collaborator-invoice.dto.js +55 -0
  8. package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
  9. package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
  10. package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
  11. package/dist/dto/create-collaborator-payment.dto.js +50 -0
  12. package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
  13. package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
  14. package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
  15. package/dist/dto/list-collaborator-invoice.dto.js +8 -0
  16. package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
  17. package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
  18. package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
  19. package/dist/dto/list-collaborator-payment.dto.js +8 -0
  20. package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
  21. package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
  22. package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
  23. package/dist/dto/update-collaborator-invoice.dto.js +9 -0
  24. package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
  25. package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
  26. package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
  27. package/dist/dto/update-collaborator-payment.dto.js +9 -0
  28. package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
  29. package/dist/operations.service.d.ts +76 -0
  30. package/dist/operations.service.d.ts.map +1 -1
  31. package/dist/operations.service.js +235 -5
  32. package/dist/operations.service.js.map +1 -1
  33. package/hedhog/data/menu.yaml +27 -8
  34. package/hedhog/data/route.yaml +72 -0
  35. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +39 -3
  36. package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
  37. package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
  38. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +86 -87
  39. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +218 -10
  40. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +710 -26
  41. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +158 -38
  42. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +807 -803
  43. package/hedhog/frontend/app/_lib/api.ts.ejs +631 -480
  44. package/hedhog/frontend/app/_lib/types.ts.ejs +6 -5
  45. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
  46. package/hedhog/frontend/app/my-projects/page.tsx.ejs +16 -2
  47. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +95 -157
  48. package/hedhog/frontend/app/projects/page.tsx.ejs +42 -6
  49. package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
  50. package/hedhog/frontend/messages/en.json +96 -2
  51. package/hedhog/frontend/messages/pt.json +96 -2
  52. package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
  53. package/hedhog/table/operations_collaborator_payment.yaml +32 -0
  54. package/package.json +5 -5
  55. package/src/controllers/operations-collaborators.controller.ts +117 -8
  56. package/src/dto/create-collaborator-invoice.dto.ts +39 -0
  57. package/src/dto/create-collaborator-payment.dto.ts +35 -0
  58. package/src/dto/list-collaborator-invoice.dto.ts +3 -0
  59. package/src/dto/list-collaborator-payment.dto.ts +3 -0
  60. package/src/dto/update-collaborator-invoice.dto.ts +6 -0
  61. package/src/dto/update-collaborator-payment.dto.ts +6 -0
  62. package/src/operations.service.ts +328 -5
@@ -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,19 +149,16 @@ 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 { TaskFormSheet } from './task-form-sheet';
160
- import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
159
+ import { TaskDetailSheet, type TaskDetailSheetData } from './task-detail-sheet';
160
+ import { TaskFormSheet } from './task-form-sheet';
161
+ import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
161
162
 
162
163
  type BoardColumnId = 'todo' | 'doing' | 'review' | 'done';
163
164
 
@@ -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' },
@@ -386,7 +396,7 @@ function getInitials(value?: string | null) {
386
396
  function getPersonAvatarUrl(avatarId?: number | null) {
387
397
  return typeof avatarId === 'number' && avatarId > 0
388
398
  ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
389
- : '/placeholder.png';
399
+ : undefined;
390
400
  }
391
401
 
392
402
  function getUserPhotoUrl(photoId?: number | null) {
@@ -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;
@@ -1299,6 +1310,26 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1299
1310
  const [timesheetPrefill, setTimesheetPrefill] =
1300
1311
  useState<TimesheetEntryPrefill | null>(null);
1301
1312
 
1313
+ // Assignment management state
1314
+ const [assignmentSheetOpen, setAssignmentSheetOpen] = useState(false);
1315
+ const [editingAssignment, setEditingAssignment] = useState<
1316
+ OperationsProjectDetails['assignments'][0] | null
1317
+ >(null);
1318
+ const [assignmentFormData, setAssignmentFormData] = useState({
1319
+ collaboratorId: '',
1320
+ weeklyHours: '',
1321
+ allocationPercent: '',
1322
+ status: 'active',
1323
+ startDate: '',
1324
+ endDate: '',
1325
+ });
1326
+ const [selectedAssignmentCollaborator, setSelectedAssignmentCollaborator] =
1327
+ useState<OperationsCollaborator | null>(null);
1328
+ const [savingAssignment, setSavingAssignment] = useState(false);
1329
+ const [removingAssignmentId, setRemovingAssignmentId] = useState<
1330
+ number | null
1331
+ >(null);
1332
+
1302
1333
  const apiTasks = useMemo(() => rawTasks.map(apiTaskToBoardTask), [rawTasks]);
1303
1334
  const archivedTasks = useMemo(
1304
1335
  () =>
@@ -1506,7 +1537,11 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1506
1537
  const handleDeleteProject = useCallback(async () => {
1507
1538
  setDeletingProject(true);
1508
1539
  try {
1509
- await mutateOperations(request, `/operations/projects/${projectId}`, 'DELETE');
1540
+ await mutateOperations(
1541
+ request,
1542
+ `/operations/projects/${projectId}`,
1543
+ 'DELETE'
1544
+ );
1510
1545
  router.push('/operations/projects');
1511
1546
  } catch {
1512
1547
  // ignore
@@ -1540,6 +1575,245 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1540
1575
  [commonT, project, projectId]
1541
1576
  );
1542
1577
 
1578
+ const loadCollaboratorOptions = useCallback(
1579
+ async ({
1580
+ page,
1581
+ pageSize,
1582
+ search,
1583
+ }: {
1584
+ page: number;
1585
+ pageSize: number;
1586
+ search: string;
1587
+ }) => {
1588
+ const params = new URLSearchParams({
1589
+ page: String(page),
1590
+ pageSize: String(pageSize),
1591
+ sortField: 'displayName',
1592
+ sortOrder: 'asc',
1593
+ });
1594
+ if (search.trim()) params.set('search', search.trim());
1595
+ const result = await fetchOperations<
1596
+ PaginatedResponse<OperationsCollaborator>
1597
+ >(request, `/operations/collaborators?${params.toString()}`);
1598
+ const items = result?.data ?? [];
1599
+ const total = result?.total ?? 0;
1600
+ return { items, hasMore: page * pageSize < total };
1601
+ },
1602
+ [request]
1603
+ );
1604
+
1605
+ const loadAssignmentCollaboratorById = useCallback(
1606
+ async (collaboratorId: number) => {
1607
+ const collaborator = await fetchOperations<OperationsCollaboratorDetails>(
1608
+ request,
1609
+ `/operations/collaborators/${collaboratorId}`
1610
+ );
1611
+ setSelectedAssignmentCollaborator(collaborator);
1612
+ return collaborator;
1613
+ },
1614
+ [request]
1615
+ );
1616
+
1617
+ const createAssignmentCollaborator = useCallback(
1618
+ async (values: Record<string, string>) => {
1619
+ const displayName = values.displayName?.trim() ?? '';
1620
+ const weeklyCapacityHours = parseNumberInput(
1621
+ values.weeklyCapacityHours ?? ''
1622
+ );
1623
+
1624
+ if (!displayName) {
1625
+ return null;
1626
+ }
1627
+
1628
+ return mutateOperations<OperationsCollaborator>(
1629
+ request,
1630
+ '/operations/collaborators',
1631
+ 'POST',
1632
+ {
1633
+ displayName,
1634
+ weeklyCapacityHours,
1635
+ status: 'active',
1636
+ autoGenerateContractDraft: false,
1637
+ }
1638
+ );
1639
+ },
1640
+ [request]
1641
+ );
1642
+
1643
+ const syncAssignmentFromWeeklyHours = useCallback(
1644
+ (
1645
+ weeklyHours: string,
1646
+ currentFormData: typeof assignmentFormData,
1647
+ collaborator?: OperationsCollaborator | null
1648
+ ) => {
1649
+ const capacity = collaborator?.weeklyCapacityHours;
1650
+ if (!capacity || capacity <= 0) {
1651
+ return {
1652
+ weeklyHours,
1653
+ allocationPercent: currentFormData.allocationPercent,
1654
+ };
1655
+ }
1656
+
1657
+ const parsedHours = parseNumberInput(weeklyHours);
1658
+ if (parsedHours == null) {
1659
+ return { weeklyHours, allocationPercent: '' };
1660
+ }
1661
+
1662
+ return {
1663
+ weeklyHours,
1664
+ allocationPercent: formatAssignmentNumericValue(
1665
+ (parsedHours / capacity) * 100
1666
+ ),
1667
+ };
1668
+ },
1669
+ []
1670
+ );
1671
+
1672
+ const syncAssignmentFromAllocation = useCallback(
1673
+ (
1674
+ allocationPercent: string,
1675
+ currentFormData: typeof assignmentFormData,
1676
+ collaborator?: OperationsCollaborator | null
1677
+ ) => {
1678
+ const capacity = collaborator?.weeklyCapacityHours;
1679
+ if (!capacity || capacity <= 0) {
1680
+ return {
1681
+ weeklyHours: currentFormData.weeklyHours,
1682
+ allocationPercent,
1683
+ };
1684
+ }
1685
+
1686
+ const parsedPercent = parseNumberInput(allocationPercent);
1687
+ if (parsedPercent == null) {
1688
+ return { weeklyHours: '', allocationPercent };
1689
+ }
1690
+
1691
+ return {
1692
+ weeklyHours: formatAssignmentNumericValue(
1693
+ (parsedPercent / 100) * capacity
1694
+ ),
1695
+ allocationPercent,
1696
+ };
1697
+ },
1698
+ []
1699
+ );
1700
+
1701
+ const openAddAssignment = useCallback(() => {
1702
+ setEditingAssignment(null);
1703
+ setSelectedAssignmentCollaborator(null);
1704
+ setAssignmentFormData({
1705
+ collaboratorId: '',
1706
+ weeklyHours: '',
1707
+ allocationPercent: '',
1708
+ status: 'active',
1709
+ startDate: '',
1710
+ endDate: '',
1711
+ });
1712
+ setAssignmentSheetOpen(true);
1713
+ }, []);
1714
+
1715
+ const openEditAssignment = useCallback(
1716
+ (assignment: OperationsProjectDetails['assignments'][0]) => {
1717
+ setEditingAssignment(assignment);
1718
+ setSelectedAssignmentCollaborator(null);
1719
+ setAssignmentFormData({
1720
+ collaboratorId: String(assignment.collaboratorId),
1721
+ weeklyHours:
1722
+ assignment.weeklyHours != null ? String(assignment.weeklyHours) : '',
1723
+ allocationPercent:
1724
+ assignment.allocationPercent != null
1725
+ ? String(assignment.allocationPercent)
1726
+ : '',
1727
+ status: assignment.status,
1728
+ startDate: assignment.startDate?.slice(0, 10) ?? '',
1729
+ endDate: assignment.endDate?.slice(0, 10) ?? '',
1730
+ });
1731
+ void loadAssignmentCollaboratorById(assignment.collaboratorId);
1732
+ setAssignmentSheetOpen(true);
1733
+ },
1734
+ [loadAssignmentCollaboratorById]
1735
+ );
1736
+
1737
+ const handleSaveAssignment = useCallback(async () => {
1738
+ if (!project) return;
1739
+ const collabId = Number(assignmentFormData.collaboratorId);
1740
+ if (!collabId) return;
1741
+ setSavingAssignment(true);
1742
+ const newEntry = {
1743
+ collaboratorId: collabId,
1744
+ weeklyHours: assignmentFormData.weeklyHours
1745
+ ? Number(assignmentFormData.weeklyHours)
1746
+ : null,
1747
+ allocationPercent: assignmentFormData.allocationPercent
1748
+ ? Number(assignmentFormData.allocationPercent)
1749
+ : null,
1750
+ status: assignmentFormData.status,
1751
+ startDate: assignmentFormData.startDate || null,
1752
+ endDate: assignmentFormData.endDate || null,
1753
+ };
1754
+ const existingMapped = project.assignments.map((a) => ({
1755
+ collaboratorId: a.collaboratorId,
1756
+ weeklyHours: a.weeklyHours ?? null,
1757
+ allocationPercent: a.allocationPercent ?? null,
1758
+ status: a.status,
1759
+ startDate: a.startDate ?? null,
1760
+ endDate: a.endDate ?? null,
1761
+ }));
1762
+ const updatedAssignments = editingAssignment
1763
+ ? existingMapped.map((a) =>
1764
+ a.collaboratorId === editingAssignment.collaboratorId ? newEntry : a
1765
+ )
1766
+ : [...existingMapped, newEntry];
1767
+ try {
1768
+ await mutateOperations(
1769
+ request,
1770
+ `/operations/projects/${projectId}`,
1771
+ 'PATCH',
1772
+ { teamAssignments: updatedAssignments }
1773
+ );
1774
+ await refetch();
1775
+ setAssignmentSheetOpen(false);
1776
+ } catch {
1777
+ // ignore
1778
+ } finally {
1779
+ setSavingAssignment(false);
1780
+ }
1781
+ }, [
1782
+ project,
1783
+ assignmentFormData,
1784
+ editingAssignment,
1785
+ request,
1786
+ projectId,
1787
+ refetch,
1788
+ ]);
1789
+
1790
+ const handleConfirmRemoveAssignment = useCallback(async () => {
1791
+ if (!project || removingAssignmentId === null) return;
1792
+ const updatedAssignments = project.assignments
1793
+ .filter((a) => a.collaboratorId !== removingAssignmentId)
1794
+ .map((a) => ({
1795
+ collaboratorId: a.collaboratorId,
1796
+ weeklyHours: a.weeklyHours ?? null,
1797
+ allocationPercent: a.allocationPercent ?? null,
1798
+ status: a.status,
1799
+ startDate: a.startDate ?? null,
1800
+ endDate: a.endDate ?? null,
1801
+ }));
1802
+ try {
1803
+ await mutateOperations(
1804
+ request,
1805
+ `/operations/projects/${projectId}`,
1806
+ 'PATCH',
1807
+ { teamAssignments: updatedAssignments }
1808
+ );
1809
+ await refetch();
1810
+ } catch {
1811
+ // ignore
1812
+ } finally {
1813
+ setRemovingAssignmentId(null);
1814
+ }
1815
+ }, [project, removingAssignmentId, request, projectId, refetch]);
1816
+
1543
1817
  const allocationChartData = useMemo(() => {
1544
1818
  if (projectStats?.allocationByCollaborator?.length) {
1545
1819
  return projectStats.allocationByCollaborator;
@@ -2140,10 +2414,13 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
2140
2414
  <TooltipTrigger asChild>
2141
2415
  <div className="flex cursor-default items-center gap-1.5 border-r px-3 py-2 transition hover:bg-muted/30">
2142
2416
  <Avatar className="size-5 shrink-0 border bg-muted">
2143
- <AvatarImage
2144
- src={getPersonAvatarUrl(project.clientAvatarId)}
2145
- alt={project.clientName || ''}
2146
- />
2417
+ <AvatarImage
2418
+ src={
2419
+ getUserPhotoUrl(project.clientUserPhotoId) ||
2420
+ getPersonAvatarUrl(project.clientAvatarId)
2421
+ }
2422
+ alt={project.clientName || ''}
2423
+ />
2147
2424
  <AvatarFallback className="text-[9px]">
2148
2425
  {getInitials(project.clientName)}
2149
2426
  </AvatarFallback>
@@ -2462,7 +2739,10 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
2462
2739
  <div className="flex items-center gap-2">
2463
2740
  <Avatar className="h-8 w-8 border border-border/60 bg-muted">
2464
2741
  <AvatarImage
2465
- src={getPersonAvatarUrl(project.clientAvatarId)}
2742
+ src={
2743
+ getUserPhotoUrl(project.clientUserPhotoId) ||
2744
+ getPersonAvatarUrl(project.clientAvatarId)
2745
+ }
2466
2746
  alt={project.clientName || commonT('labels.client')}
2467
2747
  />
2468
2748
  <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
@@ -2604,13 +2884,32 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
2604
2884
  <div className="font-medium">
2605
2885
  {project.relatedContract.name}
2606
2886
  </div>
2607
- <div className="text-sm text-muted-foreground">
2608
- {[
2609
- project.relatedContract.code,
2610
- project.relatedContract.clientName,
2611
- ]
2612
- .filter(Boolean)
2613
- .join(' • ') || commonT('labels.notAvailable')}
2887
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
2888
+ <span className="truncate">
2889
+ {project.relatedContract.code || '—'}
2890
+ </span>
2891
+ {project.relatedContract.clientName ? (
2892
+ <>
2893
+ <span>•</span>
2894
+ <Avatar className="h-4 w-4 shrink-0">
2895
+ <AvatarImage
2896
+ src={
2897
+ getUserPhotoUrl(project.clientUserPhotoId) ||
2898
+ getPersonAvatarUrl(project.clientAvatarId)
2899
+ }
2900
+ alt={project.relatedContract.clientName}
2901
+ />
2902
+ <AvatarFallback className="text-[8px] font-medium">
2903
+ {getInitials(
2904
+ project.relatedContract.clientName
2905
+ )}
2906
+ </AvatarFallback>
2907
+ </Avatar>
2908
+ <span className="truncate">
2909
+ {project.relatedContract.clientName}
2910
+ </span>
2911
+ </>
2912
+ ) : null}
2614
2913
  </div>
2615
2914
  </div>
2616
2915
  <div className="flex items-center gap-3">
@@ -3082,7 +3381,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3082
3381
  {(isOver) => (
3083
3382
  <div
3084
3383
  className={[
3085
- 'flex min-h-128 flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
3384
+ 'flex min-h-48 max-h-160 flex-col rounded-3xl border bg-linear-to-b p-3 transition-all',
3086
3385
  getColumnClassName(column.id),
3087
3386
  isOver
3088
3387
  ? 'border-primary shadow-lg ring-2 ring-primary/15'
@@ -3125,7 +3424,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3125
3424
  </div>
3126
3425
  </div>
3127
3426
 
3128
- <div className="flex flex-1 flex-col gap-2">
3427
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-1 pr-0.5">
3129
3428
  <AnimatePresence initial={false}>
3130
3429
  {filteredTaskColumns[column.id].map((task) => {
3131
3430
  const tags = getTaskTags(task);
@@ -3853,6 +4152,19 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3853
4152
  'rounded-2xl border bg-card p-4 shadow-sm',
3854
4153
  isLimitedView ? 'xl:col-span-12' : 'xl:col-span-8',
3855
4154
  ].join(' ')}
4155
+ actions={
4156
+ !isLimitedView ? (
4157
+ <Button
4158
+ size="sm"
4159
+ variant="outline"
4160
+ className="cursor-pointer"
4161
+ onClick={openAddAssignment}
4162
+ >
4163
+ <UserPlus className="mr-1.5 size-4" />
4164
+ {t('teamPanel.addCollaborator')}
4165
+ </Button>
4166
+ ) : undefined
4167
+ }
3856
4168
  >
3857
4169
  {project.assignments.length > 0 ? (
3858
4170
  <div className="space-y-4">
@@ -3960,6 +4272,34 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
3960
4272
  <ToneIcon className="size-3" />
3961
4273
  {t(`teamPanel.status.${tone.labelKey}`)}
3962
4274
  </span>
4275
+ {!isLimitedView ? (
4276
+ <div className="flex items-center gap-1">
4277
+ <Button
4278
+ type="button"
4279
+ variant="ghost"
4280
+ size="icon"
4281
+ className="size-7 cursor-pointer"
4282
+ onClick={() =>
4283
+ openEditAssignment(assignment)
4284
+ }
4285
+ >
4286
+ <Pencil className="size-3.5" />
4287
+ </Button>
4288
+ <Button
4289
+ type="button"
4290
+ variant="ghost"
4291
+ size="icon"
4292
+ className="size-7 cursor-pointer text-destructive hover:text-destructive"
4293
+ onClick={() =>
4294
+ setRemovingAssignmentId(
4295
+ assignment.collaboratorId
4296
+ )
4297
+ }
4298
+ >
4299
+ <Trash2 className="size-3.5" />
4300
+ </Button>
4301
+ </div>
4302
+ ) : null}
3963
4303
  </div>
3964
4304
  </div>
3965
4305
  </div>
@@ -4260,6 +4600,347 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
4260
4600
  }
4261
4601
  />
4262
4602
 
4603
+ {/* Assignment Add/Edit Sheet */}
4604
+ <Sheet
4605
+ open={assignmentSheetOpen}
4606
+ onOpenChange={(open) => {
4607
+ if (!open) {
4608
+ setAssignmentSheetOpen(false);
4609
+ setSelectedAssignmentCollaborator(null);
4610
+ }
4611
+ }}
4612
+ >
4613
+ <SheetContent className="w-full overflow-y-auto sm:max-w-[min(92vw,28rem)]">
4614
+ <SheetHeader>
4615
+ <SheetTitle>
4616
+ {editingAssignment
4617
+ ? t('teamPanel.editTitle')
4618
+ : t('teamPanel.addTitle')}
4619
+ </SheetTitle>
4620
+ <SheetDescription>
4621
+ {t('teamPanel.formDescription')}
4622
+ </SheetDescription>
4623
+ </SheetHeader>
4624
+ <div className="mt-6 space-y-4 px-1 sm:px-4">
4625
+ {!editingAssignment ? (
4626
+ <div className="space-y-2">
4627
+ <Label className="text-sm font-medium">
4628
+ {commonT('labels.collaborator')}
4629
+ </Label>
4630
+ <EntityPicker<OperationsCollaborator>
4631
+ value={
4632
+ assignmentFormData.collaboratorId
4633
+ ? Number(assignmentFormData.collaboratorId)
4634
+ : null
4635
+ }
4636
+ onChange={(val, option) => {
4637
+ const collaborator = option ?? null;
4638
+ setSelectedAssignmentCollaborator(collaborator);
4639
+ setAssignmentFormData((prev) => {
4640
+ const collaboratorId = val ? String(val) : '';
4641
+ if (!collaboratorId) {
4642
+ return {
4643
+ ...prev,
4644
+ collaboratorId: '',
4645
+ weeklyHours: '',
4646
+ allocationPercent: '',
4647
+ };
4648
+ }
4649
+
4650
+ const capacity = collaborator?.weeklyCapacityHours;
4651
+ if (!capacity || capacity <= 0) {
4652
+ return {
4653
+ ...prev,
4654
+ collaboratorId,
4655
+ };
4656
+ }
4657
+
4658
+ const parsedHours = parseNumberInput(prev.weeklyHours);
4659
+ const parsedPercent = parseNumberInput(
4660
+ prev.allocationPercent
4661
+ );
4662
+
4663
+ if (parsedHours != null) {
4664
+ return {
4665
+ ...prev,
4666
+ collaboratorId,
4667
+ ...syncAssignmentFromWeeklyHours(
4668
+ prev.weeklyHours,
4669
+ prev,
4670
+ collaborator
4671
+ ),
4672
+ };
4673
+ }
4674
+
4675
+ if (parsedPercent != null) {
4676
+ return {
4677
+ ...prev,
4678
+ collaboratorId,
4679
+ ...syncAssignmentFromAllocation(
4680
+ prev.allocationPercent,
4681
+ prev,
4682
+ collaborator
4683
+ ),
4684
+ };
4685
+ }
4686
+
4687
+ return {
4688
+ ...prev,
4689
+ collaboratorId,
4690
+ weeklyHours: formatAssignmentNumericValue(capacity),
4691
+ allocationPercent: '100',
4692
+ };
4693
+ });
4694
+ }}
4695
+ placeholder={commonT('labels.collaborator')}
4696
+ searchPlaceholder={commonT('labels.collaborator')}
4697
+ loadOptions={loadCollaboratorOptions}
4698
+ getOptionValue={(opt) => opt.id}
4699
+ getOptionLabel={(opt) => opt.displayName}
4700
+ renderOption={({ option }) => (
4701
+ <div className="flex min-w-0 items-center gap-2.5">
4702
+ <Avatar className="size-6 shrink-0">
4703
+ <AvatarImage
4704
+ src={
4705
+ getUserPhotoUrl(option.userPhotoId) ||
4706
+ getPersonAvatarUrl(option.personAvatarId)
4707
+ }
4708
+ alt={option.displayName}
4709
+ />
4710
+ <AvatarFallback className="text-[9px]">
4711
+ {getInitials(option.displayName)}
4712
+ </AvatarFallback>
4713
+ </Avatar>
4714
+ <div className="min-w-0">
4715
+ <div className="truncate text-sm">
4716
+ {option.displayName}
4717
+ </div>
4718
+ {option.department ? (
4719
+ <div className="truncate text-xs text-muted-foreground">
4720
+ {option.department}
4721
+ </div>
4722
+ ) : null}
4723
+ </div>
4724
+ </div>
4725
+ )}
4726
+ valueType="number"
4727
+ clearable
4728
+ allowEmptySelection
4729
+ showCreateButton
4730
+ entityLabel={commonT('labels.collaborator').toLowerCase()}
4731
+ createActionLabel={`${commonT('actions.create')} ${commonT(
4732
+ 'labels.collaborator'
4733
+ ).toLowerCase()}`}
4734
+ createTitle={`${commonT('actions.create')} ${commonT(
4735
+ 'labels.collaborator'
4736
+ ).toLowerCase()}`}
4737
+ createDescription={t('teamPanel.formDescription')}
4738
+ createFields={[
4739
+ {
4740
+ name: 'displayName',
4741
+ label: collaboratorFormT('fields.displayName'),
4742
+ placeholder: collaboratorFormT('fields.displayName'),
4743
+ required: true,
4744
+ },
4745
+ {
4746
+ name: 'weeklyCapacityHours',
4747
+ label: collaboratorFormT('fields.weeklyCapacityHours'),
4748
+ placeholder: '40',
4749
+ type: 'number',
4750
+ },
4751
+ ]}
4752
+ mapSearchToCreateValues={(search) => ({
4753
+ displayName: search,
4754
+ weeklyCapacityHours: '40',
4755
+ })}
4756
+ onCreate={createAssignmentCollaborator}
4757
+ />
4758
+ </div>
4759
+ ) : (
4760
+ <div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
4761
+ <Avatar className="size-10 border bg-muted">
4762
+ <AvatarImage
4763
+ src={
4764
+ getUserPhotoUrl(editingAssignment.userPhotoId) ||
4765
+ getPersonAvatarUrl(editingAssignment.personAvatarId)
4766
+ }
4767
+ alt={editingAssignment.collaboratorName}
4768
+ />
4769
+ <AvatarFallback className="text-xs">
4770
+ {getInitials(editingAssignment.collaboratorName)}
4771
+ </AvatarFallback>
4772
+ </Avatar>
4773
+ <div className="min-w-0">
4774
+ <div className="truncate text-sm font-semibold">
4775
+ {editingAssignment.collaboratorName}
4776
+ </div>
4777
+ {editingAssignment.roleLabel ? (
4778
+ <div className="truncate text-xs text-muted-foreground">
4779
+ {editingAssignment.roleLabel}
4780
+ </div>
4781
+ ) : null}
4782
+ </div>
4783
+ </div>
4784
+ )}
4785
+ <div className="space-y-2">
4786
+ <Label className="text-sm font-medium">
4787
+ {commonT('labels.allocationPercent')}
4788
+ </Label>
4789
+ <Input
4790
+ type="number"
4791
+ min="0"
4792
+ max="200"
4793
+ value={assignmentFormData.allocationPercent}
4794
+ onChange={(e) =>
4795
+ setAssignmentFormData((prev) => ({
4796
+ ...prev,
4797
+ ...syncAssignmentFromAllocation(
4798
+ e.target.value,
4799
+ prev,
4800
+ selectedAssignmentCollaborator
4801
+ ),
4802
+ }))
4803
+ }
4804
+ placeholder="100"
4805
+ />
4806
+ </div>
4807
+ <div className="space-y-2">
4808
+ <Label className="text-sm font-medium">
4809
+ {commonT('labels.weeklyCapacity')}
4810
+ </Label>
4811
+ <Input
4812
+ type="number"
4813
+ min="0"
4814
+ value={assignmentFormData.weeklyHours}
4815
+ onChange={(e) =>
4816
+ setAssignmentFormData((prev) => ({
4817
+ ...prev,
4818
+ ...syncAssignmentFromWeeklyHours(
4819
+ e.target.value,
4820
+ prev,
4821
+ selectedAssignmentCollaborator
4822
+ ),
4823
+ }))
4824
+ }
4825
+ placeholder="40"
4826
+ />
4827
+ </div>
4828
+ <div className="space-y-2">
4829
+ <Label className="text-sm font-medium">
4830
+ {commonT('labels.status')}
4831
+ </Label>
4832
+ <Select
4833
+ value={assignmentFormData.status}
4834
+ onValueChange={(val) =>
4835
+ setAssignmentFormData((prev) => ({ ...prev, status: val }))
4836
+ }
4837
+ >
4838
+ <SelectTrigger className="cursor-pointer">
4839
+ <SelectValue />
4840
+ </SelectTrigger>
4841
+ <SelectContent>
4842
+ <SelectItem value="active">
4843
+ {formatEnumLabel('active')}
4844
+ </SelectItem>
4845
+ <SelectItem value="inactive">
4846
+ {formatEnumLabel('inactive')}
4847
+ </SelectItem>
4848
+ <SelectItem value="completed">
4849
+ {formatEnumLabel('completed')}
4850
+ </SelectItem>
4851
+ </SelectContent>
4852
+ </Select>
4853
+ </div>
4854
+ <div className="grid grid-cols-2 gap-3">
4855
+ <div className="space-y-2">
4856
+ <Label className="text-sm font-medium">
4857
+ {commonT('labels.startDate')}
4858
+ </Label>
4859
+ <Input
4860
+ type="date"
4861
+ value={assignmentFormData.startDate}
4862
+ onChange={(e) =>
4863
+ setAssignmentFormData((prev) => ({
4864
+ ...prev,
4865
+ startDate: e.target.value,
4866
+ }))
4867
+ }
4868
+ />
4869
+ </div>
4870
+ <div className="space-y-2">
4871
+ <Label className="text-sm font-medium">
4872
+ {commonT('labels.endDate')}
4873
+ </Label>
4874
+ <Input
4875
+ type="date"
4876
+ value={assignmentFormData.endDate}
4877
+ onChange={(e) =>
4878
+ setAssignmentFormData((prev) => ({
4879
+ ...prev,
4880
+ endDate: e.target.value,
4881
+ }))
4882
+ }
4883
+ />
4884
+ </div>
4885
+ </div>
4886
+ <div className="flex gap-2 pt-2">
4887
+ <Button
4888
+ variant="outline"
4889
+ className="flex-1 cursor-pointer"
4890
+ onClick={() => setAssignmentSheetOpen(false)}
4891
+ disabled={savingAssignment}
4892
+ >
4893
+ {commonT('actions.cancel')}
4894
+ </Button>
4895
+ <Button
4896
+ className="flex-1 cursor-pointer"
4897
+ disabled={
4898
+ savingAssignment ||
4899
+ (!editingAssignment && !assignmentFormData.collaboratorId)
4900
+ }
4901
+ onClick={() => void handleSaveAssignment()}
4902
+ >
4903
+ {savingAssignment ? (
4904
+ <Loader2 className="mr-2 size-4 animate-spin" />
4905
+ ) : null}
4906
+ {commonT('actions.save')}
4907
+ </Button>
4908
+ </div>
4909
+ </div>
4910
+ </SheetContent>
4911
+ </Sheet>
4912
+
4913
+ {/* Remove Assignment Confirm Dialog */}
4914
+ <Dialog
4915
+ open={removingAssignmentId !== null}
4916
+ onOpenChange={(open) => {
4917
+ if (!open) setRemovingAssignmentId(null);
4918
+ }}
4919
+ >
4920
+ <DialogContent className="sm:max-w-sm">
4921
+ <DialogHeader>
4922
+ <DialogTitle>{t('teamPanel.removeTitle')}</DialogTitle>
4923
+ <DialogDescription>
4924
+ {t('teamPanel.removeDescription')}
4925
+ </DialogDescription>
4926
+ </DialogHeader>
4927
+ <DialogFooter className="mt-4">
4928
+ <Button
4929
+ variant="outline"
4930
+ onClick={() => setRemovingAssignmentId(null)}
4931
+ >
4932
+ {commonT('actions.cancel')}
4933
+ </Button>
4934
+ <Button
4935
+ variant="destructive"
4936
+ onClick={() => void handleConfirmRemoveAssignment()}
4937
+ >
4938
+ {commonT('actions.delete')}
4939
+ </Button>
4940
+ </DialogFooter>
4941
+ </DialogContent>
4942
+ </Dialog>
4943
+
4263
4944
  <TimesheetEntryCreateSheet
4264
4945
  open={isTimesheetEntrySheetOpen}
4265
4946
  onOpenChange={(open) => {
@@ -4341,7 +5022,10 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
4341
5022
  )
4342
5023
  : undefined
4343
5024
  }
4344
- onCountChanged={() => { setBoardState(null); void refetchTasks(); }}
5025
+ onCountChanged={() => {
5026
+ setBoardState(null);
5027
+ void refetchTasks();
5028
+ }}
4345
5029
  onSaved={() => {
4346
5030
  setBoardState(null);
4347
5031
  void refetchTasks();