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
11 changes: 10 additions & 1 deletion public/i18n/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion public/i18n/uk/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,16 @@
"createdBy": "Задачу створено",
"loading": "Завантаження задачі...",
"notFound": "Задачу не знайдено",
"downloadFailed": "Не вдалося завантажити файл"
"downloadFailed": "Не вдалося завантажити файл",
"linkedTours": "Тури з цим завданням",
"noLinkedTours": "Поки немає турів з цим завданням",
"loadingTours": "Завантажуємо пов'язані тури",
"errorLoadingTours": "Не вдалося завантажити пов'язані тури",
"statusScheduled": "Заплановано",
"statusInProgress": "Проводиться",
"statusClosed": "Основний етап завершено",
"statusFinished": "Завершено",
"statusCancelled": "Скасовано"
},
"task-form": {
"createTitle": "Створити нову задачу",
Expand Down
103 changes: 95 additions & 8 deletions src/pages/org/Tasks/TaskDetail.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,6 +37,10 @@ export default function TaskDetail() {
const [loading, setLoading] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);

const [linkedTours, setLinkedTours] = useState<LinkedTour[]>([]);
Comment thread
solenuk marked this conversation as resolved.
const [toursLoading, setToursLoading] = useState(true);
const [toursError, setToursError] = useState(false);

useEffect(() => {
if (!id) return;
let cancelled = false;
Expand All @@ -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 (
<div className="p-6">
Expand All @@ -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 <p className="text-sm text-gray-400">{t('task-detail.loadingTours')}</p>;
}
if (toursError) {
return <p className="text-sm text-red-500">{t('task-detail.errorLoadingTours')}</p>;
}
if (linkedTours.length > 0) {
return (
<div className="flex flex-col gap-3">
{linkedTours.map(tour => (
<LinkedTourCard key={tour.tourId} tour={tour} />
))}
</div>
);
}
return <p className="text-sm text-gray-400">{t('task-detail.noLinkedTours')}</p>;
};

return (
<div className="p-6 max-w-7xl mx-auto">
<BackButton text={t('task-detail.back')} to="/profile/tasks" />
Expand All @@ -102,11 +157,7 @@ export default function TaskDetail() {
<Pencil size={16} style={{ marginRight: 6 }} />
{t('task-detail.editButton')}
</button>
<button
type="button"
className={styles.deleteBtn}
onClick={() => setDeleteOpen(true)}
>
<button type="button" className={styles.deleteBtn} onClick={() => setDeleteOpen(true)}>
<Trash2 size={16} style={{ marginRight: 6 }} />
{t('task-detail.deleteButton')}
</button>
Expand All @@ -125,6 +176,11 @@ export default function TaskDetail() {
/>
</div>

<div className="mt-6">
<h2 className="text-lg font-semibold mb-3">{t('task-detail.linkedTours')}</h2>
{renderLinkedToursContent()}
</div>

<TaskDeleteModal
taskId={task.id}
open={deleteOpen}
Expand Down Expand Up @@ -190,4 +246,35 @@ function FileGroupCard({
)}
</section>
);
}
}

function LinkedTourCard({ tour }: { readonly tour: LinkedTour }) {
const { t } = useTranslation('admin');
const statusOption = EXECUTION_STATUS_OPTIONS.find(o => o.value === tour.executionStatus);

return (
<div className={styles.tourCard}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate">{tour.title}</h3>
{tour.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{tour.description}</p>
)}
<div className="flex items-center gap-1.5 mt-2 text-xs text-gray-400">
<MapPin size={13} className="shrink-0" />
<span className="truncate">{tour.location}</span>
</div>
</div>
<span
className={styles.statusBadge}
style={{
color: statusOption?.color ?? '#6b7280',
borderColor: statusOption?.color ?? '#6b7280',
}}
>
{statusOption ? t(statusOption.labelKey) : tour.executionStatus}
</span>
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions src/pages/org/Tasks/Tasks.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

22 changes: 22 additions & 0 deletions src/shared/models/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion src/shared/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,11 +64,13 @@ export const taskService = {
);
return axiosInstance.post<FileDto[]>(`${API_BASE}/api/v1/files`, formData);
},

updateFileRole: async (fileId: number, newRole: TaskFileRole): Promise<void> => {
await axiosInstance.patch(`/api/v1/files/${fileId}/role`, null, {
params: { newRole },
});
},

addOwner: async (id: number, request: AddOwnerRequestDTO) => {
const { data } = await axiosInstance.patch<TaskDTO>(`/api/v1/tasks/${id}/add-owner`, request);

Expand All @@ -82,5 +85,10 @@ export const taskService = {

return data;
},

getLinkedTours: async (taskId: number): Promise<LinkedTour[]> => {
const { data } = await axiosInstance.get<LinkedTour[]>(`/api/v1/tasks/${taskId}/linked-tours`);
return data;
},
};

Loading