diff --git a/apps/iris/package.json b/apps/iris/package.json index b195355..bcbda3c 100644 --- a/apps/iris/package.json +++ b/apps/iris/package.json @@ -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", diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 764ad01..f619a55 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -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", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 0ea7ad1..fc16f32 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -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", diff --git a/apps/iris/src/components/admin/substitution-export-dialog.tsx b/apps/iris/src/components/admin/substitution-export-dialog.tsx new file mode 100644 index 0000000..c7d5208 --- /dev/null +++ b/apps/iris/src/components/admin/substitution-export-dialog.tsx @@ -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['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(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( + + ).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', + }) + ); + } finally { + setLoading(false); + } + }; + + return ( + + + + {t('substitution.exportTitle')} + + +
+
+ + d && setDate(d)} + placeholder={t('substitution.datePlaceholder')} + /> +
+ +
+ + +
+
+ + + + +
+
+ ); +} diff --git a/apps/iris/src/components/admin/substitution-pdf.tsx b/apps/iris/src/components/admin/substitution-pdf.tsx new file mode 100644 index 0000000..74cfdaa --- /dev/null +++ b/apps/iris/src/components/admin/substitution-pdf.tsx @@ -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 ( + + + {date} + + + {labels.missingTeacher} + {labels.substituteTeacher} + {labels.class} + {labels.period} + + {rows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows are positional, no stable id + + + {row.missingTeacher} + + + {row.substituteTeacher || '-'} + + {row.cohorts || '-'} + {row.period} + + ))} + {rows.length === 0 && ( + + + {labels.noSubstitutions} + + + )} + + + + ); +} diff --git a/apps/iris/src/routes/_private/admin/timetable/substitutions.tsx b/apps/iris/src/routes/_private/admin/timetable/substitutions.tsx index 092658e..490342c 100644 --- a/apps/iris/src/routes/_private/admin/timetable/substitutions.tsx +++ b/apps/iris/src/routes/_private/admin/timetable/substitutions.tsx @@ -6,11 +6,12 @@ import { type InferResponseType, parseResponse, } from 'hono/client'; -import { Pen, Plus, RefreshCw, Trash } from 'lucide-react'; +import { FileDown, Pen, Plus, RefreshCw, Trash } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { SubstitutionDialog } from '@/components/admin/substitution-dialog'; +import { SubstitutionExportDialog } from '@/components/admin/substitution-export-dialog'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -64,6 +65,7 @@ function SubstitutionsPage() { const [search, setSearch] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [exportDialogOpen, setExportDialogOpen] = useState(false); const [selectedItem, setSelectedItem] = useState( null ); @@ -314,6 +316,10 @@ function SubstitutionsPage() {
+
)} + + {hasWritePermission && dialogOpen && (