eletrotupi / tcc/ api/src/reports/ReportDocument.tsx04084150bf5e7e1867221dfcc35651e6d37ad766
13.6 KB | 531 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 — usenexo.xyz',
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
  footnote: {
195
    fontSize: 6,
196
    color: '#aaaaaa',
197
    marginTop: 6,
198
    fontFamily: 'Helvetica-Oblique',
199
  },
200
})
201

202
function formatPeriod(start: Date, end: Date): string {
203
  const s = format(start, 'd MMM', { locale: ptBR })
204
  const e = format(end, 'd MMM yyyy', { locale: ptBR })
205
  return `${s} – ${e}`
206
}
207

208
function truncate(str: string | null, max: number): string {
209
  if (!str) return '—'
210
  return str.length > max ? str.slice(0, max) + '…' : str
211
}
212

213
function topMoodLabel(dist: ReportData['moodDistribution']): string {
214
  const top = [...dist].sort((a, b) => b.count - a.count)[0]
215
  return top && top.count > 0 ? T.moods[top.mood] : '—'
216
}
217

218
type SectionHeaderProps = { label: string }
219
function SectionHeader({ label }: SectionHeaderProps) {
220
  return (
221
    <View style={s.section}>
222
      <Text style={s.sectionTitle}>{label}</Text>
223
    </View>
224
  )
225
}
226

227
type StatBoxProps = { label: string; value: string }
228
function StatBox({ label, value }: StatBoxProps) {
229
  return (
230
    <View style={s.statBox}>
231
      <Text style={s.statLabel}>{label}</Text>
232
      <Text style={s.statValue}>{value}</Text>
233
    </View>
234
  )
235
}
236

237
const BAR_MAX_WIDTH = 350
238

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

265
const CELL = 52
266
const GAP = 4
267

268
type HeatmapRowProps = {
269
  week: number
270
  cells: Array<{ date: string; mood: BaseMoodOption | null }>
271
  startDow: number
272
}
273
function HeatmapRow({ week, cells, startDow }: HeatmapRowProps) {
274
  const totalCols = 7
275
  const svgWidth = totalCols * (CELL + GAP)
276

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

304
type TableHeaderProps = { cols: string[]; widths: number[] }
305
function TableHeader({ cols, widths }: TableHeaderProps) {
306
  return (
307
    <View style={s.tableHeaderRow}>
308
      {cols.map((col, i) => (
309
        <Text key={i}
310
          style={[s.tableCell, { width: widths[i], fontFamily: 'Helvetica-Bold' }]}>
311
          {col}
312
        </Text>
313
      ))}
314
    </View>
315
  )
316
}
317

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

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

348
      <Image src={LOGO_DATA_URI} style={s.headerLogo} />
349
    </View>
350
  )
351
}
352

353
function PageFooter() {
354
  return (
355
    <View style={s.footer} fixed>
356
      <View style={s.footerRow}>
357
        <Text style={s.footerText}>{T.tagline}</Text>
358
        <Text
359
          style={s.footerText}
360
          render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) =>
361
            `${pageNumber} / ${totalPages}`
362
          }
363
        />
364
      </View>
365
      <Text style={[s.footerText, { marginTop: 2 }]}>{T.confidential}</Text>
366
    </View>
367
  )
368
}
369

370
function buildHeatmapWeeks(
371
  dailyMoods: ReportData['dailyMoods'],
372
): Array<{
373
  week: number
374
  cells: Array<{ date: string; mood: BaseMoodOption | null }>
375
  startDow: number
376
}> {
377
  const weeks: ReturnType<typeof buildHeatmapWeeks> = []
378
  let weekNum = 1
379
  let i = 0
380

381
  if (dailyMoods.length === 0) return weeks
382

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

385
  while (i < dailyMoods.length) {
386
    const slots = 7 - (weekNum === 1 ? firstDow : 0)
387
    const cells = dailyMoods.slice(i, i + slots)
388

389
    weeks.push({ week: weekNum, cells, startDow: weekNum === 1 ? firstDow : 0 })
390
    i += slots
391
    weekNum++
392
  }
393

394
  return weeks
395
}
396

397
type Props = { data: ReportData }
398

399
export default function ReportDocument({ data }: Props) {
400
  const maxPct = Math.max(...data.moodDistribution.map(d => d.pct), 0)
401
  const daysTracked = data.dailyMoods.filter(d => d.mood !== null).length
402
  const heatmapWeeks = buildHeatmapWeeks(data.dailyMoods)
403

404
  return (
405
    <Document>
406
      {/* Page 1 */}
407
      <Page size="A4" style={s.page}>
408
        <PageHeader data={data} />
409
        <PageFooter />
410

411
        <SectionHeader label={T.sections.glance} />
412
        <View style={s.statRow}>
413
          <StatBox label={T.stats.checkins} value={String(data.totalCheckins)} />
414
          <StatBox label={T.stats.topMood} value={topMoodLabel(data.moodDistribution)} />
415
          <StatBox label={T.stats.daysTracked} value={String(daysTracked)} />
416
          <StatBox
417
            label="Período"
418
            value={`${data.dailyMoods.length}d`}
419
          />
420
        </View>
421

422
        <SectionHeader label={T.sections.distribution} />
423
        {data.moodDistribution.map(d => (
424
          <MoodBar
425
            key={d.mood}
426
            mood={d.mood}
427
            count={d.count}
428
            pct={d.pct}
429
            maxPct={maxPct}
430
          />
431
        ))}
432

433
        <SectionHeader label={T.sections.heatmap} />
434
        {heatmapWeeks.map(({ week, cells, startDow }) => (
435
          <HeatmapRow key={week} week={week} cells={cells} startDow={startDow} />
436
        ))}
437
      </Page>
438

439
      {/* Page 2 */}
440
      <Page size="A4" style={s.page}>
441
        <PageHeader data={data} />
442
        <PageFooter />
443

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

479
        <SectionHeader label={T.sections.log} />
480

481
        <Text style={[s.footnote, { marginBottom: 10 }]}>
482
          Ans = Ansiedade · Est = Estresse · Ene = Energia.
483
          Níveis percebidos de 0 a 10.
484
        </Text>
485

486
        {data.checkinLog.length > 0 ? (
487
          <View>
488
            <TableHeader
489
              cols={[
490
                T.tableHeaders.date,
491
                T.tableHeaders.mood,
492
                T.tableHeaders.anxiety,
493
                T.tableHeaders.stress,
494
                T.tableHeaders.energy,
495
                T.tableHeaders.note,
496
              ]}
497
              widths={[56, 78, 30, 30, 30, 75]}
498
            />
499
            {data.checkinLog.map((entry, i) => (
500
              <TableRow
501
                key={i}
502
                even={i % 2 === 1}
503
                cols={[
504
                  format(entry.moment, 'd MMM', { locale: ptBR }),
505
                  T.moods[entry.mood] ?? entry.mood,
506
                  String(entry.anxietyLevel),
507
                  String(entry.stressLevel),
508
                  String(entry.energyLevel),
509
                  truncate(entry.annotation?.trim() ?? "", 73),
510
                ]}
511
                widths={[56, 78, 30, 30, 30, 75]}
512
                colors={[
513
                  undefined,
514
                  MOOD_COLORS[entry.mood],
515
                  undefined,
516
                  undefined,
517
                  undefined,
518
                  undefined,
519
                ]}
520
              />
521
            ))}
522
          </View>
523
        ) : (
524
          <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
525
            Sem check-ins no período.
526
          </Text>
527
        )}
528
      </Page>
529
    </Document>
530
  )
531
}