@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.
Files changed (70) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +55 -36
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-projects.controller.d.ts +3 -0
  4. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  5. package/dist/operations.service.d.ts +58 -36
  6. package/dist/operations.service.d.ts.map +1 -1
  7. package/dist/operations.service.js +34 -34
  8. package/dist/operations.service.js.map +1 -1
  9. package/dist/operations.service.spec.js +6 -0
  10. package/dist/operations.service.spec.js.map +1 -1
  11. package/hedhog/data/menu.yaml +5 -3
  12. package/hedhog/data/route.yaml +7 -7
  13. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
  14. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -1
  15. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
  16. package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
  17. package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
  18. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  19. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +5 -4
  20. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
  21. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +0 -6
  22. package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
  23. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +23 -50
  24. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -28
  25. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +23 -6
  26. package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
  27. package/hedhog/frontend/app/_lib/api.ts.ejs +2 -2
  28. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +1 -1
  29. package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
  30. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
  31. package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
  32. package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
  33. package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
  34. package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
  35. package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
  36. package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
  37. package/hedhog/frontend/widgets/index.ts.ejs +25 -25
  38. package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
  39. package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
  40. package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
  41. package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
  42. package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
  43. package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
  44. package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
  45. package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
  46. package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
  47. package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
  48. package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
  49. package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
  50. package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
  51. package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
  52. package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
  53. package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
  54. package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
  55. package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
  56. package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
  57. package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
  58. package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
  59. package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
  60. package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
  61. package/hedhog/table/operations_collaborator.yaml +8 -8
  62. package/hedhog/table/operations_task.yaml +76 -76
  63. package/hedhog/table/operations_task_activity.yaml +51 -51
  64. package/package.json +6 -6
  65. package/src/controllers/operations-collaborators.controller.ts +9 -9
  66. package/src/controllers/operations-tasks.controller.ts +156 -156
  67. package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
  68. package/src/dto/create-collaborator.dto.ts +4 -4
  69. package/src/operations.service.spec.ts +1006 -988
  70. 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-48 max-h-160 flex-col rounded-3xl border bg-linear-to-b p-3 transition-all',
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 min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-1 pr-0.5">
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
- : undefined;
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
- <AvatarImage
2418
- src={
2419
- getUserPhotoUrl(project.clientUserPhotoId) ||
2420
- getPersonAvatarUrl(project.clientAvatarId)
2421
- }
2422
- alt={project.clientName || ''}
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
- initial={{ width: 0 }}
2527
- animate={{ width: `${displayedProgress}%` }}
2528
- transition={{ duration: 0.7, ease: 'easeOut' }}
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="flex items-center gap-2 text-sm text-muted-foreground">
2888
- <span className="truncate">
2889
- {project.relatedContract.code || '—'}
2890
- </span>
2891
- {project.relatedContract.clientName ? (
2892
- <>
2893
- <span>•</span>
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-48 max-h-160 flex-col rounded-3xl border bg-linear-to-b p-3 transition-all',
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 min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-1 pr-0.5">
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) => {