diff --git a/apps/chronos/src/_types/hc.ts b/apps/chronos/src/_types/hc.ts index 21bd9ae..41e0faa 100644 --- a/apps/chronos/src/_types/hc.ts +++ b/apps/chronos/src/_types/hc.ts @@ -1,5 +1,6 @@ import type { cohortRouter } from '#routes/cohort/_router'; import type { doorlockRouter } from '#routes/doorlock/_router'; +import type { newsRouter } from '#routes/news/_router'; import type { pingRouter } from '#routes/ping/_router'; import type { rolesRouter } from '#routes/roles/_router'; import type { timetableRouter } from '#routes/timetable/_router'; @@ -7,6 +8,7 @@ import type { usersRouter } from '#routes/users/_router'; export type CohortRouter = typeof cohortRouter; export type DoorlockRouter = typeof doorlockRouter; +export type NewsRouter = typeof newsRouter; export type PingRouter = typeof pingRouter; export type RolesRouter = typeof rolesRouter; export type TimetableRouter = typeof timetableRouter; diff --git a/apps/chronos/src/routes/news/announcements.ts b/apps/chronos/src/routes/news/announcements.ts index 7c320cb..5bd6b1c 100644 --- a/apps/chronos/src/routes/news/announcements.ts +++ b/apps/chronos/src/routes/news/announcements.ts @@ -336,9 +336,9 @@ export const updateAnnouncement = newsFactory.createHandlers( // Validate date range with existing values const validFrom = body.validFrom ?? existing.validFrom; const validUntil = body.validUntil ?? existing.validUntil; - if (validUntil <= validFrom) { + if (validUntil < validFrom) { throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'validUntil must be after validFrom', + message: 'validUntil must be on or after validFrom', }); } diff --git a/apps/chronos/src/utils/news/schemas.ts b/apps/chronos/src/utils/news/schemas.ts index 876e315..d35eda3 100644 --- a/apps/chronos/src/utils/news/schemas.ts +++ b/apps/chronos/src/utils/news/schemas.ts @@ -27,8 +27,8 @@ export const dateRangeBodySchema = z validFrom: z.coerce.date(), validUntil: z.coerce.date(), }) - .refine((data) => data.validUntil > data.validFrom, { - message: 'validUntil must be after validFrom', + .refine((data) => data.validUntil >= data.validFrom, { + message: 'validUntil must be on or after validFrom', path: ['validUntil'], }); @@ -43,12 +43,12 @@ export const dateRangeUpdateBodySchema = z .refine( (data) => { if (data.validFrom && data.validUntil) { - return data.validUntil > data.validFrom; + return data.validUntil >= data.validFrom; } return true; }, { - message: 'validUntil must be after validFrom', + message: 'validUntil must be on or after validFrom', path: ['validUntil'], } ); diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 197e35f..ff14782 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -95,12 +95,22 @@ "featureFlags": {}, "admin": { "timetable": "Timetable", + "communications": "Communications", "doorlock": "Doorlock", "management": "Management", "users": "Users", "roles": "Roles" }, "timetable": { + "selectClass": "Select class", + "noClassFound": "No class found", + "searchClass": "Search for a class...", + "selectTeacher": "Select teacher", + "noTeacherFound": "No teacher found", + "searchTeacher": "Search for a teacher...", + "selectClassroom": "Select classroom", + "noClassroomFound": "No classroom found", + "searchClassroom": "Search for a classroom...", "import": "Import Timetable", "importDescription": "Upload and import timetable data from ÓMAN XML files", "uploadFile": "Upload Timetable File", @@ -213,6 +223,35 @@ "loadError": "Unable to load moved lessons", "loadErrorMessage": "Please try again later." }, + "news": { + "title": "News" + }, + "announcements": { + "title": "Announcements", + "description": "Manage school announcements and news", + "create": "Create Announcement", + "edit": "Edit Announcement", + "save": "Save", + "delete": "Delete", + "content": "Content", + "validFrom": "Valid From", + "validUntil": "Valid Until", + "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", + "updateError": "Failed to update announcement", + "deleteSuccess": "Announcement deleted successfully", + "deleteError": "Failed to delete announcement", + "deleteConfirm": "Delete Announcement", + "deleteDescription": "Are you sure you want to delete this announcement? This action cannot be undone.", + "loadError": "Unable to load announcements", + "loadErrorMessage": "Please try again later." + }, "auth": { "error": { "returnToHome": "Return to home", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 02cf42f..9a52e7f 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -84,12 +84,22 @@ "featureFlags": {}, "admin": { "timetable": "Órarend", + "communications": "Kommunikáció", "doorlock": "Ajtózár", "management": "Kezelés", "users": "Felhasználók", "roles": "Szerepkörök" }, "timetable": { + "selectClass": "Osztály kiválasztása", + "noClassFound": "Nem található osztály", + "searchClass": "Osztály keresése...", + "selectTeacher": "Tanár kiválasztása", + "noTeacherFound": "Nem található tanár", + "searchTeacher": "Tanár keresése...", + "selectClassroom": "Terem kiválasztása", + "noClassroomFound": "Nem található terem", + "searchClassroom": "Terem keresése...", "import": "Órarend importálása", "importDescription": "ÓMAN XML fájlok feltöltése és importálása", "uploadFile": "Órarend fájl feltöltése", @@ -213,6 +223,35 @@ "loadError": "Nem sikerült betölteni az áthelyezett órákat", "loadErrorMessage": "Kérlek próbáld újra később." }, + "news": { + "title": "Hírek" + }, + "announcements": { + "title": "Bejelentések", + "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", + "validUntil": "Érvényes eddig", + "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", + "updateError": "Nem sikerült frissíteni a bejelentést", + "deleteSuccess": "Bejelentés sikeresen törölve", + "deleteError": "Nem sikerült törölni a bejelentést", + "deleteConfirm": "Bejelentés törlése", + "deleteDescription": "Törölni akarod ezt a bejelentést? Ez a művelet nem vonható vissza.", + "loadError": "Nem sikerült betölteni a bejelentéseket", + "loadErrorMessage": "Kérlek próbáld újra később." + }, "auth": { "error": { "returnToHome": "Vissza a kezdőlapra", diff --git a/apps/iris/src/components/admin/announcements-dialog.tsx b/apps/iris/src/components/admin/announcements-dialog.tsx new file mode 100644 index 0000000..ba6e4bd --- /dev/null +++ b/apps/iris/src/components/admin/announcements-dialog.tsx @@ -0,0 +1,228 @@ +import type { InferRequestType, InferResponseType } from 'hono/client'; +import { Save } from 'lucide-react'; +import { type FormEvent, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DatePicker } from '@/components/ui/date-picker'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { api } from '@/utils/hc'; + +type AnnouncementApiResponse = InferResponseType< + typeof api.news.announcements.$get +>; +type AnnouncementItem = NonNullable[number]; + +type CohortApiResponse = InferResponseType; +type Cohort = NonNullable[number]; + +type AnnouncementPayload = InferRequestType< + typeof api.news.announcements.$post +>['json']; + +type AnnouncementDialogProps = { + cohorts: Cohort[]; + isSubmitting: boolean; + item?: AnnouncementItem | null; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: AnnouncementPayload) => Promise; + open: boolean; +}; + +const startOfDay = (d: Date): Date => { + const out = new Date(d); + out.setHours(0, 0, 0, 0); + return out; +}; + +const endOfDay = (d: Date): Date => { + const out = new Date(d); + out.setHours(23, 59, 59, 999); + return out; +}; + +const initialState = (item?: AnnouncementItem | null) => { + const defaultContent: Array<{ content: string; type: string }> = [ + { + content: '', + type: 'text', + }, + ]; + + return { + cohortIds: item?.cohortIds ?? [], + content: (Array.isArray(item?.content) + ? item.content + : defaultContent) as Array<{ + content: string; + type: string; + }>, + title: item?.title ?? '', + validFrom: startOfDay( + item?.validFrom ? new Date(item.validFrom) : new Date() + ), + validUntil: endOfDay( + item?.validUntil ? new Date(item.validUntil) : new Date() + ), + }; +}; + +export function AnnouncementsDialog({ + cohorts, + isSubmitting, + item, + onOpenChange, + onSubmit, + open, +}: AnnouncementDialogProps) { + const { t } = useTranslation(); + const [formState, setFormState] = useState(initialState(item)); + + useEffect(() => { + setFormState(initialState(item)); + }, [item]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + await onSubmit({ + cohortIds: formState.cohortIds, + content: formState.content, + title: formState.title, + validFrom: formState.validFrom, + validUntil: formState.validUntil, + }); + }; + + return ( + + +
+ + + {item ? t('announcements.edit') : t('announcements.create')} + + + +
+
+ + + setFormState((prev) => ({ ...prev, title: e.target.value })) + } + placeholder="Announcement title" + value={formState.title} + /> +
+ +
+ +