Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/chronos/src/_types/hc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';
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;
Expand Down
4 changes: 2 additions & 2 deletions apps/chronos/src/routes/news/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}

Expand Down
8 changes: 4 additions & 4 deletions apps/chronos/src/utils/news/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});

Expand All @@ -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'],
}
);
Expand Down
39 changes: 39 additions & 0 deletions apps/iris/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions apps/iris/public/locales/hu/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
228 changes: 228 additions & 0 deletions apps/iris/src/components/admin/announcements-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<AnnouncementApiResponse['data']>[number];

type CohortApiResponse = InferResponseType<typeof api.cohort.index.$get>;
type Cohort = NonNullable<CohortApiResponse['data']>[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<void>;
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<HTMLFormElement>) => {
e.preventDefault();
await onSubmit({
cohortIds: formState.cohortIds,
content: formState.content,
title: formState.title,
validFrom: formState.validFrom,
validUntil: formState.validUntil,
});
};

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] max-w-lg flex-col p-2">
<div className="flex-1 overflow-y-auto p-6">
<DialogHeader>
<DialogTitle>
{item ? t('announcements.edit') : t('announcements.create')}
</DialogTitle>
</DialogHeader>

<form
className="mt-4 space-y-4"
id="announcementForm"
onSubmit={handleSubmit}
>
<div className="space-y-2">
<Label htmlFor="title">{t('announcements.title')}</Label>
<Input
id="title"
onChange={(e) =>
setFormState((prev) => ({ ...prev, title: e.target.value }))
}
placeholder="Announcement title"
value={formState.title}
/>
</div>

<div className="space-y-2">
<Label htmlFor="content">{t('announcements.content')}</Label>
<textarea
className="flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
id="content"
onChange={(e) =>
setFormState((prev) => ({
...prev,
content: [{ content: e.target.value, type: 'text' }],
}))
}
placeholder="Announcement content"
value={formState.content[0]?.content || ''}
/>
</div>

<div className="space-y-2">
<Label htmlFor="validFrom">{t('announcements.validFrom')}</Label>
<DatePicker
date={formState.validFrom}
onDateChange={(date) =>
setFormState((prev) => ({
...prev,
validFrom: startOfDay(date ?? new Date()),
}))
}
/>
</div>

<div className="space-y-2">
<Label htmlFor="validUntil">
{t('announcements.validUntil')}
</Label>
<DatePicker
date={formState.validUntil}
onDateChange={(date) =>
setFormState((prev) => ({
...prev,
validUntil: endOfDay(date ?? new Date()),
}))
}
/>
</div>

<div className="space-y-2">
<Label>{t('announcements.cohorts')}</Label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-2">
{cohorts.map((cohort) => (
<div className="flex items-center gap-2" key={cohort.id}>
<Checkbox
checked={formState.cohortIds.includes(cohort.id)}
id={`cohort-${cohort.id}`}
onCheckedChange={(checked) => {
if (checked) {
setFormState((prev) => ({
...prev,
cohortIds: [...prev.cohortIds, cohort.id],
}));
} else {
setFormState((prev) => ({
...prev,
cohortIds: prev.cohortIds.filter(
(id) => id !== cohort.id
),
}));
}
}}
/>
<label
className="cursor-pointer font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor={`cohort-${cohort.id}`}
>
{cohort.name}
</label>
</div>
))}
</div>
</div>
</form>
</div>

<DialogFooter className="border-t p-4">
<Button
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
{t('common.cancel')}
</Button>
<Button disabled={isSubmitting} form="announcementForm" type="submit">
<Save className="h-4 w-4" />
{t('announcements.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading