- Pridaná kompletná správa používateľov (CRUD, reset hesla, zmena roly) pre ROOT/ADMIN - Backend: POST /users endpoint, createUser controller, validácia - Frontend: UserManagement, UserForm, PasswordResetModal komponenty - Settings prístupné pre ROOT aj ADMIN (AdminRoute) - Notifikačný systém s snooze funkcionalitou - Aktualizácia HELPDESK_INIT_V2.md dokumentácie Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft, Check, Clock } from 'lucide-react';
|
|
import { tasksApi } from '@/services/tasks.api';
|
|
import { settingsApi } from '@/services/settings.api';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { useNotificationStore } from '@/store/notificationStore';
|
|
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
|
|
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
|
|
notificationId?: string; // Ak je detail otvorený z notifikácie
|
|
}
|
|
|
|
export function TaskDetail({ taskId, onClose, notificationId }: TaskDetailProps) {
|
|
const queryClient = useQueryClient();
|
|
const { user } = useAuthStore();
|
|
const [newComment, setNewComment] = useState('');
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [snoozeOpen, setSnoozeOpen] = useState(false);
|
|
|
|
// Notifikácie - len ak bol detail otvorený z notifikácie
|
|
const { notifications, markAsRead, snooze } = useNotificationStore();
|
|
const snoozeOptions = useSnoozeOptions();
|
|
|
|
// Konkrétna notifikácia ak existuje
|
|
const notification = notificationId
|
|
? notifications.find((n) => n.id === notificationId && !n.isRead)
|
|
: null;
|
|
|
|
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ý');
|
|
// Označiť notifikáciu ako prečítanú ak existuje
|
|
if (notificationId) {
|
|
markAsRead(notificationId);
|
|
}
|
|
},
|
|
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á');
|
|
// Označiť notifikáciu ako prečítanú ak existuje
|
|
if (notificationId) {
|
|
markAsRead(notificationId);
|
|
}
|
|
},
|
|
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">
|
|
{/* Tlačidlá pre notifikáciu */}
|
|
{notification && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => markAsRead(notification.id)}
|
|
title="Označiť ako prečítané"
|
|
>
|
|
<Check className="h-4 w-4 mr-1" />
|
|
Prečítané
|
|
</Button>
|
|
<div className="relative">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSnoozeOpen(!snoozeOpen)}
|
|
title="Odložiť notifikáciu"
|
|
>
|
|
<Clock className="h-4 w-4 mr-1" />
|
|
Odložiť
|
|
</Button>
|
|
{snoozeOpen && (
|
|
<div className="absolute right-0 top-full mt-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
|
|
{snoozeOptions.map((option) => (
|
|
<button
|
|
key={option.label}
|
|
onClick={() => {
|
|
snooze(notification.id, calculateSnoozeMinutes(option));
|
|
setSnoozeOpen(false);
|
|
}}
|
|
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{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>
|
|
);
|
|
}
|