From e7bb2c0af1b0a3ae7921e359ebc5a37a1d5ab00c Mon Sep 17 00:00:00 2001 From: AnastasiaRakuta Date: Tue, 6 Oct 2026 13:25:46 +0200 Subject: [PATCH 1/9] Added tour model --- src/shared/models/tour.ts | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/shared/models/tour.ts diff --git a/src/shared/models/tour.ts b/src/shared/models/tour.ts new file mode 100644 index 0000000..301f35b --- /dev/null +++ b/src/shared/models/tour.ts @@ -0,0 +1,58 @@ +export type ExecutionStatus = + | 'SCHEDULED' + | 'IN_PROGRESS' + | 'CLOSED' + | 'FINISHED' + | 'CANCELLED'; + +export const EXECUTION_STATUSES: ExecutionStatus[] = [ + 'SCHEDULED', + 'IN_PROGRESS', + 'CLOSED', + 'FINISHED', + 'CANCELLED', +]; + +export interface TourResponse { + id: number; + stageId: number; + title: string; + description: string | null; + dateStart: string; // ISO-8601 UTC + dateFinish: string; // ISO-8601 UTC + sortPosition: number; + location: string; + executionStatus: ExecutionStatus; + version: number; +} + +export interface CreateTourRequest { + title: string; + description?: string | null; + dateStart: string; // ISO-8601 UTC + dateFinish: string; // ISO-8601 UTC + location: string; +} + +export interface UpdateTourRequest { + title: string; + description?: string | null; + dateStart: string; // ISO-8601 UTC + dateFinish: string; // ISO-8601 UTC + location: string; + sortPosition?: number; + version: number; +} + +export interface ChangeTourStatusRequest { + status: ExecutionStatus; + version: number; +} + +export interface ReorderToursRequest { + tourIds: number[]; +} + +export interface TourTreeNode extends TourResponse { + taskCount?: number; +} From 874e16bb0831712c477e1c83753e87aba30415e2 Mon Sep 17 00:00:00 2001 From: AnastasiaRakuta Date: Tue, 6 Oct 2026 13:31:07 +0200 Subject: [PATCH 2/9] Added tour service --- src/shared/services/tourService.ts | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/shared/services/tourService.ts diff --git a/src/shared/services/tourService.ts b/src/shared/services/tourService.ts new file mode 100644 index 0000000..26d0e7c --- /dev/null +++ b/src/shared/services/tourService.ts @@ -0,0 +1,74 @@ +import { axiosInstance } from '@shared/api/axiosInstance'; +import type { + ChangeTourStatusRequest, + CreateTourRequest, + ReorderToursRequest, + TourResponse, + UpdateTourRequest, +} from '@shared/models/tour'; + +export const tourService = { + getToursByStage: async (stageId: number): Promise => { + const { data } = await axiosInstance.get( + `/api/v1/stages/${stageId}/tours` + ); + return data; + }, + + getTourById: async (tourId: number): Promise => { + const { data } = await axiosInstance.get(`/api/v1/tours/${tourId}`); + return data; + }, + + createTour: async ( + stageId: number, + payload: CreateTourRequest + ): Promise => { + const { data } = await axiosInstance.post( + `/api/v1/stages/${stageId}/tours`, + payload + ); + return data; + }, + + updateTour: async ( + stageId: number, + tourId: number, + payload: UpdateTourRequest + ): Promise => { + const { data } = await axiosInstance.put( + `/api/v1/stages/${stageId}/tours/${tourId}`, + payload + ); + return data; + }, + + changeTourStatus: async ( + stageId: number, + tourId: number, + payload: ChangeTourStatusRequest + ): Promise => { + const { data } = await axiosInstance.patch( + `/api/v1/stages/${stageId}/tours/${tourId}/status`, + payload + ); + return data; + }, + + reorderTours: async ( + stageId: number, + payload: ReorderToursRequest + ): Promise => { + const { data } = await axiosInstance.patch( + `/api/v1/stages/${stageId}/tours/order`, + payload + ); + return data; + }, + + deleteTour: async (stageId: number, tourId: number): Promise => { + await axiosInstance.delete(`/api/v1/stages/${stageId}/tours/${tourId}`); + }, +}; + +export default tourService; From 09e5af8d892e49895f9d134f06e18d63f2e9aafb Mon Sep 17 00:00:00 2001 From: AnastasiaRakuta Date: Tue, 6 Oct 2026 13:37:48 +0200 Subject: [PATCH 3/9] Added localization --- public/i18n/en/admin.json | 96 +++++++++++++++++++++++++++++++++++++++ public/i18n/uk/admin.json | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index c7615d2..0849fcb 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -381,5 +381,101 @@ "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", + "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..fe95d67 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -383,5 +383,101 @@ "title": "Тури етапу", "noTours": "Турів для цього етапу ще не створено." } + }, + "tours": { + "title": "Тури етапу", + "addTour": "Додати тур", + "editTour": "Редагувати тур", + "deleteTour": "Видалити тур", + "reorderTours": "Змінити черговість", + "noTours": "У цьому етапі ще немає турів. Створіть перший тур для проведення змагань.", + "tourNumber": "Тур {{number}}", + "location": "Локація", + "duration": "Тривалість", + "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 From ac4e9f599227f352497d74430bcd603c63ba4717 Mon Sep 17 00:00:00 2001 From: AnastasiaRakuta Date: Tue, 6 Oct 2026 14:36:10 +0200 Subject: [PATCH 4/9] Added tour functional --- .../org/Competitions/components/StageCard.tsx | 7 +- .../components/StageToursSection.tsx | 272 +++++++++++-- .../components/Stages.module.scss | 168 ++++++++ .../org/Competitions/components/TourCard.tsx | 282 +++++++++++++ .../components/TourConfirmModal.tsx | 131 ++++++ .../components/TourExecutionStatusBadge.tsx | 68 ++++ .../Competitions/components/TourFormModal.tsx | 376 ++++++++++++++++++ .../components/TourReorderModal.tsx | 165 ++++++++ src/shared/components/ConfirmModal.tsx | 2 +- 9 files changed, 1429 insertions(+), 42 deletions(-) create mode 100644 src/pages/org/Competitions/components/TourCard.tsx create mode 100644 src/pages/org/Competitions/components/TourConfirmModal.tsx create mode 100644 src/pages/org/Competitions/components/TourExecutionStatusBadge.tsx create mode 100644 src/pages/org/Competitions/components/TourFormModal.tsx create mode 100644 src/pages/org/Competitions/components/TourReorderModal.tsx 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/StageToursSection.tsx b/src/pages/org/Competitions/components/StageToursSection.tsx index 361fa25..067b9b2 100644 --- a/src/pages/org/Competitions/components/StageToursSection.tsx +++ b/src/pages/org/Competitions/components/StageToursSection.tsx @@ -1,66 +1,258 @@ -import type { StageTourItem } from '@shared/models/stage'; -import { MapPin, Plus } from 'lucide-react'; -import React from 'react'; +import type { CompetitionStatus } from '@shared/models/competition'; +import type { StageResponse } from '@shared/models/stage'; +import type { ExecutionStatus, TourResponse } from '@shared/models/tour'; +import { tourService } from '@shared/services/tourService'; +import { ArrowUpDown, Loader2, Plus } from 'lucide-react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import styles from './Stages.module.scss'; + +import TourCard from './TourCard'; +import TourConfirmModal, { + type TourConfirmActionType, + type TourConfirmModalState, +} from './TourConfirmModal'; +import TourFormModal from './TourFormModal'; +import TourReorderModal from './TourReorderModal'; export interface StageToursSectionProps { - readonly tours: StageTourItem[]; + readonly stage: StageResponse; + readonly competitionStatus: CompetitionStatus; readonly isArchived: boolean; + readonly onTourMutated?: () => void; } +const NEXT_TOUR_STATUS: Record, ExecutionStatus> = { + START: 'IN_PROGRESS', + CLOSE: 'CLOSED', + RESUME: 'IN_PROGRESS', + FINISH: 'FINISHED', + CANCEL: 'CANCELLED', +}; + export const StageToursSection: React.FC = ({ - tours, + stage, + competitionStatus, isArchived, + onTourMutated, }) => { const { t } = useTranslation('admin'); + const [tours, setTours] = useState([]); + const [loading, setLoading] = useState(true); + + // Modals + const [formModalOpen, setFormModalOpen] = useState(false); + const [reorderModalOpen, setReorderModalOpen] = useState(false); + const [selectedTour, setSelectedTour] = useState(null); + + const [confirmModal, setConfirmModal] = useState({ + open: false, + type: 'START', + isLoading: false, + }); + + const fetchTours = useCallback(async () => { + setLoading(true); + try { + const data = await tourService.getToursByStage(stage.id); + const sorted = [...data].sort((a, b) => a.sortPosition - b.sortPosition); + setTours(sorted); + } catch { + toast.error(t('tours.validation.loadError')); + } finally { + setLoading(false); + } + }, [stage.id, t]); + + useEffect(() => { + void fetchTours(); + }, [fetchTours]); + + const canReorder = + tours.length > 1 && tours.every((tour) => tour.executionStatus === 'SCHEDULED'); + + const handleOpenCreate = () => { + setSelectedTour(null); + setFormModalOpen(true); + }; + + const handleOpenEdit = (tour: TourResponse) => { + setSelectedTour(tour); + setFormModalOpen(true); + }; + + const handleStatusChangeAction = (tour: TourResponse, action: TourConfirmActionType) => { + setSelectedTour(tour); + setConfirmModal({ + open: true, + type: action, + isLoading: false, + }); + }; + + const handleConfirmAction = async (extendedFinishDate?: string) => { + if (!selectedTour) return; + setConfirmModal((prev) => ({ ...prev, isLoading: true })); + + try { + if (confirmModal.type === 'DELETE') { + await tourService.deleteTour(stage.id, selectedTour.id); + toast.success(t('tours.validation.deleteSuccess')); + } else { + // If resuming with extended finish date, update date first + if (confirmModal.type === 'RESUME' && extendedFinishDate) { + await tourService.updateTour(stage.id, selectedTour.id, { + title: selectedTour.title, + description: selectedTour.description, + dateStart: selectedTour.dateStart, + dateFinish: extendedFinishDate, + location: selectedTour.location, + sortPosition: selectedTour.sortPosition, + version: selectedTour.version, + }); + } + + const nextStatus = NEXT_TOUR_STATUS[confirmModal.type]; + await tourService.changeTourStatus(stage.id, selectedTour.id, { + status: nextStatus, + version: selectedTour.version, + }); + toast.success(t('tours.validation.statusChangeSuccess')); + } + + setConfirmModal({ open: false, type: 'START', isLoading: false }); + await fetchTours(); + onTourMutated?.(); + } catch (err: any) { + if (err?.response?.status === 409) { + toast.error(t('tours.validation.conflictError')); + await fetchTours(); + } else { + const backendMessage = err?.response?.data?.message; + toast.error(backendMessage || t('tours.validation.loadError')); + } + setConfirmModal((prev) => ({ ...prev, isLoading: false })); + } + }; + return (
-
- - {t('stages.tours.title')} - +
+
+ + {t('tours.title')} + + + {tours.length} + +
+ {!isArchived && ( - +
+ {tours.length > 1 && ( + + )} + + +
)}
- {tours.length > 0 ? ( -
- {tours.map((tour) => ( -
-
- - {tour.title} - - - {tour.executionStatus} - -
- {tour.location && ( -
- - {tour.location} -
- )} -
- ))} + {loading ? ( +
+ + {t('tours.loading')} +
+ ) : tours.length > 0 ? ( +
+ {tours.map((tour, index) => { + const previousTour = index > 0 ? tours[index - 1] : null; + const previousTourFinished = + !previousTour || previousTour.executionStatus === 'FINISHED'; + + return ( + handleStatusChangeAction(t, 'DELETE')} + onStatusChange={handleStatusChangeAction} + /> + ); + })}
) : ( -
-

{t('stages.tours.noTours')}

+
+

{t('tours.noTours')}

)} + + {/* Form Modal */} + {formModalOpen && ( + setFormModalOpen(false)} + onSuccess={() => { + void fetchTours(); + onTourMutated?.(); + }} + /> + )} + + {/* Reorder Modal */} + {reorderModalOpen && ( + setReorderModalOpen(false)} + onSuccess={() => { + void fetchTours(); + onTourMutated?.(); + }} + /> + )} + + {/* Confirmation Modal */} + setConfirmModal((prev) => ({ ...prev, open: false }))} + onConfirm={handleConfirmAction} + />
); }; diff --git a/src/pages/org/Competitions/components/Stages.module.scss b/src/pages/org/Competitions/components/Stages.module.scss index 48c453b..8c5872d 100644 --- a/src/pages/org/Competitions/components/Stages.module.scss +++ b/src/pages/org/Competitions/components/Stages.module.scss @@ -325,4 +325,172 @@ text-align: center; } +/* Tour Execution Status Badges */ +.tourStatusBadge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.tourStatusScheduled { + background-color: #f3f4f6; + color: #4b5563; + border: 1px solid #e5e7eb; +} + +.tourStatusInProgress { + background-color: #ecfdf5; + color: #047857; + border: 1px solid #a7f3d0; +} + +.tourStatusClosed { + background-color: #fffbeb; + color: #b45309; + border: 1px solid #fde68a; +} + +.tourStatusFinished { + background-color: #eef2ff; + color: #4338ca; + border: 1px solid #c7d2fe; +} + +.tourStatusCancelled { + background-color: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} + +.tourPulseDot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #10b981; + animation: pulseGreen 1.5s infinite; +} + +@keyframes pulseGreen { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 70% { + transform: scale(1); + box-shadow: 0 0 0 5px rgba(16, 185, 129, 0); + } + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +/* Tour Reorder Styles */ +.reorderList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0; +} + +.reorderItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background-color: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: background-color 0.15s ease; + + &:hover { + background-color: #f3f4f6; + } +} + +.reorderItemInfo { + display: flex; + align-items: center; + gap: 12px; +} + +.reorderIndexBadge { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #2563eb; + color: #ffffff; + font-size: 0.75rem; + font-weight: 700; +} + +.reorderActions { + display: flex; + align-items: center; + gap: 4px; +} + +.reorderButton { + padding: 4px; + color: #6b7280; + border-radius: 6px; + background: transparent; + border: 1px solid transparent; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #ffffff; + color: #111827; + border-color: #d1d5db; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +/* Tour Full Card */ +.tourCard { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px 16px; + transition: all 0.15s ease; + + &:hover { + border-color: #d1d5db; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03); + } +} + +.tourCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.tourCardMeta { + display: flex; + align-items: center; + gap: 16px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed #f3f4f6; + font-size: 0.75rem; + color: #6b7280; + flex-wrap: wrap; +} diff --git a/src/pages/org/Competitions/components/TourCard.tsx b/src/pages/org/Competitions/components/TourCard.tsx new file mode 100644 index 0000000..5aa5af6 --- /dev/null +++ b/src/pages/org/Competitions/components/TourCard.tsx @@ -0,0 +1,282 @@ +import type { CompetitionStatus } from '@shared/models/competition'; +import type { StageStatus } from '@shared/models/stage'; +import type { TourResponse } from '@shared/models/tour'; +import { + Calendar, + Check, + FileText, + MapPin, + MoreVertical, + Pause, + Pencil, + Play, + Trash2, + XCircle, +} from 'lucide-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; + + +import styles from './Stages.module.scss'; +import type { TourConfirmActionType } from './TourConfirmModal'; +import TourExecutionStatusBadge from './TourExecutionStatusBadge'; + +interface TourCardProps { + readonly tour: TourResponse; + readonly stageStatus: StageStatus; + readonly competitionStatus: CompetitionStatus; + readonly isArchived: boolean; + readonly previousTourFinished: boolean; + readonly onEdit: (tour: TourResponse) => void; + readonly onDelete: (tour: TourResponse) => void; + readonly onStatusChange: (tour: TourResponse, action: TourConfirmActionType) => void; +} + +const formatDate = (isoStr: string, locale = 'uk-UA'): string => { + try { + const d = new Date(isoStr); + return d.toLocaleDateString(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return isoStr; + } +}; + +const formatDuration = (startIso: string, finishIso: string): string => { + try { + const start = new Date(startIso).getTime(); + const finish = new Date(finishIso).getTime(); + const diffHours = Math.round((finish - start) / (1000 * 60 * 60)); + return `${diffHours} год`; + } catch { + return ''; + } +}; + +export const TourCard: React.FC = ({ + tour, + stageStatus, + competitionStatus, + isArchived, + previousTourFinished, + onEdit, + onDelete, + onStatusChange, +}) => { + const { t, i18n } = useTranslation('admin'); + const [showMenu, setShowMenu] = useState(false); + + // Preconditions checks + const canStart = + competitionStatus === 'PUBLISHED' && + stageStatus === 'IN_PROGRESS' && + previousTourFinished && + tour.executionStatus === 'SCHEDULED'; + + const canClose = + stageStatus === 'IN_PROGRESS' && tour.executionStatus === 'IN_PROGRESS'; + + const canResume = + stageStatus === 'IN_PROGRESS' && tour.executionStatus === 'CLOSED'; + + const canFinish = + tour.executionStatus === 'IN_PROGRESS' || tour.executionStatus === 'CLOSED'; + + const isCancellable = + tour.executionStatus !== 'FINISHED' && tour.executionStatus !== 'CANCELLED'; + + const getStartDisabledTooltip = (): string => { + if (canStart) return ''; + if (competitionStatus !== 'PUBLISHED') { + return t('tours.preconditions.competitionNotPublished'); + } + if (stageStatus !== 'IN_PROGRESS') { + return t('tours.preconditions.stageNotInProgress'); + } + if (!previousTourFinished) { + return t('tours.preconditions.previousTourNotFinished'); + } + return ''; + }; + + return ( +
+
+
+ + {t('tours.tourNumber', { number: tour.sortPosition })} + +
{tour.title}
+ +
+ +
+ {/* Quick status action button */} + {!isArchived && tour.executionStatus === 'SCHEDULED' && ( + + )} + + {!isArchived && tour.executionStatus === 'IN_PROGRESS' && ( + <> + + + + )} + + {!isArchived && tour.executionStatus === 'CLOSED' && ( + <> + + + + )} + + {/* More actions dropdown */} + {!isArchived && ( +
+ + + {showMenu && ( +
setShowMenu(false)} + > + + + + + {isCancellable && ( + + )} + + +
+ )} +
+ )} +
+
+ +
+
+ + + {formatDate(tour.dateStart, i18n.language)} —{' '} + {formatDate(tour.dateFinish, i18n.language)} ( + {formatDuration(tour.dateStart, tour.dateFinish)}) + +
+ + {tour.location && ( +
+ + {tour.location} +
+ )} +
+ + {tour.description && ( +

+ {tour.description} +

+ )} +
+ ); +}; + +export default TourCard; diff --git a/src/pages/org/Competitions/components/TourConfirmModal.tsx b/src/pages/org/Competitions/components/TourConfirmModal.tsx new file mode 100644 index 0000000..034ada2 --- /dev/null +++ b/src/pages/org/Competitions/components/TourConfirmModal.tsx @@ -0,0 +1,131 @@ +/* eslint-disable react-hooks/purity */ +import { ConfirmModal } from '@shared/components/ConfirmModal'; +import type { TourResponse } from '@shared/models/tour'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import styles from './Stages.module.scss'; + +export type TourConfirmActionType = + | 'START' + | 'CLOSE' + | 'RESUME' + | 'FINISH' + | 'CANCEL' + | 'DELETE'; + +export interface TourConfirmModalState { + open: boolean; + type: TourConfirmActionType; + isLoading: boolean; +} + +export interface TourConfirmModalProps { + readonly modalState: TourConfirmModalState; + readonly tour: TourResponse | null; + readonly onClose: () => void; + readonly onConfirm: (extendedFinishDate?: string) => void; +} + +export const TourConfirmModal: React.FC = ({ + modalState, + tour, + onClose, + onConfirm, +}) => { + const { t } = useTranslation('admin'); + const { open, type, isLoading } = modalState; + + const [extendedFinish, setExtendedFinish] = useState(''); + const [dateError, setDateError] = useState(null); + + if (!open || !tour) return null; + + const isResumeExpired = + type === 'RESUME' && new Date(tour.dateFinish).getTime() <= Date.now(); + + const titles: Record = { + START: t('tours.confirm.startTitle'), + CLOSE: t('tours.confirm.closeTitle'), + RESUME: t('tours.confirm.resumeTitle'), + FINISH: t('tours.confirm.finishTitle'), + CANCEL: t('tours.confirm.cancelTitle'), + DELETE: t('tours.confirm.deleteTitle'), + }; + + const messages: Record = { + START: t('tours.confirm.startMessage', { title: tour.title }), + CLOSE: t('tours.confirm.closeMessage', { title: tour.title }), + RESUME: t('tours.confirm.resumeMessage', { title: tour.title }), + FINISH: t('tours.confirm.finishMessage', { title: tour.title }), + CANCEL: t('tours.confirm.cancelMessage', { title: tour.title }), + DELETE: t('tours.confirm.deleteMessage', { title: tour.title }), + }; + + const getConfirmText = (): string => { + if (!isLoading) { + return t('competitionLifecycle.confirmYes'); + } + if (type === 'DELETE') { + return t('tours.confirm.deleting'); + } + return t('tours.confirm.statusChanging'); + }; + + const handleConfirm = () => { + if (isResumeExpired) { + if (!extendedFinish) { + setDateError(t('tours.confirm.extraTimeRequired')); + return; + } + const finishDate = new Date(extendedFinish); + if (finishDate.getTime() <= Date.now()) { + setDateError(t('tours.preconditions.finishDateInPast')); + return; + } + onConfirm(finishDate.toISOString()); + } else { + onConfirm(); + } + }; + + return ( + +

{messages[type]}

+ {isResumeExpired && ( +
+ + { + setExtendedFinish(e.target.value); + if (dateError) setDateError(null); + }} + className={`${styles.input} text-xs`} + /> + {dateError && {dateError}} +
+ )} +
+ } + confirmText={getConfirmText()} + cancelText={t('competitionLifecycle.confirmNo')} + isLoading={isLoading} + onConfirm={handleConfirm} + /> + ); +}; + +export default TourConfirmModal; diff --git a/src/pages/org/Competitions/components/TourExecutionStatusBadge.tsx b/src/pages/org/Competitions/components/TourExecutionStatusBadge.tsx new file mode 100644 index 0000000..68e66be --- /dev/null +++ b/src/pages/org/Competitions/components/TourExecutionStatusBadge.tsx @@ -0,0 +1,68 @@ +import type { ExecutionStatus } from '@shared/models/tour'; +import { CheckCircle2, Clock, Pause, Play, XCircle } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import styles from './Stages.module.scss'; + +interface TourExecutionStatusBadgeProps { + readonly status: ExecutionStatus; +} + +export const TourExecutionStatusBadge: React.FC = ({ + status, +}) => { + const { t } = useTranslation('admin'); + + switch (status) { + case 'SCHEDULED': + return ( + + + {t('tours.status.SCHEDULED')} + + ); + + case 'IN_PROGRESS': + return ( + + + ); + + case 'CLOSED': + return ( + + + {t('tours.status.CLOSED')} + + ); + + case 'FINISHED': + return ( + + + {t('tours.status.FINISHED')} + + ); + + case 'CANCELLED': + return ( + + + {t('tours.status.CANCELLED')} + + ); + + default: + return null; + } +}; + +export default TourExecutionStatusBadge; diff --git a/src/pages/org/Competitions/components/TourFormModal.tsx b/src/pages/org/Competitions/components/TourFormModal.tsx new file mode 100644 index 0000000..e199d72 --- /dev/null +++ b/src/pages/org/Competitions/components/TourFormModal.tsx @@ -0,0 +1,376 @@ +import type { + CreateTourRequest, + TourResponse, + UpdateTourRequest, +} from '@shared/models/tour'; +import { tourService } from '@shared/services/tourService'; +import { Calendar, Loader2, MapPin, X } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; + + +import styles from './Stages.module.scss'; + +interface TourFormModalProps { + readonly open: boolean; + readonly stageId: number; + readonly stageDates: { + dateStart: string; + dateFinish: string; + }; + readonly initialTour: TourResponse | null; + readonly existingTours: TourResponse[]; + readonly onClose: () => void; + readonly onSuccess: (tour: TourResponse) => void; +} + +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 TourFormModal: React.FC = ({ + open, + stageId, + stageDates, + initialTour, + existingTours, + onClose, + onSuccess, +}) => { + const { t } = useTranslation('admin'); + const isEditMode = Boolean(initialTour); + + const [title, setTitle] = useState(''); + const [location, setLocation] = useState(''); + const [dateStart, setDateStart] = useState(''); + const [dateFinish, setDateFinish] = useState(''); + const [description, setDescription] = useState(''); + const [sortPosition, setSortPosition] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [titleError, setTitleError] = useState(null); + const [locationError, setLocationError] = useState(null); + const [dateError, setDateError] = useState(null); + + useEffect(() => { + if (!open) return; + + if (initialTour) { + setTitle(initialTour.title); + setLocation(initialTour.location); + setDateStart(toLocalDatetimeInputValue(initialTour.dateStart)); + setDateFinish(toLocalDatetimeInputValue(initialTour.dateFinish)); + setDescription(initialTour.description || ''); + setSortPosition(initialTour.sortPosition); + } else { + setTitle(''); + setLocation(''); + setDateStart(toLocalDatetimeInputValue(stageDates.dateStart)); + setDateFinish(toLocalDatetimeInputValue(stageDates.dateFinish)); + setDescription(''); + setSortPosition(undefined); + } + + setTitleError(null); + setLocationError(null); + setDateError(null); + }, [open, initialTour, stageDates.dateStart, stageDates.dateFinish]); + + if (!open) return null; + + const validate = (): boolean => { + let isValid = true; + const trimmedTitle = title.trim(); + const trimmedLoc = location.trim(); + + if (!trimmedTitle) { + setTitleError(t('tours.validation.titleRequired')); + isValid = false; + } else if (trimmedTitle.length < 3) { + setTitleError(t('tours.validation.titleMinLength')); + isValid = false; + } else if (trimmedTitle.length > 255) { + setTitleError(t('tours.validation.titleMaxLength')); + isValid = false; + } else { + const isDuplicate = existingTours.some( + (tour) => + (!initialTour || tour.id !== initialTour.id) && + tour.title.toLowerCase() === trimmedTitle.toLowerCase() + ); + if (isDuplicate) { + setTitleError(t('tours.validation.duplicateTitle')); + isValid = false; + } else { + setTitleError(null); + } + } + + if (!trimmedLoc) { + setLocationError(t('tours.validation.locationRequired')); + isValid = false; + } else { + setLocationError(null); + } + + if (!dateStart || !dateFinish) { + setDateError(t('tours.validation.datesRequired')); + isValid = false; + } else { + const start = new Date(dateStart); + const finish = new Date(dateFinish); + const sStart = new Date(stageDates.dateStart); + const sFinish = new Date(stageDates.dateFinish); + + if (finish <= start) { + setDateError(t('tours.validation.finishMustBeAfterStart')); + isValid = false; + } else if (start < sStart || finish > sFinish) { + setDateError( + t('tours.validation.datesOutOfBounds', { + start: sStart.toLocaleDateString(), + finish: sFinish.toLocaleDateString(), + }) + ); + isValid = false; + } else { + setDateError(null); + } + } + + return isValid; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setIsSubmitting(true); + try { + const isoStart = new Date(dateStart).toISOString(); + const isoFinish = new Date(dateFinish).toISOString(); + const trimmedDesc = description.trim() ? description.trim() : null; + + if (isEditMode && initialTour) { + const payload: UpdateTourRequest = { + title: title.trim(), + description: trimmedDesc, + dateStart: isoStart, + dateFinish: isoFinish, + location: location.trim(), + sortPosition, + version: initialTour.version, + }; + const updated = await tourService.updateTour(stageId, initialTour.id, payload); + toast.success(t('tours.validation.updateSuccess')); + onSuccess(updated); + onClose(); + } else { + const payload: CreateTourRequest = { + title: title.trim(), + description: trimmedDesc, + dateStart: isoStart, + dateFinish: isoFinish, + location: location.trim(), + }; + const created = await tourService.createTour(stageId, payload); + toast.success(t('tours.validation.createSuccess')); + onSuccess(created); + onClose(); + } + } catch (err: any) { + if (err?.response?.status === 409) { + toast.error(t('tours.validation.conflictError')); + onClose(); + } else { + const backendMessage = err?.response?.data?.message; + toast.error(backendMessage || t('tours.validation.loadError')); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

+ {isEditMode ? t('tours.modal.editTitle') : t('tours.modal.createTitle')} +

+ +
+ +
+ {/* Title */} +
+ + { + setTitle(e.target.value); + if (titleError) setTitleError(null); + }} + placeholder={t('tours.modal.titlePlaceholder')} + className={`${styles.input} ${titleError ? styles.inputInvalid : ''}`} + maxLength={255} + /> + {titleError && {titleError}} +
+ + {/* Location */} +
+ + { + setLocation(e.target.value); + if (locationError) setLocationError(null); + }} + placeholder={t('tours.modal.locationPlaceholder')} + className={`${styles.input} ${locationError ? styles.inputInvalid : ''}`} + /> + {locationError && {locationError}} +
+ + {/* Dates */} +
+
+ + { + setDateStart(e.target.value); + if (dateError) setDateError(null); + }} + className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} + /> +
+ +
+ + { + setDateFinish(e.target.value); + if (dateError) setDateError(null); + }} + className={`${styles.input} ${dateError ? styles.inputInvalid : ''}`} + /> +
+
+ {dateError && {dateError}} + + {/* Sort Position (Edit mode only) */} + {isEditMode && ( +
+ + setSortPosition(Number(e.target.value) || 1)} + className={styles.input} + /> +
+ )} + + {/* Description */} +
+ +