Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/iris/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"dependencies": {
"@base-ui-components/react": "^1.0.0-rc.0",
"@base-ui/react": "^1.4.1",
"@expo-google-fonts/noto-sans": "^0.4.2",
"@fontsource-variable/outfit": "^5.2.8",
"@react-pdf/renderer": "^4.5.1",
"@sentry/react": "^10.53.1",
Expand Down
8 changes: 7 additions & 1 deletion apps/iris/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,13 @@
"cardTitle": "Substitution on {{date}}",
"notAvailable": "N/A",
"period": "Period",
"noCohorts": "No class"
"noCohorts": "No class",
"export": "Export",
"exportTitle": "Export Substitutions",
"exportFormat": "Format",
"exporting": "Exporting...",
"exportAbsentTeacher": "Absent Teacher",
"exportClass": "Class"
},
"movedLesson": {
"title": "Moved Lessons",
Expand Down
8 changes: 7 additions & 1 deletion apps/iris/public/locales/hu/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,13 @@
"cardTitle": "Helyettesítés ezen a napon: {{date}}",
"notAvailable": "N/A",
"period": "Óra",
"noCohorts": "Nincsen osztály"
"noCohorts": "Nincsen osztály",
"export": "Exportálás",
"exportTitle": "Helyettesítések exportálása",
"exportFormat": "Formátum",
"exporting": "Exportálás...",
"exportAbsentTeacher": "Hiányzó tanár",
"exportClass": "Osztály"
},
"movedLesson": {
"title": "Áthelyezett órák",
Expand Down
219 changes: 219 additions & 0 deletions apps/iris/src/components/admin/substitution-export-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { pdf } from '@react-pdf/renderer';
import dayjs from 'dayjs';
import type { InferResponseType } from 'hono/client';
import { Loader2Icon } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { api } from '@/utils/hc';
import {
type SubstitutionExportRow,
SubstitutionPDF,
} from './substitution-pdf';

type SubstitutionItem = NonNullable<
InferResponseType<typeof api.timetable.substitutions.$get>['data']
>[number];

type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
substitutions: SubstitutionItem[];
};

function buildRows(
substitutions: SubstitutionItem[],
date: Date
): SubstitutionExportRow[] {
const target = dayjs(date).format('YYYY-MM-DD');
const rows: SubstitutionExportRow[] = [];

for (const sub of substitutions) {
if (dayjs(sub.substitution.date).format('YYYY-MM-DD') !== target) {
continue;
}
const substituteTeacher = sub.teacher
? `${sub.teacher.firstName} ${sub.teacher.lastName}`
: '';

for (const lesson of sub.lessons) {
if (!lesson) {
continue;
}
const missingTeacher = (lesson.teachers ?? [])
.map((t) => t.name)
.join(', ');
const cohorts = (lesson.cohorts ?? []).join(', ');
const period = lesson.period ? `${lesson.period.period}.` : '?';
rows.push({ cohorts, missingTeacher, period, substituteTeacher });
}
}

return rows;
}

