@hed-hog/operations 0.0.317 → 0.0.319

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 (137) hide show
  1. package/dist/controllers/operations-collaborator-costs.controller.d.ts +144 -0
  2. package/dist/controllers/operations-collaborator-costs.controller.d.ts.map +1 -0
  3. package/dist/controllers/operations-collaborator-costs.controller.js +162 -0
  4. package/dist/controllers/operations-collaborator-costs.controller.js.map +1 -0
  5. package/dist/controllers/operations-collaborators.controller.d.ts +14 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +11 -0
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-projects.controller.d.ts +31 -0
  10. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-projects.controller.js +23 -0
  12. package/dist/controllers/operations-projects.controller.js.map +1 -1
  13. package/dist/controllers/operations-reports.controller.d.ts +199 -0
  14. package/dist/controllers/operations-reports.controller.d.ts.map +1 -0
  15. package/dist/controllers/operations-reports.controller.js +53 -0
  16. package/dist/controllers/operations-reports.controller.js.map +1 -0
  17. package/dist/controllers/operations-tasks.controller.d.ts +41 -2
  18. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-tasks.controller.js +17 -5
  20. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  21. package/dist/dto/create-collaborator-cost.dto.d.ts +16 -0
  22. package/dist/dto/create-collaborator-cost.dto.d.ts.map +1 -0
  23. package/dist/dto/create-collaborator-cost.dto.js +88 -0
  24. package/dist/dto/create-collaborator-cost.dto.js.map +1 -0
  25. package/dist/dto/create-collaborator.dto.d.ts +0 -1
  26. package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
  27. package/dist/dto/create-collaborator.dto.js +0 -6
  28. package/dist/dto/create-collaborator.dto.js.map +1 -1
  29. package/dist/dto/create-cost-type.dto.d.ts +13 -0
  30. package/dist/dto/create-cost-type.dto.d.ts.map +1 -0
  31. package/dist/dto/create-cost-type.dto.js +87 -0
  32. package/dist/dto/create-cost-type.dto.js.map +1 -0
  33. package/dist/dto/list-approvals.dto.d.ts +2 -0
  34. package/dist/dto/list-approvals.dto.d.ts.map +1 -1
  35. package/dist/dto/list-approvals.dto.js +10 -0
  36. package/dist/dto/list-approvals.dto.js.map +1 -1
  37. package/dist/dto/list-collaborator-costs.dto.d.ts +5 -0
  38. package/dist/dto/list-collaborator-costs.dto.d.ts.map +1 -0
  39. package/dist/dto/list-collaborator-costs.dto.js +23 -0
  40. package/dist/dto/list-collaborator-costs.dto.js.map +1 -0
  41. package/dist/dto/list-cost-types.dto.d.ts +6 -0
  42. package/dist/dto/list-cost-types.dto.d.ts.map +1 -0
  43. package/dist/dto/list-cost-types.dto.js +35 -0
  44. package/dist/dto/list-cost-types.dto.js.map +1 -0
  45. package/dist/dto/list-my-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-my-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-my-projects.dto.js +23 -0
  48. package/dist/dto/list-my-projects.dto.js.map +1 -0
  49. package/dist/dto/list-my-tasks.dto.d.ts +6 -0
  50. package/dist/dto/list-my-tasks.dto.d.ts.map +1 -0
  51. package/dist/dto/list-my-tasks.dto.js +33 -0
  52. package/dist/dto/list-my-tasks.dto.js.map +1 -0
  53. package/dist/dto/list-projects.dto.d.ts +1 -0
  54. package/dist/dto/list-projects.dto.d.ts.map +1 -1
  55. package/dist/dto/list-projects.dto.js +7 -0
  56. package/dist/dto/list-projects.dto.js.map +1 -1
  57. package/dist/dto/list-reports.dto.d.ts +16 -0
  58. package/dist/dto/list-reports.dto.d.ts.map +1 -0
  59. package/dist/dto/list-reports.dto.js +75 -0
  60. package/dist/dto/list-reports.dto.js.map +1 -0
  61. package/dist/dto/list-tasks.dto.d.ts +2 -0
  62. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  63. package/dist/dto/list-tasks.dto.js +12 -0
  64. package/dist/dto/list-tasks.dto.js.map +1 -1
  65. package/dist/dto/list-timesheets.dto.d.ts +2 -0
  66. package/dist/dto/list-timesheets.dto.d.ts.map +1 -1
  67. package/dist/dto/list-timesheets.dto.js +10 -0
  68. package/dist/dto/list-timesheets.dto.js.map +1 -1
  69. package/dist/dto/update-collaborator-cost.dto.d.ts +6 -0
  70. package/dist/dto/update-collaborator-cost.dto.d.ts.map +1 -0
  71. package/dist/dto/update-collaborator-cost.dto.js +9 -0
  72. package/dist/dto/update-collaborator-cost.dto.js.map +1 -0
  73. package/dist/dto/update-task.dto.d.ts +1 -0
  74. package/dist/dto/update-task.dto.d.ts.map +1 -1
  75. package/dist/dto/update-task.dto.js +6 -0
  76. package/dist/dto/update-task.dto.js.map +1 -1
  77. package/dist/operations.module.d.ts.map +1 -1
  78. package/dist/operations.module.js +4 -0
  79. package/dist/operations.module.js.map +1 -1
  80. package/dist/operations.service.d.ts +457 -3
  81. package/dist/operations.service.d.ts.map +1 -1
  82. package/dist/operations.service.js +1445 -208
  83. package/dist/operations.service.js.map +1 -1
  84. package/dist/operations.service.spec.js +31 -7
  85. package/dist/operations.service.spec.js.map +1 -1
  86. package/hedhog/data/menu.yaml +112 -7
  87. package/hedhog/data/operations_cost_type.yaml +166 -0
  88. package/hedhog/data/route.yaml +185 -0
  89. package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -0
  90. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +94 -15
  91. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +219 -94
  92. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +21 -32
  93. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +178 -89
  94. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +1185 -0
  95. package/hedhog/frontend/app/_components/operations-calendar-view.tsx.ejs +306 -0
  96. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +943 -782
  97. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +223 -0
  98. package/hedhog/frontend/app/_lib/api.ts.ejs +162 -0
  99. package/hedhog/frontend/app/_lib/types.ts.ejs +229 -3
  100. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +11 -3
  101. package/hedhog/frontend/app/approvals/page.tsx.ejs +191 -46
  102. package/hedhog/frontend/app/collaborators/page.tsx.ejs +133 -25
  103. package/hedhog/frontend/app/my-projects/[id]/page.tsx.ejs +11 -0
  104. package/hedhog/frontend/app/my-projects/page.tsx.ejs +440 -0
  105. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +1304 -0
  106. package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -0
  107. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -0
  108. package/hedhog/frontend/app/timesheets/page.tsx.ejs +322 -58
  109. package/hedhog/frontend/messages/en.json +234 -25
  110. package/hedhog/frontend/messages/pt.json +234 -25
  111. package/hedhog/table/operations_collaborator.yaml +0 -4
  112. package/hedhog/table/operations_collaborator_compensation_history.yaml +28 -0
  113. package/hedhog/table/operations_collaborator_cost.yaml +56 -0
  114. package/hedhog/table/operations_cost_type.yaml +38 -0
  115. package/package.json +6 -6
  116. package/src/controllers/operations-collaborator-costs.controller.ts +147 -0
  117. package/src/controllers/operations-collaborators.controller.ts +19 -8
  118. package/src/controllers/operations-projects.controller.ts +19 -8
  119. package/src/controllers/operations-reports.controller.ts +32 -0
  120. package/src/controllers/operations-tasks.controller.ts +32 -12
  121. package/src/dto/create-collaborator-cost.dto.ts +78 -0
  122. package/src/dto/create-collaborator.dto.ts +9 -14
  123. package/src/dto/create-cost-type.dto.ts +62 -0
  124. package/src/dto/list-approvals.dto.ts +8 -0
  125. package/src/dto/list-collaborator-costs.dto.ts +8 -0
  126. package/src/dto/list-cost-types.dto.ts +19 -0
  127. package/src/dto/list-my-projects.dto.ts +8 -0
  128. package/src/dto/list-my-tasks.dto.ts +17 -0
  129. package/src/dto/list-projects.dto.ts +7 -1
  130. package/src/dto/list-reports.dto.ts +51 -0
  131. package/src/dto/list-tasks.dto.ts +11 -1
  132. package/src/dto/list-timesheets.dto.ts +8 -0
  133. package/src/dto/update-collaborator-cost.dto.ts +4 -0
  134. package/src/dto/update-task.dto.ts +6 -0
  135. package/src/operations.module.ts +7 -3
  136. package/src/operations.service.spec.ts +45 -7
  137. package/src/operations.service.ts +1992 -225
