diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index c7615d2..9f55339 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -381,5 +381,102 @@ "title": "Stage Tours", "noTours": "No tours created for this stage yet." } + }, + "tours": { + "title": "Stage Tours", + "addTour": "Add Tour", + "editTour": "Edit Tour", + "deleteTour": "Delete Tour", + "reorderTours": "Reorder Tours", + "noTours": "There are no tours in this stage yet. Create the first tour to conduct the competition.", + "tourNumber": "Tour {{number}}", + "location": "Location", + "duration": "Duration", + "durationHours": "{{count}} h", + "loading": "Loading tours...", + "status": { + "SCHEDULED": "Scheduled", + "IN_PROGRESS": "In Progress", + "CLOSED": "Suspended", + "FINISHED": "Finished", + "CANCELLED": "Cancelled" + }, + "actions": { + "start": "Start Tour", + "close": "Suspend Submissions", + "resume": "Resume Tour", + "finish": "Finish Tour", + "cancel": "Cancel Tour", + "manageTasks": "Tour Tasks", + "edit": "Edit", + "delete": "Delete" + }, + "confirm": { + "startTitle": "Start Tour", + "startMessage": "Are you sure you want to start '{{title}}'? Participants will gain access to tasks and submission.", + "closeTitle": "Suspend Submissions", + "closeMessage": "Are you sure you want to switch '{{title}}' to 'Suspended'? New file submissions will be temporarily blocked.", + "resumeTitle": "Resume Tour", + "resumeMessage": "Are you sure you want to resume '{{title}}'? Submissions will be reopened.", + "finishTitle": "Finish Tour", + "finishMessage": "Are you sure you want to finish '{{title}}'? This action is permanent and cannot be undone.", + "cancelTitle": "Cancel Tour", + "cancelMessage": "Are you sure you want to cancel tour '{{title}}'? All tour activities will be terminated.", + "deleteTitle": "Delete Tour", + "deleteMessage": "Are you sure you want to delete tour '{{title}}'? All tour data will be permanently deleted.", + "statusChanging": "Updating status...", + "deleting": "Deleting...", + "extraTimeLabel": "New Tour Finish Date & Time (Extra time)", + "extraTimeRequired": "Specify a new future finish date & time" + }, + "modal": { + "createTitle": "Create New Tour", + "editTitle": "Edit Tour", + "reorderTitle": "Tour Execution Order", + "reorderDescription": "Change the order of tours using the buttons below. Order can only be changed before the stage starts.", + "titleLabel": "Tour Title", + "titlePlaceholder": "e.g., Tour 1: Practical Programming", + "locationLabel": "Location / Venue", + "locationPlaceholder": "e.g., Room 204 or Online Platform", + "dateStartLabel": "Tour Start", + "dateFinishLabel": "Tour Finish", + "stageBoundsHint": "Stage time boundary: {{start}} — {{finish}}", + "descriptionLabel": "Description & Regulations", + "descriptionPlaceholder": "Specify evaluation rules, regulations, or meeting link...", + "sortPositionLabel": "Sort Position", + "saveButton": "Save Changes", + "createButton": "Create Tour", + "cancelButton": "Cancel", + "saving": "Saving...", + "moveUp": "Move Up", + "moveDown": "Move Down", + "applyOrder": "Apply Order" + }, + "preconditions": { + "competitionNotPublished": "Competition must be published before starting the tour", + "stageNotInProgress": "Parent stage must have 'In Progress' status", + "previousTourNotFinished": "Cannot start tour: previous tour has not yet finished", + "finishDateInPast": "Tour finish date is in the past. Update tour dates first to provide extra time", + "cannotReorderStarted": "Tours can only be reordered when all tours are scheduled", + "cannotDeleteLast": "Cannot delete the last tour of an active competition", + "stageNotEditable": "Stage is completed or locked for modifications" + }, + "validation": { + "titleRequired": "Tour title is required", + "titleMinLength": "Tour title must be at least 3 characters", + "titleMaxLength": "Tour title must not exceed 255 characters", + "locationRequired": "Location is required", + "datesRequired": "Specify start and finish dates", + "finishMustBeAfterStart": "Finish time must be later than start time", + "datesOutOfBounds": "Tour dates must fall within the stage dates ({{start}} — {{finish}})", + "duplicateTitle": "A tour with this title already exists in this stage", + "conflictError": "Tour was updated by another user. Page has been reloaded.", + "loadError": "Failed to load stage tours", + "createSuccess": "Tour created successfully!", + "updateSuccess": "Tour updated successfully!", + "deleteSuccess": "Tour deleted successfully!", + "statusChangeSuccess": "Tour status changed successfully!", + "reorderSuccess": "Tour order saved successfully!" + } } } \ No newline at end of file diff --git a/public/i18n/uk/admin.json b/public/i18n/uk/admin.json index 7dc2cd6..6a224e9 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -383,5 +383,102 @@ "title": "Тури етапу", "noTours": "Турів для цього етапу ще не створено." } + }, + "tours": { + "title": "Тури етапу", + "addTour": "Додати тур", + "editTour": "Редагувати тур", + "deleteTour": "Видалити тур", + "reorderTours": "Змінити черговість", + "noTours": "У цьому етапі ще немає турів. Створіть перший тур для проведення змагань.", + "tourNumber": "Тур {{number}}", + "location": "Локація", + "duration": "Тривалість", + "durationHours": "{{count}} год", + "loading": "Завантаження турів...", + "status": { + "SCHEDULED": "Заплановано", + "IN_PROGRESS": "Триває", + "CLOSED": "Призупинено", + "FINISHED": "Завершено", + "CANCELLED": "Скасовано" + }, + "actions": { + "start": "Розпочати тур", + "close": "Призупинити прийом", + "resume": "Відновити тур", + "finish": "Завершити тур", + "cancel": "Скасувати тур", + "manageTasks": "Завдання туру", + "edit": "Редагувати", + "delete": "Видалити" + }, + "confirm": { + "startTitle": "Розпочати тур", + "startMessage": "Ви впевнені, що хочете розпочати «{{title}}»? Учасники отримають доступ до завдань та прийому робіт.", + "closeTitle": "Призупинити здачу робіт", + "closeMessage": "Ви впевнені, що хочете перевести «{{title}}» у статус «Призупинено»? Прийом нових відповідей буде тимчасово заблоковано.", + "resumeTitle": "Відновити тур", + "resumeMessage": "Ви впевнені, що хочете відновити проведення «{{title}}»? Прийом відповідей буде розблоковано.", + "finishTitle": "Завершити тур", + "finishMessage": "Ви впевнені, що хочете остаточно завершити «{{title}}»? Цю дію неможливо відмінити.", + "cancelTitle": "Скасувати тур", + "cancelMessage": "Ви впевнені, що хочете скасувати тур «{{title}}»? Усі активності туру буде зупинено.", + "deleteTitle": "Видалити тур", + "deleteMessage": "Ви впевнені, що хочете видалити тур «{{title}}»? Дані туру будуть безповоротно видалені.", + "statusChanging": "Оновлення статусу...", + "deleting": "Видалення...", + "extraTimeLabel": "Новий час завершення туру (подовження)", + "extraTimeRequired": "Вкажіть новий час завершення туру в майбутньому" + }, + "modal": { + "createTitle": "Створення нового туру", + "editTitle": "Редагування туру", + "reorderTitle": "Черговість проведення турів", + "reorderDescription": "Змініть порядок виконання турів за допомогою кнопок. Порядок можна редагувати лише до початку проведення етапу.", + "titleLabel": "Назва туру", + "titlePlaceholder": "Наприклад: Тур 1: Практичне програмування", + "locationLabel": "Місце проведення / Локація", + "locationPlaceholder": "Наприклад: Ауд. 204 або Онлайн платформа", + "dateStartLabel": "Початок туру", + "dateFinishLabel": "Завершення туру", + "stageBoundsHint": "Часові рамки етапу: {{start}} — {{finish}}", + "descriptionLabel": "Опис та регламент", + "descriptionPlaceholder": "Вкажіть правила оцінювання, регламент або посилання на підключення...", + "sortPositionLabel": "Порядковий номер", + "saveButton": "Зберегти зміни", + "createButton": "Створити тур", + "cancelButton": "Скасувати", + "saving": "Збереження...", + "moveUp": "Перемістити вище", + "moveDown": "Перемістити нижче", + "applyOrder": "Застосувати черговість" + }, + "preconditions": { + "competitionNotPublished": "Олімпіада повинна бути опублікована перед початком туру", + "stageNotInProgress": "Батьківський етап повинен мати статус «Триває»", + "previousTourNotFinished": "Неможливо розпочати тур: попередній тур ще не завершено", + "finishDateInPast": "Час завершення туру вже минув. Оновіть дати туру для надання додаткового часу", + "cannotReorderStarted": "Черговість можна змінювати лише коли всі тури заплановані", + "cannotDeleteLast": "Неможливо видалити останній тур з активної олімпіади", + "stageNotEditable": "Етап завершено або заблоковано для редагування" + }, + "validation": { + "titleRequired": "Назва туру обов'язкова", + "titleMinLength": "Назва туру повинна містити щонайменше 3 символи", + "titleMaxLength": "Назва туру не повинна перевищувати 255 символів", + "locationRequired": "Локація обов'язкова", + "datesRequired": "Вкажіть дату початку та завершення", + "finishMustBeAfterStart": "Час завершення повинен бути пізнішим за час початку", + "datesOutOfBounds": "Дати туру повинні бути в межах дат етапу ({{start}} — {{finish}})", + "duplicateTitle": "Тур з такою назвою вже існує в цьому етапі", + "conflictError": "Дані туру було оновлено іншим користувачем. Сторінку оновлено.", + "loadError": "Не вдалося завантажити тури етапу", + "createSuccess": "Тур успішно створено!", + "updateSuccess": "Тур успішно оновлено!", + "deleteSuccess": "Тур успішно видалено!", + "statusChangeSuccess": "Статус туру успішно змінено!", + "reorderSuccess": "Черговість турів збережено!" + } } } \ No newline at end of file diff --git a/src/pages/org/Competitions/CompetitionForm.tsx b/src/pages/org/Competitions/CompetitionForm.tsx index 408cdb2..a23b727 100644 --- a/src/pages/org/Competitions/CompetitionForm.tsx +++ b/src/pages/org/Competitions/CompetitionForm.tsx @@ -2,6 +2,7 @@ import { BackButton } from '@components/BackButton'; import Editor from '@components/Editor'; import Input from '@components/Input'; import { competitionService } from '@shared/services/competitionService.ts'; +import { toLocalDatetimeInputValue } from '@utils/dateUtils'; import DOMPurify from 'dompurify'; import { Calendar, Loader2, Save } from 'lucide-react'; import React, { useEffect, useState } from 'react'; @@ -11,22 +12,6 @@ import { toast } from 'react-toastify'; import styles from './Competitions.module.scss'; -const toLocalDatetimeInputValue = (isoStr?: string | null): string => { - if (!isoStr) return ''; - try { - const date = new Date(isoStr); - if (Number.isNaN(date.getTime())) return ''; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - } catch { - return ''; - } -}; - export const CompetitionForm: React.FC = () => { const { t } = useTranslation('admin'); const navigate = useNavigate(); diff --git a/src/pages/org/Competitions/components/BaseModalDialog.tsx b/src/pages/org/Competitions/components/BaseModalDialog.tsx new file mode 100644 index 0000000..fb4205e --- /dev/null +++ b/src/pages/org/Competitions/components/BaseModalDialog.tsx @@ -0,0 +1,77 @@ +import { X } from 'lucide-react'; +import React, { useState } from 'react'; + +import { ModalFormActions } from './ModalFormActions'; +import styles from './Stages.module.scss'; + +export interface BaseModalDialogProps { + readonly open: boolean; + readonly title: string; + readonly onClose: () => void; + readonly onSubmit: () => Promise; + readonly validate: () => boolean; + readonly cancelText: string; + readonly submitText: string; + readonly savingText: string; + readonly children: React.ReactNode; +} + +export const BaseModalDialog: React.FC = ({ + open, + title, + onClose, + onSubmit, + validate, + cancelText, + submitText, + savingText, + children, +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!open) return null; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setIsSubmitting(true); + try { + await onSubmit(); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

{title}

+ +
+ +
+ {children} + + + +
+
+ ); +}; + +export default BaseModalDialog; diff --git a/src/pages/org/Competitions/components/DateRangeFields.tsx b/src/pages/org/Competitions/components/DateRangeFields.tsx new file mode 100644 index 0000000..fb7e0c2 --- /dev/null +++ b/src/pages/org/Competitions/components/DateRangeFields.tsx @@ -0,0 +1,77 @@ +import { Calendar } from 'lucide-react'; +import React from 'react'; + +import styles from './Stages.module.scss'; + +export interface DateRangeFieldsProps { + readonly idPrefix: string; + readonly startLabel: string; + readonly finishLabel: string; + readonly dateStart: string; + readonly dateFinish: string; + readonly onChangeStart: (value: string) => void; + readonly onChangeFinish: (value: string) => void; + readonly onClearError?: () => void; + readonly dateError: string | null; +} + +export const DateRangeFields: React.FC = ({ + idPrefix, + startLabel, + finishLabel, + dateStart, + dateFinish, + onChangeStart, + onChangeFinish, + onClearError, + dateError, +}) => { + return ( + <> +
+
+ + { + onChangeStart(e.target.value); + if (dateError && onClearError) onClearError(); + }} + className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} + /> +
+ +
+ + { + onChangeFinish(e.target.value); + if (dateError && onClearError) onClearError(); + }} + className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} + /> +
+
+ {dateError && {dateError}} + + ); +}; + +export default DateRangeFields; diff --git a/src/pages/org/Competitions/components/ModalFormActions.tsx b/src/pages/org/Competitions/components/ModalFormActions.tsx new file mode 100644 index 0000000..34d9a4f --- /dev/null +++ b/src/pages/org/Competitions/components/ModalFormActions.tsx @@ -0,0 +1,49 @@ +import { Loader2 } from 'lucide-react'; +import React from 'react'; + +import styles from './Stages.module.scss'; + +export interface ModalFormActionsProps { + readonly onClose: () => void; + readonly isSubmitting: boolean; + readonly cancelText: string; + readonly submitText: string; + readonly savingText: string; +} + +export const ModalFormActions: React.FC = ({ + onClose, + isSubmitting, + cancelText, + submitText, + savingText, +}) => { + return ( +
+ + +
+ ); +}; + +export default ModalFormActions; diff --git a/src/pages/org/Competitions/components/StageCard.tsx b/src/pages/org/Competitions/components/StageCard.tsx index 9eb847b..77953bb 100644 --- a/src/pages/org/Competitions/components/StageCard.tsx +++ b/src/pages/org/Competitions/components/StageCard.tsx @@ -285,7 +285,12 @@ export const StageCard: React.FC = ({ )} {/* Tours List */} - + onStatusChanged(stage)} + /> )} diff --git a/src/pages/org/Competitions/components/StageFormModal.tsx b/src/pages/org/Competitions/components/StageFormModal.tsx index bb15f52..4baa070 100644 --- a/src/pages/org/Competitions/components/StageFormModal.tsx +++ b/src/pages/org/Competitions/components/StageFormModal.tsx @@ -6,11 +6,17 @@ import type { } from '@shared/models/stage'; import { STAGE_SCOPES } from '@shared/models/stage'; import { stageService } from '@shared/services/stageService'; -import { Calendar, Loader2, X } from 'lucide-react'; +import { + toIsoRangeAndDescription, + toLocalDatetimeInputValue, + validateDateRange, +} from '@utils/dateUtils'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; +import { BaseModalDialog } from './BaseModalDialog'; +import { DateRangeFields } from './DateRangeFields'; import styles from './Stages.module.scss'; interface StageFormModalProps { @@ -27,22 +33,6 @@ interface StageFormModalProps { readonly existingStages: StageResponse[]; } -const toLocalDatetimeInputValue = (isoStr?: string | null): string => { - if (!isoStr) return ''; - try { - const date = new Date(isoStr); - if (Number.isNaN(date.getTime())) return ''; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - } catch { - return ''; - } -}; - export const StageFormModal: React.FC = ({ open, onClose, @@ -62,7 +52,6 @@ export const StageFormModal: React.FC = ({ const [dateFinish, setDateFinish] = useState(''); const [description, setDescription] = useState(''); const [sortPosition, setSortPosition] = useState(undefined); - const [isSubmitting, setIsSubmitting] = useState(false); const [titleError, setTitleError] = useState(null); const [dateError, setDateError] = useState(null); @@ -102,8 +91,6 @@ export const StageFormModal: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initialStage?.id]); - if (!open) return null; - const validate = (): boolean => { let isValid = true; const trimmedTitle = title.trim(); @@ -136,50 +123,31 @@ export const StageFormModal: React.FC = ({ setScopeError(null); } - if (!dateStart || !dateFinish) { - setDateError(t('stages.validation.datesRequired')); - isValid = false; - } else { - const start = new Date(dateStart); - const finish = new Date(dateFinish); - const compStart = new Date(competitionDates.dateStart); - const compFinish = new Date(competitionDates.dateFinish); + const stageDateErr = validateDateRange({ + dateStart, + dateFinish, + parentDateStart: competitionDates.dateStart, + parentDateFinish: competitionDates.dateFinish, + emptyError: t('stages.validation.datesRequired'), + finishBeforeStartError: t('stages.validation.dateFinishAfterStart'), + outOfBoundsError: ({ start, finish }) => + t('stages.validation.datesOutOfCompetitionBounds', { start, finish }), + }); + setDateError(stageDateErr); - if (finish <= start) { - setDateError(t('stages.validation.dateFinishAfterStart')); - isValid = false; - } else if (start < compStart || finish > compFinish) { - setDateError( - t('stages.validation.datesOutOfCompetitionBounds', { - start: compStart.toLocaleDateString(), - finish: compFinish.toLocaleDateString(), - }) - ); - isValid = false; - } else { - setDateError(null); - } - } - - return isValid; + return isValid && !stageDateErr; }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validate()) return; - - setIsSubmitting(true); + const handleFormSubmit = async () => { try { - const isoStart = new Date(dateStart).toISOString(); - const isoFinish = new Date(dateFinish).toISOString(); - const trimmedDesc = description.trim() ? description.trim() : null; + const stageDatesPayload = toIsoRangeAndDescription(dateStart, dateFinish, description); if (isEditMode && initialStage) { const payload: UpdateStageRequest = { title: title.trim(), - description: trimmedDesc, - dateStart: isoStart, - dateFinish: isoFinish, + description: stageDatesPayload.trimmedDesc, + dateStart: stageDatesPayload.isoStart, + dateFinish: stageDatesPayload.isoFinish, scope, sortPosition: sortPosition ?? initialStage.sortPosition, version: initialStage.version, @@ -191,9 +159,9 @@ export const StageFormModal: React.FC = ({ } else { const payload: CreateStageRequest = { title: title.trim(), - description: trimmedDesc, - dateStart: isoStart, - dateFinish: isoFinish, + description: stageDatesPayload.trimmedDesc, + dateStart: stageDatesPayload.isoStart, + dateFinish: stageDatesPayload.isoFinish, scope, }; const created = await stageService.createStage(competitionId, payload); @@ -210,182 +178,113 @@ export const StageFormModal: React.FC = ({ const backendMessage = err?.response?.data?.message; toast.error(backendMessage || t('stages.validation.loadError')); } - } finally { - setIsSubmitting(false); } }; return ( -
-
-
-

- {isEditMode ? t('stages.modal.editTitle') : t('stages.modal.createTitle')} -

- -
- -
- {/* Title */} -
- - { - setTitle(e.target.value); - if (titleError) setTitleError(null); - }} - placeholder={t('stages.modal.titlePlaceholder')} - className={`${styles.input} ${titleError ? styles.inputInvalid : ''}`} - maxLength={255} - /> - {titleError && {titleError}} -
- - {/* Scope */} -
- - - {scopeError && {scopeError}} -
- - {/* Dates */} -
-
- - { - setDateStart(e.target.value); - if (dateError) setDateError(null); - }} - className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} - /> -
+ + {/* Title */} +
+ + { + setTitle(e.target.value); + if (titleError) setTitleError(null); + }} + placeholder={t('stages.modal.titlePlaceholder')} + className={`${styles.input} ${titleError ? styles.inputInvalid : ''}`} + maxLength={255} + /> + {titleError && {titleError}} +
-
- - { - setDateFinish(e.target.value); - if (dateError) setDateError(null); - }} - className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} - /> -
-
- {dateError && {dateError}} + {/* Scope */} +
+ + + {scopeError && {scopeError}} +
- {/* Sort Position (Edit mode only) */} - {isEditMode && ( -
- - setSortPosition(Number(e.target.value) || 1)} - className={styles.input} - /> -
- )} + {/* Dates */} + setDateError(null)} + dateStart={dateStart} + dateFinish={dateFinish} + onChangeStart={setDateStart} + onChangeFinish={setDateFinish} + /> - {/* Description */} -
- -