eletrotupi / tcc/ api/src/reports/ReportDocument.tsx4e5ac8210eca36b8c2a4073c8eaac1d4998d8039
13.3 KB | 519 lines Raw
1
import React from 'react'
2
import {
3
  Document,
4
  Page,
5
  View,
6
  Text,
7
  Svg,
8
  Rect,
9
  Image,
10
  StyleSheet,
11
} from '@react-pdf/renderer'
12
import { format } from 'date-fns'
13
import { ptBR } from 'date-fns/locale'
14
import { BaseMoodOption, IntensityLevel, MoodComponentOption } from '@prisma/client'
15
import { ReportData } from './report.data'
16
import { LOGO_DATA_URI } from './logo'
17

18
const T = {
19
  title: 'Relatório de Humor e Bem-Estar',
20
  appName: 'Nexo',
21
  tagline: 'Nexo · Rastreador de humor',
22
  confidential: 'Confidencial — uso clínico',
23
  sections: {
24
    glance: 'Visão Geral do Período',
25
    distribution: 'Distribuição de Humor',
26
    heatmap: 'Mapa de Calor Diário',
27
    components: 'Sentimentos & Emoções',
28
    log: 'Registros e níveis (ansiedade, energia e estresse)',
29
  },
30
  stats: {
31
    checkins: 'Check-ins',
32
    topMood: 'Humor predominante',
33
    daysTracked: 'Dias rastreados',
34
  },
35
  moods: {
36
    GREAT: 'Ótimo', GOOD: 'Bom', NEUTRAL: 'Neutro',
37
    SAD: 'Triste', ANGRY: 'Irritado',
38
  } as Record<BaseMoodOption, string>,
39
  intensity: {
40
    LIGHT: 'Leve', MODERATE: 'Moderado', HIGH: 'Alto',
41
  } as Record<IntensityLevel, string>,
42
  components: {
43
    JOY: 'Alegria', ANGER: 'Raiva', SADNESS: 'Tristeza',
44
    FEAR: 'Medo', GUILT: 'Culpa', FRUSTRATION: 'Frustração',
45
    CALM: 'Calma', MOTIVATED: 'Motivação', TIREDNESS: 'Cansaço',
46
    GRATITUDE: 'Gratidão', FOCUS: 'Foco', RESTLESS: 'Agitação',
47
    RELAXED: 'Relaxado', OVERWHELMED: 'Sobrecarga',
48
  } as Record<MoodComponentOption, string>,
49
  tableHeaders: {
50
    component: 'Componente', count: 'Qtd', avgIntensity: 'Int. Média',
51
    date: 'Data', mood: 'Humor', anxiety: 'Ans', stress: 'Est',
52
    energy: 'Ene', note: 'Nota',
53
  },
54
}
55

56
const MOOD_COLORS: Record<BaseMoodOption, string> = {
57
  GREAT: '#378ADD',
58
  GOOD: '#639922',
59
  NEUTRAL: '#888780',
60
  SAD: '#1D9E75',
61
  ANGRY: '#D85A30',
62
}
63

64
const INTENSITY_COLORS: Record<IntensityLevel, string> = {
65
  LIGHT: '#639922',
66
  MODERATE: '#BA7517',
67
  HIGH: '#D85A30',
68
}
69

70
const EMPTY_CELL = '#E8E7E2'
71

