@hed-hog/operations 0.0.301 → 0.0.302

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.
@@ -16,27 +16,6 @@
16
16
  - where:
17
17
  slug: admin-operations-director
18
18
 
19
- - menu_id:
20
- where:
21
- slug: /operations
22
- icon: layout-dashboard
23
- url: /operations
24
- name:
25
- en: Dashboard
26
- pt: Dashboard
27
- slug: /operations/dashboard
28
- order: 111
29
- relations:
30
- role:
31
- - where:
32
- slug: admin
33
- - where:
34
- slug: admin-operations-collaborator
35
- - where:
36
- slug: admin-operations-supervisor
37
- - where:
38
- slug: admin-operations-director
39
-
40
19
  - menu_id:
41
20
  where:
42
21
  slug: /operations
@@ -156,7 +135,7 @@
156
135
  - menu_id:
157
136
  where:
158
137
  slug: /operations
159
- icon: palm-tree
138
+ icon: calendar-time
160
139
  url: /operations/time-off
161
140
  name:
162
141
  en: Time Off
@@ -1,343 +1,5 @@
1
- 'use client';
1
+ import { redirect } from 'next/navigation';
2
2
 
3
- import { EmptyState, Page } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
6
- import { Skeleton } from '@/components/ui/skeleton';
7
- import {
8
- Table,
9
- TableBody,
10
- TableCell,
11
- TableHead,
12
- TableHeader,
13
- TableRow,
14
- } from '@/components/ui/table';
15
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
16
- import {
17
- Building2,
18
- CheckCircle2,
19
- ClipboardList,
20
- FolderKanban,
21
- Hourglass,
22
- PlaneTakeoff,
23
- RefreshCcw,
24
- Users,
25
- } from 'lucide-react';
26
- import { useTranslations } from 'next-intl';
27
- import Link from 'next/link';
28
- import { OperationsHeader } from './_components/operations-header';
29
- import { SectionCard } from './_components/section-card';
30
- import { StatusBadge } from './_components/status-badge';
31
- import { fetchOperations } from './_lib/api';
32
- import { useOperationsAccess } from './_lib/hooks/use-operations-access';
33
- import type { OperationsDashboard, OperationsTeamOverview } from './_lib/types';
34
- import {
35
- formatDateRange,
36
- formatEnumLabel,
37
- formatHours,
38
- getStatusBadgeClass,
39
- } from './_lib/utils/format';
40
-
41
- export default function OperationsDashboardPage() {
42
- const t = useTranslations('operations.DashboardPage');
43
- const commonT = useTranslations('operations.Common');
44
- const { request, currentLocaleCode } = useApp();
45
- const access = useOperationsAccess();
46
-
47
- const {
48
- data: dashboard,
49
- isLoading,
50
- refetch,
51
- } = useQuery<OperationsDashboard>({
52
- queryKey: ['operations-dashboard', currentLocaleCode],
53
- queryFn: () =>
54
- fetchOperations<OperationsDashboard>(request, '/operations/dashboard'),
55
- });
56
-
57
- const { data: team } = useQuery<OperationsTeamOverview>({
58
- queryKey: ['operations-team-overview', currentLocaleCode],
59
- enabled: access.isSupervisor,
60
- queryFn: () =>
61
- fetchOperations<OperationsTeamOverview>(request, '/operations/team'),
62
- });
63
-
64
- const cards = dashboard
65
- ? [
66
- {
67
- key: 'projects',
68
- title: t('cards.projects'),
69
- value: dashboard.cards.projectsTotal,
70
- description: t('cards.projectsDescription', {
71
- active: dashboard.cards.activeProjects,
72
- }),
73
- icon: FolderKanban,
74
- },
75
- {
76
- key: 'timesheets',
77
- title: t('cards.timesheets'),
78
- value: dashboard.cards.visibleTimesheets,
79
- description: t('cards.timesheetsDescription', {
80
- pending: dashboard.cards.pendingTimesheets,
81
- }),
82
- icon: ClipboardList,
83
- },
84
- {
85
- key: 'timeOff',
86
- title: t('cards.timeOff'),
87
- value: dashboard.cards.timeOffRequests,
88
- description: t('cards.timeOffDescription'),
89
- icon: PlaneTakeoff,
90
- },
91
- {
92
- key: 'approvals',
93
- title: t('cards.approvals'),
94
- value: dashboard.cards.pendingApprovals,
95
- description: t('cards.approvalsDescription'),
96
- icon: Hourglass,
97
- },
98
- ]
99
- : [];
100
-
101
- return (
102
- <Page>
103
- <OperationsHeader
104
- title={t('title')}
105
- description={t('description')}
106
- current={t('breadcrumb')}
107
- actions={
108
- <div className="flex flex-wrap gap-2">
109
- <Button variant="outline" size="sm" onClick={() => void refetch()}>
110
- <RefreshCcw className="size-4" />
111
- {commonT('actions.refresh')}
112
- </Button>
113
- {access.isDirector ? (
114
- <Button variant="outline" size="sm" asChild>
115
- <Link href="/operations/departments">
116
- <Building2 className="size-4" />
117
- {commonT('actions.manageDepartments')}
118
- </Link>
119
- </Button>
120
- ) : null}
121
- <Button asChild size="sm">
122
- <Link href="/operations/timesheets">
123
- {t('actions.openTimesheets')}
124
- </Link>
125
- </Button>
126
- </div>
127
- }
128
- />
129
-
130
- {isLoading || access.isLoading ? (
131
- <div className="space-y-4">
132
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
133
- {Array.from({ length: 4 }).map((_, index) => (
134
- <Skeleton key={index} className="h-32 w-full" />
135
- ))}
136
- </div>
137
- <div className="grid gap-4 xl:grid-cols-3">
138
- <Skeleton className="h-64 w-full xl:col-span-2" />
139
- <Skeleton className="h-64 w-full" />
140
- </div>
141
- </div>
142
- ) : dashboard ? (
143
- <>
144
- <KpiCardsGrid items={cards} />
145
-
146
- <div className="grid gap-4 xl:grid-cols-3">
147
- <SectionCard
148
- title={t('scope.title')}
149
- description={t('scope.description')}
150
- className="xl:col-span-1"
151
- >
152
- <dl className="space-y-3 text-sm">
153
- <div className="flex items-center justify-between gap-4">
154
- <dt className="text-muted-foreground">
155
- {t('scope.roleScope')}
156
- </dt>
157
- <dd className="font-medium">
158
- {formatEnumLabel(dashboard.actor.roleScope)}
159
- </dd>
160
- </div>
161
- <div className="flex items-center justify-between gap-4">
162
- <dt className="text-muted-foreground">
163
- {t('scope.collaborator')}
164
- </dt>
165
- <dd className="font-medium">
166
- {dashboard.actor.collaboratorName ??
167
- commonT('labels.notAvailable')}
168
- </dd>
169
- </div>
170
- <div className="flex items-center justify-between gap-4">
171
- <dt className="text-muted-foreground">
172
- {t('scope.teamSize')}
173
- </dt>
174
- <dd className="font-medium">{dashboard.actor.teamSize}</dd>
175
- </div>
176
- </dl>
177
- </SectionCard>
178
-
179
- <SectionCard
180
- title={t('recentTimesheets.title')}
181
- description={t('recentTimesheets.description')}
182
- className="xl:col-span-2"
183
- >
184
- {dashboard.recentTimesheets.length === 0 ? (
185
- <EmptyState
186
- icon={<ClipboardList className="size-12" />}
187
- title={commonT('states.emptyTitle')}
188
- description={t('recentTimesheets.empty')}
189
- actionLabel={t('actions.openTimesheets')}
190
- onAction={() => {
191
- window.location.href = '/operations/timesheets';
192
- }}
193
- />
194
- ) : (
195
- <div className="overflow-x-auto rounded-md border">
196
- <Table>
197
- <TableHeader>
198
- <TableRow>
199
- <TableHead>{commonT('labels.collaborator')}</TableHead>
200
- <TableHead>{commonT('labels.week')}</TableHead>
201
- <TableHead>{commonT('labels.totalHours')}</TableHead>
202
- <TableHead>{commonT('labels.status')}</TableHead>
203
- </TableRow>
204
- </TableHeader>
205
- <TableBody>
206
- {dashboard.recentTimesheets.map((item) => (
207
- <TableRow key={item.id}>
208
- <TableCell className="font-medium">
209
- {item.collaboratorName}
210
- </TableCell>
211
- <TableCell>
212
- {formatDateRange(
213
- item.weekStartDate,
214
- item.weekEndDate
215
- )}
216
- </TableCell>
217
- <TableCell>{formatHours(item.totalHours)}</TableCell>
218
- <TableCell>
219
- <StatusBadge
220
- label={formatEnumLabel(item.status)}
221
- className={getStatusBadgeClass(item.status)}
222
- />
223
- </TableCell>
224
- </TableRow>
225
- ))}
226
- </TableBody>
227
- </Table>
228
- </div>
229
- )}
230
- </SectionCard>
231
- </div>
232
-
233
- <div className="grid gap-4 xl:grid-cols-2">
234
- <SectionCard
235
- title={t('nextSteps.title')}
236
- description={t('nextSteps.description')}
237
- >
238
- <div className="space-y-3 text-sm">
239
- <div className="flex items-start gap-3">
240
- <CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
241
- <p>{t('nextSteps.submitTimesheet')}</p>
242
- </div>
243
- <div className="flex items-start gap-3">
244
- <CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
245
- <p>{t('nextSteps.requestTimeOff')}</p>
246
- </div>
247
- <div className="flex items-start gap-3">
248
- <CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
249
- <p>{t('nextSteps.adjustSchedule')}</p>
250
- </div>
251
- </div>
252
- </SectionCard>
253
-
254
- <SectionCard
255
- title={t('team.title')}
256
- description={t('team.description')}
257
- >
258
- {access.isSupervisor && team ? (
259
- <>
260
- <div className="mb-4 grid gap-3 md:grid-cols-3">
261
- <div className="rounded-lg border p-4">
262
- <div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
263
- {t('team.members')}
264
- </div>
265
- <div className="mt-2 text-2xl font-semibold">
266
- {team.teamMembers.length}
267
- </div>
268
- </div>
269
- <div className="rounded-lg border p-4">
270
- <div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
271
- {t('team.projects')}
272
- </div>
273
- <div className="mt-2 text-2xl font-semibold">
274
- {team.projectCount}
275
- </div>
276
- </div>
277
- <div className="rounded-lg border p-4">
278
- <div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
279
- {t('team.pendingApprovals')}
280
- </div>
281
- <div className="mt-2 text-2xl font-semibold">
282
- {team.pendingApprovals}
283
- </div>
284
- </div>
285
- </div>
286
-
287
- {team.teamMembers.length > 0 ? (
288
- <div className="space-y-3">
289
- {team.teamMembers.slice(0, 4).map((member) => (
290
- <div
291
- key={member.id}
292
- className="flex items-center justify-between rounded-lg border px-4 py-3 text-sm"
293
- >
294
- <div>
295
- <div className="font-medium">
296
- {member.displayName}
297
- </div>
298
- <div className="text-muted-foreground">
299
- {[member.department, member.title]
300
- .filter(Boolean)
301
- .join(' • ') || commonT('labels.notAvailable')}
302
- </div>
303
- </div>
304
- <div className="text-right">
305
- <div className="font-medium">
306
- {member.activeAssignments ?? 0}{' '}
307
- {t('team.assignments')}
308
- </div>
309
- <div className="text-muted-foreground">
310
- {member.pendingApprovals ?? 0}{' '}
311
- {t('team.awaitingReview')}
312
- </div>
313
- </div>
314
- </div>
315
- ))}
316
- </div>
317
- ) : (
318
- <p className="text-sm text-muted-foreground">
319
- {t('team.empty')}
320
- </p>
321
- )}
322
- </>
323
- ) : (
324
- <div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
325
- <Users className="mt-0.5 size-4" />
326
- <p>{t('team.collaboratorMessage')}</p>
327
- </div>
328
- )}
329
- </SectionCard>
330
- </div>
331
- </>
332
- ) : (
333
- <EmptyState
334
- icon={<FolderKanban className="size-12" />}
335
- title={commonT('states.emptyTitle')}
336
- description={commonT('states.emptyDescription')}
337
- actionLabel={commonT('actions.refresh')}
338
- onAction={() => void refetch()}
339
- />
340
- )}
341
- </Page>
342
- );
3
+ export default function OperationsPage() {
4
+ redirect('/operations/projects');
343
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/operations",
3
- "version": "0.0.301",
3
+ "version": "0.0.302",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -9,13 +9,13 @@
9
9
  "@nestjs/core": "^11",
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
- "@hed-hog/api": "0.0.6",
13
- "@hed-hog/api-types": "0.0.1",
12
+ "@hed-hog/api-pagination": "0.0.7",
14
13
  "@hed-hog/api-prisma": "0.0.6",
15
14
  "@hed-hog/api-locale": "0.0.14",
16
- "@hed-hog/api-pagination": "0.0.7",
17
- "@hed-hog/core": "0.0.301",
18
- "@hed-hog/contact": "0.0.301"
15
+ "@hed-hog/api": "0.0.6",
16
+ "@hed-hog/core": "0.0.302",
17
+ "@hed-hog/contact": "0.0.302",
18
+ "@hed-hog/api-types": "0.0.1"
19
19
  },
20
20
  "exports": {
21
21
  ".": {
@@ -10,6 +10,8 @@ import {
10
10
  ForbiddenException,
11
11
  Inject,
12
12
  Injectable,
13
+ InternalServerErrorException,
14
+ Logger,
13
15
  NotFoundException,
14
16
  forwardRef,
15
17
  } from '@nestjs/common';
@@ -621,6 +623,8 @@ type PublishAccountsPayableReferencePayload = {
621
623
 
622
624
  @Injectable()
623
625
  export class OperationsService {
626
+ private readonly logger = new Logger(OperationsService.name);
627
+
624
628
  constructor(
625
629
  private readonly prisma: PrismaService,
626
630
  private readonly aiService: AiService,
@@ -3107,10 +3111,6 @@ export class OperationsService {
3107
3111
  select: {
3108
3112
  id: true,
3109
3113
  name: true,
3110
- trade_name: true,
3111
- email: true,
3112
- phone: true,
3113
- document: true,
3114
3114
  },
3115
3115
  },
3116
3116
  },
@@ -3118,7 +3118,54 @@ export class OperationsService {
3118
3118
 
3119
3119
  if (proposal) {
3120
3120
  personId = proposal.person_id ?? proposal.person?.id ?? null;
3121
- personRecord = proposal.person ?? null;
3121
+
3122
+ const [companyRow, contactRows, documentRows] =
3123
+ personId != null
3124
+ ? await Promise.all([
3125
+ (this.prisma as any).person_company.findUnique({
3126
+ where: { id: personId },
3127
+ select: { trade_name: true },
3128
+ }),
3129
+ (this.prisma as any).contact.findMany({
3130
+ where: { person_id: personId },
3131
+ include: {
3132
+ contact_type: {
3133
+ select: { code: true },
3134
+ },
3135
+ },
3136
+ orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
3137
+ }),
3138
+ (this.prisma as any).document.findMany({
3139
+ where: { person_id: personId },
3140
+ select: { value: true },
3141
+ orderBy: [{ id: 'asc' }],
3142
+ }),
3143
+ ])
3144
+ : [null, [], []];
3145
+
3146
+ const resolveContactValue = (codes: string[]) => {
3147
+ const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
3148
+ const items = Array.isArray(contactRows)
3149
+ ? contactRows.filter((contact: any) =>
3150
+ normalizedCodes.has(
3151
+ String(contact?.contact_type?.code || '').toUpperCase(),
3152
+ ),
3153
+ )
3154
+ : [];
3155
+ const primary = items.find((contact: any) => contact?.is_primary);
3156
+ const fallback = items[0];
3157
+ return primary?.value ?? fallback?.value ?? null;
3158
+ };
3159
+
3160
+ personRecord = proposal.person
3161
+ ? {
3162
+ ...proposal.person,
3163
+ trade_name: companyRow?.trade_name ?? null,
3164
+ email: resolveContactValue(['EMAIL']),
3165
+ phone: resolveContactValue(['PHONE', 'MOBILE', 'WHATSAPP']),
3166
+ document: documentRows[0]?.value ?? null,
3167
+ }
3168
+ : null;
3122
3169
  }
3123
3170
  }
3124
3171
  }
@@ -6194,8 +6241,24 @@ export class OperationsService {
6194
6241
  })
6195
6242
  );
6196
6243
  } catch (error) {
6197
- throw new BadRequestException(
6198
- 'PDF generation requires Playwright to be installed on the server.'
6244
+ const errorMessage =
6245
+ error instanceof Error ? error.message : String(error);
6246
+ const errorStack =
6247
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
6248
+ const missingPlaywrightRuntime =
6249
+ /Cannot find package ['"]playwright['"]|Cannot find module ['"]playwright['"]|Executable doesn't exist|browserType\.launch|Failed to launch|Please run the following command to download new browsers/i.test(
6250
+ errorMessage
6251
+ );
6252
+
6253
+ this.logger.error(
6254
+ `Failed to generate contract PDF for contract ${contract?.id ?? 'unknown'}. ${errorMessage}`,
6255
+ errorStack
6256
+ );
6257
+
6258
+ throw new InternalServerErrorException(
6259
+ missingPlaywrightRuntime
6260
+ ? 'PDF generation is unavailable because Playwright/Chromium is not installed on the server. Run `pnpm --filter api run playwright:install` in the API environment.'
6261
+ : 'Failed to generate the PDF document. Check server logs for details.'
6199
6262
  );
6200
6263
  } finally {
6201
6264
  await browser?.close?.();