Files
heygermany-e2e-mrg2zk24/apps/website/components/backoffice/LifecycleNotificationsCard.tsx
e2e 373b42a994
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled
chore: heygermany customer project
2026-07-11 10:06:51 +02:00

268 lines
8.9 KiB
TypeScript

'use client';
import { Button, Divider, Group, Paper, Stack, Table, Text, Textarea, Title } from '@pikku/mantine/core';
import { CandidateLifecycleNotifications } from '@heygermany/sdk';
import dayjs from 'dayjs';
import { useI18n } from '@/context/i18n-provider';
import { asI18n } from '@pikku/react';
type LifecycleNotificationsCardProps = {
lifecycleNotifications?: CandidateLifecycleNotifications | null
analysisIssuesDraft: string
onAnalysisIssuesDraftChange: (value: string) => void
onSaveAnalysisIssues: () => void
isSaving: boolean
}
type ReminderCategory = 'initial' | 'invalid'
type ReminderType = 'day_1' | 'day_3' | 'day_6'
type ReminderRow = {
type: ReminderType
sentAt: string | null
mailgunMessageId: string | null
errorMessage: string | null
lastErrorAt: string | null
}
const REMINDER_TYPES: ReminderRow[] = [
{ type: 'day_1', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
{ type: 'day_3', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
{ type: 'day_6', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
]
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const normalizeLifecycleNotifications = (value: unknown): CandidateLifecycleNotifications | null => {
if (isPlainObject(value)) {
return value as CandidateLifecycleNotifications
}
if (typeof value !== 'string') {
return null
}
try {
const parsedValue = JSON.parse(value)
return isPlainObject(parsedValue) ? parsedValue as CandidateLifecycleNotifications : null
} catch {
return null
}
}
const getString = (value: unknown) => {
if (typeof value !== 'string') {
return null
}
const normalizedValue = value.trim()
return normalizedValue.length > 0 ? normalizedValue : null
}
const normalizeMessage = (value: string | null) => {
if (!value) {
return null
}
const normalizedValue = value.replace(/\s+/g, ' ').trim()
return normalizedValue.length > 0 ? normalizedValue : null
}
const displayValue = (value: string | null) => value || '-'
const formatTimestamp = (value: string | null) => {
if (!value) {
return '-'
}
const parsedValue = dayjs(value)
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm') : value
}
const getReminderKey = (category: ReminderCategory, type: ReminderType) =>
`${category}.${type}`
const normalizeKey = (value: string) => value.toLowerCase().replace(/[_\-.]/g, '')
const getReminderValue = (
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
category: ReminderCategory,
type: ReminderType
) => {
const directValue = lifecycleNotifications?.[getReminderKey(category, type)]
if (directValue !== undefined) {
return directValue
}
const normalizedReminderKey = normalizeKey(getReminderKey(category, type))
return Object.entries(lifecycleNotifications ?? {}).find(([key]) => (
normalizeKey(key) === normalizedReminderKey
))?.[1]
}
const getStringFromKeys = (record: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
const value = getString(record[key])
if (value) {
return value
}
}
return null
}
const getReminderRows = (
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
category: ReminderCategory
) =>
REMINDER_TYPES.map((baseRow) => {
const reminderValue = getReminderValue(lifecycleNotifications, category, baseRow.type)
if (!isPlainObject(reminderValue)) {
return baseRow
}
return {
...baseRow,
sentAt: getStringFromKeys(reminderValue, ['sent_at', 'sentAt']),
mailgunMessageId: getStringFromKeys(reminderValue, ['mailgun_message_id', 'mailgunMessageId']),
errorMessage: normalizeMessage(getStringFromKeys(reminderValue, ['last_error', 'lastError'])),
lastErrorAt: getStringFromKeys(reminderValue, ['last_error_at', 'lastErrorAt']),
}
})
const renderReminderSection = (
title: string,
rows: ReminderRow[]
) => (
<Paper withBorder radius="sm" p="md">
<Stack gap="xs">
<Text fw={600}>{asI18n(title)}</Text>
<div style={{ overflowX: 'auto' }}>
<Table
withTableBorder
withColumnBorders={false}
striped={false}
highlightOnHover={false}
style={{ tableLayout: 'fixed', minWidth: 980 }}
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 120 }}>notification_type</Table.Th>
<Table.Th style={{ width: 180 }}>sent_at</Table.Th>
<Table.Th style={{ width: 280 }}>mailgun_message_id</Table.Th>
<Table.Th>last_error</Table.Th>
<Table.Th style={{ width: 180 }}>last_error_at</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.type}>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{asI18n(row.type)}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.sentAt) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.mailgunMessageId) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" c={row.errorMessage ? 'red' : 'dimmed'} style={{ whiteSpace: 'normal', overflowWrap: 'anywhere', lineHeight: 1.4 }}>
{asI18n(displayValue(row.errorMessage) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.lastErrorAt) || '-')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</Stack>
</Paper>
)
export const LifecycleNotificationsCard: React.FunctionComponent<LifecycleNotificationsCardProps> = ({
lifecycleNotifications,
analysisIssuesDraft,
onAnalysisIssuesDraftChange,
onSaveAnalysisIssues,
isSaving,
}) => {
const t = useI18n()
const normalizedLifecycleNotifications = normalizeLifecycleNotifications(lifecycleNotifications)
const initialRows = getReminderRows(normalizedLifecycleNotifications, 'initial')
const invalidRows = getReminderRows(normalizedLifecycleNotifications, 'invalid')
const analysisUpdatedAt =
isPlainObject(normalizedLifecycleNotifications?.analysis)
? getString(normalizedLifecycleNotifications.analysis.updated_at ?? normalizedLifecycleNotifications.analysis.updatedAt)
: null
return (
<Paper shadow="sm" p="lg" radius="md" withBorder mb="md">
<Stack gap="md">
<Stack gap={4}>
<Title order={4}>{t('backoffice.lifecycle.title' as any)}</Title>
<Text size="sm" c="dimmed">
{t('backoffice.lifecycle.description' as any)}
</Text>
</Stack>
{renderReminderSection(t('backoffice.lifecycle.initial' as any), initialRows)}
{renderReminderSection(t('backoffice.lifecycle.invalid' as any), invalidRows)}
<Divider />
<Paper withBorder radius="sm" p="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600}>{t('backoffice.lifecycle.customissues' as any)}</Text>
{analysisUpdatedAt ? (
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">{t('backoffice.lifecycle.updated' as any)}</Text>
<Text size="xs" c="dimmed">{asI18n(formatTimestamp(analysisUpdatedAt))}</Text>
</Group>
) : null}
</Group>
<Text size="sm" c="dimmed">
{t('backoffice.lifecycle.helptext' as any)}
</Text>
<Textarea
minRows={4}
autosize
value={analysisIssuesDraft}
onChange={(event) => onAnalysisIssuesDraftChange(event.currentTarget.value)}
placeholder={t('backoffice.lifecycle.placeholder' as any)}
/>
<Group justify="flex-end">
<Button
variant="light"
loading={isSaving}
onClick={onSaveAnalysisIssues}
>
{t('backoffice.lifecycle.save' as any)}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
);
};