268 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
};
|