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
97 changes: 97 additions & 0 deletions public/i18n/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
}
}
97 changes: 97 additions & 0 deletions public/i18n/uk/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Черговість турів збережено!"
}
}
}
17 changes: 1 addition & 16 deletions src/pages/org/Competitions/CompetitionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down
77 changes: 77 additions & 0 deletions src/pages/org/Competitions/components/BaseModalDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
readonly validate: () => boolean;
readonly cancelText: string;
readonly submitText: string;
readonly savingText: string;
readonly children: React.ReactNode;
}

export const BaseModalDialog: React.FC<BaseModalDialogProps> = ({
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 (
<div className={styles.modalOverlay}>
<div className={styles.modalCard}>
<div className={styles.modalHeader}>
<h3 className="font-semibold text-lg text-gray-900">{title}</h3>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg cursor-pointer disabled:opacity-50"
>
<X size={20} />
</button>
</div>

<form onSubmit={handleFormSubmit} className={styles.modalBody}>
{children}

<ModalFormActions
onClose={onClose}
isSubmitting={isSubmitting}
cancelText={cancelText}
submitText={submitText}
savingText={savingText}
/>
</form>
</div>
</div>
);
};

export default BaseModalDialog;
77 changes: 77 additions & 0 deletions src/pages/org/Competitions/components/DateRangeFields.tsx
Original file line number Diff line number Diff line change
@@ -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<DateRangeFieldsProps> = ({
idPrefix,
startLabel,
finishLabel,
dateStart,
dateFinish,
onChangeStart,
onChangeFinish,
onClearError,
dateError,
}) => {
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={styles.fieldGroup}>
<label htmlFor={`${idPrefix}-date-start`} className={styles.fieldLabel}>
<span className="flex items-center gap-1.5">
<Calendar size={14} className="text-gray-400" />
<span>{startLabel}</span>
<span className={styles.requiredAsterisk}>*</span>
</span>
</label>
<input
id={`${idPrefix}-date-start`}
type="datetime-local"
value={dateStart}
onChange={(e) => {
onChangeStart(e.target.value);
if (dateError && onClearError) onClearError();
}}
className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`}
/>
</div>

<div className={styles.fieldGroup}>
<label htmlFor={`${idPrefix}-date-finish`} className={styles.fieldLabel}>
<span className="flex items-center gap-1.5">
<Calendar size={14} className="text-gray-400" />
<span>{finishLabel}</span>
<span className={styles.requiredAsterisk}>*</span>
</span>
</label>
<input
id={`${idPrefix}-date-finish`}
type="datetime-local"
value={dateFinish}
onChange={(e) => {
onChangeFinish(e.target.value);
if (dateError && onClearError) onClearError();
}}
className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`}
/>
</div>
</div>
{dateError && <span className={styles.fieldError}>{dateError}</span>}
</>
);
};

export default DateRangeFields;
Loading
Loading