From 2c4abc9fa328d086ca404367009c88f4585a9cf0 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 13 Mar 2026 12:05:31 +0000 Subject: [PATCH 1/5] feat(timetable): add manage timetable functionality feat(timetable): implement update and delete routes feat(ui): add timetable selection in filter bar --- apps/chronos/src/routes/timetable/_router.ts | 3 + apps/chronos/src/routes/timetable/cohort.ts | 7 +- apps/chronos/src/routes/timetable/import.ts | 2 +- apps/chronos/src/routes/timetable/manage.ts | 177 ++++++++ apps/iris/public/locales/en/translation.json | 19 +- apps/iris/public/locales/hu/translation.json | 19 +- apps/iris/src/components/admin/sidebar.tsx | 6 + .../src/components/timetable/filter-bar.tsx | 103 +++++ apps/iris/src/components/timetable/index.tsx | 66 ++- apps/iris/src/hooks/use-selected-timetable.ts | 87 ++++ apps/iris/src/route-tree.gen.ts | 22 + .../_private/admin/timetable/manage.tsx | 380 ++++++++++++++++++ apps/iris/src/routes/_public/index.tsx | 1 + 13 files changed, 877 insertions(+), 15 deletions(-) create mode 100644 apps/chronos/src/routes/timetable/manage.ts create mode 100644 apps/iris/src/hooks/use-selected-timetable.ts create mode 100644 apps/iris/src/routes/_private/admin/timetable/manage.tsx diff --git a/apps/chronos/src/routes/timetable/_router.ts b/apps/chronos/src/routes/timetable/_router.ts index 956d8e2..5ed2e20 100644 --- a/apps/chronos/src/routes/timetable/_router.ts +++ b/apps/chronos/src/routes/timetable/_router.ts @@ -12,6 +12,7 @@ import { getLessonsForRoom, getLessonsForTeacher, } from '#routes/timetable/lesson'; +import { deleteTimetable, updateTimetable } from '#routes/timetable/manage'; import { createMovedLesson, deleteMovedLesson, @@ -38,6 +39,8 @@ export const timetableRouter = timetableFactory .get('/timetables', ...getAllTimetables) .get('/timetables/latestValid', ...getLatestValidTimetable) .get('/timetables/valid', ...getAllValidTimetables) + .patch('/timetables/:id', ...updateTimetable) + .delete('/timetables/:id', ...deleteTimetable) .post('/import', ...importRoute) // Substitution routes .get('/substitutions', ...getAllSubstitutions) diff --git a/apps/chronos/src/routes/timetable/cohort.ts b/apps/chronos/src/routes/timetable/cohort.ts index 4b93bb3..3024f1b 100644 --- a/apps/chronos/src/routes/timetable/cohort.ts +++ b/apps/chronos/src/routes/timetable/cohort.ts @@ -7,7 +7,7 @@ import { StatusCodes } from 'http-status-codes'; import z from 'zod'; import type { SuccessResponse } from '#_types/globals'; import { db } from '#database'; -import { cohort, timetable } from '#database/schema/timetable'; +import { cohort } from '#database/schema/timetable'; import { requireAuthentication } from '#middleware/auth'; import { createSelectSchema } from '#utils/zod'; import { timetableFactory } from './_factory'; @@ -45,7 +45,7 @@ export const getCohortsForTimetable = timetableFactory.createHandlers( }, tags: ['Cohort'], }), - zValidator('param', z.object({ timetableId: z.uuid() })), + zValidator('param', z.object({ timetableId: z.string() })), requireAuthentication, async (c) => { try { @@ -54,8 +54,7 @@ export const getCohortsForTimetable = timetableFactory.createHandlers( const cohorts = await db .select() .from(cohort) - .leftJoin(timetable, eq(cohort.timetableId, timetable.id)) - .where(eq(timetable.id, timetableId)); + .where(eq(cohort.timetableId, timetableId)); return c.json>({ data: cohorts, diff --git a/apps/chronos/src/routes/timetable/import.ts b/apps/chronos/src/routes/timetable/import.ts index 48aead2..60e2d66 100644 --- a/apps/chronos/src/routes/timetable/import.ts +++ b/apps/chronos/src/routes/timetable/import.ts @@ -22,7 +22,7 @@ const importResponseSchema = z.object({ const importSchema = z.object({ name: z.string(), omanXml: z.file(), - validFrom: z.date(), + validFrom: z.coerce.date(), }); export const importRoute = timetableFactory.createHandlers( diff --git a/apps/chronos/src/routes/timetable/manage.ts b/apps/chronos/src/routes/timetable/manage.ts new file mode 100644 index 0000000..eecea7f --- /dev/null +++ b/apps/chronos/src/routes/timetable/manage.ts @@ -0,0 +1,177 @@ +import { zValidator } from '@hono/zod-validator'; +import { getLogger } from '@logtape/logtape'; +import { eq } from 'drizzle-orm'; +import { HTTPException } from 'hono/http-exception'; +import { describeRoute, resolver } from 'hono-openapi'; +import { StatusCodes } from 'http-status-codes'; +import z from 'zod'; +import type { SuccessResponse } from '#_types/globals'; +import { db } from '#database'; +import { timetable } from '#database/schema/timetable'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { createSelectSchema } from '#utils/zod'; +import { timetableFactory } from './_factory'; + +const logger = getLogger(['chronos', 'timetable']); + +const timetableSelectSchema = createSelectSchema(timetable); + +const updateSchema = z.object({ + name: z.string().optional(), + validFrom: z.string().optional(), +}); + +const updateResponseSchema = z.object({ + data: timetableSelectSchema, + success: z.literal(true), +}); + +const deleteResponseSchema = z.object({ + success: z.literal(true), +}); + +export const updateTimetable = timetableFactory.createHandlers( + describeRoute({ + description: 'Update a timetable name and/or validFrom date.', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The unique identifier for the timetable.', + type: 'string', + }, + }, + ], + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(updateResponseSchema), + }, + }, + description: 'Successful Response', + }, + }, + tags: ['Timetable'], + }), + zValidator('param', z.object({ id: z.string() })), + zValidator('json', updateSchema), + requireAuthentication, + requireAuthorization('import:timetable'), + async (c) => { + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + + try { + const [existing] = await db + .select() + .from(timetable) + .where(eq(timetable.id, id)) + .limit(1); + + if (!existing) { + throw new HTTPException(StatusCodes.NOT_FOUND, { + message: 'Timetable not found', + }); + } + + const updateData: Record = {}; + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.validFrom !== undefined) { + updateData.validFrom = body.validFrom; + } + + if (Object.keys(updateData).length === 0) { + return c.json>({ + data: existing, + success: true, + }); + } + + const [updated] = await db + .update(timetable) + .set(updateData) + .where(eq(timetable.id, id)) + .returning(); + + return c.json>({ + data: updated, + success: true, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + logger.error('Error updating timetable: ', { error }); + throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + message: 'Failed to update timetable', + }); + } + } +); + +export const deleteTimetable = timetableFactory.createHandlers( + describeRoute({ + description: 'Delete a timetable and all associated data.', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The unique identifier for the timetable.', + type: 'string', + }, + }, + ], + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(deleteResponseSchema), + }, + }, + description: 'Successful Response', + }, + }, + tags: ['Timetable'], + }), + zValidator('param', z.object({ id: z.string() })), + requireAuthentication, + requireAuthorization('import:timetable'), + async (c) => { + const { id } = c.req.valid('param'); + + try { + const [existing] = await db + .select() + .from(timetable) + .where(eq(timetable.id, id)) + .limit(1); + + if (!existing) { + throw new HTTPException(StatusCodes.NOT_FOUND, { + message: 'Timetable not found', + }); + } + + await db.delete(timetable).where(eq(timetable.id, id)); + + return c.json({ + success: true, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + logger.error('Error deleting timetable: ', { error }); + throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + message: 'Failed to delete timetable', + }); + } + } +); diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index ff14782..e14a4ac 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -142,7 +142,24 @@ "filterByClassroom": "Classroom", "printPdf": "Print / PDF", "time": "Time", - "teacherFallback": "Teacher" + "teacherFallback": "Teacher", + "selectTimetable": "Select timetable", + "searchTimetable": "Search timetables...", + "noTimetableFound": "No timetable found", + "activeTimetable": "Current", + "noTimetables": "No timetables found", + "manage": "Manage Timetables", + "manageDescription": "View, edit, and delete imported timetables", + "allTimetables": "All Timetables", + "allTimetablesDescription": "A list of all imported timetables and their validity dates", + "editTimetable": "Edit", + "deleteTimetable": "Delete", + "deleteConfirm": "Delete Timetable", + "deleteDescription": "Are you sure you want to delete \"{{name}}\"? This will permanently remove the timetable and all associated data (cohorts, lessons, etc.). This action cannot be undone.", + "updateSuccess": "Timetable updated successfully", + "updateError": "Failed to update timetable", + "deleteSuccess": "Timetable deleted successfully", + "deleteError": "Failed to delete timetable" }, "substitution": { "title": "Substitutions", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 9a52e7f..ee154ea 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -131,7 +131,24 @@ "filterByClassroom": "Terem", "printPdf": "Nyomtatás / PDF", "time": "Idő", - "teacherFallback": "Tanár" + "teacherFallback": "Tanár", + "activeTimetable": "Aktuális", + "allTimetables": "Összes órarend", + "allTimetablesDescription": "Az összes importált órarend és érvényességi dátumaik", + "deleteConfirm": "Órarend törlése", + "deleteDescription": "Biztosan törölni akarod a(z) \"{{name}}\" órarendet? Ez véglegesen törli az órarendet és az összes hozzá tartozó adatot (csoportok, órák stb.). Ez a művelet nem vonható vissza.", + "deleteError": "Nem sikerült törölni az órarendet", + "deleteSuccess": "Órarend sikeresen törölve", + "deleteTimetable": "Törlés", + "editTimetable": "Szerkesztés", + "manage": "Órarendek kezelése", + "manageDescription": "Importált órarendek megtekintése, szerkesztése és törlése", + "noTimetableFound": "Nem található órarend", + "noTimetables": "Nincsenek órarendek", + "searchTimetable": "Órarend keresése...", + "selectTimetable": "Órarend kiválasztása", + "updateError": "Nem sikerült frissíteni az órarendet", + "updateSuccess": "Órarend sikeresen frissítve" }, "common": { "cancel": "Mégsem", diff --git a/apps/iris/src/components/admin/sidebar.tsx b/apps/iris/src/components/admin/sidebar.tsx index 6ae45ef..b2fa273 100644 --- a/apps/iris/src/components/admin/sidebar.tsx +++ b/apps/iris/src/components/admin/sidebar.tsx @@ -50,6 +50,12 @@ export function AdminSidebar() { () => [ { items: [ + { + icon: List, + permission: 'import:timetable', + title: t('timetable.manage'), + url: '/admin/timetable/manage', + }, { icon: Calendar, permission: 'import:timetable', diff --git a/apps/iris/src/components/timetable/filter-bar.tsx b/apps/iris/src/components/timetable/filter-bar.tsx index 89a07bc..dad48af 100644 --- a/apps/iris/src/components/timetable/filter-bar.tsx +++ b/apps/iris/src/components/timetable/filter-bar.tsx @@ -1,5 +1,6 @@ import { Building2, + CalendarDays, CheckIcon, ChevronsUpDownIcon, GraduationCap, @@ -33,6 +34,12 @@ import type { TeacherItem, } from './types'; +type TimetableItem = { + id: string; + name: string; + validFrom: string | null; +}; + const teacherLabel = (t: TeacherItem, fallback: string): string => `${t.firstName} ${t.lastName}`.trim() || fallback; @@ -109,6 +116,19 @@ const getEmptyMessage = ( return t(messages[activeFilter]); }; +const formatTimetableLabel = (tt: TimetableItem): string => { + if (tt.validFrom) { + const date = new Date(tt.validFrom); + const formatted = date.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + return `${tt.name} (${formatted})`; + } + return tt.name; +}; + export function FilterBar({ activeFilter, onFilterChange, @@ -124,6 +144,10 @@ export function FilterBar({ selectorLoading, onPrint, disabled, + timetables, + selectedTimetableId, + currentTimetableId, + onTimetableChange, }: { activeFilter: FilterType; onFilterChange: (value: FilterType) => void; @@ -139,12 +163,17 @@ export function FilterBar({ selectorLoading: boolean; onPrint: () => void; disabled?: boolean; + timetables?: TimetableItem[]; + selectedTimetableId: string | null; + currentTimetableId: string | null; + onTimetableChange: (value: string) => void; }) { const { t } = useTranslation(); const filterSelectId = `filter-${activeFilter}`; const comboboxContentId = `${filterSelectId}-content`; const selectWidthClassName = activeFilter === 'class' ? 'w-50' : 'w-60'; const [comboboxOpen, setComboboxOpen] = useState(false); + const [timetableOpen, setTimetableOpen] = useState(false); const filterOptions = getFilterOptions(activeFilter, { classrooms, @@ -173,6 +202,79 @@ export function FilterBar({ handlers[activeFilter](value); }; + const selectedTimetable = timetables?.find( + (tt) => tt.id === selectedTimetableId + ); + const timetableLabel = selectedTimetable + ? formatTimetableLabel(selectedTimetable) + : t('timetable.selectTimetable'); + const showTimetableSelector = timetables && timetables.length > 1; + + const renderTimetableSelect = () => { + if (!showTimetableSelector) { + return null; + } + + return ( + + + + {timetableLabel} + + + } + /> + + + + + {t('timetable.noTimetableFound')} + + {(timetables ?? []).map((tt) => ( + { + onTimetableChange(tt.id); + setTimetableOpen(false); + }} + value={formatTimetableLabel(tt)} + > + + + {formatTimetableLabel(tt)} + {tt.id === currentTimetableId && ( + + {t('timetable.activeTimetable')} + + )} + + + ))} + + + + + + ); + }; + const renderSelect = () => { if (selectorLoading) { return ; @@ -233,6 +335,7 @@ export function FilterBar({ return (
+ {renderTimetableSelect()} + + + +
+ ); +} diff --git a/apps/iris/src/routes/_public/index.tsx b/apps/iris/src/routes/_public/index.tsx index 1ccc7f5..0b05e04 100644 --- a/apps/iris/src/routes/_public/index.tsx +++ b/apps/iris/src/routes/_public/index.tsx @@ -8,6 +8,7 @@ export const searchSchema = z.object({ cohort: z.string().optional(), room: z.string().optional(), teacher: z.string().optional(), + timetable: z.string().optional(), }); export const Route = createFileRoute('/_public/')({ From 38658c28d0d9e44ba103b9c3349540e063ac7427 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 13 Mar 2026 12:12:32 +0000 Subject: [PATCH 2/5] refactor(timetable): streamline edit state management --- .../_private/admin/timetable/manage.tsx | 179 ++++++++++-------- 1 file changed, 96 insertions(+), 83 deletions(-) diff --git a/apps/iris/src/routes/_private/admin/timetable/manage.tsx b/apps/iris/src/routes/_private/admin/timetable/manage.tsx index 558fe06..4917864 100644 --- a/apps/iris/src/routes/_private/admin/timetable/manage.tsx +++ b/apps/iris/src/routes/_private/admin/timetable/manage.tsx @@ -1,14 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { type InferResponseType, parseResponse } from 'hono/client'; -import { - Calendar, - Check, - MoreHorizontal, - Pencil, - Trash2, - X, -} from 'lucide-react'; +import { Calendar, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -24,7 +17,6 @@ import { import { DatePicker } from '@/components/ui/date-picker'; import { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -92,7 +84,7 @@ function TimetableManagePage() { queryKey: ['timetables'], }); - const [editingId, setEditingId] = useState(null); + const [editTarget, setEditTarget] = useState(null); const [editName, setEditName] = useState(''); const [editValidFrom, setEditValidFrom] = useState(); const [deleteTarget, setDeleteTarget] = useState(null); @@ -120,7 +112,9 @@ function TimetableManagePage() { }, onSuccess: () => { toast.success(t('timetable.updateSuccess')); - setEditingId(null); + setEditTarget(null); + setEditName(''); + setEditValidFrom(undefined); queryClient.invalidateQueries({ queryKey: ['timetables'] }); }, }); @@ -155,24 +149,26 @@ function TimetableManagePage() { }); const startEdit = (tt: TimetableItem) => { - setEditingId(tt.id); + setEditTarget(tt); setEditName(tt.name); setEditValidFrom(tt.validFrom ? new Date(tt.validFrom) : undefined); }; const saveEdit = () => { - if (!(editingId && editName.trim())) { + if (!(editTarget && editName.trim())) { return; } updateMutation.mutate({ - id: editingId, + id: editTarget.id, name: editName.trim(), validFrom: editValidFrom ? dateToYYYYMMDD(editValidFrom) : '', }); }; const cancelEdit = () => { - setEditingId(null); + setEditTarget(null); + setEditName(''); + setEditValidFrom(undefined); }; const today = dateToYYYYMMDD(new Date()); @@ -242,74 +238,39 @@ function TimetableManagePage() { {sortedTimetables.map((tt) => ( - {editingId === tt.id ? ( - setEditName(e.target.value)} - value={editName} - /> - ) : ( -
- {tt.name} - {tt.id === currentTimetable?.id && ( - - {t('timetable.activeTimetable')} - - )} -
- )} -
- - {editingId === tt.id ? ( - - ) : ( - formatDate(tt.validFrom) - )} +
+ {tt.name} + {tt.id === currentTimetable?.id && ( + + {t('timetable.activeTimetable')} + + )} +
+ {formatDate(tt.validFrom)} - {editingId === tt.id ? ( -
- + } + /> + + startEdit(tt)}> + + {t('timetable.editTimetable')} + + setDeleteTarget(tt)} > - - - -
- ) : ( - - - - - } - /> - - startEdit(tt)}> - - {t('timetable.editTimetable')} - - setDeleteTarget(tt)} - > - - {t('timetable.deleteTimetable')} - - - - )} + + {t('timetable.deleteTimetable')} + + +
))} @@ -339,6 +300,52 @@ function TimetableManagePage() { {renderContent()} + { + if (!open) { + cancelEdit(); + } + }} + open={!!editTarget} + > + + + {t('timetable.editTimetable')} + + {t('timetable.allTimetablesDescription')} + + +
+ setEditName(event.target.value)} + placeholder={t('timetable.importNamePlaceholder')} + value={editName} + /> + +
+ + + + +
+
+ {/* Delete Confirmation Dialog */} { @@ -358,9 +365,13 @@ function TimetableManagePage() { - }> + From fb0e540ca10f1f86b81d02066327d841dbcfac29 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 13 Mar 2026 12:14:56 +0000 Subject: [PATCH 3/5] feat(translations): unify common terms across UI --- .devcontainer/Dockerfile | 3 ++- apps/iris/public/locales/en/translation.json | 17 ++++------------- apps/iris/public/locales/hu/translation.json | 17 ++++------------- .../components/admin/announcements-dialog.tsx | 2 +- .../components/admin/moved-lesson-dialog.tsx | 4 ++-- apps/iris/src/components/admin/roles-table.tsx | 4 ++-- .../components/admin/substitution-dialog.tsx | 4 ++-- .../_private/admin/news/announcements.tsx | 4 ++-- .../routes/_private/admin/timetable/manage.tsx | 4 +--- .../_private/admin/timetable/moved-lessons.tsx | 6 +++--- .../_private/admin/timetable/substitutions.tsx | 6 +++--- 11 files changed, 26 insertions(+), 45 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6b6dbc9..e6762df 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -15,7 +15,8 @@ RUN apt-get install -y \ vim \ htop \ jq \ - locales + locales \ + ripgrep RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index e14a4ac..20df6df 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -9,6 +9,10 @@ "loading": "Loading...", "cancel": "Cancel", "accept": "Accept", + "save": "Save", + "refresh": "Refresh", + "date": "Date", + "actions": "Actions", "back": "Back", "next": "Next", "skip": "Skip", @@ -166,10 +170,7 @@ "description": "Check out lesson substitutions and cancellations", "create": "Create Substitution", "edit": "Edit Substitution", - "save": "Save", - "refresh": "Refresh", "showPast": "Show past substitutions", - "date": "Date", "datePlaceholder": "Select a date", "substituteTeacher": "Substitute Teacher", "substituteTeacherHint": "Leave as cancelled if the lesson is not being covered.", @@ -177,7 +178,6 @@ "lessons": "Affected Lessons", "affectedLessons": "Lessons", "cohorts": "Cohorts", - "actions": "Actions", "noLessons": "No lessons available", "loadingLessons": "Loading lessons…", "selectCohort": "Filter by Class", @@ -208,10 +208,7 @@ "available": "Moved lessons available", "create": "Create Moved Lesson", "edit": "Edit Moved Lesson", - "save": "Save", - "refresh": "Refresh", "showPast": "Show past", - "date": "Date", "datePlaceholder": "Select a date", "targetDay": "Target Day", "targetPeriod": "Target Period", @@ -220,7 +217,6 @@ "periodLabel": "Period {{num}} ({{start}} – {{end}})", "lessons": "Affected Lessons", "lessonsCount": "Lessons", - "actions": "Actions", "noLessons": "No lessons available", "loadingLessons": "Loading lessons…", "selectCohort": "Filter by Class", @@ -248,7 +244,6 @@ "description": "Manage school announcements and news", "create": "Create Announcement", "edit": "Edit Announcement", - "save": "Save", "delete": "Delete", "content": "Content", "validFrom": "Valid From", @@ -256,8 +251,6 @@ "cohorts": "Cohorts (Optional)", "noCohorts": "All", "noAnnouncements": "No announcements found", - "actions": "Actions", - "refresh": "Refresh", "createSuccess": "Announcement created successfully", "createError": "Failed to create announcement", "updateSuccess": "Announcement updated successfully", @@ -284,9 +277,7 @@ "title": "Role Management", "name": "Name", "permissions": "Permissions", - "actions": "Actions", "edit": "Edit", - "delete": "Delete", "createRole": "Create Role", "editRole": "Edit Role", "save": "Save", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index ee154ea..d2f868a 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -154,6 +154,10 @@ "cancel": "Mégsem", "loading": "Betöltés...", "accept": "Rendben", + "save": "Mentés", + "refresh": "Frissítés", + "date": "Dátum", + "actions": "Műveletek", "back": "Vissza", "next": "Tovább", "skip": "Kihagyás", @@ -166,10 +170,7 @@ "description": "Órahelyettesítések és elmaradó órák elérése", "create": "Helyettesítés létrehozása", "edit": "Helyettesítés szerkesztése", - "save": "Mentés", - "refresh": "Frissítés", "showPast": "Múltbeli helyettesítések mutatása", - "date": "Dátum", "datePlaceholder": "Válassz egy dátumot", "substituteTeacher": "Helyettesítő tanár", "substituteTeacherHint": "Hagyd elmaradtra, ha az óra nem kerül megtartásra.", @@ -177,7 +178,6 @@ "lessons": "Érintett órák", "affectedLessons": "Órák", "cohorts": "Csoportok", - "actions": "Műveletek", "noLessons": "Nincsenek elérhető órák", "loadingLessons": "Órák betöltése…", "selectCohort": "Szűrés osztály szerint", @@ -208,10 +208,7 @@ "available": "Áthelyezett órák elérhetők", "create": "Áthelyezett óra létrehozása", "edit": "Áthelyezett óra szerkesztése", - "save": "Mentés", - "refresh": "Frissítés", "showPast": "Múltbeliek is", - "date": "Dátum", "datePlaceholder": "Válassz egy dátumot", "targetDay": "Célnap", "targetPeriod": "Célóra", @@ -220,7 +217,6 @@ "periodLabel": "{{num}}. óra ({{start}} – {{end}})", "lessons": "Érintett órák", "lessonsCount": "Órák", - "actions": "Műveletek", "noLessons": "Nincsenek elérhető órák", "loadingLessons": "Órák betöltése…", "selectCohort": "Szűrés osztály szerint", @@ -248,7 +244,6 @@ "description": "Iskolai bejelentések és hírek kezelése", "create": "Bejelentés létrehozása", "edit": "Bejelentés szerkesztése", - "save": "Mentés", "delete": "Törlés", "content": "Tartalom", "validFrom": "Érvényes ettől", @@ -256,8 +251,6 @@ "cohorts": "Csoportok (opcionális)", "noCohorts": "Minden", "noAnnouncements": "Nincsenek bejelentések", - "actions": "Műveletek", - "refresh": "Frissítés", "createSuccess": "Bejelentés sikeresen létrehozva", "createError": "Nem sikerült létrehozni a bejelentést", "updateSuccess": "Bejelentés sikeresen frissítve", @@ -284,9 +277,7 @@ "title": "Szerepkörök kezelése", "name": "Név", "permissions": "Jogosultságok", - "actions": "Műveletek", "edit": "Szerkesztés", - "delete": "Törlés", "createRole": "Szerepkör létrehozása", "editRole": "Szerepkör szerkesztése", "save": "Mentés", diff --git a/apps/iris/src/components/admin/announcements-dialog.tsx b/apps/iris/src/components/admin/announcements-dialog.tsx index ba6e4bd..2985b04 100644 --- a/apps/iris/src/components/admin/announcements-dialog.tsx +++ b/apps/iris/src/components/admin/announcements-dialog.tsx @@ -219,7 +219,7 @@ export function AnnouncementsDialog({ diff --git a/apps/iris/src/components/admin/moved-lesson-dialog.tsx b/apps/iris/src/components/admin/moved-lesson-dialog.tsx index 66af796..b77d1ef 100644 --- a/apps/iris/src/components/admin/moved-lesson-dialog.tsx +++ b/apps/iris/src/components/admin/moved-lesson-dialog.tsx @@ -270,7 +270,7 @@ export function MovedLessonDialog({ onSubmit={handleSubmit} >
- + - {isCreate ? t('movedLesson.create') : t('movedLesson.save')} + {isCreate ? t('movedLesson.create') : t('common.save')} diff --git a/apps/iris/src/components/admin/roles-table.tsx b/apps/iris/src/components/admin/roles-table.tsx index 415ed5f..9c37873 100644 --- a/apps/iris/src/components/admin/roles-table.tsx +++ b/apps/iris/src/components/admin/roles-table.tsx @@ -56,7 +56,7 @@ export function RolesTable({ roles }: RolesTableProps) { {t('roles.name')} {t('roles.permissions')} - {t('roles.actions')} + {t('common.actions')} @@ -103,7 +103,7 @@ export function RolesTable({ roles }: RolesTableProps) { size="sm" variant="destructive" > - {t('roles.delete')} + {t('common.delete')}
diff --git a/apps/iris/src/components/admin/substitution-dialog.tsx b/apps/iris/src/components/admin/substitution-dialog.tsx index b27af99..b80b55d 100644 --- a/apps/iris/src/components/admin/substitution-dialog.tsx +++ b/apps/iris/src/components/admin/substitution-dialog.tsx @@ -233,7 +233,7 @@ export function SubstitutionDialog({ onSubmit={handleSubmit} >
- + @@ -341,7 +341,7 @@ export function SubstitutionDialog({ type="submit" > - {isCreate ? t('substitution.create') : t('substitution.save')} + {isCreate ? t('substitution.create') : t('common.save')} diff --git a/apps/iris/src/routes/_private/admin/news/announcements.tsx b/apps/iris/src/routes/_private/admin/news/announcements.tsx index fe85d7c..90ad5eb 100644 --- a/apps/iris/src/routes/_private/admin/news/announcements.tsx +++ b/apps/iris/src/routes/_private/admin/news/announcements.tsx @@ -304,7 +304,7 @@ function AnnouncementsPage() { variant="outline" > - {t('announcements.refresh')} + {t('common.refresh')} {hasWritePermission && ( {hasWritePermission && ( {hasWritePermission && ( From cb40a285caef7347eb56381bdfa3845cd64cf2c9 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 13 Mar 2026 12:46:37 +0000 Subject: [PATCH 5/5] feat(routes): add path parameters for ID in doorlock and news routes --- apps/chronos/src/routes/doorlock/cards.ts | 22 ++++ apps/chronos/src/routes/doorlock/devices.ts | 22 ++++ apps/chronos/src/routes/doorlock/ota.ts | 11 ++ apps/chronos/src/routes/doorlock/self.ts | 22 ++++ apps/chronos/src/routes/doorlock/stats.ts | 11 ++ .../src/routes/doorlock/websocket-handler.ts | 25 ++++ apps/chronos/src/routes/news/announcements.ts | 44 +++++++ apps/chronos/src/routes/news/blogs.ts | 44 +++++++ .../src/routes/news/system-messages.ts | 36 ++++++ apps/chronos/src/routes/roles/index.ts | 116 ++++++++++++++++-- apps/chronos/src/routes/users/index.ts | 39 +++++- 11 files changed, 379 insertions(+), 13 deletions(-) diff --git a/apps/chronos/src/routes/doorlock/cards.ts b/apps/chronos/src/routes/doorlock/cards.ts index ea5fb82..c472be5 100644 --- a/apps/chronos/src/routes/doorlock/cards.ts +++ b/apps/chronos/src/routes/doorlock/cards.ts @@ -243,6 +243,17 @@ export const createCardRoute = doorlockFactory.createHandlers( export const updateCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update an access card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The access card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -320,6 +331,17 @@ export const updateCardRoute = doorlockFactory.createHandlers( export const deleteCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Delete an access card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The access card ID.', + type: 'string', + }, + }, + ], responses: { 200: { description: 'Card deleted' }, 404: { description: 'Card not found' }, diff --git a/apps/chronos/src/routes/doorlock/devices.ts b/apps/chronos/src/routes/doorlock/devices.ts index f1889d9..3052c94 100644 --- a/apps/chronos/src/routes/doorlock/devices.ts +++ b/apps/chronos/src/routes/doorlock/devices.ts @@ -152,6 +152,17 @@ export const createDeviceRoute = doorlockFactory.createHandlers( export const updateDeviceRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update an existing doorlock device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -216,6 +227,17 @@ export const updateDeviceRoute = doorlockFactory.createHandlers( export const deleteDeviceRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Delete a doorlock device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], responses: { 200: { description: 'Device deleted' }, }, diff --git a/apps/chronos/src/routes/doorlock/ota.ts b/apps/chronos/src/routes/doorlock/ota.ts index 6c70835..1d6464e 100644 --- a/apps/chronos/src/routes/doorlock/ota.ts +++ b/apps/chronos/src/routes/doorlock/ota.ts @@ -21,6 +21,17 @@ const otaPayloadSchema = z.object({ export const triggerDeviceOtaRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Trigger an OTA update on a specific device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/doorlock/self.ts b/apps/chronos/src/routes/doorlock/self.ts index 124f35a..497f51a 100644 --- a/apps/chronos/src/routes/doorlock/self.ts +++ b/apps/chronos/src/routes/doorlock/self.ts @@ -94,6 +94,17 @@ export const listSelfCardsRoute = doorlockFactory.createHandlers( export const updateSelfCardFrozenRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update the frozen state of a user-owned card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The user-owned card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -162,6 +173,17 @@ export const activateVirtualCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Activate an authorized device using a user-owned virtual card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The user-owned card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/doorlock/stats.ts b/apps/chronos/src/routes/doorlock/stats.ts index 6dd1339..c920750 100644 --- a/apps/chronos/src/routes/doorlock/stats.ts +++ b/apps/chronos/src/routes/doorlock/stats.ts @@ -190,6 +190,17 @@ export const doorlockStatsRoute = doorlockFactory.createHandlers( export const deviceStatsRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Get device health statistics', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/doorlock/websocket-handler.ts b/apps/chronos/src/routes/doorlock/websocket-handler.ts index d6a2e8a..eb88032 100644 --- a/apps/chronos/src/routes/doorlock/websocket-handler.ts +++ b/apps/chronos/src/routes/doorlock/websocket-handler.ts @@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { and, eq } from 'drizzle-orm'; import { upgradeWebSocket } from 'hono/bun'; import { HTTPException } from 'hono/http-exception'; +import { describeRoute } from 'hono-openapi'; import { z } from 'zod'; import { db } from '#database'; import { @@ -181,6 +182,30 @@ export const syncDatabase = async (deviceId: string) => { }; export const websocketHandler = doorlockFactory.createHandlers( + describeRoute({ + description: + 'Upgrade an authenticated device connection to WebSocket for doorlock events.', + parameters: [ + { + in: 'header', + name: 'X-Aegis-Device-Token', + required: true, + schema: { + description: 'Device API token used for WebSocket authentication.', + type: 'string', + }, + }, + ], + responses: { + 101: { + description: 'Switching Protocols', + }, + 401: { + description: 'Invalid or missing device token', + }, + }, + tags: ['Doorlock'], + }), async (c, next) => { const gotToken = c.req.header('X-Aegis-Device-Token'); if (!gotToken) { diff --git a/apps/chronos/src/routes/news/announcements.ts b/apps/chronos/src/routes/news/announcements.ts index 5bd6b1c..54f6803 100644 --- a/apps/chronos/src/routes/news/announcements.ts +++ b/apps/chronos/src/routes/news/announcements.ts @@ -76,6 +76,26 @@ export const listAnnouncements = newsFactory.createHandlers( describeRoute({ description: 'List active announcements within date range, filtered by user cohort', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'includeExpired', + required: false, + schema: { default: false, type: 'boolean' }, + }, + ], responses: { 200: { content: { @@ -166,6 +186,14 @@ export const listAnnouncements = newsFactory.createHandlers( export const getAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Get a single announcement by ID', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { @@ -293,6 +321,14 @@ export const createAnnouncement = newsFactory.createHandlers( export const updateAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Update an existing announcement', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], requestBody: { content: { 'application/json': { @@ -406,6 +442,14 @@ export const updateAnnouncement = newsFactory.createHandlers( export const deleteAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Delete an announcement', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/news/blogs.ts b/apps/chronos/src/routes/news/blogs.ts index 6c0df15..dae07cd 100644 --- a/apps/chronos/src/routes/news/blogs.ts +++ b/apps/chronos/src/routes/news/blogs.ts @@ -71,6 +71,20 @@ const checkSlugExists = async (slug: string, excludeId?: string) => { export const listPublishedBlogs = newsFactory.createHandlers( describeRoute({ description: 'List published blog posts (public, no auth required)', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + ], responses: { 200: { content: { @@ -123,6 +137,14 @@ export const listPublishedBlogs = newsFactory.createHandlers( export const getBlogBySlug = newsFactory.createHandlers( describeRoute({ description: 'Get a published blog post by slug (public, no auth required)', + parameters: [ + { + in: 'path', + name: 'slug', + required: true, + schema: { type: 'string' }, + }, + ], responses: { 200: { content: { @@ -173,6 +195,20 @@ export const getBlogBySlug = newsFactory.createHandlers( export const listDrafts = newsFactory.createHandlers( describeRoute({ description: 'List all blog posts including drafts (requires permission)', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + ], responses: { 200: { content: { @@ -225,6 +261,14 @@ export const getBlogById = newsFactory.createHandlers( describeRoute({ description: 'Get any blog post by ID including drafts (requires permission)', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/news/system-messages.ts b/apps/chronos/src/routes/news/system-messages.ts index a3c132d..5aa457c 100644 --- a/apps/chronos/src/routes/news/system-messages.ts +++ b/apps/chronos/src/routes/news/system-messages.ts @@ -76,6 +76,26 @@ export const listSystemMessages = newsFactory.createHandlers( describeRoute({ description: 'List active system messages within date range, filtered by user cohort', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'includeExpired', + required: false, + schema: { default: false, type: 'boolean' }, + }, + ], responses: { 200: { content: { @@ -163,6 +183,14 @@ export const listSystemMessages = newsFactory.createHandlers( export const getSystemMessage = newsFactory.createHandlers( describeRoute({ description: 'Get a single system message by ID', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { @@ -290,6 +318,14 @@ export const createSystemMessage = newsFactory.createHandlers( export const updateSystemMessage = newsFactory.createHandlers( describeRoute({ description: 'Update an existing system message', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/roles/index.ts b/apps/chronos/src/routes/roles/index.ts index 4fce175..9a72707 100644 --- a/apps/chronos/src/routes/roles/index.ts +++ b/apps/chronos/src/routes/roles/index.ts @@ -1,6 +1,6 @@ import { zValidator } from '@hono/zod-validator'; import { HTTPException } from 'hono/http-exception'; -import { describeRoute } from 'hono-openapi'; +import { describeRoute, resolver } from 'hono-openapi'; import { StatusCodes } from 'http-status-codes'; import z from 'zod'; import type { SuccessResponse } from '#_types/globals'; @@ -13,6 +13,16 @@ export const listPermissions = rolesFactory.createHandlers( description: 'List all known permissions registered by the application', responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + data: z.object({ permissions: z.array(z.string()) }), + success: z.literal(true), + }) + ), + }, + }, description: 'List of permissions', }, }, @@ -32,6 +42,23 @@ export const listRoles = rolesFactory.createHandlers( description: 'List all roles with their permissions', responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + data: z.object({ + roles: z.array( + z.object({ + can: z.array(z.string()), + name: z.string(), + }) + ), + }), + success: z.literal(true), + }) + ), + }, + }, description: 'List of roles', }, }, @@ -66,11 +93,46 @@ const createRoleSchema = z.object({ permissions: z.array(z.string()).default([]), }); +const updateRoleSchema = z.object({ + permissions: z.array(z.string()), +}); + +const roleResponseSchema = z.object({ + data: z.object({ + can: z.array(z.string()), + name: z.string(), + }), + success: z.literal(true), +}); + +const roleNameParamSchema = z.object({ name: z.string() }); + +const createRoleRequestBodySchema = ( + await resolver(createRoleSchema).toOpenAPISchema() +).schema; + +const updateRoleRequestBodySchema = ( + await resolver(updateRoleSchema).toOpenAPISchema() +).schema; + export const createRole = rolesFactory.createHandlers( describeRoute({ description: 'Create a new role', + requestBody: { + content: { + 'application/json': { + schema: createRoleRequestBodySchema, + }, + }, + description: 'Role details to create.', + }, responses: { 201: { + content: { + 'application/json': { + schema: resolver(roleResponseSchema), + }, + }, description: 'Role created', }, }, @@ -108,15 +170,35 @@ export const createRole = rolesFactory.createHandlers( } ); -const updateRoleSchema = z.object({ - permissions: z.array(z.string()), -}); - export const updateRole = rolesFactory.createHandlers( describeRoute({ description: 'Update permissions for a role', + parameters: [ + { + in: 'path', + name: 'name', + required: true, + schema: { + description: 'Role name to update.', + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: updateRoleRequestBodySchema, + }, + }, + description: 'Permissions payload for the role.', + }, responses: { 200: { + content: { + 'application/json': { + schema: resolver(roleResponseSchema), + }, + }, description: 'Role updated', }, }, @@ -125,7 +207,7 @@ export const updateRole = rolesFactory.createHandlers( requireAuthentication, requireAuthorization('roles:manage'), zValidator('json', updateRoleSchema), - zValidator('param', z.object({ name: z.string() })), + zValidator('param', roleNameParamSchema), async (c) => { const { name: roleName } = c.req.valid('param'); if (!roleName) { @@ -154,8 +236,28 @@ export const updateRole = rolesFactory.createHandlers( export const deleteRole = rolesFactory.createHandlers( describeRoute({ description: 'Delete a role', + parameters: [ + { + in: 'path', + name: 'name', + required: true, + schema: { + description: 'Role name to delete.', + type: 'string', + }, + }, + ], responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + success: z.literal(true), + }) + ), + }, + }, description: 'Role deleted', }, }, @@ -163,7 +265,7 @@ export const deleteRole = rolesFactory.createHandlers( }), requireAuthentication, requireAuthorization('roles:manage'), - zValidator('param', z.object({ name: z.string() })), + zValidator('param', roleNameParamSchema), async (c) => { const { name: roleName } = c.req.valid('param'); diff --git a/apps/chronos/src/routes/users/index.ts b/apps/chronos/src/routes/users/index.ts index 1bf5176..79b4e91 100644 --- a/apps/chronos/src/routes/users/index.ts +++ b/apps/chronos/src/routes/users/index.ts @@ -29,6 +29,26 @@ const updateUserBodySchema = ( export const listUsers = usersFactory.createHandlers( describeRoute({ description: 'List users', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, maximum: 100, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'search', + required: false, + schema: { type: 'string' }, + }, + ], responses: { 200: { description: 'List of users', @@ -81,6 +101,17 @@ export const listUsers = usersFactory.createHandlers( export const updateUser = usersFactory.createHandlers( describeRoute({ description: 'Update user', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'User ID to update.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { schema: updateUserBodySchema }, @@ -95,14 +126,10 @@ export const updateUser = usersFactory.createHandlers( }), requireAuthentication, requireAuthorization('users:manage'), + zValidator('param', z.object({ id: z.string() })), zValidator('json', userUpdatePayload), async (c) => { - const userId = c.req.param('id'); - if (!userId) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'User ID is required', - }); - } + const { id: userId } = c.req.valid('param'); const { nickname, roles } = c.req.valid('json'); const [updatedUser] = await db