72
const s = StyleSheet.create({
73
  page: {
74
    fontFamily: 'Helvetica',
75
    fontSize: 9,
76
    paddingTop: 40,
77
    paddingBottom: 50,
78
    paddingHorizontal: 40,
79
    color: '#1a1a1a',
80
  },
81
  header: {
82
    flexDirection: 'row',
83
    justifyContent: 'space-between',
84
    alignItems: 'flex-start',
85
    marginBottom: 4,
86
    paddingBottom: 8,
87
    borderBottomWidth: 1,
88
    borderBottomColor: '#e0e0e0',
89
  },
90
  headerTitle: {
91
    fontSize: 13,
92
    fontFamily: 'Helvetica-Bold',
93
  },
94
  headerLogo: {
95
    width: 48,
96
    height: 48,
97
    objectFit: 'contain',
98
  },
99
  headerSub: {
100
    fontSize: 8,
101
    color: '#666666',
102
    marginTop: 2,
103
  },
104
  footer: {
105
    position: 'absolute',
106
    bottom: 20,
107
    left: 40,
108
    right: 40,
109
    flexDirection: 'column',
110
    borderTopWidth: 1,
111
    borderTopColor: '#e0e0e0',
112
    paddingTop: 5,
113
  },
114
  footerRow: {
115
    flexDirection: 'row',
116
    justifyContent: 'space-between',
117
  },
118
  footerText: {
119
    fontSize: 7,
120
    color: '#888888',
121
  },
122
  section: {
123
    marginTop: 16,
124
    marginBottom: 4,
125
  },
126
  sectionTitle: {
127
    fontFamily: 'Helvetica-Bold',
128
    fontSize: 10,
129
    marginBottom: 6,
130
    paddingBottom: 3,
131
    borderBottomWidth: 1,
132
    borderBottomColor: '#e0e0e0',
133
  },
134
  statRow: {
135
    flexDirection: 'row',
136
    gap: 8,
137
    marginBottom: 4,
138
  },
139
  statBox: {
140
    flex: 1,
141
    borderWidth: 1,
142
    borderColor: '#e0e0e0',
143
    borderRadius: 4,
144
    padding: 8,
145
  },
146
  statLabel: {
147
    fontSize: 7,
148
    color: '#888888',
149
    marginBottom: 3,
150
  },
151
  statValue: {
152
    fontSize: 12,
153
    fontFamily: 'Helvetica-Bold',
154
  },
155
  barRow: {
156
    flexDirection: 'row',
157
    alignItems: 'center',
158
    marginBottom: 4,
159
  },
160
  barLabel: {
161
    width: 60,
162
    fontSize: 8,
163
  },
164
  barPct: {
165
    width: 32,
166
    fontSize: 8,
167
    textAlign: 'right',
168
    color: '#666666',
169
  },
170
  heatmapRow: {
171
    flexDirection: 'row',
172
    alignItems: 'center',
173
    marginBottom: 2,
174
  },
175
  heatmapWeekLabel: {
176
    width: 24,
177
    fontSize: 7,
178
    color: '#888888',
179
  },
180
  tableHeaderRow: {
181
    flexDirection: 'row',
182
    backgroundColor: '#f0f0f0',
183
    paddingVertical: 4,
184
    paddingHorizontal: 4,
185
  },
186
  tableRow: {
187
    flexDirection: 'row',
188
    paddingVertical: 3,
189
    paddingHorizontal: 4,
190
  },
191
  tableCell: {
192
    fontSize: 7,
193
  },
194
})
195

196
function formatPeriod(start: Date, end: Date): string {
197
  const s = format(start, 'd MMM', { locale: ptBR })
198
  const e = format(end, 'd MMM yyyy', { locale: ptBR })
199
  return `${s} – ${e}`
200
}
201

202
function truncate(str: string | null, max: number): string {
203
  if (!str) return '—'
204
  return str.length > max ? str.slice(0, max) + '…' : str
205
}
206

207
function topMoodLabel(dist: ReportData['moodDistribution']): string {
208
  const top = [...dist].sort((a, b) => b.count - a.count)[0]
209
  return top && top.count > 0 ? T.moods[top.mood] : '—'
210
}
211

212
type SectionHeaderProps = { label: string }
213
function SectionHeader({ label }: SectionHeaderProps) {
214
  return (
215
    <View style={s.section}>
216
      <Text style={s.sectionTitle}>{label}</Text>
217
    </View>
218
  )
219
}
220

221
type StatBoxProps = { label: string; value: string }
222
function StatBox({ label, value }: StatBoxProps) {
223
  return (
224
    <View style={s.statBox}>
225
      <Text style={s.statLabel}>{label}</Text>
226
      <Text style={s.statValue}>{value}</Text>
227
    </View>
228
  )
229
}
230

231
const BAR_MAX_WIDTH = 350
232

233
type MoodBarProps = {
234
  mood: BaseMoodOption
235
  count: number
236
  pct: number
237
  maxPct: number
238
}
239
function MoodBar({ mood, count, pct, maxPct }: MoodBarProps) {
240
  const barWidth = maxPct > 0 ? (pct / maxPct) * BAR_MAX_WIDTH : 0
241
  const color = MOOD_COLORS[mood]
242
  return (
243
    <View style={s.barRow}>
244
      <Text style={s.barLabel}>{T.moods[mood]}</Text>
245
      <Svg width={BAR_MAX_WIDTH} height={12}>
246
        <Rect x={0} y={2} width={BAR_MAX_WIDTH} height={8}
247
          rx={2} fill={EMPTY_CELL} />
248
        {barWidth > 0 && (
249
          <Rect x={0} y={2} width={barWidth} height={8}
250
            rx={2} fill={color} />
251
        )}
252
      </Svg>
253
      <Text style={s.barPct}>{pct}%</Text>
254
      <Text style={[s.barPct, { width: 24 }]}>{count}</Text>
255
    </View>
256
  )
257
}
258