@@ -0,0 +1,884 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent } from '@/components/ui/card';
6
+ import { EntityPicker } from '@/components/ui/entity-picker';
7
+ import { Input } from '@/components/ui/input';
8
+ import { InputMoney } from '@/components/ui/input-money';
9
+ import { Label } from '@/components/ui/label';
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@/components/ui/select';
17
+ import { Skeleton } from '@/components/ui/skeleton';
18
+ import { Switch } from '@/components/ui/switch';
19
+ import { Textarea } from '@/components/ui/textarea';
20
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
21
+ import {
22
+ AlertCircle,
23
+ Check,
24
+ DollarSign,
25
+ Pencil,
26
+ Plus,
27
+ Trash2,
28
+ X,
29
+ } from 'lucide-react';
30
+ import { useTranslations } from 'next-intl';
31
+ import { useCallback, useMemo, useState } from 'react';
32
+ import {
33
+ type CollaboratorCost,
34
+ type CostType,
35
+ createCollaboratorCost,
36
+ createCostType,
37
+ deleteCollaboratorCost,
38
+ fetchCollaboratorCosts,
39
+ fetchCostTypes,
40
+ getOperationsErrorMessage,
41
+ updateCollaboratorCost,
42
+ } from '../_lib/api';
43
+ import { formatCurrency, formatDate } from '../_lib/utils/format';
44
+
45
+ const RECURRENCE_VALUES = ['monthly', 'one_time', 'yearly'] as const;
46
+ type Recurrence = (typeof RECURRENCE_VALUES)[number];
47
+
48
+ /** Normalize to monthly equivalent for KPI computation. */
49
+ function toMonthly(
50
+ amount: number,
51
+ recurrence: string,
52
+ depreciationMonths?: number | null
53
+ ): number {
54
+ switch (recurrence) {
55
+ case 'one_time':
56
+ return depreciationMonths && depreciationMonths > 0
57
+ ? amount / depreciationMonths
58
+ : 0;
59
+ case 'monthly':
60
+ return amount;
61
+ case 'yearly':
62
+ return amount / 12;
63
+ default:
64
+ return amount;
65
+ }
66
+ }
67
+
68
+ type CostFormState = {
69
+ costTypeId: number | null;
70
+ description: string;
71
+ amount: string;
72
+ recurrence: Recurrence;
73
+ startDate: string;
74
+ endDate: string;
75
+ allocatable: boolean;
76
+ depreciationMonths: string;
77
+ notes: string;
78
+ };
79
+
80
+ type FormErrors = Partial<
81
+ Record<keyof CostFormState, string> & { submit: string }
82
+ >;
83
+
84
+ function emptyCostForm(): CostFormState {
85
+ return {
86
+ costTypeId: null,
87
+ description: '',
88
+ amount: '',
89
+ recurrence: 'monthly',
90
+ startDate: '',
91
+ endDate: '',
92
+ allocatable: true,
93
+ depreciationMonths: '',
94
+ notes: '',
95
+ };
96
+ }
97
+
98
+ function costToForm(cost: CollaboratorCost): CostFormState {
99
+ return {
100
+ costTypeId: cost.costTypeId,
101
+ description: cost.description ?? '',
102
+ amount: cost.amount,
103
+ recurrence: cost.recurrence as Recurrence,
104
+ startDate: cost.startDate ?? cost.referenceDate ?? '',
105
+ endDate: cost.endDate ?? '',
106
+ allocatable: cost.allocatable ?? true,
107
+ depreciationMonths:
108
+ cost.depreciationMonths != null ? String(cost.depreciationMonths) : '',
109
+ notes: cost.notes ?? '',
110
+ };
111
+ }
112
+
113
+ function validateForm(
114
+ form: CostFormState,
115
+ t: (k: string) => string
116
+ ): FormErrors {
117
+ const errors: FormErrors = {};
118
+
119
+ if (!form.costTypeId) {
120
+ errors.costTypeId = t('validation.required');
121
+ }
122
+
123
+ const amt = parseFloat(
124
+ String(form.amount).replace(/\./g, '').replace(',', '.')
125
+ );
126
+ if (!form.amount || isNaN(amt) || amt <= 0) {
127
+ errors.amount = t('validation.greaterThanZero');
128
+ }
129
+
130
+ if (form.startDate && form.endDate && form.endDate <= form.startDate) {
131
+ errors.endDate = t('validation.endDateAfterStart');
132
+ }
133
+
134
+ if (form.recurrence === 'one_time' && form.depreciationMonths) {
135
+ const months = parseInt(form.depreciationMonths, 10);
136
+ if (isNaN(months) || months < 1) {
137
+ errors.depreciationMonths = t('validation.positiveInteger');
138
+ }
139
+ }
140
+
141
+ return errors;
142
+ }
143
+
144
+ type Props = {
145
+ collaboratorId: number;
146
+ disabled?: boolean;
147
+ remunerationValue?: number;
148
+ weeklyCapacity?: number;
149
+ onCostsChanged?: () => void;
150
+ };
151
+
152
+ export function CollaboratorCostsSection({
153
+ collaboratorId,
154
+ disabled = false,
155
+ weeklyCapacity,
156
+ onCostsChanged,
157
+ }: Props) {
158
+ const t = useTranslations('operations.CollaboratorCostsSection');
159
+ const commonT = useTranslations('operations.Common');
160
+ const { request, showToastHandler, getSettingValue, currentLocaleCode } =
161
+ useApp();
162
+
163
+ const [isAdding, setIsAdding] = useState(false);
164
+ const [editingCostId, setEditingCostId] = useState<number | null>(null);
165
+ const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
166
+ const [form, setForm] = useState<CostFormState>(emptyCostForm());
167
+ const [formErrors, setFormErrors] = useState<FormErrors>({});
168
+ const [saving, setSaving] = useState(false);
169
+ const [selectedCostType, setSelectedCostType] = useState<CostType | null>(
170
+ null
171
+ );
172
+
173
+ const {
174
+ data: costs = [],
175
+ isLoading,
176
+ error,
177
+ refetch,
178
+ } = useQuery<CollaboratorCost[]>({
179
+ queryKey: ['operations-collaborator-costs', collaboratorId],
180
+ queryFn: () => fetchCollaboratorCosts(request, collaboratorId),
181
+ });
182
+
183
+ // ─── Computed KPIs ─────────────────────────────────────────────────────────
184
+ const kpi = useMemo(() => {
185
+ const monthlyTotal = costs.reduce(
186
+ (sum, c) =>
187
+ sum + toMonthly(Number(c.amount), c.recurrence, c.depreciationMonths),
188
+ 0
189
+ );
190
+ const allocatableTotal = costs.reduce(
191
+ (sum, c) =>
192
+ (c.allocatable ?? true)
193
+ ? sum +
194
+ toMonthly(Number(c.amount), c.recurrence, c.depreciationMonths)
195
+ : sum,
196
+ 0
197
+ );
198
+ const nonAllocatableTotal = monthlyTotal - allocatableTotal;
199
+ const costPerHour =
200
+ weeklyCapacity && weeklyCapacity > 0
201
+ ? monthlyTotal / (weeklyCapacity * 4.33)
202
+ : null;
203
+
204
+ return { monthlyTotal, allocatableTotal, nonAllocatableTotal, costPerHour };
205
+ }, [costs, weeklyCapacity]);
206
+
207
+ const fmt = useCallback(
208
+ (value: number) =>
209
+ formatCurrency(value, getSettingValue, currentLocaleCode),
210
+ [getSettingValue, currentLocaleCode]
211
+ );
212
+
213
+ // ─── Form helpers ──────────────────────────────────────────────────────────
214
+ const setField = useCallback(
215
+ <K extends keyof CostFormState>(key: K, value: CostFormState[K]) => {
216
+ setForm((prev) => ({ ...prev, [key]: value }));
217
+ setFormErrors((prev) => {
218
+ if (!prev[key]) return prev;
219
+ const next = { ...prev };
220
+ delete next[key];
221
+ return next;
222
+ });
223
+ },
224
+ []
225
+ );
226
+
227
+ const openAdd = () => {
228
+ setEditingCostId(null);
229
+ setForm(emptyCostForm());
230
+ setFormErrors({});
231
+ setSelectedCostType(null);
232
+ setIsAdding(true);
233
+ setConfirmDeleteId(null);
234
+ };
235
+
236
+ const openEdit = (cost: CollaboratorCost) => {
237
+ setIsAdding(false);
238
+ setConfirmDeleteId(null);
239
+ setEditingCostId(cost.id);
240
+ setForm(costToForm(cost));
241
+ setFormErrors({});
242
+ // Reconstruct a minimal CostType so isDepreciable/isAllocatable defaults to unknown (show fields)
243
+ setSelectedCostType({
244
+ id: cost.costTypeId,
245
+ slug: cost.costTypeSlug,
246
+ name: cost.costTypeName,
247
+ isActive: true,
248
+ });
249
+ };
250
+
251
+ const closeForm = () => {
252
+ setIsAdding(false);
253
+ setEditingCostId(null);
254
+ setForm(emptyCostForm());
255
+ setFormErrors({});
256
+ setSelectedCostType(null);
257
+ };
258
+
259
+ // ─── Save / delete ─────────────────────────────────────────────────────────
260
+ const handleSave = async () => {
261
+ const errors = validateForm(form, (k) => t(k as Parameters<typeof t>[0]));
262
+ if (Object.keys(errors).length > 0) {
263
+ setFormErrors(errors);
264
+ return;
265
+ }
266
+
267
+ const numericAmount = parseFloat(
268
+ String(form.amount).replace(/\./g, '').replace(',', '.')
269
+ );
270
+
271
+ setSaving(true);
272
+ try {
273
+ const depMonths =
274
+ form.recurrence === 'one_time' && form.depreciationMonths
275
+ ? parseInt(form.depreciationMonths, 10)
276
+ : null;
277
+
278
+ const payload = {
279
+ costTypeId: form.costTypeId!,
280
+ amount: numericAmount,
281
+ recurrence: form.recurrence,
282
+ allocatable: form.allocatable,
283
+ // map startDate → referenceDate for current backend compat
284
+ referenceDate: form.startDate || null,
285
+ startDate: form.startDate || null,
286
+ endDate: form.endDate || null,
287
+ depreciationMonths: depMonths,
288
+ description: form.description || null,
289
+ notes: form.notes || null,
290
+ };
291
+
292
+ if (editingCostId !== null) {
293
+ await updateCollaboratorCost(
294
+ request,
295
+ collaboratorId,
296
+ editingCostId,
297
+ payload
298
+ );
299
+ showToastHandler?.('success', t('messages.updateSuccess'));
300
+ } else {
301
+ await createCollaboratorCost(request, collaboratorId, payload);
302
+ showToastHandler?.('success', t('messages.createSuccess'));
303
+ }
304
+
305
+ closeForm();
306
+ await refetch();
307
+ onCostsChanged?.();
308
+ } catch (err) {
309
+ showToastHandler?.(
310
+ 'error',
311
+ getOperationsErrorMessage(err, t('messages.saveError'))
312
+ );
313
+ } finally {
314
+ setSaving(false);
315
+ }
316
+ };
317
+
318
+ const handleDelete = async (costId: number) => {
319
+ try {
320
+ await deleteCollaboratorCost(request, collaboratorId, costId);
321
+ showToastHandler?.('success', t('messages.deleteSuccess'));
322
+ setConfirmDeleteId(null);
323
+ await refetch();
324
+ onCostsChanged?.();
325
+ } catch (err) {
326
+ showToastHandler?.(
327
+ 'error',
328
+ getOperationsErrorMessage(err, t('messages.deleteError'))
329
+ );
330
+ }
331
+ };
332
+
333
+ const getRecurrenceLabel = (value: string) => {
334
+ switch (value) {
335
+ case 'one_time':
336
+ return t('recurrence.one_time');
337
+ case 'monthly':
338
+ return t('recurrence.monthly');
339
+ case 'yearly':
340
+ return t('recurrence.yearly');
341
+ default:
342
+ return value;
343
+ }
344
+ };
345
+
346
+ const formatValidityRange = (cost: CollaboratorCost) => {
347
+ const start = cost.startDate ?? cost.referenceDate;
348
+ const end = cost.endDate;
349
+ if (start && end) {
350
+ return `${formatDate(start, getSettingValue, currentLocaleCode)} – ${formatDate(end, getSettingValue, currentLocaleCode)}`;
351
+ }
352
+ if (start) {
353
+ return formatDate(start, getSettingValue, currentLocaleCode);
354
+ }
355
+ return '—';
356
+ };
357
+
358
+ const isFormOpen = isAdding || editingCostId !== null;
359
+ const readOnly = disabled;
360
+
361
+ // ─── Render ────────────────────────────────────────────────────────────────
362
+ return (
363
+ <div className="space-y-3">
364
+ {/* Header */}
365
+ <div className="flex items-center justify-between">
366
+ <div className="space-y-0.5">
367
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
368
+ {t('title')}
369
+ </h3>
370
+ <p className="text-[11px] text-muted-foreground/80">
371
+ {t('description')}
372
+ </p>
373
+ </div>
374
+ {!readOnly && !isFormOpen && (
375
+ <Button
376
+ variant="ghost"
377
+ size="sm"
378
+ className="gap-1.5 text-muted-foreground hover:text-foreground"
379
+ onClick={openAdd}
380
+ disabled={isLoading}
381
+ >
382
+ <Plus className="size-3.5" />
383
+ {t('actions.add')}
384
+ </Button>
385
+ )}
386
+ </div>
387
+
388
+ {/* KPI summary cards */}
389
+ {isLoading ? (
390
+ <div className="grid grid-cols-2 gap-2">
391
+ {Array.from({ length: 4 }).map((_, i) => (
392
+ <Skeleton key={i} className="h-16 w-full rounded-lg" />
393
+ ))}
394
+ </div>
395
+ ) : (
396
+ <div className="grid grid-cols-2 gap-2">
397
+ <Card className="py-0">
398
+ <CardContent className="flex items-center gap-2 px-3 py-2.5">
399
+ <DollarSign className="size-4 shrink-0 text-muted-foreground" />
400
+ <div className="min-w-0">
401
+ <p className="truncate text-[10px] text-muted-foreground">
402
+ {t('summary.monthlyEstimate')}
403
+ </p>
404
+ <p className="truncate text-sm font-semibold tabular-nums">
405
+ {fmt(kpi.monthlyTotal)}
406
+ </p>
407
+ </div>
408
+ </CardContent>
409
+ </Card>
410
+ <Card className="py-0">
411
+ <CardContent className="flex items-center gap-2 px-3 py-2.5">
412
+ <Check className="size-4 shrink-0 text-green-500" />
413
+ <div className="min-w-0">
414
+ <p className="truncate text-[10px] text-muted-foreground">
415
+ {t('summary.allocatable')}
416
+ </p>
417
+ <p className="truncate text-sm font-semibold tabular-nums">
418
+ {fmt(kpi.allocatableTotal)}
419
+ </p>
420
+ </div>
421
+ </CardContent>
422
+ </Card>
423
+ <Card className="py-0">
424
+ <CardContent className="flex items-center gap-2 px-3 py-2.5">
425
+ <X className="size-4 shrink-0 text-muted-foreground" />
426
+ <div className="min-w-0">
427
+ <p className="truncate text-[10px] text-muted-foreground">
428
+ {t('summary.nonAllocatable')}
429
+ </p>
430
+ <p className="truncate text-sm font-semibold tabular-nums">
431
+ {fmt(kpi.nonAllocatableTotal)}
432
+ </p>
433
+ </div>
434
+ </CardContent>
435
+ </Card>
436
+ <Card className="py-0">
437
+ <CardContent className="flex items-center gap-2 px-3 py-2.5">
438
+ <DollarSign className="size-4 shrink-0 text-muted-foreground" />
439
+ <div className="min-w-0">
440
+ <p className="truncate text-[10px] text-muted-foreground">
441
+ {t('summary.costPerHour')}
442
+ </p>
443
+ <p className="truncate text-sm font-semibold tabular-nums">
444
+ {kpi.costPerHour != null
445
+ ? fmt(kpi.costPerHour)
446
+ : commonT('labels.notAvailable')}
447
+ </p>
448
+ </div>
449
+ </CardContent>
450
+ </Card>
451
+ </div>
452
+ )}
453
+
454
+ {/* Error state */}
455
+ {!isLoading && error ? (
456
+ <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm text-destructive">
457
+ <AlertCircle className="size-4 shrink-0" />
458
+ {t('messages.loadError')}
459
+ </div>
460
+ ) : null}
461
+
462
+ {/* ── Inline form ──────────────────────────────────────────────────── */}
463
+ {isFormOpen && (
464
+ <Card className="border-primary/20 bg-card shadow-sm">
465
+ <CardContent className="p-4">
466
+ {/* Form title + close */}
467
+ <div className="mb-4 flex items-center justify-between">
468
+ <p className="text-sm font-semibold">
469
+ {editingCostId !== null ? t('actions.edit') : t('actions.add')}
470
+ </p>
471
+ <Button
472
+ variant="ghost"
473
+ size="icon"
474
+ className="size-7"
475
+ onClick={closeForm}
476
+ disabled={saving}
477
+ >
478
+ <X className="size-3.5" />
479
+ </Button>
480
+ </div>
481
+
482
+ <div className="grid gap-2 sm:grid-cols-2">
483
+ {/* Cost type — full width */}
484
+ <div className="sm:col-span-2">
485
+ <Label className="mb-1 block text-xs">
486
+ {t('fields.costType')}
487
+ <span className="ml-0.5 text-destructive">*</span>
488
+ </Label>
489
+ <EntityPicker<CostType>
490
+ placeholder={t('placeholders.costType')}
491
+ value={form.costTypeId}
492
+ onChange={(value, option) => {
493
+ const costType = option ?? null;
494
+ setField('costTypeId', value as number | null);
495
+ setSelectedCostType(costType);
496
+ if (costType) {
497
+ if (costType.defaultRecurrence) {
498
+ setField(
499
+ 'recurrence',
500
+ costType.defaultRecurrence as Recurrence
501
+ );
502
+ }
503
+ if (costType.isAllocatable != null) {
504
+ setField(
505
+ 'allocatable',
506
+ Boolean(costType.isAllocatable)
507
+ );
508
+ }
509
+ }
510
+ }}
511
+ valueType="number"
512
+ loadOptions={async ({ search, page, pageSize }) => {
513
+ const items = await fetchCostTypes(request, {
514
+ search,
515
+ active: true,
516
+ page,
517
+ pageSize,
518
+ });
519
+ return { items, hasMore: false };
520
+ }}
521
+ getOptionValue={(o) => o.id}
522
+ getOptionLabel={(o) => o.name}
523
+ getOptionDescription={(o) => {
524
+ const parts = [o.code, o.category].filter(Boolean);
525
+ return parts.length > 0 ? parts.join(' · ') : undefined;
526
+ }}
527
+ showCreateButton
528
+ createFields={[
529
+ {
530
+ name: 'name',
531
+ label: t('fields.costTypeName'),
532
+ placeholder: t('placeholders.costTypeName'),
533
+ required: true,
534
+ },
535
+ {
536
+ name: 'code',
537
+ label: t('fields.costTypeCode'),
538
+ placeholder: t('placeholders.costTypeCode'),
539
+ },
540
+ {
541
+ name: 'category',
542
+ label: t('fields.category'),
543
+ placeholder: t('placeholders.category'),
544
+ },
545
+ {
546
+ name: 'description',
547
+ label: t('fields.costTypeDescription'),
548
+ placeholder: t('placeholders.costTypeDescription'),
549
+ },
550
+ ]}
551
+ onCreate={async (values) => {
552
+ const created = await createCostType(request, {
553
+ name: values.name as string,
554
+ code: (values.code as string) || null,
555
+ category: (values.category as string) || null,
556
+ description: (values.description as string) || null,
557
+ });
558
+ return created ?? null;
559
+ }}
560
+ mapSearchToCreateValues={(search) => ({ name: search })}
561
+ createActionLabel={t('actions.createCostType')}
562
+ />
563
+ {selectedCostType?.category ? (
564
+ <p className="mt-1 text-[11px] text-muted-foreground">
565
+ {t('fields.category')}: {selectedCostType.category}
566
+ </p>
567
+ ) : null}
568
+ {formErrors.costTypeId ? (
569
+ <p className="mt-1 text-[11px] text-destructive">
570
+ {formErrors.costTypeId}
571
+ </p>
572
+ ) : null}
573
+ </div>
574
+
575
+ {/* Description — full width */}
576
+ <div className="sm:col-span-2">
577
+ <Label className="mb-1 block text-xs">
578
+ {t('fields.description')}
579
+ </Label>
580
+ <Input
581
+ value={form.description}
582
+ onChange={(e) => setField('description', e.target.value)}
583
+ placeholder={t('placeholders.description')}
584
+ />
585
+ </div>
586
+
587
+ {/* Amount */}
588
+ <div>
589
+ <Label className="mb-1 block text-xs">
590
+ {t('fields.amount')}
591
+ <span className="ml-0.5 text-destructive">*</span>
592
+ </Label>
593
+ <InputMoney
594
+ value={form.amount}
595
+ onChange={(e) => setField('amount', e.target.value)}
596
+ placeholder="0,00"
597
+ />
598
+ {formErrors.amount ? (
599
+ <p className="mt-1 text-[11px] text-destructive">
600
+ {formErrors.amount}
601
+ </p>
602
+ ) : null}
603
+ </div>
604
+
605
+ {/* Recurrence */}
606
+ <div>
607
+ <Label className="mb-1 block text-xs">
608
+ {t('fields.recurrence')}
609
+ </Label>
610
+ <Select
611
+ value={form.recurrence}
612
+ onValueChange={(v) => {
613
+ setField('recurrence', v as Recurrence);
614
+ if (v !== 'one_time') {
615
+ setField('depreciationMonths', '');
616
+ }
617
+ }}
618
+ >
619
+ <SelectTrigger className="w-full">
620
+ <SelectValue />
621
+ </SelectTrigger>
622
+ <SelectContent>
623
+ {RECURRENCE_VALUES.map((r) => (
624
+ <SelectItem key={r} value={r}>
625
+ {getRecurrenceLabel(r)}
626
+ </SelectItem>
627
+ ))}
628
+ </SelectContent>
629
+ </Select>
630
+ </div>
631
+
632
+ {/* Start date */}
633
+ <div>
634
+ <Label className="mb-1 block text-xs">
635
+ {t('fields.startDate')}
636
+ </Label>
637
+ <input
638
+ type="date"
639
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
640
+ value={form.startDate}
641
+ onChange={(e) => setField('startDate', e.target.value)}
642
+ />
643
+ </div>
644
+
645
+ {/* End date */}
646
+ <div>
647
+ <Label className="mb-1 block text-xs">
648
+ {t('fields.endDate')}
649
+ </Label>
650
+ <input
651
+ type="date"
652
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
653
+ value={form.endDate}
654
+ min={form.startDate || undefined}
655
+ onChange={(e) => setField('endDate', e.target.value)}
656
+ />
657
+ {formErrors.endDate ? (
658
+ <p className="mt-1 text-[11px] text-destructive">
659
+ {formErrors.endDate}
660
+ </p>
661
+ ) : null}
662
+ </div>
663
+
664
+ {/* Depreciation months — only for one_time and when type is depreciable */}
665
+ {form.recurrence === 'one_time' &&
666
+ selectedCostType?.isDepreciable !== false && (
667
+ <div className="sm:col-span-2">
668
+ <Label className="mb-1 block text-xs">
669
+ {t('fields.depreciationMonths')}
670
+ </Label>
671
+ <Input
672
+ type="number"
673
+ min={1}
674
+ step={1}
675
+ value={form.depreciationMonths}
676
+ onChange={(e) =>
677
+ setField('depreciationMonths', e.target.value)
678
+ }
679
+ placeholder={t('placeholders.depreciationMonths')}
680
+ />
681
+ {formErrors.depreciationMonths ? (
682
+ <p className="mt-1 text-[11px] text-destructive">
683
+ {formErrors.depreciationMonths}
684
+ </p>
685
+ ) : null}
686
+ <p className="mt-1 text-[11px] text-muted-foreground">
687
+ {t('hints.depreciationMonths')}
688
+ </p>
689
+ </div>
690
+ )}
691
+
692
+ {/* Allocatable toggle — full width */}
693
+ <div className="flex items-center gap-3 sm:col-span-2">
694
+ <Switch
695
+ id="cost-allocatable"
696
+ checked={form.allocatable}
697
+ onCheckedChange={(v) => setField('allocatable', v)}
698
+ />
699
+ <Label
700
+ htmlFor="cost-allocatable"
701
+ className="cursor-pointer text-xs"
702
+ >
703
+ {t('fields.allocatable')}
704
+ </Label>
705
+ </div>
706
+
707
+ {/* Notes — full width */}
708
+ <div className="sm:col-span-2">
709
+ <Label className="mb-1 block text-xs">
710
+ {t('fields.notes')}
711
+ </Label>
712
+ <Textarea
713
+ rows={2}
714
+ className="resize-none"
715
+ value={form.notes}
716
+ onChange={(e) => setField('notes', e.target.value)}
717
+ placeholder={t('placeholders.notes')}
718
+ />
719
+ </div>
720
+ </div>
721
+
722
+ {/* Actions */}
723
+ <div className="mt-4 flex justify-end gap-2 border-t pt-3">
724
+ <Button
725
+ variant="ghost"
726
+ size="sm"
727
+ onClick={closeForm}
728
+ disabled={saving}
729
+ >
730
+ <X className="mr-1 size-3.5" />
731
+ {commonT('actions.cancel')}
732
+ </Button>
733
+ <Button
734
+ size="sm"
735
+ onClick={() => void handleSave()}
736
+ disabled={saving || !form.costTypeId || !form.amount}
737
+ >
738
+ <Check className="mr-1 size-3.5" />
739
+ {saving ? t('actions.saving') : commonT('actions.save')}
740
+ </Button>
741
+ </div>
742
+ </CardContent>
743
+ </Card>
744
+ )}
745
+
746
+ {/* ── Cost list ─────────────────────────────────────────────────────── */}
747
+ {isLoading ? (
748
+ <div className="space-y-1.5">
749
+ {Array.from({ length: 3 }).map((_, i) => (
750
+ <Skeleton key={i} className="h-11 w-full rounded-lg" />
751
+ ))}
752
+ </div>
753
+ ) : costs.length > 0 ? (
754
+ <div className="overflow-hidden rounded-lg border">
755
+ {costs.map((cost, idx) => (
756
+ <div
757
+ key={cost.id}
758
+ className={
759
+ 'group flex items-start gap-2 px-3 py-2.5 transition-colors hover:bg-muted/30' +
760
+ (idx > 0 ? ' border-t' : '') +
761
+ (editingCostId === cost.id ? ' bg-primary/5' : '')
762
+ }
763
+ >
764
+ {/* Info */}
765
+ <div className="min-w-0 flex-1">
766
+ <p className="truncate text-sm font-medium leading-tight">
767
+ {cost.costTypeName}
768
+ </p>
769
+ {cost.description ? (
770
+ <p className="mt-0.5 truncate text-[11px] text-muted-foreground">
771
+ {cost.description}
772
+ </p>
773
+ ) : null}
774
+ <div className="mt-1 flex flex-wrap items-center gap-1">
775
+ <Badge
776
+ variant="secondary"
777
+ className="h-4.5 px-1.5 text-[10px] font-normal"
778
+ >
779
+ {getRecurrenceLabel(cost.recurrence)}
780
+ </Badge>
781
+ {(cost.allocatable ?? true) && (
782
+ <Badge
783
+ variant="outline"
784
+ className="h-4.5 border-green-500/40 px-1.5 text-[10px] font-normal text-green-600 dark:border-green-500/30 dark:text-green-400"
785
+ >
786
+ {t('table.allocatable')}
787
+ </Badge>
788
+ )}
789
+ {cost.startDate || cost.referenceDate ? (
790
+ <span className="text-[10px] text-muted-foreground">
791
+ {formatValidityRange(cost)}
792
+ </span>
793
+ ) : null}
794
+ </div>
795
+ </div>
796
+
797
+ {/* Amount + actions */}
798
+ <div className="flex shrink-0 items-start gap-1">
799
+ <div className="text-right">
800
+ <p className="text-sm font-semibold tabular-nums">
801
+ {fmt(Number(cost.amount))}
802
+ </p>
803
+ {cost.recurrence === 'one_time' && cost.depreciationMonths ? (
804
+ <p className="text-[10px] text-muted-foreground">
805
+ /{cost.depreciationMonths}m
806
+ </p>
807
+ ) : null}
808
+ </div>
809
+ {!readOnly &&
810
+ (confirmDeleteId === cost.id ? (
811
+ <div className="flex items-center gap-0.5 pt-0.5">
812
+ <Button
813
+ variant="destructive"
814
+ size="sm"
815
+ className="h-6 px-2 text-[10px]"
816
+ onClick={() => void handleDelete(cost.id)}
817
+ >
818
+ {commonT('actions.delete')}
819
+ </Button>
820
+ <Button
821
+ variant="ghost"
822
+ size="sm"
823
+ className="h-6 px-2 text-[10px]"
824
+ onClick={() => setConfirmDeleteId(null)}
825
+ >
826
+ {commonT('actions.cancel')}
827
+ </Button>
828
+ </div>
829
+ ) : (
830
+ <div className="flex items-center gap-0.5 pt-0.5 opacity-0 transition-opacity group-hover:opacity-100">
831
+ <Button
832
+ variant="ghost"
833
+ size="icon"
834
+ className="size-6"
835
+ onClick={() => openEdit(cost)}
836
+ disabled={isFormOpen}
837
+ >
838
+ <Pencil className="size-3" />
839
+ </Button>
840
+ <Button
841
+ variant="ghost"
842
+ size="icon"
843
+ className="size-6 text-destructive hover:text-destructive"
844
+ onClick={() => setConfirmDeleteId(cost.id)}
845
+ disabled={isFormOpen}
846
+ >
847
+ <Trash2 className="size-3" />
848
+ </Button>
849
+ </div>
850
+ ))}
851
+ </div>
852
+ </div>
853
+ ))}
854
+ {!readOnly && !isFormOpen && (
855
+ <button
856
+ type="button"
857
+ onClick={openAdd}
858
+ className="flex w-full items-center gap-1.5 border-t px-3 py-2 text-[11px] text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
859
+ >
860
+ <Plus className="size-3" />
861
+ {t('actions.add')}
862
+ </button>
863
+ )}
864
+ </div>
865
+ ) : !isFormOpen && !error ? (
866
+ <div className="flex flex-col items-center gap-2 rounded-lg border border-dashed py-6 text-center">
867
+ <DollarSign className="size-8 text-muted-foreground/40" />
868
+ <p className="text-sm text-muted-foreground">{t('empty')}</p>
869
+ {!readOnly && (
870
+ <Button
871
+ variant="ghost"
872
+ size="sm"
873
+ className="gap-1.5"
874
+ onClick={openAdd}
875
+ >
876
+ <Plus className="size-3.5" />
877
+ {t('actions.add')}
878
+ </Button>
879
+ )}
880
+ </div>
881
+ ) : null}
882
+ </div>
883
+ );
884
+ }