@hed-hog/operations 0.0.332 → 0.0.347
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 +55 -36
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +3 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/operations.service.d.ts +58 -36
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +34 -34
- 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 +5 -3
- package/hedhog/data/route.yaml +7 -7
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -1
- 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 +0 -6
- package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +23 -50
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -28
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +23 -6
- package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
- package/hedhog/frontend/app/_lib/api.ts.ejs +2 -2
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +1 -1
- 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/operations/operations/en.json +2100 -0
- package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
- 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 +9 -9
- 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 +40 -42
|
@@ -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';
|
|
@@ -96,7 +96,6 @@ export function ProjectAssignmentsTab({
|
|
|
96
96
|
0
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
-
// When editing, show projected total
|
|
100
99
|
const editingProject =
|
|
101
100
|
editingId != null ? projects.find((p) => p.id === editingId) : null;
|
|
102
101
|
const projectedTotal =
|
|
@@ -157,7 +156,6 @@ export function ProjectAssignmentsTab({
|
|
|
157
156
|
}
|
|
158
157
|
);
|
|
159
158
|
|
|
160
|
-
// Auto-rebalance: distribute remaining % among other projects
|
|
161
159
|
if (autoRebalance && newPercent != null) {
|
|
162
160
|
const others = projects.filter((p) => p.id !== projectId);
|
|
163
161
|
if (others.length > 0) {
|
|
@@ -177,7 +175,6 @@ export function ProjectAssignmentsTab({
|
|
|
177
175
|
} else {
|
|
178
176
|
newValue = Math.round((remaining / others.length) * 100) / 100;
|
|
179
177
|
}
|
|
180
|
-
// Ensure the last item corrects rounding drift
|
|
181
178
|
if (idx === others.length - 1) {
|
|
182
179
|
const alreadyAssigned = others
|
|
183
180
|
.slice(0, idx)
|
|
@@ -271,7 +268,6 @@ export function ProjectAssignmentsTab({
|
|
|
271
268
|
|
|
272
269
|
return (
|
|
273
270
|
<div className="space-y-3">
|
|
274
|
-
{/* Allocation summary bar */}
|
|
275
271
|
{projects.length > 0 && (
|
|
276
272
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2">
|
|
277
273
|
<div className="flex items-center gap-3">
|
|
@@ -304,7 +300,6 @@ export function ProjectAssignmentsTab({
|
|
|
304
300
|
</div>
|
|
305
301
|
{!disabled && (
|
|
306
302
|
<div className="flex items-center gap-3">
|
|
307
|
-
{/* Auto-rebalance toggle */}
|
|
308
303
|
<div className="flex items-center gap-1.5">
|
|
309
304
|
<SlidersHorizontal className="size-3 text-muted-foreground" />
|
|
310
305
|
<span className="text-[11px] text-muted-foreground">
|
|
@@ -316,7 +311,6 @@ export function ProjectAssignmentsTab({
|
|
|
316
311
|
className="scale-75"
|
|
317
312
|
/>
|
|
318
313
|
</div>
|
|
319
|
-
{/* Distribute equally */}
|
|
320
314
|
<Button
|
|
321
315
|
type="button"
|
|
322
316
|
variant="outline"
|
|
@@ -112,22 +112,22 @@ export function ProjectCostReportScreen({ projectId }: Props) {
|
|
|
112
112
|
isReimbursable !== 'all' ? isReimbursable === 'true' : undefined,
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
-
const { data: report, isLoading } = useQuery<ProjectCostReport>({
|
|
116
|
-
queryKey: ['project-cost-report', projectId, filters],
|
|
117
|
-
queryFn: () =>
|
|
118
|
-
getProjectCostReport(request, projectId, filters) as Promise<ProjectCostReport>,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const { data: categories = [] } = useQuery<ProjectCostCategory[]>({
|
|
122
|
-
queryKey: ['project-cost-categories-list'],
|
|
123
|
-
queryFn: () =>
|
|
124
|
-
listProjectCostCategories(request, {}) as Promise<ProjectCostCategory[]>,
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
const { data: costTypes = [] } = useQuery<ProjectCostType[]>({
|
|
128
|
-
queryKey: ['project-cost-types-list'],
|
|
129
|
-
queryFn: () => listProjectCostTypes(request, {}) as Promise<ProjectCostType[]>,
|
|
130
|
-
});
|
|
115
|
+
const { data: report, isLoading } = useQuery<ProjectCostReport>({
|
|
116
|
+
queryKey: ['project-cost-report', projectId, filters],
|
|
117
|
+
queryFn: () =>
|
|
118
|
+
getProjectCostReport(request, projectId, filters) as Promise<ProjectCostReport>,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const { data: categories = [] } = useQuery<ProjectCostCategory[]>({
|
|
122
|
+
queryKey: ['project-cost-categories-list'],
|
|
123
|
+
queryFn: () =>
|
|
124
|
+
listProjectCostCategories(request, {}) as Promise<ProjectCostCategory[]>,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const { data: costTypes = [] } = useQuery<ProjectCostType[]>({
|
|
128
|
+
queryKey: ['project-cost-types-list'],
|
|
129
|
+
queryFn: () => listProjectCostTypes(request, {}) as Promise<ProjectCostType[]>,
|
|
130
|
+
});
|
|
131
131
|
|
|
132
132
|
const fmt = (v: number) =>
|
|
133
133
|
formatCurrency(v, getSettingValue, currentLocaleCode);
|
|
@@ -423,13 +423,13 @@ export function ProjectCostReportScreen({ projectId }: Props) {
|
|
|
423
423
|
))}
|
|
424
424
|
</Pie>
|
|
425
425
|
<ChartTooltip
|
|
426
|
-
content={({ active, payload }) => {
|
|
427
|
-
if (!active || !payload?.length) return null;
|
|
428
|
-
const d = payload[0]?.payload;
|
|
429
|
-
if (!d) return null;
|
|
430
|
-
|
|
431
|
-
return (
|
|
432
|
-
<div className="rounded-md bg-background px-3 py-2 text-sm shadow-md border">
|
|
426
|
+
content={({ active, payload }) => {
|
|
427
|
+
if (!active || !payload?.length) return null;
|
|
428
|
+
const d = payload[0]?.payload;
|
|
429
|
+
if (!d) return null;
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<div className="rounded-md bg-background px-3 py-2 text-sm shadow-md border">
|
|
433
433
|
<p className="font-medium">
|
|
434
434
|
{d.category_name ?? '—'}
|
|
435
435
|
</p>
|
|
@@ -396,7 +396,7 @@ function getInitials(value?: string | null) {
|
|
|
396
396
|
function getPersonAvatarUrl(avatarId?: number | null) {
|
|
397
397
|
return typeof avatarId === 'number' && avatarId > 0
|
|
398
398
|
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
|
|
399
|
-
:
|
|
399
|
+
: '/placeholder.png';
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
function getUserPhotoUrl(photoId?: number | null) {
|
|
@@ -1309,8 +1309,6 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
1309
1309
|
useState(false);
|
|
1310
1310
|
const [timesheetPrefill, setTimesheetPrefill] =
|
|
1311
1311
|
useState<TimesheetEntryPrefill | null>(null);
|
|
1312
|
-
|
|
1313
|
-
// Assignment management state
|
|
1314
1312
|
const [assignmentSheetOpen, setAssignmentSheetOpen] = useState(false);
|
|
1315
1313
|
const [editingAssignment, setEditingAssignment] = useState<
|
|
1316
1314
|
OperationsProjectDetails['assignments'][0] | null
|
|
@@ -2414,13 +2412,13 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
2414
2412
|
<TooltipTrigger asChild>
|
|
2415
2413
|
<div className="flex cursor-default items-center gap-1.5 border-r px-3 py-2 transition hover:bg-muted/30">
|
|
2416
2414
|
<Avatar className="size-5 shrink-0 border bg-muted">
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2415
|
+
<AvatarImage
|
|
2416
|
+
src={
|
|
2417
|
+
getUserPhotoUrl(project.clientUserPhotoId) ||
|
|
2418
|
+
getPersonAvatarUrl(project.clientAvatarId)
|
|
2419
|
+
}
|
|
2420
|
+
alt={project.clientName || ''}
|
|
2421
|
+
/>
|
|
2424
2422
|
<AvatarFallback className="text-[9px]">
|
|
2425
2423
|
{getInitials(project.clientName)}
|
|
2426
2424
|
</AvatarFallback>
|
|
@@ -2521,12 +2519,11 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
2521
2519
|
<div className="flex cursor-default items-center gap-2 border-r px-3 py-2 transition hover:bg-muted/30">
|
|
2522
2520
|
<Gauge className="size-3.5 shrink-0 text-muted-foreground" />
|
|
2523
2521
|
<div className="flex items-center gap-1.5">
|
|
2524
|
-
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
|
|
2525
|
-
<motion.div
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
className="h-full rounded-full bg-primary"
|
|
2522
|
+
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
|
|
2523
|
+
<motion.div
|
|
2524
|
+
animate={{ width: `${displayedProgress}%` }}
|
|
2525
|
+
transition={{ duration: 0.7, ease: 'easeOut' }}
|
|
2526
|
+
className="h-full rounded-full bg-primary"
|
|
2530
2527
|
/>
|
|
2531
2528
|
</div>
|
|
2532
2529
|
<span className="w-8 text-right font-semibold tabular-nums">
|
|
@@ -2739,10 +2736,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
2739
2736
|
<div className="flex items-center gap-2">
|
|
2740
2737
|
<Avatar className="h-8 w-8 border border-border/60 bg-muted">
|
|
2741
2738
|
<AvatarImage
|
|
2742
|
-
src={
|
|
2743
|
-
getUserPhotoUrl(project.clientUserPhotoId) ||
|
|
2744
|
-
getPersonAvatarUrl(project.clientAvatarId)
|
|
2745
|
-
}
|
|
2739
|
+
src={getPersonAvatarUrl(project.clientAvatarId)}
|
|
2746
2740
|
alt={project.clientName || commonT('labels.client')}
|
|
2747
2741
|
/>
|
|
2748
2742
|
<AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
|
|
@@ -2884,32 +2878,13 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
2884
2878
|
<div className="font-medium">
|
|
2885
2879
|
{project.relatedContract.name}
|
|
2886
2880
|
</div>
|
|
2887
|
-
<div className="
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
<Avatar className="h-4 w-4 shrink-0">
|
|
2895
|
-
<AvatarImage
|
|
2896
|
-
src={
|
|
2897
|
-
getUserPhotoUrl(project.clientUserPhotoId) ||
|
|
2898
|
-
getPersonAvatarUrl(project.clientAvatarId)
|
|
2899
|
-
}
|
|
2900
|
-
alt={project.relatedContract.clientName}
|
|
2901
|
-
/>
|
|
2902
|
-
<AvatarFallback className="text-[8px] font-medium">
|
|
2903
|
-
{getInitials(
|
|
2904
|
-
project.relatedContract.clientName
|
|
2905
|
-
)}
|
|
2906
|
-
</AvatarFallback>
|
|
2907
|
-
</Avatar>
|
|
2908
|
-
<span className="truncate">
|
|
2909
|
-
{project.relatedContract.clientName}
|
|
2910
|
-
</span>
|
|
2911
|
-
</>
|
|
2912
|
-
) : null}
|
|
2881
|
+
<div className="text-sm text-muted-foreground">
|
|
2882
|
+
{[
|
|
2883
|
+
project.relatedContract.code,
|
|
2884
|
+
project.relatedContract.clientName,
|
|
2885
|
+
]
|
|
2886
|
+
.filter(Boolean)
|
|
2887
|
+
.join(' • ') || commonT('labels.notAvailable')}
|
|
2913
2888
|
</div>
|
|
2914
2889
|
</div>
|
|
2915
2890
|
<div className="flex items-center gap-3">
|
|
@@ -3381,7 +3356,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
3381
3356
|
{(isOver) => (
|
|
3382
3357
|
<div
|
|
3383
3358
|
className={[
|
|
3384
|
-
'flex min-h-
|
|
3359
|
+
'flex min-h-128 flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
|
|
3385
3360
|
getColumnClassName(column.id),
|
|
3386
3361
|
isOver
|
|
3387
3362
|
? 'border-primary shadow-lg ring-2 ring-primary/15'
|
|
@@ -3424,7 +3399,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
3424
3399
|
</div>
|
|
3425
3400
|
</div>
|
|
3426
3401
|
|
|
3427
|
-
<div className="flex
|
|
3402
|
+
<div className="flex flex-1 flex-col gap-2">
|
|
3428
3403
|
<AnimatePresence initial={false}>
|
|
3429
3404
|
{filteredTaskColumns[column.id].map((task) => {
|
|
3430
3405
|
const tags = getTaskTags(task);
|
|
@@ -4600,7 +4575,6 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
4600
4575
|
}
|
|
4601
4576
|
/>
|
|
4602
4577
|
|
|
4603
|
-
{/* Assignment Add/Edit Sheet */}
|
|
4604
4578
|
<Sheet
|
|
4605
4579
|
open={assignmentSheetOpen}
|
|
4606
4580
|
onOpenChange={(open) => {
|
|
@@ -4910,7 +4884,6 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
|
|
|
4910
4884
|
</SheetContent>
|
|
4911
4885
|
</Sheet>
|
|
4912
4886
|
|
|
4913
|
-
{/* Remove Assignment Confirm Dialog */}
|
|
4914
4887
|
<Dialog
|
|
4915
4888
|
open={removingAssignmentId !== null}
|
|
4916
4889
|
onOpenChange={(open) => {
|