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:
2026-02-03 08:53:22 +01:00
commit e4f63a135e
103 changed files with 19913 additions and 0 deletions

View 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>
);
}