From beccfbb0d91df218ed74d6b555341927acb4ec27 Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 13:51:57 +0300 Subject: [PATCH 1/6] feat: add new models for linked tours --- src/shared/models/task.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/shared/models/task.ts b/src/shared/models/task.ts index 187a30e..cd9b22e 100644 --- a/src/shared/models/task.ts +++ b/src/shared/models/task.ts @@ -56,6 +56,28 @@ export interface PendingFile { role: TaskFileRole | null; } +export type ExecutionStatus = 'SCHEDULED' | 'IN_PROGRESS' | 'CLOSED' | 'FINISHED' | 'CANCELLED'; + +export interface LinkedTour { + tourId: number; + title: string; + description: string; + location: string; + executionStatus: ExecutionStatus; +} + +export const EXECUTION_STATUS_OPTIONS: { + value: ExecutionStatus; + labelKey: string; + color: string; +}[] = [ + { value: 'SCHEDULED', labelKey: 'task-detail.statusScheduled', color: '#6b7280' }, + { value: 'IN_PROGRESS', labelKey: 'task-detail.statusInProgress', color: '#2563eb' }, + { value: 'CLOSED', labelKey: 'task-detail.statusClosed', color: '#d97706' }, + { value: 'FINISHED', labelKey: 'task-detail.statusFinished', color: '#16a34a' }, + { value: 'CANCELLED', labelKey: 'task-detail.statusCancelled', color: '#dc2626' }, +]; + export const TASK_EXTENSIONS_PROBLEM = [ '.docx', '.xlsx', From 5c4b7663f9297476c00c1dad3f59d7462ef7a896 Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 13:52:22 +0300 Subject: [PATCH 2/6] feat: add new endpoint to get the linked tours --- src/shared/services/taskService.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/shared/services/taskService.ts b/src/shared/services/taskService.ts index 9772e00..12238c0 100644 --- a/src/shared/services/taskService.ts +++ b/src/shared/services/taskService.ts @@ -7,7 +7,8 @@ import type { TaskListResponse, UpdateTaskRequest, AddOwnerRequestDTO, - RemoveOwnerRequestDTO + RemoveOwnerRequestDTO, + LinkedTour, } from '@shared/models/task'; const API_BASE = import.meta.env.VITE_API_URL; @@ -63,11 +64,13 @@ export const taskService = { ); return axiosInstance.post(`${API_BASE}/api/v1/files`, formData); }, + updateFileRole: async (fileId: number, newRole: TaskFileRole): Promise => { await axiosInstance.patch(`/api/v1/files/${fileId}/role`, null, { params: { newRole }, }); }, + addOwner: async (id: number, request: AddOwnerRequestDTO) => { const { data } = await axiosInstance.patch(`/api/v1/tasks/${id}/add-owner`, request); @@ -82,5 +85,10 @@ export const taskService = { return data; }, + + getLinkedTours: async (taskId: number): Promise => { + const { data } = await axiosInstance.get(`/api/v1/tasks/${taskId}/linked-tours`); + return data; + }, }; From 21648b506bdcaef656b2dd2b88ebeb7dcaa714d7 Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 13:52:35 +0300 Subject: [PATCH 3/6] feat: add new localization keys --- public/i18n/en/admin.json | 9 ++++++++- public/i18n/uk/admin.json | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index 92f60dc..52d86bc 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -146,7 +146,14 @@ "createdBy": "Task is created by", "loading": "Loading task...", "notFound": "Task not found", - "downloadFailed": "Failed to download file" + "downloadFailed": "Failed to download file", + "linkedTours": "Linked tours", + "noLinkedTours": "Not linked to any tour yet.", + "statusScheduled": "Scheduled", + "statusInProgress": "In progress", + "statusClosed": "Closed", + "statusFinished": "Finished", + "statusCancelled": "Cancelled" }, "task-form": { "createTitle": "Create task", diff --git a/public/i18n/uk/admin.json b/public/i18n/uk/admin.json index fae75f7..e5b0407 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -146,7 +146,14 @@ "createdBy": "Задачу створено", "loading": "Завантаження задачі...", "notFound": "Задачу не знайдено", - "downloadFailed": "Не вдалося завантажити файл" + "downloadFailed": "Не вдалося завантажити файл", + "linkedTours": "Тури з цим завданням", + "noLinkedTours": "Поки немає турів з цим завданням", + "statusScheduled": "Заплановано", + "statusInProgress": "Проводиться", + "statusClosed": "Основний етап завершено", + "statusFinished": "Завершено", + "statusCancelled": "Скасовано" }, "task-form": { "createTitle": "Створити нову задачу", From 490d7d7fb31957e026f9480b2728ccc8baacee6c Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 13:52:44 +0300 Subject: [PATCH 4/6] feat: add new styles --- src/pages/org/Tasks/Tasks.module.scss | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/pages/org/Tasks/Tasks.module.scss b/src/pages/org/Tasks/Tasks.module.scss index 11c71ed..1c66e93 100644 --- a/src/pages/org/Tasks/Tasks.module.scss +++ b/src/pages/org/Tasks/Tasks.module.scss @@ -362,3 +362,31 @@ max-width: 60vw; } } + +.tourCard { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 14px 16px; + background: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 3%); + transition: border-color 0.15s ease; + + &:hover { + border-color: #d1d5db; + } +} + +.statusBadge { + display: inline-flex; + align-items: center; + white-space: nowrap; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + border: 1px solid; + background: white; + letter-spacing: 0.02em; + flex-shrink: 0; +} + From 11d268db6e4cffb36489d91c40d0eac9badd20a8 Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 13:53:05 +0300 Subject: [PATCH 5/6] feat: add the linked tours layout --- src/pages/org/Tasks/TaskDetail.tsx | 77 ++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/src/pages/org/Tasks/TaskDetail.tsx b/src/pages/org/Tasks/TaskDetail.tsx index 6d80688..fdc446f 100644 --- a/src/pages/org/Tasks/TaskDetail.tsx +++ b/src/pages/org/Tasks/TaskDetail.tsx @@ -1,9 +1,10 @@ import { BackButton } from '@components/BackButton.tsx'; import { taskService } from '@services/taskService.ts'; import { axiosInstance } from '@shared/api/axiosInstance.ts'; -import type { FileDetailsDTO, TaskDTO } from '@shared/models/task.ts'; +import type { FileDetailsDTO, TaskDTO, LinkedTour } from '@shared/models/task.ts'; +import { EXECUTION_STATUS_OPTIONS } from '@shared/models/task'; import { formatFileSize } from '@utils/taskUtils.ts'; -import { Eye, EyeOff, FileText, FileLock, Pencil, Trash2 } from 'lucide-react'; +import { Eye, EyeOff, FileText, FileLock, Pencil, Trash2, MapPin } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; @@ -36,6 +37,8 @@ export default function TaskDetail() { const [loading, setLoading] = useState(true); const [deleteOpen, setDeleteOpen] = useState(false); + const [linkedTours, setLinkedTours] = useState([]); + useEffect(() => { if (!id) return; let cancelled = false; @@ -59,6 +62,24 @@ export default function TaskDetail() { return () => { cancelled = true; }; }, [id, navigate, t]); + useEffect(() => { + if (!id) return; + let cancelled = false; + + taskService + .getLinkedTours(Number(id)) + .then(tours => { + if (!cancelled) setLinkedTours(tours); + }) + .catch(() => { + if (!cancelled) setLinkedTours([]); + }); + + return () => { + cancelled = true; + }; + }, [id]); + if (loading) { return (
@@ -102,11 +123,7 @@ export default function TaskDetail() { {t('task-detail.editButton')} - @@ -125,6 +142,19 @@ export default function TaskDetail() { />
+
+

{t('task-detail.linkedTours')}

+ {linkedTours.length > 0 ? ( +
+ {linkedTours.map(tour => ( + + ))} +
+ ) : ( +

{t('task-detail.noLinkedTours')}

+ )} +
+ ); -} \ No newline at end of file +} + +function LinkedTourCard({ tour }: { readonly tour: LinkedTour }) { + const { t } = useTranslation('admin'); + const statusOption = EXECUTION_STATUS_OPTIONS.find(o => o.value === tour.executionStatus); + + return ( +
+
+
+

{tour.title}

+ {tour.description && ( +

{tour.description}

+ )} +
+ + {tour.location} +
+
+ + {statusOption ? t(statusOption.labelKey) : tour.executionStatus} + +
+
+ ); +} From 0ca3117ebbc3a5be71b98250a10b2a6ff3261a68 Mon Sep 17 00:00:00 2001 From: Ihor Selianyk Date: Tue, 25 Aug 2026 18:03:18 +0300 Subject: [PATCH 6/6] fix: coderabbitai issues --- public/i18n/en/admin.json | 2 ++ public/i18n/uk/admin.json | 2 ++ src/pages/org/Tasks/TaskDetail.tsx | 50 +++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index 52d86bc..ef76e8d 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -149,6 +149,8 @@ "downloadFailed": "Failed to download file", "linkedTours": "Linked tours", "noLinkedTours": "Not linked to any tour yet.", + "loadingTours": "Loading linked tours...", + "errorLoadingTours": "Failed to load linked tours.", "statusScheduled": "Scheduled", "statusInProgress": "In progress", "statusClosed": "Closed", diff --git a/public/i18n/uk/admin.json b/public/i18n/uk/admin.json index e5b0407..3abfdfe 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -149,6 +149,8 @@ "downloadFailed": "Не вдалося завантажити файл", "linkedTours": "Тури з цим завданням", "noLinkedTours": "Поки немає турів з цим завданням", + "loadingTours": "Завантажуємо пов'язані тури", + "errorLoadingTours": "Не вдалося завантажити пов'язані тури", "statusScheduled": "Заплановано", "statusInProgress": "Проводиться", "statusClosed": "Основний етап завершено", diff --git a/src/pages/org/Tasks/TaskDetail.tsx b/src/pages/org/Tasks/TaskDetail.tsx index fdc446f..c603801 100644 --- a/src/pages/org/Tasks/TaskDetail.tsx +++ b/src/pages/org/Tasks/TaskDetail.tsx @@ -1,8 +1,8 @@ import { BackButton } from '@components/BackButton.tsx'; import { taskService } from '@services/taskService.ts'; import { axiosInstance } from '@shared/api/axiosInstance.ts'; -import type { FileDetailsDTO, TaskDTO, LinkedTour } from '@shared/models/task.ts'; import { EXECUTION_STATUS_OPTIONS } from '@shared/models/task'; +import type { FileDetailsDTO, TaskDTO, LinkedTour } from '@shared/models/task.ts'; import { formatFileSize } from '@utils/taskUtils.ts'; import { Eye, EyeOff, FileText, FileLock, Pencil, Trash2, MapPin } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -38,6 +38,8 @@ export default function TaskDetail() { const [deleteOpen, setDeleteOpen] = useState(false); const [linkedTours, setLinkedTours] = useState([]); + const [toursLoading, setToursLoading] = useState(true); + const [toursError, setToursError] = useState(false); useEffect(() => { if (!id) return; @@ -66,13 +68,26 @@ export default function TaskDetail() { if (!id) return; let cancelled = false; + setLinkedTours([]); + setToursLoading(true); + setToursError(false); + taskService .getLinkedTours(Number(id)) .then(tours => { - if (!cancelled) setLinkedTours(tours); + if (!cancelled) { + setLinkedTours(tours); + } }) .catch(() => { - if (!cancelled) setLinkedTours([]); + if (!cancelled) { + setToursError(true); + } + }) + .finally(() => { + if (!cancelled) { + setToursLoading(false); + } }); return () => { @@ -100,6 +115,25 @@ export default function TaskDetail() { const referenceFiles = filesByRole(task.files, 'REFERENCE'); const solutionFiles = filesByRole(task.files, 'SOLUTION'); + const renderLinkedToursContent = () => { + if (toursLoading) { + return

{t('task-detail.loadingTours')}

; + } + if (toursError) { + return

{t('task-detail.errorLoadingTours')}

; + } + if (linkedTours.length > 0) { + return ( +
+ {linkedTours.map(tour => ( + + ))} +
+ ); + } + return

{t('task-detail.noLinkedTours')}

; + }; + return (
@@ -144,15 +178,7 @@ export default function TaskDetail() {

{t('task-detail.linkedTours')}

- {linkedTours.length > 0 ? ( -
- {linkedTours.map(tour => ( - - ))} -
- ) : ( -

{t('task-detail.noLinkedTours')}

- )} + {renderLinkedToursContent()}