@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
@@ -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,28 @@ 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
+ const editingProject =
100
+ editingId != null ? projects.find((p) => p.id === editingId) : null;
101
+ const projectedTotal =
102
+ editState && editingProject
103
+ ? projects.reduce((sum, p) => {
104
+ if (p.id === editingId) {
105
+ return sum + (parseNumberInput(editState.allocationPercent) ?? 0);
106
+ }
107
+ return sum + (p.allocationPercent ?? 0);
108
+ }, 0)
109
+ : savedTotal;
110
+
111
+ const displayTotal = editState ? projectedTotal : savedTotal;
112
+
113
+ const allocationWarning =
114
+ displayTotal < 100 ? 'idle' : displayTotal > 100 ? 'overloaded' : null;
115
+
73
116
  const startEditing = (
74
117
  project: OperationsCollaboratorDetails['assignedProjects'][number]
75
118
  ) => {
@@ -98,6 +141,8 @@ export function ProjectAssignmentsTab({
98
141
  if (!collaborator || !editState) return;
99
142
  setSaving(true);
100
143
  try {
144
+ const newPercent = parseNumberInput(editState.allocationPercent);
145
+
101
146
  await mutateOperations(
102
147
  request,
103
148
  `/operations/collaborators/${collaborator.id}/projects/${projectId}`,
@@ -105,11 +150,64 @@ export function ProjectAssignmentsTab({
105
150
  {
106
151
  roleLabel: trimToNull(editState.roleLabel),
107
152
  weeklyHours: parseNumberInput(editState.weeklyHours),
108
- allocationPercent: parseNumberInput(editState.allocationPercent),
153
+ allocationPercent: newPercent,
109
154
  startDate: trimToNull(editState.startDate),
110
155
  endDate: trimToNull(editState.endDate),
111
156
  }
112
157
  );
158
+
159
+ if (autoRebalance && newPercent != null) {
160
+ const others = projects.filter((p) => p.id !== projectId);
161
+ if (others.length > 0) {
162
+ const remaining = 100 - newPercent;
163
+ const othersTotal = others.reduce(
164
+ (sum, p) => sum + (p.allocationPercent ?? 0),
165
+ 0
166
+ );
167
+ await Promise.all(
168
+ others.map((p, idx) => {
169
+ let newValue: number;
170
+ if (othersTotal > 0) {
171
+ newValue =
172
+ Math.round(
173
+ ((p.allocationPercent ?? 0) / othersTotal) * remaining * 100
174
+ ) / 100;
175
+ } else {
176
+ newValue = Math.round((remaining / others.length) * 100) / 100;
177
+ }
178
+ if (idx === others.length - 1) {
179
+ const alreadyAssigned = others
180
+ .slice(0, idx)
181
+ .reduce((sum, op) => {
182
+ if (othersTotal > 0) {
183
+ return (
184
+ sum +
185
+ Math.round(
186
+ ((op.allocationPercent ?? 0) / othersTotal) *
187
+ remaining *
188
+ 100
189
+ ) /
190
+ 100
191
+ );
192
+ }
193
+ return (
194
+ sum + Math.round((remaining / others.length) * 100) / 100
195
+ );
196
+ }, 0);
197
+ newValue =
198
+ Math.round((remaining - alreadyAssigned) * 100) / 100;
199
+ }
200
+ return mutateOperations(
201
+ request,
202
+ `/operations/collaborators/${collaborator.id}/projects/${p.id}`,
203
+ 'PATCH',
204
+ { allocationPercent: newValue }
205
+ );
206
+ })
207
+ );
208
+ }
209
+ }
210
+
113
211
  setEditingId(null);
114
212
  setEditState(null);
115
213
  onUpdated();
@@ -120,6 +218,37 @@ export function ProjectAssignmentsTab({
120
218
  }
121
219
  };
122
220
 
221
+ const distributeEqually = async () => {
222
+ if (!collaborator) return;
223
+ const activeProjects = projects.filter((p) => p.status === 'active');
224
+ const targetProjects =
225
+ activeProjects.length > 0 ? activeProjects : projects;
226
+ if (targetProjects.length === 0) return;
227
+ setDistributing(true);
228
+ try {
229
+ const equalShare = Math.floor((100 / targetProjects.length) * 100) / 100;
230
+ const remainder =
231
+ Math.round((100 - equalShare * (targetProjects.length - 1)) * 100) /
232
+ 100;
233
+ await Promise.all(
234
+ targetProjects.map((p, idx) =>
235
+ mutateOperations(
236
+ request,
237
+ `/operations/collaborators/${collaborator.id}/projects/${p.id}`,
238
+ 'PATCH',
239
+ {
240
+ allocationPercent:
241
+ idx === targetProjects.length - 1 ? remainder : equalShare,
242
+ }
243
+ )
244
+ )
245
+ );
246
+ onUpdated();
247
+ } finally {
248
+ setDistributing(false);
249
+ }
250
+ };
251
+
123
252
  const assignProject = async (projectId: number) => {
124
253
  if (!collaborator) return;
125
254
  setAdding(true);
@@ -139,6 +268,69 @@ export function ProjectAssignmentsTab({
139
268
 
140
269
  return (
141
270
  <div className="space-y-3">
271
+ {projects.length > 0 && (
272
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2">
273
+ <div className="flex items-center gap-3">
274
+ <span className="text-xs text-muted-foreground">
275
+ {allocT('total')}:
276
+ </span>
277
+ <span
278
+ className={`text-sm font-semibold ${
279
+ allocationWarning === 'overloaded'
280
+ ? 'text-destructive'
281
+ : allocationWarning === 'idle'
282
+ ? 'text-amber-600 dark:text-amber-400'
283
+ : 'text-green-600 dark:text-green-400'
284
+ }`}
285
+ >
286
+ {displayTotal}%
287
+ </span>
288
+ {allocationWarning === 'overloaded' && (
289
+ <span className="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">
290
+ {allocT('overloaded')} +
291
+ {Math.round((displayTotal - 100) * 100) / 100}%
292
+ </span>
293
+ )}
294
+ {allocationWarning === 'idle' && (
295
+ <span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
296
+ {allocT('idle')} -{Math.round((100 - displayTotal) * 100) / 100}
297
+ %
298
+ </span>
299
+ )}
300
+ </div>
301
+ {!disabled && (
302
+ <div className="flex items-center gap-3">
303
+ <div className="flex items-center gap-1.5">
304
+ <SlidersHorizontal className="size-3 text-muted-foreground" />
305
+ <span className="text-[11px] text-muted-foreground">
306
+ {allocT('autoRebalance')}
307
+ </span>
308
+ <Switch
309
+ checked={autoRebalance}
310
+ onCheckedChange={setAutoRebalance}
311
+ className="scale-75"
312
+ />
313
+ </div>
314
+ <Button
315
+ type="button"
316
+ variant="outline"
317
+ size="sm"
318
+ className="h-6 px-2 text-[11px]"
319
+ disabled={distributing || projects.length === 0}
320
+ onClick={() => void distributeEqually()}
321
+ >
322
+ {distributing ? (
323
+ <Loader2 className="mr-1 size-3 animate-spin" />
324
+ ) : null}
325
+ {distributing
326
+ ? allocT('distributing')
327
+ : allocT('distributeEqually')}
328
+ </Button>
329
+ </div>
330
+ )}
331
+ </div>
332
+ )}
333
+
142
334
  {!disabled ? (
143
335
  <div className="flex items-center gap-2">
144
336
  <div className="min-w-0 flex-1">
@@ -161,8 +353,7 @@ export function ProjectAssignmentsTab({
161
353
  return {
162
354
  items: res.data.filter((proj) => !assignedIds.has(proj.id)),
163
355
  hasMore:
164
- (res.page ?? p) * (res.pageSize ?? ps) <
165
- (res.total ?? 0),
356
+ (res.page ?? p) * (res.pageSize ?? ps) < (res.total ?? 0),
166
357
  };
167
358
  }}
168
359
  getOptionValue={(opt) => opt.id}
@@ -194,7 +385,9 @@ export function ProjectAssignmentsTab({
194
385
  ) : null}
195
386
 
196
387
  {projects.length === 0 ? (
197
- <p className="text-sm text-muted-foreground">{detailsT('noProjects')}</p>
388
+ <p className="text-sm text-muted-foreground">
389
+ {detailsT('noProjects')}
390
+ </p>
198
391
  ) : (
199
392
  <>
200
393
  <div className="space-y-2">
@@ -243,8 +436,13 @@ export function ProjectAssignmentsTab({
243
436
  />
244
437
  </div>
245
438
  <div className="space-y-1">
246
- <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
439
+ <div className="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
247
440
  {commonT('labels.allocationPercent')}
441
+ {autoRebalance && (
442
+ <span className="rounded bg-primary/10 px-1 text-[9px] text-primary">
443
+ auto
444
+ </span>
445
+ )}
248
446
  </div>
249
447
  <div className="relative">
250
448
  <Input
@@ -370,7 +568,9 @@ export function ProjectAssignmentsTab({
370
568
  onClick={() => startEditing(project)}
371
569
  >
372
570
  <Pencil className="size-3" />
373
- <span className="sr-only">{commonT('actions.edit')}</span>
571
+ <span className="sr-only">
572
+ {commonT('actions.edit')}
573
+ </span>
374
574
  </Button>
375
575
  ) : null}
376
576
  </div>
@@ -417,7 +617,9 @@ export function ProjectAssignmentsTab({
417
617
  <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
418
618
  <SheetHeader>
419
619
  <SheetTitle>{detailsT('createProject')}</SheetTitle>
420
- <SheetDescription>{detailsT('createProjectDescription')}</SheetDescription>
620
+ <SheetDescription>
621
+ {detailsT('createProjectDescription')}
622
+ </SheetDescription>
421
623
  </SheetHeader>
422
624
  <ProjectFormScreen
423
625
  onCancel={() => setCreateSheetOpen(false)}
@@ -432,4 +634,4 @@ export function ProjectAssignmentsTab({
432
634
  </Sheet>
433
635
  </div>
434
636
  );
435
- }
637
+ }