chore: heygermany customer project
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user