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:
384
frontend/src/pages/Dashboard.tsx
Normal file
384
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
CheckSquare,
|
||||
Users,
|
||||
Wrench,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
User,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { get } from '@/services/api';
|
||||
import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui';
|
||||
import { TaskDetail } from '@/pages/tasks/TaskDetail';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Task, Project } from '@/types';
|
||||
|
||||
interface DashboardStats {
|
||||
projects: { total: number; active: number };
|
||||
tasks: { total: number; pending: number; inProgress: number };
|
||||
customers: { total: number; active: number };
|
||||
equipment: { total: number; upcomingRevisions: number };
|
||||
rma: { total: number; pending: number };
|
||||
}
|
||||
|
||||
interface DashboardToday {
|
||||
myTasks: Task[];
|
||||
myProjects: Project[];
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||
|
||||
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => get<DashboardStats>('/dashboard'),
|
||||
});
|
||||
|
||||
const { data: todayData, isLoading: todayLoading } = useQuery({
|
||||
queryKey: ['dashboard-today'],
|
||||
queryFn: () => get<DashboardToday>('/dashboard/today'),
|
||||
});
|
||||
|
||||
if (statsLoading || todayLoading) {
|
||||
return <LoadingOverlay />;
|
||||
}
|
||||
|
||||
const stats = statsData?.data;
|
||||
const today = todayData?.data;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Projekty',
|
||||
icon: FolderKanban,
|
||||
value: stats?.projects.total ?? 0,
|
||||
subtitle: `${stats?.projects.active ?? 0} aktívnych`,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
href: '/projects',
|
||||
},
|
||||
{
|
||||
title: 'Úlohy',
|
||||
icon: CheckSquare,
|
||||
value: stats?.tasks.total ?? 0,
|
||||
subtitle: `${stats?.tasks.inProgress ?? 0} v progrese`,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950/30',
|
||||
href: '/tasks',
|
||||
},
|
||||
{
|
||||
title: 'Zákazníci',
|
||||
icon: Users,
|
||||
value: stats?.customers.total ?? 0,
|
||||
subtitle: `${stats?.customers.active ?? 0} aktívnych`,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-950/30',
|
||||
href: '/customers',
|
||||
},
|
||||
{
|
||||
title: 'Zariadenia',
|
||||
icon: Wrench,
|
||||
value: stats?.equipment.total ?? 0,
|
||||
subtitle: `${stats?.equipment.upcomingRevisions ?? 0} revízií`,
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
|
||||
href: '/equipment',
|
||||
},
|
||||
{
|
||||
title: 'RMA',
|
||||
icon: RotateCcw,
|
||||
value: stats?.rma.total ?? 0,
|
||||
subtitle: `${stats?.rma.pending ?? 0} otvorených`,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-50 dark:bg-red-950/30',
|
||||
href: '/rma',
|
||||
},
|
||||
];
|
||||
|
||||
// Rozdelenie úloh podľa urgentnosti
|
||||
const urgentTasks = today?.myTasks?.filter(t => {
|
||||
if (!t.deadline) return false;
|
||||
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return daysUntil <= 2;
|
||||
}) || [];
|
||||
|
||||
const normalTasks = today?.myTasks?.filter(t => {
|
||||
if (!t.deadline) return true;
|
||||
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return daysUntil > 2;
|
||||
}) || [];
|
||||
|
||||
const isOverdue = (deadline: string) => {
|
||||
return new Date(deadline) < new Date();
|
||||
};
|
||||
|
||||
const getDaysUntilDeadline = (deadline: string) => {
|
||||
const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return `${Math.abs(days)} dní po termíne`;
|
||||
if (days === 0) return 'Dnes';
|
||||
if (days === 1) return 'Zajtra';
|
||||
return `${days} dní`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Štatistické karty */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Link key={card.title} to={card.href}>
|
||||
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${card.bgColor}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<card.icon className={`h-5 w-5 ${card.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{card.value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Urgentné úlohy - zobrazí sa len ak existujú */}
|
||||
{urgentTasks.length > 0 && (
|
||||
<Card className="border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Urgentné úlohy ({urgentTasks.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{urgentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => setDetailTaskId(task.id)}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-background border border-red-200 dark:border-red-800 cursor-pointer hover:border-red-400 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">{task.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs">
|
||||
{task.project && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<FolderKanban className="h-3 w-3" />
|
||||
{task.project.name}
|
||||
</span>
|
||||
)}
|
||||
{task.createdBy && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
{task.createdBy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{task.deadline && (
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded ${isOverdue(task.deadline) ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'}`}>
|
||||
{getDaysUntilDeadline(task.deadline)}
|
||||
</span>
|
||||
)}
|
||||
<Badge color={task.priority?.color}>{task.priority?.name}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Moje úlohy */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-green-500" />
|
||||
Moje úlohy
|
||||
{today?.myTasks && today.myTasks.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({today.myTasks.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Link to="/tasks" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
Všetky <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{normalTasks.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{normalTasks.slice(0, 5).map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => setDetailTaskId(task.id)}
|
||||
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge color={task.status?.color} className="text-xs">{task.status?.name}</Badge>
|
||||
<Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
{task.project && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderKanban className="h-3 w-3" />
|
||||
{task.project.name}
|
||||
</span>
|
||||
)}
|
||||
{task.createdBy && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
Zadal: {task.createdBy.name}
|
||||
</span>
|
||||
)}
|
||||
{task.deadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
{formatDate(task.deadline)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{normalTasks.length > 5 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
+{normalTasks.length - 5} ďalších úloh
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : today?.myTasks?.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Nemáte žiadne priradené úlohy</p>
|
||||
<Link to="/tasks" className="text-sm text-primary hover:underline mt-2 inline-block">
|
||||
Zobraziť všetky úlohy →
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Moje projekty */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderKanban className="h-5 w-5 text-blue-500" />
|
||||
Moje projekty
|
||||
{today?.myProjects && today.myProjects.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({today.myProjects.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Link to="/projects" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
Všetky <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{today?.myProjects && today.myProjects.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{today.myProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{project.name}</p>
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1 mt-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge color={project.status?.color}>{project.status?.name}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
{project._count?.tasks ?? 0} úloh
|
||||
</span>
|
||||
{project.hardDeadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
Termín: {formatDate(project.hardDeadline)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Nemáte žiadne aktívne projekty</p>
|
||||
<Link to="/projects" className="text-sm text-primary hover:underline mt-2 inline-block">
|
||||
Zobraziť všetky projekty →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upozornenie na revízie */}
|
||||
{(stats?.equipment.upcomingRevisions ?? 0) > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
<CardTitle className="text-orange-700 dark:text-orange-400">Blížiace sa revízie</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-orange-600 dark:text-orange-300">
|
||||
Máte {stats?.equipment.upcomingRevisions} zariadení s blížiacou sa revíziou v nasledujúcich 30 dňoch.
|
||||
</p>
|
||||
<Link to="/equipment" className="text-sm text-orange-700 dark:text-orange-400 hover:underline mt-2 inline-block font-medium">
|
||||
Skontrolovať zariadenia →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Detail úlohy */}
|
||||
{detailTaskId && (
|
||||
<TaskDetail
|
||||
taskId={detailTaskId}
|
||||
onClose={() => {
|
||||
setDetailTaskId(null);
|
||||
// Refresh dashboard data po zatvorení
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
frontend/src/pages/Login.tsx
Normal file
82
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Neplatný email'),
|
||||
password: z.string().min(1, 'Heslo je povinné'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isAuthenticated } = useAuthStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Redirect when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await login(data);
|
||||
toast.success('Úspešne prihlásený');
|
||||
} catch {
|
||||
toast.error('Neplatné prihlasovacie údaje');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Helpdesk</CardTitle>
|
||||
<p className="text-muted-foreground">Prihláste sa do svojho účtu</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="vas@email.sk"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
label="Heslo"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Prihlásiť sa
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/customers/CustomerForm.tsx
Normal file
188
frontend/src/pages/customers/CustomerForm.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { customersApi, type CreateCustomerData } from '@/services/customers.api';
|
||||
import type { Customer } from '@/types';
|
||||
import { Button, Input, Textarea, ModalFooter } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const customerSchema = z.object({
|
||||
name: z.string().min(1, 'Názov je povinný'),
|
||||
address: z.string().optional(),
|
||||
email: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
ico: z.string().optional(),
|
||||
dic: z.string().optional(),
|
||||
icdph: z.string().optional(),
|
||||
contactPerson: z.string().optional(),
|
||||
contactEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||
contactPhone: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
active: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
type CustomerFormData = z.input<typeof customerSchema>;
|
||||
|
||||
interface CustomerFormProps {
|
||||
customer: Customer | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CustomerForm({ customer, onClose }: CustomerFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!customer;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CustomerFormData>({
|
||||
resolver: zodResolver(customerSchema),
|
||||
defaultValues: customer
|
||||
? {
|
||||
name: customer.name,
|
||||
address: customer.address || '',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || '',
|
||||
ico: customer.ico || '',
|
||||
dic: customer.dic || '',
|
||||
icdph: customer.icdph || '',
|
||||
contactPerson: customer.contactPerson || '',
|
||||
contactEmail: customer.contactEmail || '',
|
||||
contactPhone: customer.contactPhone || '',
|
||||
notes: customer.notes || '',
|
||||
active: customer.active,
|
||||
}
|
||||
: { active: true },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateCustomerData) => customersApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
toast.success('Zákazník bol vytvorený');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri vytváraní zákazníka');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CreateCustomerData) => customersApi.update(customer!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
toast.success('Zákazník bol aktualizovaný');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri aktualizácii zákazníka');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CustomerFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
email: data.email || undefined,
|
||||
contactEmail: data.contactEmail || undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
id="name"
|
||||
label="Názov *"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<Input
|
||||
id="phone"
|
||||
label="Telefón"
|
||||
{...register('phone')}
|
||||
/>
|
||||
<Input
|
||||
id="address"
|
||||
label="Adresa"
|
||||
{...register('address')}
|
||||
/>
|
||||
<Input
|
||||
id="ico"
|
||||
label="IČO"
|
||||
{...register('ico')}
|
||||
/>
|
||||
<Input
|
||||
id="dic"
|
||||
label="DIČ"
|
||||
{...register('dic')}
|
||||
/>
|
||||
<Input
|
||||
id="icdph"
|
||||
label="IČ DPH"
|
||||
{...register('icdph')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
<h3 className="font-medium">Kontaktná osoba</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Input
|
||||
id="contactPerson"
|
||||
label="Meno"
|
||||
{...register('contactPerson')}
|
||||
/>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
label="Email"
|
||||
error={errors.contactEmail?.message}
|
||||
{...register('contactEmail')}
|
||||
/>
|
||||
<Input
|
||||
id="contactPhone"
|
||||
label="Telefón"
|
||||
{...register('contactPhone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
id="notes"
|
||||
label="Poznámky"
|
||||
rows={3}
|
||||
{...register('notes')}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register('active')} className="rounded" />
|
||||
<span className="text-sm">Aktívny</span>
|
||||
</label>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/customers/CustomersList.tsx
Normal file
164
frontend/src/pages/customers/CustomersList.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import type { Customer } from '@/types';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import { CustomerForm } from './CustomerForm';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function CustomersList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Customer | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['customers', search],
|
||||
queryFn: () => customersApi.getAll({ search, limit: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => customersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
toast.success('Zákazník bol vymazaný');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní zákazníka');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (customer: Customer) => {
|
||||
setEditingCustomer(customer);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingCustomer(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Zákazníci</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nový zákazník
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Hľadať zákazníkov..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Telefón</TableHead>
|
||||
<TableHead>IČO</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((customer) => (
|
||||
<TableRow key={customer.id}>
|
||||
<TableCell className="font-medium">{customer.name}</TableCell>
|
||||
<TableCell>{customer.email || '-'}</TableCell>
|
||||
<TableCell>{customer.phone || '-'}</TableCell>
|
||||
<TableCell>{customer.ico || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={customer.active ? 'default' : 'secondary'}>
|
||||
{customer.active ? 'Aktívny' : 'Neaktívny'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(customer)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(customer)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
Žiadni zákazníci
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingCustomer ? 'Upraviť zákazníka' : 'Nový zákazník'}
|
||||
size="lg"
|
||||
>
|
||||
<CustomerForm customer={editingCustomer} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať zákazníka "{deleteConfirm?.name}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/customers/index.ts
Normal file
1
frontend/src/pages/customers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CustomersList } from './CustomersList';
|
||||
229
frontend/src/pages/equipment/EquipmentForm.tsx
Normal file
229
frontend/src/pages/equipment/EquipmentForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { equipmentApi, type CreateEquipmentData } from '@/services/equipment.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import type { Equipment } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const equipmentSchema = z.object({
|
||||
name: z.string().min(1, 'Názov je povinný'),
|
||||
typeId: z.string().min(1, 'Typ je povinný'),
|
||||
brand: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
customerId: z.string().optional(),
|
||||
address: z.string().min(1, 'Adresa je povinná'),
|
||||
location: z.string().optional(),
|
||||
partNumber: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
installDate: z.string().optional(),
|
||||
warrantyEnd: z.string().optional(),
|
||||
warrantyStatus: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
active: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
type EquipmentFormData = z.input<typeof equipmentSchema>;
|
||||
|
||||
interface EquipmentFormProps {
|
||||
equipment: Equipment | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!equipment;
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-select'],
|
||||
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||
});
|
||||
|
||||
const { data: typesData } = useQuery({
|
||||
queryKey: ['equipment-types'],
|
||||
queryFn: () => settingsApi.getEquipmentTypes(),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EquipmentFormData>({
|
||||
resolver: zodResolver(equipmentSchema),
|
||||
defaultValues: equipment
|
||||
? {
|
||||
name: equipment.name,
|
||||
typeId: equipment.typeId,
|
||||
brand: equipment.brand || '',
|
||||
model: equipment.model || '',
|
||||
customerId: equipment.customerId || '',
|
||||
address: equipment.address,
|
||||
location: equipment.location || '',
|
||||
partNumber: equipment.partNumber || '',
|
||||
serialNumber: equipment.serialNumber || '',
|
||||
installDate: equipment.installDate?.split('T')[0] || '',
|
||||
warrantyEnd: equipment.warrantyEnd?.split('T')[0] || '',
|
||||
warrantyStatus: equipment.warrantyStatus || '',
|
||||
description: equipment.description || '',
|
||||
notes: equipment.notes || '',
|
||||
active: equipment.active,
|
||||
}
|
||||
: { active: true },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEquipmentData) => equipmentApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||
toast.success('Zariadenie bolo vytvorené');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri vytváraní zariadenia');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CreateEquipmentData) => equipmentApi.update(equipment!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||
toast.success('Zariadenie bolo aktualizované');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri aktualizácii zariadenia');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: EquipmentFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
customerId: data.customerId || undefined,
|
||||
installDate: data.installDate || undefined,
|
||||
warrantyEnd: data.warrantyEnd || undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||
const typeOptions = typesData?.data.map((t) => ({ value: t.id, label: t.name })) || [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
id="name"
|
||||
label="Názov *"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
<Select
|
||||
id="typeId"
|
||||
label="Typ *"
|
||||
options={typeOptions}
|
||||
error={errors.typeId?.message}
|
||||
{...register('typeId')}
|
||||
/>
|
||||
<Input
|
||||
id="brand"
|
||||
label="Značka"
|
||||
{...register('brand')}
|
||||
/>
|
||||
<Input
|
||||
id="model"
|
||||
label="Model"
|
||||
{...register('model')}
|
||||
/>
|
||||
<Select
|
||||
id="customerId"
|
||||
label="Zákazník"
|
||||
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
|
||||
{...register('customerId')}
|
||||
/>
|
||||
<Input
|
||||
id="partNumber"
|
||||
label="Part Number"
|
||||
{...register('partNumber')}
|
||||
/>
|
||||
<Input
|
||||
id="serialNumber"
|
||||
label="Sériové číslo"
|
||||
{...register('serialNumber')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="address"
|
||||
label="Adresa *"
|
||||
error={errors.address?.message}
|
||||
{...register('address')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="location"
|
||||
label="Umiestnenie"
|
||||
placeholder="napr. 2. poschodie, serverovňa"
|
||||
{...register('location')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Input
|
||||
id="installDate"
|
||||
type="date"
|
||||
label="Dátum inštalácie"
|
||||
{...register('installDate')}
|
||||
/>
|
||||
<Input
|
||||
id="warrantyEnd"
|
||||
type="date"
|
||||
label="Záruka do"
|
||||
{...register('warrantyEnd')}
|
||||
/>
|
||||
<Input
|
||||
id="warrantyStatus"
|
||||
label="Stav záruky"
|
||||
{...register('warrantyStatus')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
id="description"
|
||||
label="Popis"
|
||||
rows={2}
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="notes"
|
||||
label="Poznámky"
|
||||
rows={2}
|
||||
{...register('notes')}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register('active')} className="rounded" />
|
||||
<span className="text-sm">Aktívne</span>
|
||||
</label>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
173
frontend/src/pages/equipment/EquipmentList.tsx
Normal file
173
frontend/src/pages/equipment/EquipmentList.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { equipmentApi } from '@/services/equipment.api';
|
||||
import type { Equipment } from '@/types';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import { EquipmentForm } from './EquipmentForm';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function EquipmentList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingEquipment, setEditingEquipment] = useState<Equipment | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Equipment | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['equipment', search],
|
||||
queryFn: () => equipmentApi.getAll({ search, limit: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => equipmentApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||
toast.success('Zariadenie bolo vymazané');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní zariadenia');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (equipment: Equipment) => {
|
||||
setEditingEquipment(equipment);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingEquipment(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Zariadenia</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nové zariadenie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Hľadať zariadenia..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Typ</TableHead>
|
||||
<TableHead>Zákazník</TableHead>
|
||||
<TableHead>Adresa</TableHead>
|
||||
<TableHead>Sériové číslo</TableHead>
|
||||
<TableHead>Záruka do</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((equipment) => (
|
||||
<TableRow key={equipment.id}>
|
||||
<TableCell className="font-medium">{equipment.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={equipment.type.color}>{equipment.type.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{equipment.customer?.name || '-'}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{equipment.address}</TableCell>
|
||||
<TableCell>{equipment.serialNumber || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{equipment.warrantyEnd ? formatDate(equipment.warrantyEnd) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={equipment.active ? 'default' : 'secondary'}>
|
||||
{equipment.active ? 'Aktívne' : 'Neaktívne'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(equipment)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(equipment)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
Žiadne zariadenia
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingEquipment ? 'Upraviť zariadenie' : 'Nové zariadenie'}
|
||||
size="lg"
|
||||
>
|
||||
<EquipmentForm equipment={editingEquipment} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať zariadenie "{deleteConfirm?.name}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/equipment/index.ts
Normal file
1
frontend/src/pages/equipment/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EquipmentList } from './EquipmentList';
|
||||
191
frontend/src/pages/projects/ProjectForm.tsx
Normal file
191
frontend/src/pages/projects/ProjectForm.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { projectsApi, type CreateProjectData } from '@/services/projects.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import type { Project } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().min(1, 'Názov je povinný'),
|
||||
description: z.string().optional(),
|
||||
customerId: z.string().optional(),
|
||||
ownerId: z.string().optional(), // Backend nastaví aktuálneho používateľa ak prázdne
|
||||
statusId: z.string().optional(), // Backend nastaví predvolený ak prázdne
|
||||
softDeadline: z.string().optional(),
|
||||
hardDeadline: z.string().optional(),
|
||||
});
|
||||
|
||||
type ProjectFormData = z.infer<typeof projectSchema>;
|
||||
|
||||
interface ProjectFormProps {
|
||||
project: Project | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const isEditing = !!project;
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-select'],
|
||||
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||
});
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['task-statuses'],
|
||||
queryFn: () => settingsApi.getTaskStatuses(),
|
||||
});
|
||||
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['users-select'],
|
||||
queryFn: () => settingsApi.getUsers(),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ProjectFormData>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
defaultValues: project
|
||||
? {
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
customerId: project.customerId || '',
|
||||
ownerId: project.ownerId,
|
||||
statusId: project.statusId,
|
||||
softDeadline: project.softDeadline?.split('T')[0] || '',
|
||||
hardDeadline: project.hardDeadline?.split('T')[0] || '',
|
||||
}
|
||||
: {
|
||||
ownerId: user?.id || '',
|
||||
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateProjectData) => projectsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success('Projekt bol vytvorený');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Create project error:', error);
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní projektu';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CreateProjectData) => projectsApi.update(project!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success('Projekt bol aktualizovaný');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Update project error:', error);
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii projektu';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ProjectFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
customerId: data.customerId || undefined,
|
||||
ownerId: data.ownerId || undefined,
|
||||
statusId: data.statusId || undefined,
|
||||
softDeadline: data.softDeadline || undefined,
|
||||
hardDeadline: data.hardDeadline || undefined,
|
||||
};
|
||||
|
||||
console.log('Submitting project data:', cleanData);
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
label="Názov *"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="description"
|
||||
label="Popis"
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
id="customerId"
|
||||
label="Zákazník"
|
||||
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
|
||||
{...register('customerId')}
|
||||
/>
|
||||
<Select
|
||||
id="ownerId"
|
||||
label="Vlastník *"
|
||||
options={userOptions}
|
||||
error={errors.ownerId?.message}
|
||||
{...register('ownerId')}
|
||||
/>
|
||||
<Select
|
||||
id="statusId"
|
||||
label="Stav *"
|
||||
options={statusOptions}
|
||||
error={errors.statusId?.message}
|
||||
{...register('statusId')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
id="softDeadline"
|
||||
type="date"
|
||||
label="Mäkký termín"
|
||||
{...register('softDeadline')}
|
||||
/>
|
||||
<Input
|
||||
id="hardDeadline"
|
||||
type="date"
|
||||
label="Tvrdý termín"
|
||||
{...register('hardDeadline')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/projects/ProjectsList.tsx
Normal file
165
frontend/src/pages/projects/ProjectsList.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { projectsApi } from '@/services/projects.api';
|
||||
import type { Project } from '@/types';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import { ProjectForm } from './ProjectForm';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function ProjectsList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Project | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['projects', search],
|
||||
queryFn: () => projectsApi.getAll({ search, limit: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => projectsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success('Projekt bol vymazaný');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní projektu');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (project: Project) => {
|
||||
setEditingProject(project);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Projekty</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nový projekt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Hľadať projekty..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Zákazník</TableHead>
|
||||
<TableHead>Vlastník</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead>Termín</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-medium">{project.name}</TableCell>
|
||||
<TableCell>{project.customer?.name || '-'}</TableCell>
|
||||
<TableCell>{project.owner.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={project.status.color}>{project.status.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.hardDeadline ? formatDate(project.hardDeadline) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(project)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(project)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
Žiadne projekty
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingProject ? 'Upraviť projekt' : 'Nový projekt'}
|
||||
size="lg"
|
||||
>
|
||||
<ProjectForm project={editingProject} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať projekt "{deleteConfirm?.name}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/projects/index.ts
Normal file
1
frontend/src/pages/projects/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectsList } from './ProjectsList';
|
||||
309
frontend/src/pages/rma/RMAForm.tsx
Normal file
309
frontend/src/pages/rma/RMAForm.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { rmaApi, type CreateRMAData, type UpdateRMAData } from '@/services/rma.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import type { RMA } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const rmaSchema = z.object({
|
||||
customerId: z.string().optional(),
|
||||
customerName: z.string().optional(),
|
||||
customerAddress: z.string().optional(),
|
||||
customerEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||
customerPhone: z.string().optional(),
|
||||
customerICO: z.string().optional(),
|
||||
submittedBy: z.string().min(1, 'Podávajúci je povinný'),
|
||||
productName: z.string().min(1, 'Názov produktu je povinný'),
|
||||
invoiceNumber: z.string().optional(),
|
||||
purchaseDate: z.string().optional(),
|
||||
productNumber: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
accessories: z.string().optional(),
|
||||
issueDescription: z.string().min(1, 'Popis problému je povinný'),
|
||||
statusId: z.string().min(1, 'Stav je povinný'),
|
||||
proposedSolutionId: z.string().optional(),
|
||||
requiresApproval: z.boolean().optional().default(false),
|
||||
receivedDate: z.string().optional(),
|
||||
receivedLocation: z.string().optional(),
|
||||
internalNotes: z.string().optional(),
|
||||
resolutionNotes: z.string().optional(),
|
||||
assignedToId: z.string().optional(),
|
||||
});
|
||||
|
||||
type RMAFormData = z.input<typeof rmaSchema>;
|
||||
|
||||
interface RMAFormProps {
|
||||
rma: RMA | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RMAForm({ rma, onClose }: RMAFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!rma;
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-select'],
|
||||
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||
});
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['rma-statuses'],
|
||||
queryFn: () => settingsApi.getRMAStatuses(),
|
||||
});
|
||||
|
||||
const { data: solutionsData } = useQuery({
|
||||
queryKey: ['rma-solutions'],
|
||||
queryFn: () => settingsApi.getRMASolutions(),
|
||||
});
|
||||
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['users-select'],
|
||||
queryFn: () => settingsApi.getUsers(),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RMAFormData>({
|
||||
resolver: zodResolver(rmaSchema),
|
||||
defaultValues: rma
|
||||
? {
|
||||
customerId: rma.customerId || '',
|
||||
customerName: rma.customerName || '',
|
||||
customerAddress: rma.customerAddress || '',
|
||||
customerEmail: rma.customerEmail || '',
|
||||
customerPhone: rma.customerPhone || '',
|
||||
customerICO: rma.customerICO || '',
|
||||
submittedBy: rma.submittedBy,
|
||||
productName: rma.productName,
|
||||
invoiceNumber: rma.invoiceNumber || '',
|
||||
purchaseDate: rma.purchaseDate?.split('T')[0] || '',
|
||||
productNumber: rma.productNumber || '',
|
||||
serialNumber: rma.serialNumber || '',
|
||||
accessories: rma.accessories || '',
|
||||
issueDescription: rma.issueDescription,
|
||||
statusId: rma.statusId,
|
||||
proposedSolutionId: rma.proposedSolutionId || '',
|
||||
requiresApproval: rma.requiresApproval,
|
||||
receivedDate: rma.receivedDate?.split('T')[0] || '',
|
||||
receivedLocation: rma.receivedLocation || '',
|
||||
internalNotes: rma.internalNotes || '',
|
||||
resolutionNotes: rma.resolutionNotes || '',
|
||||
assignedToId: rma.assignedToId || '',
|
||||
}
|
||||
: {
|
||||
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||
requiresApproval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateRMAData) => rmaApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||
toast.success('RMA bolo vytvorené');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri vytváraní RMA');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateRMAData) => rmaApi.update(rma!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||
toast.success('RMA bolo aktualizované');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri aktualizácii RMA');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: RMAFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
customerId: data.customerId || undefined,
|
||||
customerEmail: data.customerEmail || undefined,
|
||||
purchaseDate: data.purchaseDate || undefined,
|
||||
receivedDate: data.receivedDate || undefined,
|
||||
proposedSolutionId: data.proposedSolutionId || undefined,
|
||||
assignedToId: data.assignedToId || undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData as CreateRMAData);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||
const solutionOptions = solutionsData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||
<h3 className="font-medium">Zákazník</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
id="customerId"
|
||||
label="Existujúci zákazník"
|
||||
options={[{ value: '', label: '-- Alebo zadajte manuálne --' }, ...customerOptions]}
|
||||
{...register('customerId')}
|
||||
/>
|
||||
<Input
|
||||
id="customerName"
|
||||
label="Názov zákazníka"
|
||||
{...register('customerName')}
|
||||
/>
|
||||
<Input
|
||||
id="customerEmail"
|
||||
type="email"
|
||||
label="Email"
|
||||
error={errors.customerEmail?.message}
|
||||
{...register('customerEmail')}
|
||||
/>
|
||||
<Input
|
||||
id="customerPhone"
|
||||
label="Telefón"
|
||||
{...register('customerPhone')}
|
||||
/>
|
||||
<Input
|
||||
id="customerAddress"
|
||||
label="Adresa"
|
||||
{...register('customerAddress')}
|
||||
/>
|
||||
<Input
|
||||
id="customerICO"
|
||||
label="IČO"
|
||||
{...register('customerICO')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
<h3 className="font-medium">Produkt</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
id="submittedBy"
|
||||
label="Podávajúci *"
|
||||
error={errors.submittedBy?.message}
|
||||
{...register('submittedBy')}
|
||||
/>
|
||||
<Input
|
||||
id="productName"
|
||||
label="Názov produktu *"
|
||||
error={errors.productName?.message}
|
||||
{...register('productName')}
|
||||
/>
|
||||
<Input
|
||||
id="invoiceNumber"
|
||||
label="Číslo faktúry"
|
||||
{...register('invoiceNumber')}
|
||||
/>
|
||||
<Input
|
||||
id="purchaseDate"
|
||||
type="date"
|
||||
label="Dátum nákupu"
|
||||
{...register('purchaseDate')}
|
||||
/>
|
||||
<Input
|
||||
id="productNumber"
|
||||
label="Číslo produktu"
|
||||
{...register('productNumber')}
|
||||
/>
|
||||
<Input
|
||||
id="serialNumber"
|
||||
label="Sériové číslo"
|
||||
{...register('serialNumber')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="accessories"
|
||||
label="Príslušenstvo"
|
||||
{...register('accessories')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="issueDescription"
|
||||
label="Popis problému *"
|
||||
rows={3}
|
||||
error={errors.issueDescription?.message}
|
||||
{...register('issueDescription')}
|
||||
/>
|
||||
|
||||
<hr className="my-4" />
|
||||
<h3 className="font-medium">Stav a spracovanie</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
id="statusId"
|
||||
label="Stav *"
|
||||
options={statusOptions}
|
||||
error={errors.statusId?.message}
|
||||
{...register('statusId')}
|
||||
/>
|
||||
<Select
|
||||
id="proposedSolutionId"
|
||||
label="Navrhované riešenie"
|
||||
options={[{ value: '', label: '-- Žiadne --' }, ...solutionOptions]}
|
||||
{...register('proposedSolutionId')}
|
||||
/>
|
||||
<Select
|
||||
id="assignedToId"
|
||||
label="Priradené"
|
||||
options={[{ value: '', label: '-- Nepriradené --' }, ...userOptions]}
|
||||
{...register('assignedToId')}
|
||||
/>
|
||||
<Input
|
||||
id="receivedDate"
|
||||
type="date"
|
||||
label="Dátum prijatia"
|
||||
{...register('receivedDate')}
|
||||
/>
|
||||
<Input
|
||||
id="receivedLocation"
|
||||
label="Miesto prijatia"
|
||||
{...register('receivedLocation')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register('requiresApproval')} className="rounded" />
|
||||
<span className="text-sm">Vyžaduje schválenie</span>
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
id="internalNotes"
|
||||
label="Interné poznámky"
|
||||
rows={2}
|
||||
{...register('internalNotes')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="resolutionNotes"
|
||||
label="Poznámky k riešeniu"
|
||||
rows={2}
|
||||
{...register('resolutionNotes')}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
167
frontend/src/pages/rma/RMAList.tsx
Normal file
167
frontend/src/pages/rma/RMAList.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { rmaApi } from '@/services/rma.api';
|
||||
import type { RMA } from '@/types';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import { RMAForm } from './RMAForm';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function RMAList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingRMA, setEditingRMA] = useState<RMA | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<RMA | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['rma', search],
|
||||
queryFn: () => rmaApi.getAll({ search, limit: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => rmaApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||
toast.success('RMA bolo vymazané');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní RMA');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (rma: RMA) => {
|
||||
setEditingRMA(rma);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingRMA(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">RMA</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nové RMA
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Hľadať RMA..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Číslo RMA</TableHead>
|
||||
<TableHead>Zákazník</TableHead>
|
||||
<TableHead>Produkt</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead>Prijaté</TableHead>
|
||||
<TableHead>Priradené</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((rma) => (
|
||||
<TableRow key={rma.id}>
|
||||
<TableCell className="font-medium">{rma.rmaNumber}</TableCell>
|
||||
<TableCell>{rma.customer?.name || rma.customerName || '-'}</TableCell>
|
||||
<TableCell>{rma.productName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={rma.status.color}>{rma.status.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rma.receivedDate ? formatDate(rma.receivedDate) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{rma.assignedTo?.name || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(rma)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(rma)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
Žiadne RMA
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingRMA ? 'Upraviť RMA' : 'Nové RMA'}
|
||||
size="xl"
|
||||
>
|
||||
<RMAForm rma={editingRMA} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať RMA "{deleteConfirm?.rmaNumber}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/rma/index.ts
Normal file
1
frontend/src/pages/rma/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RMAList } from './RMAList';
|
||||
332
frontend/src/pages/settings/SettingsDashboard.tsx
Normal file
332
frontend/src/pages/settings/SettingsDashboard.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type ConfigTab = 'taskStatuses' | 'priorities' | 'equipmentTypes' | 'revisionTypes' | 'rmaStatuses' | 'rmaSolutions' | 'userRoles';
|
||||
|
||||
// Spoločný interface pre konfiguračné entity
|
||||
interface ConfigItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const tabs: { key: ConfigTab; label: string }[] = [
|
||||
{ key: 'taskStatuses', label: 'Stavy úloh' },
|
||||
{ key: 'priorities', label: 'Priority' },
|
||||
{ key: 'equipmentTypes', label: 'Typy zariadení' },
|
||||
{ key: 'revisionTypes', label: 'Typy revízií' },
|
||||
{ key: 'rmaStatuses', label: 'RMA stavy' },
|
||||
{ key: 'rmaSolutions', label: 'RMA riešenia' },
|
||||
{ key: 'userRoles', label: 'Užívateľské role' },
|
||||
];
|
||||
|
||||
export function SettingsDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>('taskStatuses');
|
||||
const [editItem, setEditItem] = useState<ConfigItem | null>(null);
|
||||
const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const { data: taskStatuses, isLoading: loadingStatuses } = useQuery({
|
||||
queryKey: ['task-statuses'],
|
||||
queryFn: () => settingsApi.getTaskStatuses(),
|
||||
enabled: activeTab === 'taskStatuses',
|
||||
});
|
||||
|
||||
const { data: priorities, isLoading: loadingPriorities } = useQuery({
|
||||
queryKey: ['priorities'],
|
||||
queryFn: () => settingsApi.getPriorities(),
|
||||
enabled: activeTab === 'priorities',
|
||||
});
|
||||
|
||||
const { data: equipmentTypes, isLoading: loadingEquipmentTypes } = useQuery({
|
||||
queryKey: ['equipment-types'],
|
||||
queryFn: () => settingsApi.getEquipmentTypes(),
|
||||
enabled: activeTab === 'equipmentTypes',
|
||||
});
|
||||
|
||||
const { data: revisionTypes, isLoading: loadingRevisionTypes } = useQuery({
|
||||
queryKey: ['revision-types'],
|
||||
queryFn: () => settingsApi.getRevisionTypes(),
|
||||
enabled: activeTab === 'revisionTypes',
|
||||
});
|
||||
|
||||
const { data: rmaStatuses, isLoading: loadingRmaStatuses } = useQuery({
|
||||
queryKey: ['rma-statuses'],
|
||||
queryFn: () => settingsApi.getRMAStatuses(),
|
||||
enabled: activeTab === 'rmaStatuses',
|
||||
});
|
||||
|
||||
const { data: rmaSolutions, isLoading: loadingRmaSolutions } = useQuery({
|
||||
queryKey: ['rma-solutions'],
|
||||
queryFn: () => settingsApi.getRMASolutions(),
|
||||
enabled: activeTab === 'rmaSolutions',
|
||||
});
|
||||
|
||||
const { data: userRoles, isLoading: loadingUserRoles } = useQuery({
|
||||
queryKey: ['user-roles'],
|
||||
queryFn: () => settingsApi.getUserRoles(),
|
||||
enabled: activeTab === 'userRoles',
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => {
|
||||
switch (tab) {
|
||||
case 'taskStatuses': return settingsApi.deleteTaskStatus(id);
|
||||
case 'priorities': return settingsApi.deletePriority(id);
|
||||
case 'equipmentTypes': return settingsApi.deleteEquipmentType(id);
|
||||
case 'revisionTypes': return settingsApi.deleteRevisionType(id);
|
||||
case 'rmaStatuses': return settingsApi.deleteRMAStatus(id);
|
||||
case 'rmaSolutions': return settingsApi.deleteRMASolution(id);
|
||||
case 'userRoles': return settingsApi.deleteUserRole(id);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries();
|
||||
toast.success('Položka bola vymazaná');
|
||||
setDeleteItem(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní položky');
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes ||
|
||||
loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles;
|
||||
|
||||
const getCurrentData = (): ConfigItem[] => {
|
||||
switch (activeTab) {
|
||||
case 'taskStatuses': return (taskStatuses?.data || []) as ConfigItem[];
|
||||
case 'priorities': return (priorities?.data || []) as ConfigItem[];
|
||||
case 'equipmentTypes': return (equipmentTypes?.data || []) as ConfigItem[];
|
||||
case 'revisionTypes': return (revisionTypes?.data || []) as ConfigItem[];
|
||||
case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[];
|
||||
case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[];
|
||||
case 'userRoles': return (userRoles?.data || []) as ConfigItem[];
|
||||
}
|
||||
};
|
||||
|
||||
const data: ConfigItem[] = getCurrentData();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Nastavenia</h1>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant={activeTab === tab.key ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{tabs.find(t => t.key === activeTab)?.label}</CardTitle>
|
||||
<Button size="sm" onClick={() => setEditItem({ id: '', code: '', name: '' })}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Pridať
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kód</TableHead>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Farba</TableHead>
|
||||
<TableHead>Poradie</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
{item.color && (
|
||||
<Badge color={item.color}>{item.color}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.order ?? 0}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditItem(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteItem({ id: item.id, name: item.name })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Žiadne položky
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={!!editItem}
|
||||
onClose={() => setEditItem(null)}
|
||||
title={editItem?.id ? 'Upraviť položku' : 'Nová položka'}
|
||||
>
|
||||
<ConfigItemForm
|
||||
item={editItem}
|
||||
tab={activeTab}
|
||||
onClose={() => setEditItem(null)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteItem}
|
||||
onClose={() => setDeleteItem(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať položku "{deleteItem?.name}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteItem(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteItem && deleteMutation.mutate({ tab: activeTab, id: deleteItem.id })}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfigItemFormProps {
|
||||
item: ConfigItem | null;
|
||||
tab: ConfigTab;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConfigItemForm({ item, tab, onClose }: ConfigItemFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = item?.id ? true : false;
|
||||
const [formData, setFormData] = useState({
|
||||
code: item?.code || '',
|
||||
name: item?.name || '',
|
||||
color: item?.color || '',
|
||||
order: item?.order || 0,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = { ...formData, order: Number(formData.order) };
|
||||
if (isEditing && item?.id) {
|
||||
switch (tab) {
|
||||
case 'taskStatuses': return settingsApi.updateTaskStatus(item.id, data);
|
||||
case 'priorities': return settingsApi.updatePriority(item.id, data);
|
||||
case 'equipmentTypes': return settingsApi.updateEquipmentType(item.id, data);
|
||||
case 'revisionTypes': return settingsApi.updateRevisionType(item.id, { ...data, intervalDays: 365, reminderDays: 30 });
|
||||
case 'rmaStatuses': return settingsApi.updateRMAStatus(item.id, data);
|
||||
case 'rmaSolutions': return settingsApi.updateRMASolution(item.id, data);
|
||||
case 'userRoles': return settingsApi.updateUserRole(item.id, { ...data, permissions: {}, level: 0 });
|
||||
}
|
||||
} else {
|
||||
switch (tab) {
|
||||
case 'taskStatuses': return settingsApi.createTaskStatus({ ...data, isInitial: false, isFinal: false });
|
||||
case 'priorities': return settingsApi.createPriority({ ...data, level: 0 });
|
||||
case 'equipmentTypes': return settingsApi.createEquipmentType(data);
|
||||
case 'revisionTypes': return settingsApi.createRevisionType({ ...data, intervalDays: 365, reminderDays: 30 });
|
||||
case 'rmaStatuses': return settingsApi.createRMAStatus({ ...data, isInitial: false, isFinal: false });
|
||||
case 'rmaSolutions': return settingsApi.createRMASolution(data);
|
||||
case 'userRoles': return settingsApi.createUserRole({ ...data, permissions: {}, level: 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries();
|
||||
toast.success(isEditing ? 'Položka bola aktualizovaná' : 'Položka bola vytvorená');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri ukladaní položky');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }} className="space-y-4">
|
||||
<Input
|
||||
label="Kód *"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Názov *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Farba"
|
||||
type="color"
|
||||
value={formData.color || '#3b82f6'}
|
||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Poradie"
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={mutation.isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/settings/index.ts
Normal file
1
frontend/src/pages/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsDashboard } from './SettingsDashboard';
|
||||
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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/pages/tasks/TaskForm.tsx
Normal file
204
frontend/src/pages/tasks/TaskForm.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { tasksApi, type CreateTaskData } from '@/services/tasks.api';
|
||||
import { projectsApi } from '@/services/projects.api';
|
||||
import { settingsApi } from '@/services/settings.api'; // Pre statusy a priority
|
||||
import type { Task } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter, UserSelect } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, 'Názov je povinný'),
|
||||
description: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
statusId: z.string().optional(),
|
||||
priorityId: z.string().optional(),
|
||||
deadline: z.string().optional(),
|
||||
});
|
||||
|
||||
type TaskFormData = z.infer<typeof taskSchema>;
|
||||
|
||||
interface TaskFormProps {
|
||||
task: Task | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!task;
|
||||
|
||||
// State pre vybraných používateľov
|
||||
const [selectedAssignees, setSelectedAssignees] = useState<string[]>(
|
||||
task?.assignees?.map((a) => a.userId) || []
|
||||
);
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ['projects-select'],
|
||||
queryFn: () => projectsApi.getAll({ limit: 1000 }),
|
||||
});
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['task-statuses'],
|
||||
queryFn: () => settingsApi.getTaskStatuses(),
|
||||
});
|
||||
|
||||
const { data: prioritiesData } = useQuery({
|
||||
queryKey: ['priorities'],
|
||||
queryFn: () => settingsApi.getPriorities(),
|
||||
});
|
||||
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
defaultValues: task
|
||||
? {
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
projectId: task.projectId || '',
|
||||
statusId: task.statusId,
|
||||
priorityId: task.priorityId,
|
||||
deadline: task.deadline?.split('T')[0] || '',
|
||||
}
|
||||
: {
|
||||
title: '',
|
||||
description: '',
|
||||
projectId: '',
|
||||
statusId: '',
|
||||
priorityId: '',
|
||||
deadline: '',
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTaskData) => tasksApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
toast.success('Úloha bola vytvorená');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Create task error:', error);
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní úlohy';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CreateTaskData) => tasksApi.update(task!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
toast.success('Úloha bola aktualizovaná');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Update task error:', error);
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii úlohy';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: TaskFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
projectId: data.projectId || undefined,
|
||||
statusId: data.statusId || undefined,
|
||||
priorityId: data.priorityId || undefined,
|
||||
deadline: data.deadline || undefined,
|
||||
// Pre create: undefined ak prázdne (backend priradí default)
|
||||
// Pre update: vždy poslať pole (aj prázdne) aby sa aktualizovali assignees
|
||||
assigneeIds: isEditing ? selectedAssignees : (selectedAssignees.length > 0 ? selectedAssignees : undefined),
|
||||
};
|
||||
|
||||
console.log('Submitting task data:', cleanData);
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const projectOptions = projectsData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||
const priorityOptions = prioritiesData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||
|
||||
// Pripraviť počiatočných používateľov pre editáciu (už priradení)
|
||||
const initialAssignees = task?.assignees?.map((a) => ({
|
||||
id: a.userId,
|
||||
name: a.user?.name || '',
|
||||
email: a.user?.email || '',
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Input
|
||||
id="title"
|
||||
label="Názov *"
|
||||
error={errors.title?.message}
|
||||
{...register('title')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="description"
|
||||
label="Popis"
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
id="projectId"
|
||||
label="Projekt"
|
||||
options={[{ value: '', label: '-- Bez projektu --' }, ...projectOptions]}
|
||||
{...register('projectId')}
|
||||
/>
|
||||
<Select
|
||||
id="statusId"
|
||||
label="Stav"
|
||||
options={[{ value: '', label: '-- Predvolený --' }, ...statusOptions]}
|
||||
{...register('statusId')}
|
||||
/>
|
||||
<Select
|
||||
id="priorityId"
|
||||
label="Priorita"
|
||||
options={[{ value: '', label: '-- Predvolená --' }, ...priorityOptions]}
|
||||
{...register('priorityId')}
|
||||
/>
|
||||
<Input
|
||||
id="deadline"
|
||||
type="date"
|
||||
label="Termín"
|
||||
{...register('deadline')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserSelect
|
||||
label="Priradiť na"
|
||||
selectedIds={selectedAssignees}
|
||||
onChange={setSelectedAssignees}
|
||||
initialUsers={initialAssignees}
|
||||
placeholder="Vyhľadať používateľa..."
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
196
frontend/src/pages/tasks/TasksList.tsx
Normal file
196
frontend/src/pages/tasks/TasksList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search, MessageSquare } from 'lucide-react';
|
||||
import { tasksApi } from '@/services/tasks.api';
|
||||
import type { Task } from '@/types';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
} from '@/components/ui';
|
||||
import { TaskForm } from './TaskForm';
|
||||
import { TaskDetail } from './TaskDetail';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function TasksList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Task | null>(null);
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['tasks', search],
|
||||
queryFn: () => tasksApi.getAll({ search, limit: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => tasksApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
toast.success('Úloha bola vymazaná');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní úlohy');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (task: Task) => {
|
||||
setEditingTask(task);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingTask(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Úlohy</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nová úloha
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Hľadať úlohy..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Projekt</TableHead>
|
||||
<TableHead>Zadal</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead>Priorita</TableHead>
|
||||
<TableHead>Termín</TableHead>
|
||||
<TableHead>Priradení</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">
|
||||
<button
|
||||
onClick={() => setDetailTaskId(task.id)}
|
||||
className="text-left hover:text-primary hover:underline"
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{task.project?.name || '-'}</TableCell>
|
||||
<TableCell>{task.createdBy?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={task.status.color}>{task.status.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={task.priority.color}>{task.priority.name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{task.deadline ? formatDate(task.deadline) : '-'}</TableCell>
|
||||
<TableCell>
|
||||
{task.assignees.length > 0
|
||||
? task.assignees.map((a) => a.user.name).join(', ')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setDetailTaskId(task.id)} title="Detail">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
Žiadne úlohy
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingTask ? 'Upraviť úlohu' : 'Nová úloha'}
|
||||
size="lg"
|
||||
>
|
||||
<TaskForm task={editingTask} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať úlohu "{deleteConfirm?.title}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{detailTaskId && (
|
||||
<TaskDetail
|
||||
taskId={detailTaskId}
|
||||
onClose={() => setDetailTaskId(null)}
|
||||
onEdit={(task) => {
|
||||
setDetailTaskId(null);
|
||||
handleEdit(task);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/pages/tasks/index.ts
Normal file
1
frontend/src/pages/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TasksList } from './TasksList';
|
||||
Reference in New Issue
Block a user