259
const CELL = 52
260
const GAP = 4
261

262
type HeatmapRowProps = {
263
  week: number
264
  cells: Array<{ date: string; mood: BaseMoodOption | null }>
265
  startDow: number
266
}
267
function HeatmapRow({ week, cells, startDow }: HeatmapRowProps) {
268
  const totalCols = 7
269
  const svgWidth = totalCols * (CELL + GAP)
270

271
  return (
272
    <View style={s.heatmapRow}>
273
      <Text style={s.heatmapWeekLabel}>S{week}</Text>
274
      <Svg width={svgWidth} height={CELL}>
275
        {Array.from({ length: totalCols }).map((_, col) => {
276
          const dayIndex = col - startDow
277
          const cell = dayIndex >= 0 ? cells[dayIndex] : undefined
278
          const fill = cell
279
            ? cell.mood ? MOOD_COLORS[cell.mood] : EMPTY_CELL
280
            : '#f8f8f8'
281
          return (
282
            <Rect
283
              key={col}
284
              x={col * (CELL + GAP)}
285
              y={0}
286
              width={CELL}
287
              height={CELL}
288
              rx={3}
289
              fill={fill}
290
            />
291
          )
292
        })}
293
      </Svg>
294
    </View>
295
  )
296
}
297

298
type TableHeaderProps = { cols: string[]; widths: number[] }
299
function TableHeader({ cols, widths }: TableHeaderProps) {
300
  return (
301
    <View style={s.tableHeaderRow}>
302
      {cols.map((col, i) => (
303
        <Text key={i}
304
          style={[s.tableCell, { width: widths[i], fontFamily: 'Helvetica-Bold' }]}>
305
          {col}
306
        </Text>
307
      ))}
308
    </View>
309
  )
310
}
311

312
type TableRowProps = {
313
  cols: string[]
314
  widths: number[]
315
  even: boolean
316
  colors?: (string | undefined)[]
317
}
318
function TableRow({ cols, widths, even, colors }: TableRowProps) {
319
  return (
320
    <View style={[s.tableRow, { backgroundColor: even ? '#F8F7F4' : '#ffffff' }]}>
321
      {cols.map((col, i) => (
322
        <Text key={i}
323
          style={[s.tableCell, { width: widths[i], color: colors?.[i] ?? '#1a1a1a' }]}>
324
          {col}
325
        </Text>
326
      ))}
327
    </View>
328
  )
329
}
330

331
type PageHeaderProps = { data: ReportData }
332
function PageHeader({ data }: PageHeaderProps) {
333
  const name = [data.user.firstName, data.user.lastName].filter(Boolean).join(' ')
334
  const period = formatPeriod(data.periodStart, data.periodEnd)
335
  return (
336
    <View style={s.header}>
337
      <View>
338
        <Text style={s.headerTitle}>{T.title}</Text>
339
        <Text style={s.headerSub}>{name} · {period}</Text>
340
      </View>
341

342
      <Image src={LOGO_DATA_URI} style={s.headerLogo} />
343
    </View>
344
  )
345
}
346

347
function PageFooter() {
348
  return (
349
    <View style={s.footer} fixed>
350
      <View style={s.footerRow}>
351
        <Text style={s.footerText}>{T.tagline}</Text>
352
        <Text
353
          style={s.footerText}
354
          render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) =>
355
            `${pageNumber} / ${totalPages}`
356
          }
357
        />
358
      </View>
359
      <Text style={[s.footerText, { marginTop: 2 }]}>{T.confidential}</Text>
360
    </View>
361
  )
362
}
363

364
function buildHeatmapWeeks(
365
  dailyMoods: ReportData['dailyMoods'],
366
): Array<{
367
  week: number
368
  cells: Array<{ date: string; mood: BaseMoodOption | null }>
369
  startDow: number
370
}> {
371
  const weeks: ReturnType<typeof buildHeatmapWeeks> = []
372
  let weekNum = 1
373
  let i = 0
374

375
  if (dailyMoods.length === 0) return weeks
376

377
  const firstDow = new Date(dailyMoods[0].date + 'T12:00:00').getDay()
378

379
  while (i < dailyMoods.length) {
380
    const slots = 7 - (weekNum === 1 ? firstDow : 0)
381
    const cells = dailyMoods.slice(i, i + slots)
382

383
    weeks.push({ week: weekNum, cells, startDow: weekNum === 1 ? firstDow : 0 })
384
    i += slots
385
    weekNum++
386
  }
387

388
  return weeks
389
}
390

