@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
@@ -10,9 +10,17 @@ import {
10
10
  SheetHeader,
11
11
  SheetTitle,
12
12
  } from '@/components/ui/sheet';
13
- import { Check, Loader2, Pencil, Plus, X } from 'lucide-react';
13
+ import { Switch } from '@/components/ui/switch';
14
+ import {
15
+ Check,
16
+ Loader2,
17
+ Pencil,
18
+ Plus,
19
+ SlidersHorizontal,
20
+ X,
21
+ } from 'lucide-react';
14
22
  import { useTranslations } from 'next-intl';
15
- import { useState } from 'react';
23
+ import { useEffect, useState } from 'react';
16
24
  import { fetchOperations, mutateOperations } from '../_lib/api';
17
25
  import type {
18
26
  OperationsCollaboratorDetails,
@@ -29,6 +37,7 @@ import { ProjectFormScreen } from './project-form-screen';
29
37
  import { StatusBadge } from './status-badge';
30
38
 
31
39
  const PAGE_SIZE = 10;
40
+ const LS_AUTO_REBALANCE_KEY = 'operations.projectAllocation.autoRebalance';
32
41
 
33
42
  type AssignmentEditState = {
34
43
  roleLabel: string;
@@ -53,14 +62,26 @@ export function ProjectAssignmentsTab({
53
62
  }: ProjectAssignmentsTabProps) {
54
63
  const commonT = useTranslations('operations.Common');
55
64
  const detailsT = useTranslations('operations.CollaboratorDetailsPage');
65
+ const allocT = useTranslations(
66
+ 'operations.CollaboratorFormPage.projectAllocation'
67
+ );
56
68
 
57
69
  const [page, setPage] = useState(0);
58
70
  const [editingId, setEditingId] = useState<number | null>(null);
59
71
  const [editState, setEditState] = useState<AssignmentEditState | null>(null);
60
72
  const [saving, setSaving] = useState(false);
73
+ const [distributing, setDistributing] = useState(false);
61
74
  const [adding, setAdding] = useState(false);
62
75
  const [createSheetOpen, setCreateSheetOpen] = useState(false);
63
76
  const [pickerKey, setPickerKey] = useState(0);
77
+ const [autoRebalance, setAutoRebalance] = useState(() => {
78
+ if (typeof window === 'undefined') return false;
79
+ return localStorage.getItem(LS_AUTO_REBALANCE_KEY) === 'true';
80
+ });
81
+
82
+ useEffect(() => {
83
+ localStorage.setItem(LS_AUTO_REBALANCE_KEY, String(autoRebalance));
84
+ }, [autoRebalance]);
64
85
 
65
86
  const projects = collaborator?.assignedProjects ?? [];
66
87
  const assignedIds = new Set(projects.map((p) => p.id));
@@ -70,6 +91,29 @@ export function ProjectAssignmentsTab({
70
91
  (page + 1) * PAGE_SIZE
71
92
  );
72
93
 
94
+ const savedTotal = projects.reduce(
95
+ (sum, p) => sum + (p.allocationPercent ?? 0),
96
+ 0
97
+ );
98
+
99
+ // When editing, show projected total
100
+ const editingProject =
101
+ editingId != null ? projects.find((p) => p.id === editingId) : null;
102
+ const projectedTotal =
103
+ editState && editingProject
104
+ ? projects.reduce((sum, p) => {
105
+ if (p.id === editingId) {
106
+ return sum + (parseNumberInput(editState.allocationPercent) ?? 0);
107
+ }
108
+ return sum + (p.allocationPercent ?? 0);
109
+ }, 0)
110
+ : savedTotal;
111
+
112
+ const displayTotal = editState ? projectedTotal : savedTotal;
113
+
114
+ const allocationWarning =
115
+ displayTotal < 100 ? 'idle' : displayTotal > 100 ? 'overloaded' : null;
116
+
73
117
  const startEditing = (
74
118
  project: OperationsCollaboratorDetails['assignedProjects'][number]
75
119
  ) => {
@@ -98,6 +142,8 @@ export function ProjectAssignmentsTab({
98
142
  if (!collaborator || !editState) return;
99
143
  setSaving(true);
100
144
  try {
145
+ const newPercent = parseNumberInput(editState.allocationPercent);
146
+
101
147
  await mutateOperations(
102
148
  request,
103
149
  `/operations/collaborators/${collaborator.id}/projects/${projectId}`,
@@ -105,11 +151,66 @@ export function ProjectAssignmentsTab({
105
151
  {
106
152
  roleLabel: trimToNull(editState.roleLabel),
107
153
  weeklyHours: parseNumberInput(editState.weeklyHours),
108
- allocationPercent: parseNumberInput(editState.allocationPercent),
154
+ allocationPercent: newPercent,
109
155
  startDate: trimToNull(editState.startDate),
110
156
  endDate: trimToNull(editState.endDate),
111
157
  }
112
158
  );
159
+
160
+ // Auto-rebalance: distribute remaining % among other projects
161
+ if (autoRebalance && newPercent != null) {
162
+ const others = projects.filter((p) => p.id !== projectId);
163
+ if (others.length > 0) {
164
+ const remaining = 100 - newPercent;
165
+ const othersTotal = others.reduce(
166
+ (sum, p) => sum + (p.allocationPercent ?? 0),
167
+ 0
168
+ );
169
+ await Promise.all(
170
+ others.map((p, idx) => {
171
+ let newValue: number;
172
+ if (othersTotal > 0) {
173
+ newValue =
174
+ Math.round(
175
+ ((p.allocationPercent ?? 0) / othersTotal) * remaining * 100
176
+ ) / 100;
177
+ } else {
178
+ newValue = Math.round((remaining / others.length) * 100) / 100;
179
+ }
180
+ // Ensure the last item corrects rounding drift
181
+ if (idx === others.length - 1) {
182
+ const alreadyAssigned = others
183
+ .slice(0, idx)
184
+ .reduce((sum, op) => {
185
+ if (othersTotal > 0) {
186
+ return (
187
+ sum +
188
+ Math.round(
189
+ ((op.allocationPercent ?? 0) / othersTotal) *
190
+ remaining *
191
+ 100
192
+ ) /
193
+ 100
194
+ );
195
+ }
196
+ return (
197
+ sum + Math.round((remaining / others.length) * 100) / 100
198
+ );
199
+ }, 0);
200
+ newValue =
201
+ Math.round((remaining - alreadyAssigned) * 100) / 100;
202
+ }
203
+ return mutateOperations(
204
+ request,
205
+ `/operations/collaborators/${collaborator.id}/projects/${p.id}`,
206
+ 'PATCH',
207
+ { allocationPercent: newValue }
208
+ );
209
+ })
210
+ );
211
+ }
212
+ }
213
+
113
214
  setEditingId(null);
114
215
  setEditState(null);
115
216
  onUpdated();
@@ -120,6 +221,37 @@ export function ProjectAssignmentsTab({
120
221
  }
121
222
  };
122
223
 
224
+ const distributeEqually = async () => {
225
+ if (!collaborator) return;
226
+ const activeProjects = projects.filter((p) => p.status === 'active');
227
+ const targetProjects =
228
+ activeProjects.length > 0 ? activeProjects : projects;
229
+ if (targetProjects.length === 0) return;
230
+ setDistributing(true);
231
+ try {
232
+ const equalShare = Math.floor((100 / targetProjects.length) * 100) / 100;
233
+ const remainder =
234
+ Math.round((100 - equalShare * (targetProjects.length - 1)) * 100) /
235
+ 100;
236
+ await Promise.all(
237
+ targetProjects.map((p, idx) =>
238
+ mutateOperations(
239
+ request,
240
+ `/operations/collaborators/${collaborator.id}/projects/${p.id}`,
241
+ 'PATCH',
242
+ {
243
+ allocationPercent:
244
+ idx === targetProjects.length - 1 ? remainder : equalShare,
245
+ }
246
+ )
247
+ )
248
+ );
249
+ onUpdated();
250
+ } finally {
251
+ setDistributing(false);
252
+ }
253
+ };
254
+
123
255
  const assignProject = async (projectId: number) => {
124
256
  if (!collaborator) return;
125
257
  setAdding(true);
@@ -139,6 +271,72 @@ export function ProjectAssignmentsTab({
139
271
 
140
272
  return (
141
273
  <div className="space-y-3">
274
+ {/* Allocation summary bar */}
275
+ {projects.length > 0 && (
276
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2">
277
+ <div className="flex items-center gap-3">
278
+ <span className="text-xs text-muted-foreground">
279
+ {allocT('total')}:
280
+ </span>
281
+ <span
282
+ className={`text-sm font-semibold ${
283
+ allocationWarning === 'overloaded'
284
+ ? 'text-destructive'
285
+ : allocationWarning === 'idle'
286
+ ? 'text-amber-600 dark:text-amber-400'
287
+ : 'text-green-600 dark:text-green-400'
288
+ }`}
289
+ >
290
+ {displayTotal}%
291
+ </span>
292
+ {allocationWarning === 'overloaded' && (
293
+ <span className="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">
294
+ {allocT('overloaded')} +
295
+ {Math.round((displayTotal - 100) * 100) / 100}%
296
+ </span>
297
+ )}
298
+ {allocationWarning === 'idle' && (
299
+ <span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
300
+ {allocT('idle')} -{Math.round((100 - displayTotal) * 100) / 100}
301
+ %
302
+ </span>
303
+ )}
304
+ </div>
305
+ {!disabled && (
306
+ <div className="flex items-center gap-3">
307
+ {/* Auto-rebalance toggle */}
308
+ <div className="flex items-center gap-1.5">
309
+ <SlidersHorizontal className="size-3 text-muted-foreground" />
310
+ <span className="text-[11px] text-muted-foreground">
311
+ {allocT('autoRebalance')}
312
+ </span>
313
+ <Switch
314
+ checked={autoRebalance}
315
+ onCheckedChange={setAutoRebalance}
316
+ className="scale-75"
317
+ />
318
+ </div>
319
+ {/* Distribute equally */}
320
+ <Button
321
+ type="button"
322
+ variant="outline"
323
+ size="sm"
324
+ className="h-6 px-2 text-[11px]"
325
+ disabled={distributing || projects.length === 0}
326
+ onClick={() => void distributeEqually()}
327
+ >
328
+ {distributing ? (
329
+ <Loader2 className="mr-1 size-3 animate-spin" />
330
+ ) : null}
331
+ {distributing
332
+ ? allocT('distributing')
333
+ : allocT('distributeEqually')}
334
+ </Button>
335
+ </div>
336
+ )}
337
+ </div>
338
+ )}
339
+
142
340
  {!disabled ? (
143
341
  <div className="flex items-center gap-2">
144
342
  <div className="min-w-0 flex-1">
@@ -161,8 +359,7 @@ export function ProjectAssignmentsTab({
161
359
  return {
162
360
  items: res.data.filter((proj) => !assignedIds.has(proj.id)),
163
361
  hasMore:
164
- (res.page ?? p) * (res.pageSize ?? ps) <
165
- (res.total ?? 0),
362
+ (res.page ?? p) * (res.pageSize ?? ps) < (res.total ?? 0),
166
363
  };
167
364
  }}
168
365
  getOptionValue={(opt) => opt.id}
@@ -194,7 +391,9 @@ export function ProjectAssignmentsTab({
194
391
  ) : null}
195
392
 
196
393
  {projects.length === 0 ? (
197
- <p className="text-sm text-muted-foreground">{detailsT('noProjects')}</p>
394
+ <p className="text-sm text-muted-foreground">
395
+ {detailsT('noProjects')}
396
+ </p>
198
397
  ) : (
199
398
  <>
200
399
  <div className="space-y-2">
@@ -243,8 +442,13 @@ export function ProjectAssignmentsTab({
243
442
  />
244
443
  </div>
245
444
  <div className="space-y-1">
246
- <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
445
+ <div className="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
247
446
  {commonT('labels.allocationPercent')}
447
+ {autoRebalance && (
448
+ <span className="rounded bg-primary/10 px-1 text-[9px] text-primary">
449
+ auto
450
+ </span>
451
+ )}
248
452
  </div>
249
453
  <div className="relative">
250
454
  <Input
@@ -370,7 +574,9 @@ export function ProjectAssignmentsTab({
370
574
  onClick={() => startEditing(project)}
371
575
  >
372
576
  <Pencil className="size-3" />
373
- <span className="sr-only">{commonT('actions.edit')}</span>
577
+ <span className="sr-only">
578
+ {commonT('actions.edit')}
579
+ </span>
374
580
  </Button>
375
581
  ) : null}
376
582
  </div>
@@ -417,7 +623,9 @@ export function ProjectAssignmentsTab({
417
623
  <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
418
624
  <SheetHeader>
419
625
  <SheetTitle>{detailsT('createProject')}</SheetTitle>
420
- <SheetDescription>{detailsT('createProjectDescription')}</SheetDescription>
626
+ <SheetDescription>
627
+ {detailsT('createProjectDescription')}
628
+ </SheetDescription>
421
629
  </SheetHeader>
422
630
  <ProjectFormScreen
423
631
  onCancel={() => setCreateSheetOpen(false)}
@@ -432,4 +640,4 @@ export function ProjectAssignmentsTab({
432
640
  </Sheet>
433
641
  </div>
434
642
  );
435
- }
643
+ }