diff --git a/public/i18n/en/admin.json b/public/i18n/en/admin.json index 92f60dc..ef76e8d 100644 --- a/public/i18n/en/admin.json +++ b/public/i18n/en/admin.json @@ -146,7 +146,16 @@ "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.", + "loadingTours": "Loading linked tours...", + "errorLoadingTours": "Failed to load linked tours.", + "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..3abfdfe 100644 --- a/public/i18n/uk/admin.json +++ b/public/i18n/uk/admin.json @@ -146,7 +146,16 @@ "createdBy": "Задачу створено", "loading": "Завантаження задачі...", "notFound": "Задачу не знайдено", - "downloadFailed": "Не вдалося завантажити файл" + "downloadFailed": "Не вдалося завантажити файл", + "linkedTours": "Тури з цим завданням", + "noLinkedTours": "Поки немає турів з цим завданням", + "loadingTours": "Завантажуємо пов'язані тури", + "errorLoadingTours": "Не вдалося завантажити пов'язані тури", + "statusScheduled": "Заплановано", + "statusInProgress": "Проводиться", + "statusClosed": "Основний етап завершено", + "statusFinished": "Завершено", + "statusCancelled": "Скасовано" }, "task-form": { "createTitle": "Створити нову задачу", diff --git a/src/pages/org/Tasks/TaskDetail.tsx b/src/pages/org/Tasks/TaskDetail.tsx index 6d80688..c603801 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 { 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 } 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,10 @@ export default function TaskDetail() { const [loading, setLoading] = useState(true); const [deleteOpen, setDeleteOpen] = useState(false); + const [linkedTours, setLinkedTours] = useState([]); + const [toursLoading, setToursLoading] = useState(true); + const [toursError, setToursError] = useState(false); + useEffect(() => { if (!id) return; let cancelled = false; @@ -59,6 +64,37 @@ export default function TaskDetail() { return () => { cancelled = true; }; }, [id, navigate, t]); + useEffect(() => { + if (!id) return; + let cancelled = false; + + setLinkedTours([]); + setToursLoading(true); + setToursError(false); + + taskService + .getLinkedTours(Number(id)) + .then(tours => { + if (!cancelled) { + setLinkedTours(tours); + } + }) + .catch(() => { + if (!cancelled) { + setToursError(true); + } + }) + .finally(() => { + if (!cancelled) { + setToursLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [id]); + if (loading) { return (
@@ -79,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 (
@@ -102,11 +157,7 @@ export default function TaskDetail() { {t('task-detail.editButton')} - @@ -125,6 +176,11 @@ export default function TaskDetail() { />
+
+

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

+ {renderLinkedToursContent()} +
+ ); -} \ 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} + +
+
+ ); +} 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; +} + 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', 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; + }, };