@hed-hog/operations 0.0.332 → 0.0.338
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 +0 -54
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +0 -100
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/controllers/operations-contracts.controller.d.ts +12 -12
- package/dist/operations.service.d.ts +0 -76
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +7 -230
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +6 -0
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +8 -27
- package/hedhog/data/route.yaml +0 -72
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -39
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
- package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
- package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
- package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +5 -4
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
- package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +10 -218
- package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +24 -708
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +38 -158
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +1 -5
- package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
- package/hedhog/frontend/app/_lib/api.ts.ejs +0 -151
- package/hedhog/frontend/app/_lib/types.ts.ejs +0 -1
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +0 -18
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
- package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
- package/hedhog/frontend/messages/en.json +2 -96
- package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
- package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
- package/hedhog/frontend/messages/pt.json +2 -96
- package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/index.ts.ejs +25 -25
- package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
- package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
- package/hedhog/table/operations_collaborator.yaml +8 -8
- package/hedhog/table/operations_task.yaml +76 -76
- package/hedhog/table/operations_task_activity.yaml +51 -51
- package/package.json +6 -6
- package/src/controllers/operations-collaborators.controller.ts +8 -117
- package/src/controllers/operations-tasks.controller.ts +156 -156
- package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
- package/src/dto/create-collaborator.dto.ts +4 -4
- package/src/operations.service.spec.ts +1006 -988
- package/src/operations.service.ts +7 -323
- package/dist/dto/create-collaborator-invoice.dto.d.ts +0 -11
- package/dist/dto/create-collaborator-invoice.dto.d.ts.map +0 -1
- package/dist/dto/create-collaborator-invoice.dto.js +0 -55
- package/dist/dto/create-collaborator-invoice.dto.js.map +0 -1
- package/dist/dto/create-collaborator-payment.dto.d.ts +0 -10
- package/dist/dto/create-collaborator-payment.dto.d.ts.map +0 -1
- package/dist/dto/create-collaborator-payment.dto.js +0 -50
- package/dist/dto/create-collaborator-payment.dto.js.map +0 -1
- package/dist/dto/list-collaborator-invoice.dto.d.ts +0 -4
- package/dist/dto/list-collaborator-invoice.dto.d.ts.map +0 -1
- package/dist/dto/list-collaborator-invoice.dto.js +0 -8
- package/dist/dto/list-collaborator-invoice.dto.js.map +0 -1
- package/dist/dto/list-collaborator-payment.dto.d.ts +0 -4
- package/dist/dto/list-collaborator-payment.dto.d.ts.map +0 -1
- package/dist/dto/list-collaborator-payment.dto.js +0 -8
- package/dist/dto/list-collaborator-payment.dto.js.map +0 -1
- package/dist/dto/update-collaborator-invoice.dto.d.ts +0 -6
- package/dist/dto/update-collaborator-invoice.dto.d.ts.map +0 -1
- package/dist/dto/update-collaborator-invoice.dto.js +0 -9
- package/dist/dto/update-collaborator-invoice.dto.js.map +0 -1
- package/dist/dto/update-collaborator-payment.dto.d.ts +0 -6
- package/dist/dto/update-collaborator-payment.dto.d.ts.map +0 -1
- package/dist/dto/update-collaborator-payment.dto.js +0 -9
- package/dist/dto/update-collaborator-payment.dto.js.map +0 -1
- package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +0 -443
- package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +0 -429
- package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +0 -953
- package/hedhog/table/operations_collaborator_invoice.yaml +0 -35
- package/hedhog/table/operations_collaborator_payment.yaml +0 -32
- package/src/dto/create-collaborator-invoice.dto.ts +0 -39
- package/src/dto/create-collaborator-payment.dto.ts +0 -35
- package/src/dto/list-collaborator-invoice.dto.ts +0 -3
- package/src/dto/list-collaborator-payment.dto.ts +0 -3
- package/src/dto/update-collaborator-invoice.dto.ts +0 -6
- package/src/dto/update-collaborator-payment.dto.ts +0 -6
|
@@ -82,12 +82,12 @@ export function CollaboratorTimesheetsTab({
|
|
|
82
82
|
if (!timesheet.approvalId) return;
|
|
83
83
|
setApprovingId(timesheet.id);
|
|
84
84
|
try {
|
|
85
|
-
await mutateOperations(
|
|
86
|
-
request,
|
|
87
|
-
`/operations/approvals/${timesheet.approvalId}/approve`,
|
|
88
|
-
'POST',
|
|
89
|
-
{}
|
|
90
|
-
);
|
|
85
|
+
await mutateOperations(
|
|
86
|
+
request,
|
|
87
|
+
`/operations/approvals/${timesheet.approvalId}/approve`,
|
|
88
|
+
'POST',
|
|
89
|
+
{}
|
|
90
|
+
);
|
|
91
91
|
void refetch();
|
|
92
92
|
} finally {
|
|
93
93
|
setApprovingId(null);
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
6
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
7
|
+
import { useApp } from '@hed-hog/next-app-provider';
|
|
8
|
+
import { LoaderCircle, Sparkles } from 'lucide-react';
|
|
9
|
+
import { useTranslations } from 'next-intl';
|
|
10
|
+
import { useMemo, useState } from 'react';
|
|
11
|
+
import { mutateOperations } from '../_lib/api';
|
|
12
|
+
import { SectionCard } from './section-card';
|
|
13
|
+
|
|
14
|
+
type ContractContentEditorProps = {
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
editorTitle: string;
|
|
18
|
+
editorDescription: string;
|
|
19
|
+
previewTitle: string;
|
|
20
|
+
previewDescription: string;
|
|
21
|
+
previewFallbackHtml?: string;
|
|
22
|
+
promptContext?: Record<string, string | number | boolean | null | undefined>;
|
|
23
|
+
compact?: boolean;
|
|
24
|
+
descriptionMode?: 'inline' | 'tooltip';
|
|
25
|
+
layout?: 'split' | 'tabs';
|
|
26
|
+
showPreview?: boolean;
|
|
27
|
+
chrome?: 'card' | 'plain';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function ContractContentEditor({
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
editorTitle,
|
|
34
|
+
editorDescription,
|
|
35
|
+
previewTitle,
|
|
36
|
+
previewDescription,
|
|
37
|
+
previewFallbackHtml,
|
|
38
|
+
promptContext,
|
|
39
|
+
compact = false,
|
|
40
|
+
descriptionMode = 'inline',
|
|
41
|
+
layout = 'split',
|
|
42
|
+
showPreview = true,
|
|
43
|
+
chrome = 'card',
|
|
44
|
+
}: ContractContentEditorProps) {
|
|
45
|
+
const t = useTranslations('operations.ContractContentEditor');
|
|
46
|
+
const { request, showToastHandler, getSettingValue } = useApp();
|
|
47
|
+
const [prompt, setPrompt] = useState('');
|
|
48
|
+
const [isGenerating, setIsGenerating] = useState(false);
|
|
49
|
+
|
|
50
|
+
const hasOpenAi = Boolean(getSettingValue('ai-openai-api-key-enabled'));
|
|
51
|
+
const hasGemini = Boolean(getSettingValue('ai-gemini-api-key-enabled'));
|
|
52
|
+
const provider = hasOpenAi ? 'openai' : hasGemini ? 'gemini' : null;
|
|
53
|
+
const providerLabel = provider === 'openai' ? 'OpenAI' : 'Gemini';
|
|
54
|
+
|
|
55
|
+
const promptContextSummary = useMemo(
|
|
56
|
+
() =>
|
|
57
|
+
Object.entries(promptContext ?? {})
|
|
58
|
+
.filter(
|
|
59
|
+
([, rawValue]) =>
|
|
60
|
+
rawValue !== null &&
|
|
61
|
+
rawValue !== undefined &&
|
|
62
|
+
String(rawValue).trim()
|
|
63
|
+
)
|
|
64
|
+
.map(([key, rawValue]) => `${key}: ${String(rawValue)}`)
|
|
65
|
+
.join('\n'),
|
|
66
|
+
[promptContext]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const fillSuggestedPrompt = () => {
|
|
70
|
+
setPrompt(
|
|
71
|
+
t('suggestedPrompt', {
|
|
72
|
+
subject: String(
|
|
73
|
+
promptContext?.name ||
|
|
74
|
+
promptContext?.project_name ||
|
|
75
|
+
promptContext?.client_name ||
|
|
76
|
+
'contrato'
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const generateWithAi = async () => {
|
|
83
|
+
if (!provider) {
|
|
84
|
+
showToastHandler?.('error', t('messages.missingConfiguration'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const trimmedPrompt = prompt.trim();
|
|
89
|
+
if (!trimmedPrompt) {
|
|
90
|
+
showToastHandler?.('error', t('messages.missingPrompt'));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setIsGenerating(true);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await mutateOperations<{ content?: string }>(
|
|
98
|
+
request,
|
|
99
|
+
'/ai/chat',
|
|
100
|
+
'POST',
|
|
101
|
+
{
|
|
102
|
+
provider,
|
|
103
|
+
message: trimmedPrompt,
|
|
104
|
+
systemPrompt: [
|
|
105
|
+
'You are drafting professional business contract content for a back-office operations system.',
|
|
106
|
+
'Return ONLY clean HTML suitable for a rich text editor preview.',
|
|
107
|
+
'Do not include Markdown fences, explanations, or commentary.',
|
|
108
|
+
'When useful, keep placeholders like {{client_name}}, {{project_name}}, {{project_code}}, {{start_date}}, {{end_date}}, {{budget_amount}}, and {{monthly_hour_cap}} in the generated content.',
|
|
109
|
+
'Prefer short sections, headings, bullet points, and concise legal-style language.',
|
|
110
|
+
value?.trim()
|
|
111
|
+
? `Current HTML content to improve or rewrite:\n${value}`
|
|
112
|
+
: 'There is no current contract content yet.',
|
|
113
|
+
promptContextSummary
|
|
114
|
+
? `Context:\n${promptContextSummary}`
|
|
115
|
+
: 'No extra context was provided.',
|
|
116
|
+
].join('\n\n'),
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const generatedHtml = String(response?.content ?? '').trim();
|
|
121
|
+
if (!generatedHtml) {
|
|
122
|
+
throw new Error('Empty AI response');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
onChange(generatedHtml);
|
|
126
|
+
showToastHandler?.(
|
|
127
|
+
'success',
|
|
128
|
+
t('messages.generateSuccess', { provider: providerLabel })
|
|
129
|
+
);
|
|
130
|
+
} catch {
|
|
131
|
+
showToastHandler?.('error', t('messages.generateError'));
|
|
132
|
+
} finally {
|
|
133
|
+
setIsGenerating(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const renderSection = (
|
|
138
|
+
title: string,
|
|
139
|
+
description: string,
|
|
140
|
+
content: React.ReactNode
|
|
141
|
+
) => {
|
|
142
|
+
if (chrome === 'plain') {
|
|
143
|
+
return (
|
|
144
|
+
<div className="space-y-3">
|
|
145
|
+
<div className="space-y-1">
|
|
146
|
+
<h3 className="text-lg font-semibold">{title}</h3>
|
|
147
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
148
|
+
</div>
|
|
149
|
+
{content}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<SectionCard
|
|
156
|
+
title={title}
|
|
157
|
+
description={description}
|
|
158
|
+
compact={compact}
|
|
159
|
+
descriptionMode={descriptionMode}
|
|
160
|
+
>
|
|
161
|
+
{content}
|
|
162
|
+
</SectionCard>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const editorSection = renderSection(
|
|
167
|
+
editorTitle,
|
|
168
|
+
editorDescription,
|
|
169
|
+
<RichTextEditor value={value} onChange={onChange} />
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const previewSection = renderSection(
|
|
173
|
+
previewTitle,
|
|
174
|
+
previewDescription,
|
|
175
|
+
<div
|
|
176
|
+
className="prose prose-sm max-w-none rounded-lg border p-4"
|
|
177
|
+
dangerouslySetInnerHTML={{
|
|
178
|
+
__html: value || previewFallbackHtml || `<p>${t('emptyPreview')}</p>`,
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="space-y-4">
|
|
185
|
+
{provider ? (
|
|
186
|
+
<div className="rounded-lg border border-dashed px-3 py-3">
|
|
187
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
188
|
+
<div className="space-y-1">
|
|
189
|
+
<div className="flex items-center gap-2 text-sm font-medium">
|
|
190
|
+
<Sparkles className="size-4 text-primary" />
|
|
191
|
+
<span>{t('assistantTitle')}</span>
|
|
192
|
+
</div>
|
|
193
|
+
<p className="text-xs text-muted-foreground">
|
|
194
|
+
{t('assistantDescription', { provider: providerLabel })}
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant="outline"
|
|
200
|
+
size="sm"
|
|
201
|
+
className="cursor-pointer"
|
|
202
|
+
onClick={fillSuggestedPrompt}
|
|
203
|
+
>
|
|
204
|
+
{t('actions.useSuggestion')}
|
|
205
|
+
</Button>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="mt-3 space-y-2">
|
|
209
|
+
<Textarea
|
|
210
|
+
rows={compact ? 2 : 3}
|
|
211
|
+
value={prompt}
|
|
212
|
+
placeholder={t('promptPlaceholder')}
|
|
213
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
214
|
+
/>
|
|
215
|
+
<div className="flex flex-wrap gap-2">
|
|
216
|
+
<Button
|
|
217
|
+
type="button"
|
|
218
|
+
size="sm"
|
|
219
|
+
className="cursor-pointer"
|
|
220
|
+
disabled={isGenerating}
|
|
221
|
+
onClick={() => void generateWithAi()}
|
|
222
|
+
>
|
|
223
|
+
{isGenerating ? (
|
|
224
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
225
|
+
) : (
|
|
226
|
+
<Sparkles className="size-4" />
|
|
227
|
+
)}
|
|
228
|
+
{t('actions.generate')}
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
) : null}
|
|
234
|
+
|
|
235
|
+
{!showPreview ? (
|
|
236
|
+
editorSection
|
|
237
|
+
) : layout === 'tabs' ? (
|
|
238
|
+
<Tabs defaultValue="editor" className="w-full">
|
|
239
|
+
<TabsList className="grid w-full grid-cols-2">
|
|
240
|
+
<TabsTrigger value="editor">{editorTitle}</TabsTrigger>
|
|
241
|
+
<TabsTrigger value="preview">{previewTitle}</TabsTrigger>
|
|
242
|
+
</TabsList>
|
|
243
|
+
<TabsContent value="editor" className="mt-4">
|
|
244
|
+
{editorSection}
|
|
245
|
+
</TabsContent>
|
|
246
|
+
<TabsContent value="preview" className="mt-4">
|
|
247
|
+
{previewSection}
|
|
248
|
+
</TabsContent>
|
|
249
|
+
</Tabs>
|
|
250
|
+
) : (
|
|
251
|
+
<div className="grid gap-4 xl:grid-cols-2">
|
|
252
|
+
{editorSection}
|
|
253
|
+
{previewSection}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { EmptyState, Page } from '@/components/entity-list';
|
|
4
|
-
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
5
4
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
6
5
|
import { Button } from '@/components/ui/button';
|
|
7
6
|
import {
|
|
@@ -37,6 +36,7 @@ import {
|
|
|
37
36
|
TableRow,
|
|
38
37
|
} from '@/components/ui/table';
|
|
39
38
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
39
|
+
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
40
40
|
import {
|
|
41
41
|
closestCenter,
|
|
42
42
|
DndContext,
|
|
@@ -85,8 +85,8 @@ import { OperationsHeader } from './operations-header';
|
|
|
85
85
|
import { SectionCard } from './section-card';
|
|
86
86
|
import { StatusBadge } from './status-badge';
|
|
87
87
|
import {
|
|
88
|
-
TaskCommentsSection,
|
|
89
88
|
TaskDetailSheet,
|
|
89
|
+
TaskCommentsSection,
|
|
90
90
|
type TaskDetailSheetData,
|
|
91
91
|
} from './task-detail-sheet';
|
|
92
92
|
import { TaskFileAttachments } from './task-file-attachments';
|
|
@@ -834,7 +834,7 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
|
|
|
834
834
|
{(isOver) => (
|
|
835
835
|
<div
|
|
836
836
|
className={[
|
|
837
|
-
'flex min-h-
|
|
837
|
+
'flex min-h-128 flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
|
|
838
838
|
getColumnClassName(column.id),
|
|
839
839
|
isOver
|
|
840
840
|
? 'border-primary shadow-lg ring-2 ring-primary/15'
|
|
@@ -870,7 +870,7 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
|
|
|
870
870
|
</div>
|
|
871
871
|
</div>
|
|
872
872
|
|
|
873
|
-
<div className="flex
|
|
873
|
+
<div className="flex flex-1 flex-col gap-2">
|
|
874
874
|
<AnimatePresence initial={false}>
|
|
875
875
|
{taskColumns[column.id].map((task) => {
|
|
876
876
|
const tags = getTaskTags(task);
|
|
@@ -1309,6 +1309,7 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
|
|
|
1309
1309
|
}}
|
|
1310
1310
|
/>
|
|
1311
1311
|
|
|
1312
|
+
|
|
1312
1313
|
<Sheet
|
|
1313
1314
|
open={taskFormOpen}
|
|
1314
1315
|
onOpenChange={(open) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PersonPicker as PersonSelectWithCreate } from '../../contact/_components/person-picker';
|
|
@@ -10,17 +10,9 @@ import {
|
|
|
10
10
|
SheetHeader,
|
|
11
11
|
SheetTitle,
|
|
12
12
|
} from '@/components/ui/sheet';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
Check,
|
|
16
|
-
Loader2,
|
|
17
|
-
Pencil,
|
|
18
|
-
Plus,
|
|
19
|
-
SlidersHorizontal,
|
|
20
|
-
X,
|
|
21
|
-
} from 'lucide-react';
|
|
13
|
+
import { Check, Loader2, Pencil, Plus, X } from 'lucide-react';
|
|
22
14
|
import { useTranslations } from 'next-intl';
|
|
23
|
-
import {
|
|
15
|
+
import { useState } from 'react';
|
|
24
16
|
import { fetchOperations, mutateOperations } from '../_lib/api';
|
|
25
17
|
import type {
|
|
26
18
|
OperationsCollaboratorDetails,
|
|
@@ -37,7 +29,6 @@ import { ProjectFormScreen } from './project-form-screen';
|
|
|
37
29
|
import { StatusBadge } from './status-badge';
|
|
38
30
|
|
|
39
31
|
const PAGE_SIZE = 10;
|
|
40
|
-
const LS_AUTO_REBALANCE_KEY = 'operations.projectAllocation.autoRebalance';
|
|
41
32
|
|
|
42
33
|
type AssignmentEditState = {
|
|
43
34
|
roleLabel: string;
|
|
@@ -62,26 +53,14 @@ export function ProjectAssignmentsTab({
|
|
|
62
53
|
}: ProjectAssignmentsTabProps) {
|
|
63
54
|
const commonT = useTranslations('operations.Common');
|
|
64
55
|
const detailsT = useTranslations('operations.CollaboratorDetailsPage');
|
|
65
|
-
const allocT = useTranslations(
|
|
66
|
-
'operations.CollaboratorFormPage.projectAllocation'
|
|
67
|
-
);
|
|
68
56
|
|
|
69
57
|
const [page, setPage] = useState(0);
|
|
70
58
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
71
59
|
const [editState, setEditState] = useState<AssignmentEditState | null>(null);
|
|
72
60
|
const [saving, setSaving] = useState(false);
|
|
73
|
-
const [distributing, setDistributing] = useState(false);
|
|
74
61
|
const [adding, setAdding] = useState(false);
|
|
75
62
|
const [createSheetOpen, setCreateSheetOpen] = useState(false);
|
|
76
63
|
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]);
|
|
85
64
|
|
|
86
65
|
const projects = collaborator?.assignedProjects ?? [];
|
|
87
66
|
const assignedIds = new Set(projects.map((p) => p.id));
|
|
@@ -91,29 +70,6 @@ export function ProjectAssignmentsTab({
|
|
|
91
70
|
(page + 1) * PAGE_SIZE
|
|
92
71
|
);
|
|
93
72
|
|
|
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
|
-
|
|
117
73
|
const startEditing = (
|
|
118
74
|
project: OperationsCollaboratorDetails['assignedProjects'][number]
|
|
119
75
|
) => {
|
|
@@ -142,8 +98,6 @@ export function ProjectAssignmentsTab({
|
|
|
142
98
|
if (!collaborator || !editState) return;
|
|
143
99
|
setSaving(true);
|
|
144
100
|
try {
|
|
145
|
-
const newPercent = parseNumberInput(editState.allocationPercent);
|
|
146
|
-
|
|
147
101
|
await mutateOperations(
|
|
148
102
|
request,
|
|
149
103
|
`/operations/collaborators/${collaborator.id}/projects/${projectId}`,
|
|
@@ -151,66 +105,11 @@ export function ProjectAssignmentsTab({
|
|
|
151
105
|
{
|
|
152
106
|
roleLabel: trimToNull(editState.roleLabel),
|
|
153
107
|
weeklyHours: parseNumberInput(editState.weeklyHours),
|
|
154
|
-
allocationPercent:
|
|
108
|
+
allocationPercent: parseNumberInput(editState.allocationPercent),
|
|
155
109
|
startDate: trimToNull(editState.startDate),
|
|
156
110
|
endDate: trimToNull(editState.endDate),
|
|
157
111
|
}
|
|
158
112
|
);
|
|
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
|
-
|
|
214
113
|
setEditingId(null);
|
|
215
114
|
setEditState(null);
|
|
216
115
|
onUpdated();
|
|
@@ -221,37 +120,6 @@ export function ProjectAssignmentsTab({
|
|
|
221
120
|
}
|
|
222
121
|
};
|
|
223
122
|
|
|
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
|
-
|
|
255
123
|
const assignProject = async (projectId: number) => {
|
|
256
124
|
if (!collaborator) return;
|
|
257
125
|
setAdding(true);
|
|
@@ -271,72 +139,6 @@ export function ProjectAssignmentsTab({
|
|
|
271
139
|
|
|
272
140
|
return (
|
|
273
141
|
<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
|
-
|
|
340
142
|
{!disabled ? (
|
|
341
143
|
<div className="flex items-center gap-2">
|
|
342
144
|
<div className="min-w-0 flex-1">
|
|
@@ -359,7 +161,8 @@ export function ProjectAssignmentsTab({
|
|
|
359
161
|
return {
|
|
360
162
|
items: res.data.filter((proj) => !assignedIds.has(proj.id)),
|
|
361
163
|
hasMore:
|
|
362
|
-
(res.page ?? p) * (res.pageSize ?? ps) <
|
|
164
|
+
(res.page ?? p) * (res.pageSize ?? ps) <
|
|
165
|
+
(res.total ?? 0),
|
|
363
166
|
};
|
|
364
167
|
}}
|
|
365
168
|
getOptionValue={(opt) => opt.id}
|
|
@@ -391,9 +194,7 @@ export function ProjectAssignmentsTab({
|
|
|
391
194
|
) : null}
|
|
392
195
|
|
|
393
196
|
{projects.length === 0 ? (
|
|
394
|
-
<p className="text-sm text-muted-foreground">
|
|
395
|
-
{detailsT('noProjects')}
|
|
396
|
-
</p>
|
|
197
|
+
<p className="text-sm text-muted-foreground">{detailsT('noProjects')}</p>
|
|
397
198
|
) : (
|
|
398
199
|
<>
|
|
399
200
|
<div className="space-y-2">
|
|
@@ -442,13 +243,8 @@ export function ProjectAssignmentsTab({
|
|
|
442
243
|
/>
|
|
443
244
|
</div>
|
|
444
245
|
<div className="space-y-1">
|
|
445
|
-
<div className="
|
|
246
|
+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
446
247
|
{commonT('labels.allocationPercent')}
|
|
447
|
-
{autoRebalance && (
|
|
448
|
-
<span className="rounded bg-primary/10 px-1 text-[9px] text-primary">
|
|
449
|
-
auto
|
|
450
|
-
</span>
|
|
451
|
-
)}
|
|
452
248
|
</div>
|
|
453
249
|
<div className="relative">
|
|
454
250
|
<Input
|
|
@@ -574,9 +370,7 @@ export function ProjectAssignmentsTab({
|
|
|
574
370
|
onClick={() => startEditing(project)}
|
|
575
371
|
>
|
|
576
372
|
<Pencil className="size-3" />
|
|
577
|
-
<span className="sr-only">
|
|
578
|
-
{commonT('actions.edit')}
|
|
579
|
-
</span>
|
|
373
|
+
<span className="sr-only">{commonT('actions.edit')}</span>
|
|
580
374
|
</Button>
|
|
581
375
|
) : null}
|
|
582
376
|
</div>
|
|
@@ -623,9 +417,7 @@ export function ProjectAssignmentsTab({
|
|
|
623
417
|
<SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
|
|
624
418
|
<SheetHeader>
|
|
625
419
|
<SheetTitle>{detailsT('createProject')}</SheetTitle>
|
|
626
|
-
<SheetDescription>
|
|
627
|
-
{detailsT('createProjectDescription')}
|
|
628
|
-
</SheetDescription>
|
|
420
|
+
<SheetDescription>{detailsT('createProjectDescription')}</SheetDescription>
|
|
629
421
|
</SheetHeader>
|
|
630
422
|
<ProjectFormScreen
|
|
631
423
|
onCancel={() => setCreateSheetOpen(false)}
|
|
@@ -640,4 +432,4 @@ export function ProjectAssignmentsTab({
|
|
|
640
432
|
</Sheet>
|
|
641
433
|
</div>
|
|
642
434
|
);
|
|
643
|
-
}
|
|
435
|
+
}
|