391
type Props = { data: ReportData }
392

393
export default function ReportDocument({ data }: Props) {
394
  const maxPct = Math.max(...data.moodDistribution.map(d => d.pct), 0)
395
  const daysTracked = data.dailyMoods.filter(d => d.mood !== null).length
396
  const heatmapWeeks = buildHeatmapWeeks(data.dailyMoods)
397

398
  return (
399
    <Document>
400
      {/* Page 1 */}
401
      <Page size="A4" style={s.page}>
402
        <PageHeader data={data} />
403
        <PageFooter />
404

405
        <SectionHeader label={T.sections.glance} />
406
        <View style={s.statRow}>
407
          <StatBox label={T.stats.checkins} value={String(data.totalCheckins)} />
408
          <StatBox label={T.stats.topMood} value={topMoodLabel(data.moodDistribution)} />
409
          <StatBox label={T.stats.daysTracked} value={String(daysTracked)} />
410
          <StatBox
411
            label="Período"
412
            value={`${data.dailyMoods.length}d`}
413
          />
414
        </View>
415

416
        <SectionHeader label={T.sections.distribution} />
417
        {data.moodDistribution.map(d => (
418
          <MoodBar
419
            key={d.mood}
420
            mood={d.mood}
421
            count={d.count}
422
            pct={d.pct}
423
            maxPct={maxPct}
424
          />
425
        ))}
426

427
        <SectionHeader label={T.sections.heatmap} />
428
        {heatmapWeeks.map(({ week, cells, startDow }) => (
429
          <HeatmapRow key={week} week={week} cells={cells} startDow={startDow} />
430
        ))}
431
      </Page>
432

433
      {/* Page 2 */}
434
      <Page size="A4" style={s.page}>
435
        <PageHeader data={data} />
436
        <PageFooter />
437

438
        <SectionHeader label={T.sections.components} />
439
        {data.topComponents.length > 0 ? (
440
          <View>
441
            <TableHeader
442
              cols={[
443
                T.tableHeaders.component,
444
                T.tableHeaders.count,
445
                T.tableHeaders.avgIntensity,
446
              ]}
447
              widths={[200, 60, 100]}
448
            />
449
            {data.topComponents.map((c, i) => (
450
              <TableRow
451
                key={c.component}
452
                even={i % 2 === 1}
453
                cols={[
454
                  T.components[c.component] ?? c.component,
455
                  String(c.count),
456
                  T.intensity[c.avgIntensity],
457
                ]}
458
                widths={[200, 60, 100]}
459
                colors={[
460
                  undefined,
461
                  undefined,
462
                  INTENSITY_COLORS[c.avgIntensity],
463
                ]}
464
              />
465
            ))}
466
          </View>
467
        ) : (
468
          <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
469
            Sem componentes registrados no período.
470
          </Text>
471
        )}
472

473
        <SectionHeader label={T.sections.log} />
474
        {data.checkinLog.length > 0 ? (
475
          <View>
476
            <TableHeader
477
              cols={[
478
                T.tableHeaders.date,
479
                T.tableHeaders.mood,
480
                T.tableHeaders.anxiety,
481
                T.tableHeaders.stress,
482
                T.tableHeaders.energy,
483
                T.tableHeaders.note,
484
              ]}
485
              widths={[48, 62, 28, 28, 28, 150]}
486
            />
487
            {data.checkinLog.map((entry, i) => (
488
              <TableRow
489
                key={i}
490
                even={i % 2 === 1}
491
                cols={[
492
                  format(entry.moment, 'd MMM', { locale: ptBR }),
493
                  T.moods[entry.mood] ?? entry.mood,
494
                  String(entry.anxietyLevel),
495
                  String(entry.stressLevel),
496
                  String(entry.energyLevel),
497
                  truncate(entry.annotation, 28),
498
                ]}
499
                widths={[48, 62, 28, 28, 28, 150]}
500
                colors={[
501
                  undefined,
502
                  MOOD_COLORS[entry.mood],
503
                  undefined,
504
                  undefined,
505
                  undefined,
506
                  undefined,
507
                ]}
508
              />
509
            ))}
510
          </View>
511
        ) : (
512
          <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
513
            Sem check-ins no período.
514
          </Text>
515
        )}
516
      </Page>
517
    </Document>
518
  )
519
}