@hed-hog/operations 0.0.318 → 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.
- package/dist/controllers/operations-collaborator-costs.controller.d.ts +144 -0
- package/dist/controllers/operations-collaborator-costs.controller.d.ts.map +1 -0
- package/dist/controllers/operations-collaborator-costs.controller.js +162 -0
- package/dist/controllers/operations-collaborator-costs.controller.js.map +1 -0
- package/dist/controllers/operations-collaborators.controller.d.ts +14 -0
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +11 -0
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +31 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.js +23 -0
- package/dist/controllers/operations-projects.controller.js.map +1 -1
- package/dist/controllers/operations-reports.controller.d.ts +199 -0
- package/dist/controllers/operations-reports.controller.d.ts.map +1 -0
- package/dist/controllers/operations-reports.controller.js +53 -0
- package/dist/controllers/operations-reports.controller.js.map +1 -0
- package/dist/controllers/operations-tasks.controller.d.ts +41 -2
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +17 -5
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-collaborator-cost.dto.d.ts +16 -0
- package/dist/dto/create-collaborator-cost.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-cost.dto.js +88 -0
- package/dist/dto/create-collaborator-cost.dto.js.map +1 -0
- package/dist/dto/create-collaborator.dto.d.ts +0 -1
- package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
- package/dist/dto/create-collaborator.dto.js +0 -6
- package/dist/dto/create-collaborator.dto.js.map +1 -1
- package/dist/dto/create-cost-type.dto.d.ts +13 -0
- package/dist/dto/create-cost-type.dto.d.ts.map +1 -0
- package/dist/dto/create-cost-type.dto.js +87 -0
- package/dist/dto/create-cost-type.dto.js.map +1 -0
- package/dist/dto/list-approvals.dto.d.ts +2 -0
- package/dist/dto/list-approvals.dto.d.ts.map +1 -1
- package/dist/dto/list-approvals.dto.js +10 -0
- package/dist/dto/list-approvals.dto.js.map +1 -1
- package/dist/dto/list-collaborator-costs.dto.d.ts +5 -0
- package/dist/dto/list-collaborator-costs.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-costs.dto.js +23 -0
- package/dist/dto/list-collaborator-costs.dto.js.map +1 -0
- package/dist/dto/list-cost-types.dto.d.ts +6 -0
- package/dist/dto/list-cost-types.dto.d.ts.map +1 -0
- package/dist/dto/list-cost-types.dto.js +35 -0
- package/dist/dto/list-cost-types.dto.js.map +1 -0
- package/dist/dto/list-my-projects.dto.d.ts +5 -0
- package/dist/dto/list-my-projects.dto.d.ts.map +1 -0
- package/dist/dto/list-my-projects.dto.js +23 -0
- package/dist/dto/list-my-projects.dto.js.map +1 -0
- package/dist/dto/list-my-tasks.dto.d.ts +6 -0
- package/dist/dto/list-my-tasks.dto.d.ts.map +1 -0
- package/dist/dto/list-my-tasks.dto.js +33 -0
- package/dist/dto/list-my-tasks.dto.js.map +1 -0
- package/dist/dto/list-projects.dto.d.ts +1 -0
- package/dist/dto/list-projects.dto.d.ts.map +1 -1
- package/dist/dto/list-projects.dto.js +7 -0
- package/dist/dto/list-projects.dto.js.map +1 -1
- package/dist/dto/list-reports.dto.d.ts +16 -0
- package/dist/dto/list-reports.dto.d.ts.map +1 -0
- package/dist/dto/list-reports.dto.js +75 -0
- package/dist/dto/list-reports.dto.js.map +1 -0
- package/dist/dto/list-tasks.dto.d.ts +2 -0
- package/dist/dto/list-tasks.dto.d.ts.map +1 -1
- package/dist/dto/list-tasks.dto.js +12 -0
- package/dist/dto/list-tasks.dto.js.map +1 -1
- package/dist/dto/list-timesheets.dto.d.ts +2 -0
- package/dist/dto/list-timesheets.dto.d.ts.map +1 -1
- package/dist/dto/list-timesheets.dto.js +10 -0
- package/dist/dto/list-timesheets.dto.js.map +1 -1
- package/dist/dto/update-collaborator-cost.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-cost.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-cost.dto.js +9 -0
- package/dist/dto/update-collaborator-cost.dto.js.map +1 -0
- package/dist/dto/update-task.dto.d.ts +1 -0
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +6 -0
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.module.d.ts.map +1 -1
- package/dist/operations.module.js +4 -0
- package/dist/operations.module.js.map +1 -1
- package/dist/operations.service.d.ts +457 -3
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +1445 -208
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +31 -7
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +112 -7
- package/hedhog/data/operations_cost_type.yaml +166 -0
- package/hedhog/data/route.yaml +185 -0
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -0
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +94 -15
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +219 -94
- package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +21 -32
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +178 -89
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +1185 -0
- package/hedhog/frontend/app/_components/operations-calendar-view.tsx.ejs +306 -0
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +943 -782
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +223 -0
- package/hedhog/frontend/app/_lib/api.ts.ejs +162 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +229 -3
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +11 -3
- package/hedhog/frontend/app/approvals/page.tsx.ejs +191 -46
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +133 -25
- package/hedhog/frontend/app/my-projects/[id]/page.tsx.ejs +11 -0
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +440 -0
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +1304 -0
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -0
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -0
- package/hedhog/frontend/app/timesheets/page.tsx.ejs +322 -58
- package/hedhog/frontend/messages/en.json +234 -25
- package/hedhog/frontend/messages/pt.json +234 -25
- package/hedhog/table/operations_collaborator.yaml +0 -4
- package/hedhog/table/operations_collaborator_compensation_history.yaml +28 -0
- package/hedhog/table/operations_collaborator_cost.yaml +56 -0
- package/hedhog/table/operations_cost_type.yaml +38 -0
- package/package.json +6 -6
- package/src/controllers/operations-collaborator-costs.controller.ts +147 -0
- package/src/controllers/operations-collaborators.controller.ts +19 -8
- package/src/controllers/operations-projects.controller.ts +19 -8
- package/src/controllers/operations-reports.controller.ts +32 -0
- package/src/controllers/operations-tasks.controller.ts +32 -12
- package/src/dto/create-collaborator-cost.dto.ts +78 -0
- package/src/dto/create-collaborator.dto.ts +9 -14
- package/src/dto/create-cost-type.dto.ts +62 -0
- package/src/dto/list-approvals.dto.ts +8 -0
- package/src/dto/list-collaborator-costs.dto.ts +8 -0
- package/src/dto/list-cost-types.dto.ts +19 -0
- package/src/dto/list-my-projects.dto.ts +8 -0
- package/src/dto/list-my-tasks.dto.ts +17 -0
- package/src/dto/list-projects.dto.ts +7 -1
- package/src/dto/list-reports.dto.ts +51 -0
- package/src/dto/list-tasks.dto.ts +11 -1
- package/src/dto/list-timesheets.dto.ts +8 -0
- package/src/dto/update-collaborator-cost.dto.ts +4 -0
- package/src/dto/update-task.dto.ts +6 -0
- package/src/operations.module.ts +7 -3
- package/src/operations.service.spec.ts +45 -7
- 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
|
+
}
|