Initial commit: Helpdesk application setup
- Backend: Node.js/TypeScript with Prisma ORM - Frontend: Vite + TypeScript - Project configuration and documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
356
frontend/src/pages/tasks/TaskDetail.tsx
Normal file
356
frontend/src/pages/tasks/TaskDetail.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft } from 'lucide-react';
|
||||
import { tasksApi } from '@/services/tasks.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { Task } from '@/types';
|
||||
import { Button, Badge, Textarea, Select } from '@/components/ui';
|
||||
import { TaskForm } from './TaskForm';
|
||||
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
userId: string;
|
||||
user?: { id: string; name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TaskDetailProps {
|
||||
taskId: string;
|
||||
onClose: () => void;
|
||||
onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód
|
||||
}
|
||||
|
||||
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { data: taskData, isLoading } = useQuery({
|
||||
queryKey: ['task', taskId],
|
||||
queryFn: () => tasksApi.getById(taskId),
|
||||
});
|
||||
|
||||
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
||||
queryKey: ['task-comments', taskId],
|
||||
queryFn: () => tasksApi.getComments(taskId),
|
||||
});
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['task-statuses'],
|
||||
queryFn: () => settingsApi.getTaskStatuses(),
|
||||
});
|
||||
|
||||
const { data: prioritiesData } = useQuery({
|
||||
queryKey: ['priorities'],
|
||||
queryFn: () => settingsApi.getPriorities(),
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (content: string) => tasksApi.addComment(taskId, content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
|
||||
setNewComment('');
|
||||
toast.success('Komentár bol pridaný');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
toast.error(axiosError.response?.data?.message || 'Chyba pri pridávaní komentára');
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskMutation = useMutation({
|
||||
mutationFn: (data: { statusId?: string; priorityId?: string }) => tasksApi.update(taskId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||
toast.success('Úloha bola aktualizovaná');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri aktualizácii úlohy');
|
||||
},
|
||||
});
|
||||
|
||||
const task = taskData?.data;
|
||||
const comments = (commentsData?.data || []) as Comment[];
|
||||
const statuses = statusesData?.data || [];
|
||||
const priorities = prioritiesData?.data || [];
|
||||
|
||||
// Kontrola oprávnení - môže komentovať/meniť autor alebo priradený
|
||||
const isCreator = user && task && (
|
||||
task.createdById === user.id || task.createdBy?.id === user.id
|
||||
);
|
||||
const isAssignee = user && task && task.assignees?.some(a =>
|
||||
a.userId === user.id || a.user?.id === user.id
|
||||
);
|
||||
|
||||
const canComment = isCreator || isAssignee;
|
||||
const canChangeStatus = isCreator || isAssignee; // Stav môže meniť autor + priradený
|
||||
const canChangePriority = isCreator; // Prioritu môže meniť len zadávateľ
|
||||
const canEdit = isCreator; // Len zadávateľ môže editovať úlohu
|
||||
|
||||
const handleSubmitComment = () => {
|
||||
if (!newComment.trim()) return;
|
||||
addCommentMutation.mutate(newComment.trim());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSubmitComment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (statusId: string) => {
|
||||
if (statusId && statusId !== task?.statusId) {
|
||||
updateTaskMutation.mutate({ statusId });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityChange = (priorityId: string) => {
|
||||
if (priorityId && priorityId !== task?.priorityId) {
|
||||
updateTaskMutation.mutate({ priorityId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-lg p-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusOptions = statuses.map(s => ({ value: s.id, label: s.name }));
|
||||
const priorityOptions = priorities.map(p => ({ value: p.id, label: p.name }));
|
||||
|
||||
// Edit mód - zobrazí formulár
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsEditing(false)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-xl font-semibold">Upraviť úlohu</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-6">
|
||||
<TaskForm
|
||||
task={task}
|
||||
onClose={handleEditComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// View mód - zobrazí detail
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b">
|
||||
<div className="flex-1 pr-4">
|
||||
<h2 className="text-xl font-semibold">{task.title}</h2>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge color={task.status.color}>{task.status.name}</Badge>
|
||||
<Badge color={task.priority.color}>{task.priority.name}</Badge>
|
||||
{task.status.isFinal && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Dokončená
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Upraviť
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Quick actions - zmena statusu a priority */}
|
||||
{(canChangeStatus || canChangePriority) && (
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
{canChangeStatus && (
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť stav</label>
|
||||
<Select
|
||||
id="status"
|
||||
options={statusOptions}
|
||||
value={task.statusId}
|
||||
onChange={(e) => handleStatusChange(e.target.value)}
|
||||
disabled={updateTaskMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canChangePriority && (
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť prioritu</label>
|
||||
<Select
|
||||
id="priority"
|
||||
options={priorityOptions}
|
||||
value={task.priorityId}
|
||||
onChange={(e) => handlePriorityChange(e.target.value)}
|
||||
disabled={updateTaskMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span>Zadal:</span>
|
||||
<span className="text-foreground font-medium">{task.createdBy?.name || '-'}</span>
|
||||
</div>
|
||||
{task.project && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>Projekt:</span>
|
||||
<span className="text-foreground font-medium">{task.project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.deadline && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Termín:</span>
|
||||
<span className={`font-medium ${new Date(task.deadline) < new Date() ? 'text-red-500' : 'text-foreground'}`}>
|
||||
{formatDate(task.deadline)}
|
||||
{new Date(task.deadline) < new Date() && ' (po termíne!)'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.assignees && task.assignees.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Priradení:</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{task.assignees.map(a => a.user?.name || a.userId).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{task.description ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Popis</h3>
|
||||
<p className="text-sm whitespace-pre-wrap bg-muted/50 rounded-md p-3">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
Bez popisu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Komentáre ({comments.length})
|
||||
</h3>
|
||||
|
||||
{/* Add comment - na vrchu */}
|
||||
{canComment && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<Textarea
|
||||
placeholder="Napíšte komentár... (Ctrl+Enter pre odoslanie)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmitComment}
|
||||
disabled={!newComment.trim() || addCommentMutation.isPending}
|
||||
isLoading={addCommentMutation.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Odoslať
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{commentsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Načítavam...</p>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Zatiaľ žiadne komentáre
|
||||
</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="bg-muted/50 rounded-md p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{comment.user?.name || 'Neznámy'}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canComment && (
|
||||
<p className="text-sm text-muted-foreground italic mt-4">
|
||||
Komentovať môže len autor úlohy alebo priradený používateľ.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between items-center p-4 border-t text-xs text-muted-foreground">
|
||||
<span>Vytvorené: {formatDateTime(task.createdAt)}</span>
|
||||
<span>Aktualizované: {formatDateTime(task.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user