function downloadCsv(
rows: SubstitutionExportRow[],
filename: string,
labels: {
missingTeacher: string;
substituteTeacher: string;
class: string;
period: string;
}
) {
const dangerous = ['=', '+', '-', '@', '\t', '\r'];
const escapeS = (v: string) => {
const safe = dangerous.some((c) => v.startsWith(c)) ? `'${v}` : v;
return safe.includes(';') || safe.includes('"') || safe.includes('\n')
? `"${safe.replace(/"/g, '""')}"`
: safe;
};

const lines = [
[
labels.missingTeacher,
labels.substituteTeacher,
labels.class,
labels.period,
]
.map(escapeS)
.join(';'),
...rows.map((r) =>
[r.missingTeacher, r.substituteTeacher, r.cohorts, r.period]
.map(escapeS)
.join(';')
),
];

// BOM for Excel UTF-8 recognition
const bom = '';
const blob = new Blob([bom + lines.join('\r\n')], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

export function SubstitutionExportDialog({
open,
onOpenChange,
substitutions,
}: Props) {
const { i18n, t } = useTranslation();
const [date, setDate] = useState<Date>(new Date());
const [format, setFormat] = useState<'pdf' | 'csv'>('pdf');
const [loading, setLoading] = useState(false);

const labels = {
class: t('substitution.exportClass'),
missingTeacher: t('substitution.exportAbsentTeacher'),
noSubstitutions: t('substitution.noSubstitutions'),
period: t('substitution.period'),
substituteTeacher: t('substitution.substituteTeacher'),
};

const handleExport = async () => {
setLoading(true);
try {
const rows = buildRows(substitutions, date);
const dateLabel = new Intl.DateTimeFormat(
i18n.language === 'hu' ? 'hu-HU' : 'en-US'
).format(date);
const isoDate = dayjs(date).format('YYYY-MM-DD');

if (format === 'pdf') {
const blob = await pdf(
<SubstitutionPDF date={dateLabel} labels={labels} rows={rows} />
).toBlob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
} else {
downloadCsv(rows, `substitutions-${isoDate}.csv`, labels);
}

onOpenChange(false);
} catch (error) {
toast.error(
t('error.generic', {
message: error instanceof Error ? error.message : 'Export failed',
})
);
Comment on lines +157 to +162
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm translation keys used by the export flow exist in en/hu locales.
fd -t f translation.json apps/iris/public/locales | while read -r f; do
  echo "== $f =="
  jq '{ "error.generic": (.error.generic // "MISSING"),
        "substitution.exportTitle": (.substitution.exportTitle // "MISSING"),
        "substitution.exportNoData": (.substitution.exportNoData // "MISSING"),
        "substitution.exportFormat": (.substitution.exportFormat // "MISSING"),
        "substitution.exportClass": (.substitution.exportClass // "MISSING") }' "$f"
done

Repository: filcdev/filc

Length of output: 638


apps/iris/src/components/admin/substitution-export-dialog.tsx (162-167): use a localized fallback for non-Error export failures

  • error.generic already exists in both en/hu and supports message interpolation ({{message}}).
  • Remove the hardcoded 'Export failed' fallback and supply a t(...)-based localized message instead (add a locale key if needed).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/iris/src/components/admin/substitution-export-dialog.tsx` around lines
162 - 167, The catch block in substitution-export-dialog.tsx currently passes a
hardcoded 'Export failed' string as the fallback message to t('error.generic');
update it to use a localized fallback by calling t(...) for the fallback (e.g.
use t('error.exportFailed') as the fallback message) so the toast.error uses
t('error.generic', { message: error instanceof Error ? error.message :
t('error.exportFailed') }); if the key 'error.exportFailed' (or a similarly
named key) doesn't exist, add it to the locale files for both en and hu.

} finally {
setLoading(false);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent showCloseButton={!loading}>
<DialogHeader>
<DialogTitle>{t('substitution.exportTitle')}</DialogTitle>
</DialogHeader>

<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('substitution.date')}</Label>
<DatePicker
date={date}
disabled={loading}
onDateChange={(d) => d && setDate(d)}
placeholder={t('substitution.datePlaceholder')}
/>
</div>

<div className="space-y-2">
<Label>{t('substitution.exportFormat')}</Label>
<Select
disabled={loading}
onValueChange={(v) => setFormat(v as 'pdf' | 'csv')}
value={format}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="csv">CSV</SelectItem>
</SelectContent>
</Select>
</div>
</div>

<DialogFooter showCloseButton={!loading}>
<Button disabled={loading} onClick={handleExport}>
{loading ? (
<>
<Loader2Icon className="animate-spin" />
{t('substitution.exporting')}
</>
) : (
t('substitution.export')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
135 changes: 135 additions & 0 deletions apps/iris/src/components/admin/substitution-pdf.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import notoSansRegular from '@expo-google-fonts/noto-sans/400Regular/NotoSans_400Regular.ttf?url';
import notoSansBold from '@expo-google-fonts/noto-sans/700Bold/NotoSans_700Bold.ttf?url';
import {
Document,
Font,
Page,
StyleSheet,
Text,
View,
} from '@react-pdf/renderer';

Font.register({
family: 'NotoSans',
fonts: [
{ fontWeight: 400, src: notoSansRegular },
{ fontWeight: 700, src: notoSansBold },
],
});

// Prevent automatic hyphenation so column headers stay on one line
Font.registerHyphenationCallback((word) => [word]);

export type SubstitutionExportRow = {
missingTeacher: string;
substituteTeacher: string;
cohorts: string;
period: string;
};

type Labels = {
missingTeacher: string;
substituteTeacher: string;
class: string;
period: string;
noSubstitutions: string;
};

type Props = {
rows: SubstitutionExportRow[];
date: string;
labels: Labels;
};

const FONT_STACK = 'NotoSans';

const styles = StyleSheet.create({
bold: {
fontWeight: 700,
},
cell: {
flex: 1,
padding: '6 8',
},
dash: {
color: '#9ca3af',
},
header: {
fontSize: 14,
fontWeight: 700,
marginBottom: 16,
},
headerCell: {
flex: 1,
fontWeight: 700,
padding: '6 8',
},
headerPeriodCell: {
flexShrink: 0,
fontWeight: 700,
padding: '6 8',
width: 80,
},
page: {
fontFamily: FONT_STACK,
fontSize: 10,
padding: 32,
},
periodCell: {
flexShrink: 0,
padding: '6 8',
width: 80,
},
table: {
width: '100%',
},
tableHeaderRow: {
borderBottomColor: '#111827',
borderBottomWidth: 2,
flexDirection: 'row',
marginBottom: 2,
},
tableRow: {
borderBottomColor: '#e5e7eb',
borderBottomWidth: 1,
flexDirection: 'row',
},
});

export function SubstitutionPDF({ rows, date, labels }: Props) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.header}>{date}</Text>
<View style={styles.table}>
<View style={styles.tableHeaderRow}>
<Text style={styles.headerCell}>{labels.missingTeacher}</Text>
<Text style={styles.headerCell}>{labels.substituteTeacher}</Text>
<Text style={styles.headerCell}>{labels.class}</Text>
<Text style={styles.headerPeriodCell}>{labels.period}</Text>
</View>
{rows.map((row, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional, no stable id
<View key={i} style={styles.tableRow}>
<Text style={[styles.cell, styles.bold]}>
{row.missingTeacher}
</Text>
<Text style={[styles.cell, styles.bold]}>
{row.substituteTeacher || '-'}
</Text>
<Text style={styles.cell}>{row.cohorts || '-'}</Text>
<Text style={styles.periodCell}>{row.period}</Text>
</View>
))}
{rows.length === 0 && (
<View style={styles.tableRow}>
<Text style={[styles.cell, styles.dash, { width: '100%' }]}>
{labels.noSubstitutions}
</Text>
</View>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</View>
</Page>
</Document>
);
}
Loading