@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.
- package/dist/controllers/operations-collaborators.controller.d.ts +54 -0
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +100 -0
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/dto/create-collaborator-invoice.dto.d.ts +11 -0
- package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-invoice.dto.js +55 -0
- package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
- package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-payment.dto.js +50 -0
- package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
- package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
- package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-invoice.dto.js +8 -0
- package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
- package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-payment.dto.js +8 -0
- package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
- package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-invoice.dto.js +9 -0
- package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-payment.dto.js +9 -0
- package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
- package/dist/operations.service.d.ts +76 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +235 -5
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/menu.yaml +27 -8
- package/hedhog/data/route.yaml +72 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +39 -3
- package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
- package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +86 -87
- package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +218 -10
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +710 -26
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +158 -38
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +807 -803
- package/hedhog/frontend/app/_lib/api.ts.ejs +631 -480
- package/hedhog/frontend/app/_lib/types.ts.ejs +6 -5
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +16 -2
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +95 -157
- package/hedhog/frontend/app/projects/page.tsx.ejs +42 -6
- package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
- package/hedhog/frontend/messages/en.json +96 -2
- package/hedhog/frontend/messages/pt.json +96 -2
- package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
- package/hedhog/table/operations_collaborator_payment.yaml +32 -0
- package/package.json +5 -5
- package/src/controllers/operations-collaborators.controller.ts +117 -8
- package/src/dto/create-collaborator-invoice.dto.ts +39 -0
- package/src/dto/create-collaborator-payment.dto.ts +35 -0
- package/src/dto/list-collaborator-invoice.dto.ts +3 -0
- package/src/dto/list-collaborator-payment.dto.ts +3 -0
- package/src/dto/update-collaborator-invoice.dto.ts +6 -0
- package/src/dto/update-collaborator-payment.dto.ts +6 -0
- 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 {
|
|
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:
|
|
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">
|
|
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">
|
|
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>
|
|
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
|
+
}
|