Revízny systém - kompletná implementácia

- Backend: CRUD revízií, schedule endpoint (agregovaný plán), skip revízia, stats
- Shared utility revisionSchedule.ts - centralizovaná logika výpočtu cyklov
- Equipment detail s revíznym plánom, históriou a prílohami
- Frontend: RevisionsList s tabmi (nadchádzajúce/po termíne/vykonané/preskočené)
- Pozičné labeling cyklov (eliminuje drift 4×90≠365)
- EquipmentRevisionSchedule model (many-to-many typy revízií)
- Aktualizovaná dokumentácia HELPDESK_INIT_V2.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 21:59:23 +01:00
parent 2ca0c4f4d8
commit da265ff097
28 changed files with 4587 additions and 149 deletions

View File

@@ -16,6 +16,7 @@ import { TasksList } from '@/pages/tasks';
import { EquipmentList } from '@/pages/equipment';
import { RMAList } from '@/pages/rma';
import { SettingsDashboard } from '@/pages/settings';
import { RevisionsList } from '@/pages/revisions';
const queryClient = new QueryClient({
defaultOptions: {
@@ -87,6 +88,7 @@ function AppRoutes() {
<Route path="/projects" element={<ProjectsList />} />
<Route path="/tasks" element={<TasksList />} />
<Route path="/equipment" element={<EquipmentList />} />
<Route path="/revisions" element={<RevisionsList />} />
<Route path="/rma" element={<RMAList />} />
<Route
path="/settings"

View File

@@ -5,6 +5,7 @@ import {
CheckSquare,
Users,
Wrench,
ClipboardCheck,
RotateCcw,
X,
} from 'lucide-react';
@@ -17,6 +18,7 @@ const navItems = [
{ to: '/projects', icon: FolderKanban, label: 'Zákazky' },
{ to: '/customers', icon: Users, label: 'Zákazníci' },
{ to: '/equipment', icon: Wrench, label: 'Zariadenia' },
{ to: '/revisions', icon: ClipboardCheck, label: 'Revízie' },
{ to: '/rma', icon: RotateCcw, label: 'RMA' },
];

View File

@@ -10,7 +10,7 @@ interface ModalProps {
title?: string;
children: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
}
export function Modal({ isOpen, onClose, title, children, className, size = 'md' }: ModalProps) {
@@ -37,6 +37,10 @@ export function Modal({ isOpen, onClose, title, children, className, size = 'md'
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
};
return createPortal(
@@ -44,7 +48,7 @@ export function Modal({ isOpen, onClose, title, children, className, size = 'md'
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div
className={cn(
'relative z-50 w-full rounded-lg bg-background p-6 shadow-lg',
'relative z-50 w-full rounded-lg bg-background p-6 shadow-lg max-h-[90vh] overflow-y-auto',
sizes[size],
className
)}

View File

@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query';
import { settingsApi } from '@/services/settings.api';
import { AlertTriangle, Clock, CheckCircle, SkipForward, type LucideIcon } from 'lucide-react';
export interface RevisionThreshold {
days: number;
label: string;
color: string;
}
const DEFAULT_THRESHOLDS: RevisionThreshold[] = [
{ days: 30, label: 'Blíži sa', color: '#EAB308' },
{ days: 14, label: 'Blíži sa!', color: '#F97316' },
{ days: 7, label: 'Urgentné!', color: '#EF4444' },
];
const OVERDUE_COLOR = '#DC2626';
const OK_COLOR = '#22C55E';
const SKIP_COLOR = '#9CA3AF';
export type RevisionContext = 'schedule' | 'performed' | 'skipped';
export interface RevisionStatusResult {
label: string;
color: string | null;
icon: LucideIcon | null;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
}
export function useRevisionStatus() {
const { data: settingData } = useQuery({
queryKey: ['system-setting', 'REVISION_STATUS_THRESHOLDS'],
queryFn: () => settingsApi.getSystemSetting('REVISION_STATUS_THRESHOLDS'),
staleTime: 5 * 60 * 1000,
});
const thresholds: RevisionThreshold[] = Array.isArray(settingData?.data?.value)
? (settingData.data.value as RevisionThreshold[])
: DEFAULT_THRESHOLDS;
// Zoradiť vzostupne podľa dní
const sorted = [...thresholds].sort((a, b) => a.days - b.days);
function getStatus(nextDueDate?: string | null, context?: RevisionContext | boolean): RevisionStatusResult {
// Spätná kompatibilita: boolean → context
let ctx: RevisionContext;
if (typeof context === 'boolean') {
ctx = context ? 'schedule' : 'performed';
} else {
ctx = context || 'schedule';
}
if (ctx === 'performed') {
return { label: 'Vykonaná', color: OK_COLOR, icon: CheckCircle, variant: 'secondary' };
}
if (ctx === 'skipped') {
return { label: 'Vynechaná', color: SKIP_COLOR, icon: SkipForward, variant: 'secondary' };
}
// context === 'schedule' → countdown logika
if (!nextDueDate) {
return { label: '-', color: null, icon: null, variant: 'secondary' };
}
const now = new Date();
const due = new Date(nextDueDate);
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Po termíne
if (diffDays < 0) {
return {
label: `Po termíne (${Math.abs(diffDays)} dní)`,
color: OVERDUE_COLOR,
icon: AlertTriangle,
variant: 'destructive',
};
}
// Nájsť prvý prah kde diffDays <= threshold.days
for (const threshold of sorted) {
if (diffDays <= threshold.days) {
return {
label: `${threshold.label} (${diffDays} dní)`,
color: threshold.color,
icon: Clock,
variant: 'default',
};
}
}
// V poriadku - ďaleko od termínu
return {
label: `Za ${diffDays} dní`,
color: OK_COLOR,
icon: CheckCircle,
variant: 'default',
};
}
return { getStatus, thresholds };
}

View File

@@ -31,6 +31,7 @@ import type { Task } from '@/types';
interface DashboardToday {
myTasks: Task[];
myTasksTotal: number;
}
// Ikona podľa typu notifikácie
@@ -134,7 +135,7 @@ export function Dashboard() {
}, {} as Record<string, Task[]>);
// Štatistiky
const totalTasks = today?.myTasks?.length || 0;
const totalTasks = today?.myTasksTotal ?? today?.myTasks?.length ?? 0;
const overdueTasks = today?.myTasks?.filter(t => t.deadline && new Date(t.deadline) < new Date()) || [];
const todayTasks = today?.myTasks?.filter(t => {
if (!t.deadline) return false;

View File

@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Calendar, MapPin, Building2, Wrench, Plus, CalendarClock, ClipboardCheck, SkipForward, ChevronDown, ChevronRight, Pencil } from 'lucide-react';
import { equipmentApi } from '@/services/equipment.api';
import type { Equipment, Revision } from '@/types';
import {
Badge,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
LoadingOverlay,
Button,
} from '@/components/ui';
import { useRevisionStatus } from '@/hooks/useRevisionStatus';
import { formatDate } from '@/lib/utils';
interface EquipmentDetailProps {
equipment: Equipment;
onNewRevision?: (typeId?: string) => void;
onSkipRevision?: (date: string, typeId: string) => void;
onEditRevision?: (revision: Revision) => void;
}
export function EquipmentDetail({ equipment, onNewRevision, onSkipRevision, onEditRevision }: EquipmentDetailProps) {
const { getStatus } = useRevisionStatus();
const [expandedRevision, setExpandedRevision] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['equipment-detail', equipment.id],
queryFn: () => equipmentApi.getById(equipment.id),
});
const detail = data?.data;
const revisions: Revision[] = (detail as Record<string, unknown>)?.revisions as Revision[] || [];
const revisionSchedules = (detail as Record<string, unknown>)?.revisionSchedules as Array<{
revisionType: { id: string; name: string; color?: string; intervalDays: number };
}> || [];
// Načítať schedule ak sú priradené typy revízií
const { data: scheduleData } = useQuery({
queryKey: ['equipment-schedule', equipment.id],
queryFn: () => equipmentApi.getSchedule(equipment.id),
enabled: revisionSchedules.length > 0,
});
const schedule = scheduleData?.data;
const hasSchedules = revisionSchedules.length > 0;
const formatInterval = (days: number) => {
if (days >= 365) return `${Math.round(days / 365)} rok`;
if (days >= 90) return `štvrťročná`;
if (days >= 30) return `mesačná`;
if (days >= 14) return `${Math.round(days / 7)} týždne`;
return `${days} dní`;
};
return (
<div className="space-y-6">
{/* Info o zariadení */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Wrench className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Typ:</span>
<Badge color={equipment.type.color}>{equipment.type.name}</Badge>
</div>
{equipment.customer && (
<div className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Zákazník:</span>
<span className="font-medium">{equipment.customer.name}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Adresa:</span>
<span>{equipment.address}</span>
</div>
{equipment.installDate && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Dátum inštalácie:</span>
<span>{formatDate(equipment.installDate)}</span>
</div>
)}
{equipment.revisionCycleStart && (
<div className="flex items-center gap-2 text-sm">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Východzia revízia:</span>
<span>{formatDate(equipment.revisionCycleStart)}</span>
</div>
)}
</div>
<div className="space-y-3">
{equipment.serialNumber && (
<div className="text-sm">
<span className="text-muted-foreground">Sériové číslo: </span>
<span className="font-mono">{equipment.serialNumber}</span>
</div>
)}
{equipment.brand && (
<div className="text-sm">
<span className="text-muted-foreground">Značka: </span>
<span>{equipment.brand}</span>
</div>
)}
{equipment.description && (
<div className="text-sm">
<span className="text-muted-foreground">Popis: </span>
<span>{equipment.description}</span>
</div>
)}
{revisionSchedules.length > 0 && (
<div className="text-sm">
<span className="text-muted-foreground">Revízne typy: </span>
<span className="flex flex-wrap gap-1 mt-1">
{revisionSchedules.map((s) => (
<Badge key={s.revisionType.id} color={s.revisionType.color}>
{s.revisionType.name} ({formatInterval(s.revisionType.intervalDays)})
</Badge>
))}
</span>
</div>
)}
</div>
</div>
{/* Revízny plán - nadchádzajúce termíny */}
{schedule && schedule.upcomingDates.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">
Revízny plán
</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{schedule.upcomingDates.slice(0, 12).map((item, idx) => {
const isComposite = item.revisionTypes.length > 1;
const dominant = isComposite
? item.revisionTypes.reduce((a, b) => a.intervalDays >= b.intervalDays ? a : b)
: item.revisionTypes[0];
const daysUntil = Math.ceil(
(new Date(item.date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const status = getStatus(item.date, true);
const StatusIcon = status.icon;
return (
<div
key={idx}
className={`rounded-lg border p-3 ${
isComposite ? 'border-primary/50 bg-primary/5' : ''
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{formatDate(item.date)}</span>
<div className="flex items-center gap-1">
{StatusIcon && <StatusIcon className="h-3.5 w-3.5" style={{ color: status.color || undefined }} />}
<span className="text-xs" style={{ color: status.color || undefined }}>
{daysUntil} dní
</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color={dominant?.color} className="text-xs">
{item.label}
</Badge>
</div>
<div className="flex items-center gap-0.5">
{onNewRevision && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => onNewRevision(dominant?.id)}
title="Vykonať revíziu"
>
<ClipboardCheck className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
{onSkipRevision && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => onSkipRevision(item.date, dominant?.id || '')}
title="Vynechať revíziu"
>
<SkipForward className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Revízie */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">História revízií ({revisions.length})</h3>
{onNewRevision && (
<Button size="sm" onClick={() => onNewRevision()}>
<Plus className="mr-1 h-3.5 w-3.5" />
Pridať záznam
</Button>
)}
</div>
{isLoading ? (
<LoadingOverlay />
) : revisions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
Pre toto zariadenie zatiaľ neboli vykonané žiadne revízie.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Typ revízie</TableHead>
<TableHead>Vykonaná</TableHead>
<TableHead>Nasledujúci termín</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Výsledok</TableHead>
<TableHead>Vykonal</TableHead>
<TableHead>Poznámka</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.map((revision) => {
const statusContext = revision.status === 'skipped' ? 'skipped' as const : 'performed' as const;
const status = getStatus(revision.nextDueDate, statusContext);
const StatusIcon = status.icon;
const isExpanded = expandedRevision === revision.id;
const resultText = revision.status === 'skipped' ? (revision.skipReason || '-') : (revision.result || '-');
const notesText = revision.notes || '-';
return (
<React.Fragment key={revision.id}>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedRevision(isExpanded ? null : revision.id)}
>
<TableCell className="whitespace-nowrap">
<div className="flex items-center gap-1">
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
}
<Badge color={revision.type.color}>{revision.type.name}</Badge>
</div>
</TableCell>
<TableCell className="whitespace-nowrap font-medium">{formatDate(revision.performedDate)}</TableCell>
<TableCell className="whitespace-nowrap">
{revision.nextDueDate ? formatDate(revision.nextDueDate) : '-'}
</TableCell>
<TableCell className="whitespace-nowrap">
<div className="flex items-center gap-1">
{StatusIcon && <StatusIcon className="h-3.5 w-3.5" style={{ color: status.color || undefined }} />}
<Badge color={status.color || undefined} variant={status.variant}>{status.label}</Badge>
</div>
</TableCell>
<TableCell className="max-w-[150px] truncate">{resultText}</TableCell>
<TableCell className="whitespace-nowrap">{revision.performedBy?.name || '-'}</TableCell>
<TableCell className="max-w-[200px] truncate">{notesText}</TableCell>
</TableRow>
{isExpanded && (
<TableRow>
<TableCell colSpan={7} className="bg-muted/30 py-3 px-6">
<div className="flex items-start justify-between gap-4">
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm flex-1">
<div>
<span className="font-medium text-muted-foreground">Výsledok:</span>
<p className="mt-0.5 whitespace-pre-wrap">{resultText}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Zistenia:</span>
<p className="mt-0.5 whitespace-pre-wrap">{revision.findings || '-'}</p>
</div>
<div className="col-span-2">
<span className="font-medium text-muted-foreground">Poznámka:</span>
<p className="mt-0.5 whitespace-pre-wrap">{notesText}</p>
</div>
{revision.status === 'skipped' && revision.skipReason && (
<div className="col-span-2">
<span className="font-medium text-muted-foreground">Dôvod vynechania:</span>
<p className="mt-0.5 whitespace-pre-wrap">{revision.skipReason}</p>
</div>
)}
</div>
{onEditRevision && (
<Button
size="sm"
variant="outline"
onClick={(e) => { e.stopPropagation(); onEditRevision(revision); }}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
Upraviť
</Button>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { customersApi } from '@/services/customers.api';
import { settingsApi } from '@/services/settings.api';
import { getFiles, generateTempId } from '@/services/upload.api';
import type { Equipment, Attachment } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload } from '@/components/ui';
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload, Badge } from '@/components/ui';
import toast from 'react-hot-toast';
const equipmentSchema = z.object({
@@ -22,6 +22,7 @@ const equipmentSchema = z.object({
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
installDate: z.string().optional(),
revisionCycleStart: z.string().optional(),
warrantyEnd: z.string().optional(),
warrantyStatus: z.string().optional(),
description: z.string().optional(),
@@ -40,6 +41,9 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
const queryClient = useQueryClient();
const isEditing = !!equipment;
const [files, setFiles] = useState<Attachment[]>([]);
const [selectedRevisionTypeIds, setSelectedRevisionTypeIds] = useState<string[]>(
() => equipment?.revisionSchedules?.map((s) => s.revisionType.id) || []
);
// Generate stable tempId for new equipment file uploads
const tempId = useMemo(() => generateTempId(), []);
@@ -54,6 +58,11 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
queryFn: () => settingsApi.getEquipmentTypes(),
});
const { data: revisionTypesData } = useQuery({
queryKey: ['revision-types'],
queryFn: () => settingsApi.getRevisionTypes(),
});
// Load files when editing
useQuery({
queryKey: ['equipment-files', equipment?.id],
@@ -84,6 +93,7 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
partNumber: equipment.partNumber || '',
serialNumber: equipment.serialNumber || '',
installDate: equipment.installDate?.split('T')[0] || '',
revisionCycleStart: equipment.revisionCycleStart?.split('T')[0] || '',
warrantyEnd: equipment.warrantyEnd?.split('T')[0] || '',
warrantyStatus: equipment.warrantyStatus || '',
description: equipment.description || '',
@@ -109,6 +119,7 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
mutationFn: (data: CreateEquipmentData) => equipmentApi.update(equipment!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
queryClient.invalidateQueries({ queryKey: ['equipment-detail'] });
toast.success('Zariadenie bolo aktualizované');
onClose();
},
@@ -118,11 +129,13 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
});
const onSubmit = (data: EquipmentFormData) => {
const cleanData = {
const cleanData: CreateEquipmentData = {
...data,
customerId: data.customerId || undefined,
installDate: data.installDate || undefined,
revisionCycleStart: data.revisionCycleStart || undefined,
warrantyEnd: data.warrantyEnd || undefined,
revisionTypeIds: selectedRevisionTypeIds,
};
if (isEditing) {
@@ -133,10 +146,24 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
}
};
const toggleRevisionType = (id: string) => {
setSelectedRevisionTypeIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
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 })) || [];
const revisionTypes = revisionTypesData?.data?.filter((rt) => rt.active !== false) || [];
const formatInterval = (days: number) => {
if (days >= 365) return `${Math.round(days / 365)} rok`;
if (days >= 90) return `${Math.round(days / 90)} štvrťrok`;
if (days >= 30) return `${Math.round(days / 30)} mes.`;
return `${days} dní`;
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -196,13 +223,59 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
{...register('location')}
/>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2">
<Input
id="installDate"
type="date"
label="Dátum inštalácie"
{...register('installDate')}
/>
<Input
id="revisionCycleStart"
type="date"
label="Východzia revízia"
{...register('revisionCycleStart')}
/>
</div>
{revisionTypes.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Typy revízií
</label>
<p className="text-xs text-muted-foreground mb-2">
Ak nezadáte dátum východzej revízie, použije sa dátum inštalácie.
</p>
<div className="flex flex-wrap gap-2">
{revisionTypes.map((rt) => {
const isSelected = selectedRevisionTypeIds.includes(rt.id);
return (
<button
key={rt.id}
type="button"
onClick={() => toggleRevisionType(rt.id)}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
{rt.color && (
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: rt.color }}
/>
)}
{rt.name}
<span className="text-xs opacity-70">({formatInterval(rt.intervalDays)})</span>
</button>
);
})}
</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
id="warrantyEnd"
type="date"

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Plus, Pencil, Trash2, Search, Eye, Power, SkipForward } from 'lucide-react';
import { equipmentApi } from '@/services/equipment.api';
import { revisionsApi, type RevisionWithEquipment } from '@/services/revisions.api';
import { settingsApi } from '@/services/settings.api';
import type { Equipment } from '@/types';
import {
Button,
@@ -19,35 +21,96 @@ import {
LoadingOverlay,
Modal,
ModalFooter,
Select,
Textarea,
} from '@/components/ui';
import { EquipmentForm } from './EquipmentForm';
import { EquipmentDetail } from './EquipmentDetail';
import { RevisionForm } from '../revisions/RevisionForm';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function EquipmentList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [activeFilter, setActiveFilter] = useState('true');
const [typeFilter, setTypeFilter] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingEquipment, setEditingEquipment] = useState<Equipment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Equipment | null>(null);
const [detailEquipment, setDetailEquipment] = useState<Equipment | null>(null);
const [revisionForEquipmentId, setRevisionForEquipmentId] = useState<string | null>(null);
const [revisionForTypeId, setRevisionForTypeId] = useState<string | undefined>(undefined);
const [editingRevision, setEditingRevision] = useState<RevisionWithEquipment | null>(null);
const [skipData, setSkipData] = useState<{ equipmentId: string; typeId: string; date: string } | null>(null);
const [skipReason, setSkipReason] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['equipment', search],
queryFn: () => equipmentApi.getAll({ search, limit: 100 }),
const { data: equipmentTypesData } = useQuery({
queryKey: ['equipment-types'],
queryFn: () => settingsApi.getEquipmentTypes(),
});
const deleteMutation = useMutation({
const { data, isLoading } = useQuery({
queryKey: ['equipment', search, activeFilter, typeFilter],
queryFn: () => equipmentApi.getAll({
search,
active: activeFilter ? (activeFilter === 'true') : undefined,
typeId: typeFilter || undefined,
limit: 100,
}),
});
const deactivateMutation = useMutation({
mutationFn: (id: string) => equipmentApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Zariadenie bolo vymazané');
toast.success('Zariadenie bolo deaktivované');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní zariadenia');
toast.error('Chyba pri deaktivácii zariadenia');
},
});
const activateMutation = useMutation({
mutationFn: (id: string) => equipmentApi.update(id, { active: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Zariadenie bolo aktivované');
},
onError: () => {
toast.error('Chyba pri aktivácii zariadenia');
},
});
const skipMutation = useMutation({
mutationFn: (data: { equipmentId: string; typeId: string; scheduledDate: string; skipReason?: string }) =>
revisionsApi.skip(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
queryClient.invalidateQueries({ queryKey: ['equipment-detail'] });
queryClient.invalidateQueries({ queryKey: ['equipment-schedule'] });
queryClient.invalidateQueries({ queryKey: ['revisions'] });
toast.success('Revízia bola vynechaná');
setSkipData(null);
setSkipReason('');
},
onError: () => {
toast.error('Chyba pri vynechaní revízie');
},
});
const equipmentTypeOptions = [
{ value: '', label: 'Všetky typy' },
...(equipmentTypesData?.data.map((t) => ({ value: t.id, label: t.name })) || []),
];
const activeFilterOptions = [
{ value: '', label: 'Všetky' },
{ value: 'true', label: 'Aktívne' },
{ value: 'false', label: 'Neaktívne' },
];
const handleEdit = (equipment: Equipment) => {
setEditingEquipment(equipment);
setIsFormOpen(true);
@@ -70,7 +133,7 @@ export function EquipmentList() {
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<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
@@ -80,6 +143,16 @@ export function EquipmentList() {
className="pl-9"
/>
</div>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
options={equipmentTypeOptions}
/>
<Select
value={activeFilter}
onChange={(e) => setActiveFilter(e.target.value)}
options={activeFilterOptions}
/>
</div>
</CardHeader>
<CardContent>
@@ -101,7 +174,11 @@ export function EquipmentList() {
</TableHeader>
<TableBody>
{data?.data.map((equipment) => (
<TableRow key={equipment.id}>
<TableRow
key={equipment.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => setDetailEquipment(equipment)}
>
<TableCell className="font-medium">{equipment.name}</TableCell>
<TableCell>
<Badge color={equipment.type.color}>{equipment.type.name}</Badge>
@@ -118,12 +195,26 @@ export function EquipmentList() {
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(equipment)}>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setDetailEquipment(equipment); }} title="Detail">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleEdit(equipment); }} title="Upraviť">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(equipment)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
{equipment.active ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setDeleteConfirm(equipment); }} title="Deaktivovať">
<Power className="h-4 w-4 text-destructive" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); activateMutation.mutate(equipment.id); }}
title="Aktivovať"
>
<Power className="h-4 w-4 text-green-600" />
</Button>
)}
</TableCell>
</TableRow>
))}
@@ -152,22 +243,127 @@ export function EquipmentList() {
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
title="Potvrdiť deaktiváciu"
>
<p>Naozaj chcete vymazať zariadenie "{deleteConfirm?.name}"?</p>
<p>
Naozaj chcete deaktivovať zariadenie "{deleteConfirm?.name}"?
Zariadenie nebude vymazané, len sa skryje zo zoznamu aktívnych zariadení.
</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
onClick={() => deleteConfirm && deactivateMutation.mutate(deleteConfirm.id)}
isLoading={deactivateMutation.isPending}
>
Vymaz
Deaktivov
</Button>
</ModalFooter>
</Modal>
<Modal
isOpen={!!detailEquipment}
onClose={() => setDetailEquipment(null)}
title={detailEquipment?.name || 'Detail zariadenia'}
size="5xl"
>
{detailEquipment && (
<EquipmentDetail
equipment={detailEquipment}
onNewRevision={(typeId) => {
setRevisionForEquipmentId(detailEquipment.id);
setRevisionForTypeId(typeId);
setDetailEquipment(null);
}}
onSkipRevision={(date, typeId) => {
setSkipData({ equipmentId: detailEquipment.id, typeId, date });
setSkipReason('');
}}
onEditRevision={(revision) => {
setEditingRevision(revision as RevisionWithEquipment);
}}
/>
)}
</Modal>
<Modal
isOpen={!!revisionForEquipmentId}
onClose={() => { setRevisionForEquipmentId(null); setRevisionForTypeId(undefined); }}
title="Pridať záznam"
size="lg"
>
{revisionForEquipmentId && (
<RevisionForm
revision={null}
onClose={() => {
setRevisionForEquipmentId(null);
setRevisionForTypeId(undefined);
queryClient.invalidateQueries({ queryKey: ['equipment'] });
}}
preselectedEquipmentId={revisionForEquipmentId}
preselectedTypeId={revisionForTypeId}
/>
)}
</Modal>
<Modal
isOpen={!!skipData}
onClose={() => { setSkipData(null); setSkipReason(''); }}
title="Vynechať revíziu"
>
{skipData && (
<div className="space-y-4">
<p>
Naozaj chcete vynechať revíziu s termínom{' '}
<strong>{formatDate(skipData.date)}</strong>?
</p>
<Textarea
label="Dôvod vynechania (voliteľný)"
value={skipReason}
onChange={(e) => setSkipReason(e.target.value)}
rows={2}
placeholder="Napr. zariadenie dočasne mimo prevádzky..."
/>
<ModalFooter>
<Button variant="outline" onClick={() => { setSkipData(null); setSkipReason(''); }}>
Zrušiť
</Button>
<Button
onClick={() => skipMutation.mutate({
equipmentId: skipData.equipmentId,
typeId: skipData.typeId,
scheduledDate: skipData.date.split('T')[0],
skipReason: skipReason || undefined,
})}
isLoading={skipMutation.isPending}
>
<SkipForward className="mr-2 h-4 w-4" />
Vynechať
</Button>
</ModalFooter>
</div>
)}
</Modal>
<Modal
isOpen={!!editingRevision}
onClose={() => setEditingRevision(null)}
title="Upraviť záznam"
size="lg"
>
{editingRevision && (
<RevisionForm
revision={editingRevision}
onClose={() => {
setEditingRevision(null);
queryClient.invalidateQueries({ queryKey: ['equipment'] });
}}
preselectedEquipmentId={editingRevision.equipmentId}
/>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo } 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 { revisionsApi, type CreateRevisionData, type RevisionWithEquipment } from '@/services/revisions.api';
import { equipmentApi } from '@/services/equipment.api';
import { settingsApi } from '@/services/settings.api';
import { Button, Input, Textarea, ModalFooter, Badge } from '@/components/ui';
import { SearchableSelect } from '@/components/ui/SearchableSelect';
import toast from 'react-hot-toast';
const revisionFormSchema = z.object({
equipmentId: z.string().min(1, 'Zariadenie je povinné'),
typeId: z.string().min(1, 'Typ revízie je povinný'),
performedDate: z.string().min(1, 'Dátum vykonania je povinný'),
nextDueDate: z.string().optional(),
findings: z.string().optional(),
result: z.string().optional(),
notes: z.string().optional(),
});
type RevisionFormData = z.input<typeof revisionFormSchema>;
interface RevisionFormProps {
revision: RevisionWithEquipment | null;
onClose: () => void;
preselectedEquipmentId?: string;
preselectedTypeId?: string;
}
export function RevisionForm({ revision, onClose, preselectedEquipmentId, preselectedTypeId }: RevisionFormProps) {
const queryClient = useQueryClient();
const isEditing = !!revision;
const { data: equipmentData } = useQuery({
queryKey: ['equipment-select'],
queryFn: () => equipmentApi.getAll({ active: true, limit: 1000 }),
});
const { data: revisionTypesData } = useQuery({
queryKey: ['revision-types'],
queryFn: () => settingsApi.getRevisionTypes(),
});
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<RevisionFormData>({
resolver: zodResolver(revisionFormSchema),
defaultValues: revision
? {
equipmentId: revision.equipmentId,
typeId: revision.typeId,
performedDate: revision.performedDate?.split('T')[0] || '',
nextDueDate: revision.nextDueDate?.split('T')[0] || '',
findings: revision.findings || '',
result: revision.result || '',
notes: revision.notes || '',
}
: {
equipmentId: preselectedEquipmentId || '',
typeId: preselectedTypeId || '',
performedDate: new Date().toISOString().split('T')[0],
},
});
const watchedEquipmentId = watch('equipmentId');
const watchedTypeId = watch('typeId');
// Zistiť priradené typy revízií pre vybrané zariadenie
const selectedEquipment = useMemo(
() => equipmentData?.data.find((e) => e.id === watchedEquipmentId),
[equipmentData, watchedEquipmentId]
);
const assignedSchedules = selectedEquipment?.revisionSchedules || [];
const hasSchedules = assignedSchedules.length > 0;
// Ak je preselectedTypeId, nastaviť ho; ak zariadenie má 1 revízny typ, auto-select
useEffect(() => {
if (isEditing) return;
if (preselectedTypeId && !watchedTypeId) {
setValue('typeId', preselectedTypeId, { shouldValidate: true });
} else if (hasSchedules && !watchedTypeId && assignedSchedules.length === 1) {
setValue('typeId', assignedSchedules[0].revisionType.id, { shouldValidate: true });
}
}, [hasSchedules, assignedSchedules, isEditing, watchedTypeId, preselectedTypeId, setValue]);
const createMutation = useMutation({
mutationFn: (data: CreateRevisionData) => revisionsApi.create(data),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['revisions'] });
queryClient.invalidateQueries({ queryKey: ['equipment'] });
queryClient.invalidateQueries({ queryKey: ['equipment-detail'] });
queryClient.invalidateQueries({ queryKey: ['equipment-schedule'] });
const skippedCycles = (response.data as Record<string, unknown>)?.skippedCycles as Date[] | undefined;
if (skippedCycles && skippedCycles.length > 0) {
toast.success('Revízia bola vytvorená');
toast(`Upozornenie: ${skippedCycles.length} revízny(ch) cyklus(ov) bolo preskočených!`, {
icon: '\u26A0\uFE0F',
duration: 6000,
});
} else {
toast.success('Revízia bola vytvorená');
}
onClose();
},
onError: () => {
toast.error('Chyba pri vytváraní revízie');
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateRevisionData) => revisionsApi.update(revision!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['revisions'] });
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Revízia bola aktualizovaná');
onClose();
},
onError: () => {
toast.error('Chyba pri aktualizácii revízie');
},
});
const onSubmit = (data: RevisionFormData) => {
const cleanData: CreateRevisionData = {
equipmentId: data.equipmentId,
typeId: data.typeId,
performedDate: data.performedDate,
nextDueDate: data.nextDueDate || undefined,
findings: data.findings || undefined,
result: data.result || undefined,
notes: data.notes || undefined,
};
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const equipmentOptions = equipmentData?.data.map((e) => ({
value: e.id,
label: `${e.name} - ${e.address}${e.customer ? ` (${e.customer.name})` : ''}`,
})) || [];
const revisionTypeOptions = revisionTypesData?.data.map((t) => ({
value: t.id,
label: `${t.name} (${t.intervalDays} dní)`,
})) || [];
const formatInterval = (days: number) => {
if (days >= 365) return `${Math.round(days / 365)} rok`;
if (days >= 90) return `štvrťročná`;
if (days >= 30) return `mesačná`;
return `${days} dní`;
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<SearchableSelect
label="Zariadenie *"
options={equipmentOptions}
value={watchedEquipmentId}
onChange={(val) => {
setValue('equipmentId', val, { shouldValidate: true });
// Reset typeId pri zmene zariadenia
if (!isEditing) {
setValue('typeId', '', { shouldValidate: false });
}
}}
placeholder="Vyberte zariadenie..."
error={errors.equipmentId?.message}
disabled={isEditing}
/>
{/* Typ revízie: podľa toho či je preselected, zariadenie má plány, alebo voľný výber */}
{preselectedTypeId && !isEditing ? (
// Predvybraný typ z plánu - read-only zobrazenie
(() => {
const preselectedType = assignedSchedules.find((s) => s.revisionType.id === preselectedTypeId)?.revisionType
|| revisionTypesData?.data.find((t) => t.id === preselectedTypeId);
return preselectedType ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Typ revízie
</label>
<div className="flex items-center gap-2 rounded-md border px-3 py-2 bg-muted/30">
<Badge color={preselectedType.color}>
{preselectedType.name}
</Badge>
<span className="text-sm text-muted-foreground">
({formatInterval(preselectedType.intervalDays)})
</span>
</div>
</div>
) : null;
})()
) : hasSchedules && !isEditing ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Typ revízie *
</label>
{assignedSchedules.length === 1 ? (
<div className="flex items-center gap-2 rounded-md border px-3 py-2 bg-muted/30">
<Badge color={assignedSchedules[0].revisionType.color}>
{assignedSchedules[0].revisionType.name}
</Badge>
<span className="text-sm text-muted-foreground">
({formatInterval(assignedSchedules[0].revisionType.intervalDays)})
</span>
</div>
) : (
<div className="flex flex-wrap gap-2">
{assignedSchedules.map((s) => {
const isSelected = watchedTypeId === s.revisionType.id;
return (
<button
key={s.revisionType.id}
type="button"
onClick={() => setValue('typeId', s.revisionType.id, { shouldValidate: true })}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
{s.revisionType.color && (
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: s.revisionType.color }}
/>
)}
{s.revisionType.name}
<span className="text-xs opacity-70">({formatInterval(s.revisionType.intervalDays)})</span>
</button>
);
})}
</div>
)}
{errors.typeId?.message && (
<p className="text-sm text-destructive mt-1">{errors.typeId.message}</p>
)}
</div>
) : (
<SearchableSelect
label="Typ revízie *"
options={revisionTypeOptions}
value={watchedTypeId}
onChange={(val) => setValue('typeId', val, { shouldValidate: true })}
placeholder="Vyberte typ revízie..."
error={errors.typeId?.message}
/>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
id="performedDate"
type="date"
label="Dátum vykonania *"
error={errors.performedDate?.message}
{...register('performedDate')}
/>
<Input
id="nextDueDate"
type="date"
label="Nasledujúci termín"
{...register('nextDueDate')}
/>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Ak nevyplníte nasledujúci termín, vypočíta sa automaticky podľa intervalu.
</p>
<Input
id="result"
label="Výsledok"
placeholder="napr. OK, Vyhovuje, Nevyhovuje"
{...register('result')}
/>
<Textarea
id="findings"
label="Zistenia"
rows={2}
placeholder="Zistenia z revízie..."
{...register('findings')}
/>
<Textarea
id="notes"
label="Poznámky"
rows={2}
placeholder="Ďalšie poznámky..."
{...register('notes')}
/>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,658 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search, ClipboardCheck, SkipForward, CalendarClock, AlertTriangle, List } from 'lucide-react';
import { revisionsApi, type RevisionWithEquipment } from '@/services/revisions.api';
import { equipmentApi } from '@/services/equipment.api';
import { settingsApi } from '@/services/settings.api';
import type { Equipment, RevisionScheduleItem } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
Textarea,
} from '@/components/ui';
import { RevisionForm } from './RevisionForm';
import { EquipmentDetail } from '../equipment/EquipmentDetail';
import { useRevisionStatus } from '@/hooks/useRevisionStatus';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
type FilterTab = 'upcoming' | 'overdue' | 'skipped';
const ITEMS_PER_PAGE = 25;
export function RevisionsList() {
const queryClient = useQueryClient();
const { getStatus } = useRevisionStatus();
const [selectedFilters, setSelectedFilters] = useState<Set<FilterTab>>(new Set(['upcoming']));
const [activeTypeFilter, setActiveTypeFilter] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
// Form modal
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingRevision, setEditingRevision] = useState<RevisionWithEquipment | null>(null);
const [preselectedEquipmentId, setPreselectedEquipmentId] = useState<string>();
const [preselectedTypeId, setPreselectedTypeId] = useState<string>();
// Equipment detail modal
const [detailEquipment, setDetailEquipment] = useState<Equipment | null>(null);
// Delete modal
const [deleteConfirm, setDeleteConfirm] = useState<RevisionWithEquipment | null>(null);
// Skip modal (z equipment detail)
const [skipData, setSkipData] = useState<{ equipmentId: string; equipmentName: string; typeId: string; date: string } | null>(null);
const [skipReason, setSkipReason] = useState('');
// Derived state
const hasUpcoming = selectedFilters.has('upcoming');
const hasOverdue = selectedFilters.has('overdue');
const hasSkipped = selectedFilters.has('skipped');
const isAllMode = selectedFilters.size === 0;
const needsSchedule = !isAllMode && (hasUpcoming || hasOverdue);
const needsRevisions = isAllMode || hasSkipped;
const isMixedView = needsSchedule && needsRevisions;
const handleFilterToggle = (filter: FilterTab) => {
setSelectedFilters(prev => {
const next = new Set(prev);
if (next.has(filter)) {
next.delete(filter);
} else {
next.add(filter);
}
return next;
});
setPage(1);
};
const handleAllClick = () => {
setSelectedFilters(new Set());
setPage(1);
};
const { data: revisionTypesData } = useQuery({
queryKey: ['revision-types'],
queryFn: () => settingsApi.getRevisionTypes(),
});
const { data: statsData } = useQuery({
queryKey: ['revision-stats'],
queryFn: () => revisionsApi.getStats(),
});
// Schedule queries - vždy bežia, React Query cachuje
const scheduleQueryFilters = {
typeId: activeTypeFilter || undefined,
search: search || undefined,
limit: 200,
};
const { data: upcomingData, isLoading: upcomingLoading } = useQuery({
queryKey: ['revisions-schedule', 'upcoming', activeTypeFilter, search],
queryFn: () => revisionsApi.getSchedule({ view: 'upcoming', ...scheduleQueryFilters }),
});
const { data: overdueData, isLoading: overdueLoading } = useQuery({
queryKey: ['revisions-schedule', 'overdue', activeTypeFilter, search],
queryFn: () => revisionsApi.getSchedule({ view: 'overdue', ...scheduleQueryFilters }),
});
// Revisions query (skipped / all)
const { data: revisionsData, isLoading: revisionsLoading } = useQuery({
queryKey: ['revisions', isAllMode ? 'all' : 'skipped', activeTypeFilter, search, page],
queryFn: () =>
revisionsApi.getAll({
status: hasSkipped && !isAllMode ? 'skipped' : undefined,
typeId: activeTypeFilter || undefined,
search: search || undefined,
page,
limit: ITEMS_PER_PAGE,
}),
enabled: needsRevisions,
});
// Merge schedule items podľa vybraných filtrov
const mergedScheduleItems = useMemo(() => {
if (!needsSchedule) return [];
const items: RevisionScheduleItem[] = [
...(hasOverdue ? (overdueData?.data || []) : []),
...(hasUpcoming ? (upcomingData?.data || []) : []),
];
items.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
return items;
}, [needsSchedule, hasUpcoming, hasOverdue, upcomingData, overdueData]);
// Frontend stránkovanie pre schedule
const scheduleTotal = mergedScheduleItems.length;
const scheduleTotalPages = Math.ceil(scheduleTotal / ITEMS_PER_PAGE);
const displayedScheduleItems = isMixedView
? mergedScheduleItems // mixed → bez stránkovania
: mergedScheduleItems.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
const deleteMutation = useMutation({
mutationFn: (id: string) => revisionsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['revisions'] });
queryClient.invalidateQueries({ queryKey: ['revisions-schedule'] });
queryClient.invalidateQueries({ queryKey: ['revision-stats'] });
toast.success('Revízia bola vymazaná');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní revízie');
},
});
const skipMutation = useMutation({
mutationFn: (data: { equipmentId: string; typeId: string; scheduledDate: string; skipReason?: string }) =>
revisionsApi.skip(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['revisions'] });
queryClient.invalidateQueries({ queryKey: ['revisions-schedule'] });
queryClient.invalidateQueries({ queryKey: ['revision-stats'] });
queryClient.invalidateQueries({ queryKey: ['equipment'] });
queryClient.invalidateQueries({ queryKey: ['equipment-detail'] });
queryClient.invalidateQueries({ queryKey: ['equipment-schedule'] });
toast.success('Revízia bola vynechaná');
setSkipData(null);
setSkipReason('');
},
onError: () => {
toast.error('Chyba pri vynechaní revízie');
},
});
const handleTypeFilter = (typeId: string) => {
setActiveTypeFilter(typeId === activeTypeFilter ? '' : typeId);
setPage(1);
};
const handlePerformFromSchedule = (item: RevisionScheduleItem) => {
setPreselectedEquipmentId(item.equipmentId);
setPreselectedTypeId(item.revisionType.id);
setEditingRevision(null);
setIsFormOpen(true);
};
const handleEdit = (revision: RevisionWithEquipment) => {
setPreselectedEquipmentId(undefined);
setPreselectedTypeId(undefined);
setEditingRevision(revision);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingRevision(null);
setPreselectedEquipmentId(undefined);
setPreselectedTypeId(undefined);
};
// Otvoriť detail zariadenia
const openEquipmentDetail = async (equipmentId: string) => {
try {
const response = await equipmentApi.getById(equipmentId);
setDetailEquipment(response.data as Equipment);
} catch {
toast.error('Chyba pri načítaní zariadenia');
}
};
const stats = statsData?.data;
const allCount = stats ? stats.performed + stats.skipped : undefined;
const scheduleLoading = needsSchedule && ((hasUpcoming && upcomingLoading) || (hasOverdue && overdueLoading));
const revsLoading = needsRevisions && revisionsLoading;
const isLoading = scheduleLoading || revsLoading;
const filterTabs: { key: FilterTab; label: string; count?: number; icon: typeof CalendarClock; color: string }[] = [
{ key: 'upcoming', label: 'Nadchádzajúce', count: stats?.upcoming, icon: CalendarClock, color: 'text-blue-600' },
{ key: 'overdue', label: 'Po termíne', count: stats?.overdue, icon: AlertTriangle, color: 'text-destructive' },
{ key: 'skipped', label: 'Vynechané', count: stats?.skipped, icon: SkipForward, color: 'text-muted-foreground' },
];
// Stránkovanie pre schedule (len keď nie je mixed)
const schedulePagination = !isMixedView && needsSchedule && !needsRevisions && scheduleTotalPages > 1
? { page, total: scheduleTotal, totalPages: scheduleTotalPages, limit: ITEMS_PER_PAGE, hasPrev: page > 1, hasNext: page < scheduleTotalPages }
: null;
// Stránkovanie pre revisions (server-side)
const revisionsPagination = !isMixedView && needsRevisions && !needsSchedule
? revisionsData?.pagination
: null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Revízie</h1>
<Button onClick={() => { setEditingRevision(null); setPreselectedEquipmentId(undefined); setPreselectedTypeId(undefined); setIsFormOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Pridať záznam
</Button>
</div>
{/* Štatistiky - multi-select filtrovacie tlačidlá */}
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
{filterTabs.map((tab) => {
const Icon = tab.icon;
const isActive = selectedFilters.has(tab.key);
return (
<button
key={tab.key}
onClick={() => handleFilterToggle(tab.key)}
className={`flex items-center gap-3 rounded-lg border p-4 text-left transition-colors ${
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border bg-card hover:bg-muted/50'
}`}
>
<Icon className={`h-5 w-5 ${isActive ? 'text-primary' : tab.color}`} />
<div>
<div className="text-2xl font-bold">{tab.count ?? '-'}</div>
<div className="text-xs text-muted-foreground">{tab.label}</div>
</div>
</button>
);
})}
<button
onClick={handleAllClick}
className={`flex items-center gap-3 rounded-lg border p-4 text-left transition-colors ${
isAllMode
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border bg-card hover:bg-muted/50'
}`}
>
<List className={`h-5 w-5 ${isAllMode ? 'text-primary' : 'text-muted-foreground'}`} />
<div>
<div className="text-2xl font-bold">{allCount ?? '-'}</div>
<div className="text-xs text-muted-foreground">Všetky</div>
</div>
</button>
</div>
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Vyhľadávanie */}
<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ť (zariadenie, adresa, poznámky)..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9"
/>
</div>
{/* Typ filtre - toggle tlačidlá */}
{revisionTypesData?.data && revisionTypesData.data.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{revisionTypesData.data.map((rt) => {
const isSelected = activeTypeFilter === rt.id;
return (
<button
key={rt.id}
onClick={() => handleTypeFilter(rt.id)}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
{rt.color && (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: rt.color }}
/>
)}
{rt.name}
</button>
);
})}
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<div className="space-y-6">
{/* Schedule tabuľka (upcoming / overdue) */}
{needsSchedule && displayedScheduleItems.length > 0 && (
<div>
{isMixedView && (
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Plánované revízie ({scheduleTotal})
</h3>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Zariadenie</TableHead>
<TableHead>Zákazník</TableHead>
<TableHead>Typ revízie</TableHead>
<TableHead>Termín</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Posledná revízia</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayedScheduleItems.map((item, idx) => {
const status = getStatus(item.dueDate, 'schedule');
const StatusIcon = status.icon;
return (
<TableRow
key={`${item.equipmentId}-${item.revisionType.id}-${idx}`}
className="cursor-pointer hover:bg-muted/50"
onClick={() => openEquipmentDetail(item.equipmentId)}
>
<TableCell>
<div className="font-medium">{item.equipmentName}</div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{item.equipmentAddress}
</div>
</TableCell>
<TableCell>{item.customer?.name || '-'}</TableCell>
<TableCell>
<Badge color={item.revisionType.color}>{item.revisionType.name}</Badge>
</TableCell>
<TableCell className="font-medium">
{formatDate(item.dueDate)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{StatusIcon && <StatusIcon className="h-3.5 w-3.5" style={{ color: status.color || undefined }} />}
<Badge color={status.color || undefined} variant={status.variant}>{status.label}</Badge>
</div>
</TableCell>
<TableCell>
{item.lastPerformedDate ? formatDate(item.lastPerformedDate) : '-'}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handlePerformFromSchedule(item); }}
title="Vykonať revíziu"
>
<ClipboardCheck className="h-4 w-4 text-green-600" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{schedulePagination && renderPagination(schedulePagination)}
</div>
)}
{/* Prázdny stav pre schedule */}
{needsSchedule && !needsRevisions && displayedScheduleItems.length === 0 && !isLoading && (
<p className="text-center text-muted-foreground py-8">
{hasUpcoming && hasOverdue
? 'Žiadne plánované revízie'
: hasUpcoming
? 'Žiadne nadchádzajúce revízie'
: 'Žiadne revízie po termíne'}
</p>
)}
{/* Revisions tabuľka (skipped / all) */}
{needsRevisions && (
<div>
{isMixedView && (
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Vynechané revízie ({revisionsData?.pagination?.total || 0})
</h3>
)}
{revisionsData?.data && revisionsData.data.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zariadenie</TableHead>
<TableHead>Zákazník</TableHead>
<TableHead>Typ revízie</TableHead>
<TableHead>Dátum</TableHead>
<TableHead>Stav</TableHead>
{isAllMode && <TableHead>Výsledok</TableHead>}
{hasSkipped && !isAllMode && <TableHead>Dôvod</TableHead>}
<TableHead>Vykonal</TableHead>
<TableHead>Poznámka</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisionsData.data.map((revision) => {
const status = getStatus(revision.nextDueDate, revision.status === 'skipped' ? 'skipped' : 'performed');
const StatusIcon = status.icon;
return (
<TableRow
key={revision.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => openEquipmentDetail(revision.equipmentId)}
>
<TableCell>
<div className="font-medium">{revision.equipment.name}</div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{revision.equipment.address}
</div>
</TableCell>
<TableCell>{revision.equipment.customer?.name || '-'}</TableCell>
<TableCell>
<Badge color={revision.type.color}>{revision.type.name}</Badge>
</TableCell>
<TableCell>{formatDate(revision.performedDate)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{StatusIcon && <StatusIcon className="h-3.5 w-3.5" style={{ color: status.color || undefined }} />}
<Badge color={status.color || undefined} variant={status.variant}>{status.label}</Badge>
</div>
</TableCell>
{isAllMode && (
<TableCell>{revision.status === 'skipped' ? (revision.skipReason || '-') : (revision.result || '-')}</TableCell>
)}
{hasSkipped && !isAllMode && (
<TableCell className="max-w-[200px] truncate">
{revision.skipReason || '-'}
</TableCell>
)}
<TableCell>{revision.performedBy?.name || '-'}</TableCell>
<TableCell className="max-w-[150px] truncate">
{revision.notes || '-'}
</TableCell>
<TableCell className="text-right">
{revision.status === 'performed' && (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleEdit(revision); }}>
<Pencil className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setDeleteConfirm(revision); }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{revisionsPagination && renderPagination(revisionsPagination)}
</>
) : !isMixedView ? (
<p className="text-center text-muted-foreground py-8">
{isAllMode ? 'Žiadne revízie' : 'Žiadne vynechané revízie'}
</p>
) : null}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Equipment Detail Modal */}
<Modal
isOpen={!!detailEquipment}
onClose={() => setDetailEquipment(null)}
title={detailEquipment?.name || 'Detail zariadenia'}
size="5xl"
>
{detailEquipment && (
<EquipmentDetail
equipment={detailEquipment}
onNewRevision={(typeId) => {
setPreselectedEquipmentId(detailEquipment.id);
setPreselectedTypeId(typeId);
setEditingRevision(null);
setDetailEquipment(null);
setIsFormOpen(true);
}}
onSkipRevision={(date, typeId) => {
setSkipData({
equipmentId: detailEquipment.id,
equipmentName: detailEquipment.name,
typeId,
date,
});
setSkipReason('');
}}
onEditRevision={(revision) => {
setEditingRevision(revision as RevisionWithEquipment);
setDetailEquipment(null);
setIsFormOpen(true);
}}
/>
)}
</Modal>
{/* Form Modal */}
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingRevision ? 'Upraviť záznam' : 'Pridať záznam'}
size="lg"
>
<RevisionForm
revision={editingRevision}
onClose={handleCloseForm}
preselectedEquipmentId={preselectedEquipmentId}
preselectedTypeId={preselectedTypeId}
/>
</Modal>
{/* Delete Modal */}
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>
Naozaj chcete vymazať revíziu pre "{deleteConfirm?.equipment.name}" zo dňa{' '}
{deleteConfirm?.performedDate ? formatDate(deleteConfirm.performedDate) : ''}?
</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>
{/* Skip Modal (z equipment detail) */}
<Modal
isOpen={!!skipData}
onClose={() => { setSkipData(null); setSkipReason(''); }}
title="Vynechať revíziu"
>
{skipData && (
<div className="space-y-4">
<p>
Naozaj chcete vynechať revíziu pre zariadenie{' '}
<strong>{skipData.equipmentName}</strong> s termínom{' '}
<strong>{formatDate(skipData.date)}</strong>?
</p>
<Textarea
label="Dôvod vynechania (voliteľný)"
value={skipReason}
onChange={(e) => setSkipReason(e.target.value)}
rows={2}
placeholder="Napr. zariadenie dočasne mimo prevádzky..."
/>
<ModalFooter>
<Button variant="outline" onClick={() => { setSkipData(null); setSkipReason(''); }}>
Zrušiť
</Button>
<Button
onClick={() => skipMutation.mutate({
equipmentId: skipData.equipmentId,
typeId: skipData.typeId,
scheduledDate: skipData.date.split('T')[0],
skipReason: skipReason || undefined,
})}
isLoading={skipMutation.isPending}
>
<SkipForward className="mr-2 h-4 w-4" />
Vynechať
</Button>
</ModalFooter>
</div>
)}
</Modal>
</div>
);
function renderPagination(pagination: { page: number; total: number; totalPages: number; limit: number; hasPrev: boolean; hasNext: boolean } | null | undefined) {
if (!pagination || pagination.totalPages <= 1) return null;
return (
<div className="flex items-center justify-between border-t pt-4 mt-4">
<p className="text-sm text-muted-foreground">
Zobrazujem {(pagination.page - 1) * pagination.limit + 1} -{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} z{' '}
{pagination.total}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={!pagination.hasPrev}
>
Predchádzajúca
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!pagination.hasNext}
>
Nasledujúca
</Button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1 @@
export { RevisionsList } from './RevisionsList';

View File

@@ -21,14 +21,39 @@ export interface CreateEquipmentData {
partNumber?: string;
serialNumber?: string;
installDate?: string;
revisionCycleStart?: string;
warrantyEnd?: string;
warrantyStatus?: string;
description?: string;
notes?: string;
active?: boolean;
revisionTypeIds?: string[];
tempId?: string; // For pending file uploads
}
export interface EquipmentScheduleItem {
revisionType: { id: string; name: string; color?: string; intervalDays: number };
lastPerformed: string | null;
nextDueDate: string | null;
upcomingCycles: string[];
}
export interface EquipmentSchedule {
cycleAnchor: string;
schedules: EquipmentScheduleItem[];
upcomingDates: Array<{
date: string;
label: string;
revisionTypes: Array<{
id: string;
name: string;
color?: string;
intervalDays: number;
cycleNumber: number;
}>;
}>;
}
export type UpdateEquipmentData = Partial<CreateEquipmentData>;
export interface CreateRevisionData {
@@ -73,4 +98,8 @@ export const equipmentApi = {
createRevision: (equipmentId: string, data: CreateRevisionData) =>
post<Revision>(`/equipment/${equipmentId}/revisions`, data),
// Schedule
getSchedule: (equipmentId: string, days?: number) =>
get<EquipmentSchedule>(`/equipment/${equipmentId}/schedule${days ? `?days=${days}` : ''}`),
};

View File

@@ -0,0 +1,111 @@
import { get, getPaginated, post, put, del } from './api';
import type { Revision, RevisionScheduleItem } from '@/types';
export interface RevisionFilters {
search?: string;
equipmentId?: string;
typeId?: string;
customerId?: string;
status?: string;
dueSoon?: string;
overdue?: string;
page?: number;
limit?: number;
}
export interface ScheduleFilters {
view?: 'upcoming' | 'overdue';
typeId?: string;
customerId?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateRevisionData {
equipmentId: string;
typeId: string;
performedDate: string;
nextDueDate?: string;
findings?: string;
result?: string;
notes?: string;
}
export interface SkipRevisionData {
equipmentId: string;
typeId: string;
scheduledDate: string;
skipReason?: string;
}
export type UpdateRevisionData = Partial<Omit<CreateRevisionData, 'equipmentId'>>;
export interface RevisionStats {
upcoming: number;
overdue: number;
performed: number;
skipped: number;
}
function buildQueryString(filters: RevisionFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.equipmentId) params.append('equipmentId', filters.equipmentId);
if (filters.typeId) params.append('typeId', filters.typeId);
if (filters.customerId) params.append('customerId', filters.customerId);
if (filters.status) params.append('status', filters.status);
if (filters.dueSoon) params.append('dueSoon', filters.dueSoon);
if (filters.overdue) params.append('overdue', filters.overdue);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
function buildScheduleQueryString(filters: ScheduleFilters): string {
const params = new URLSearchParams();
if (filters.view) params.append('view', filters.view);
if (filters.typeId) params.append('typeId', filters.typeId);
if (filters.customerId) params.append('customerId', filters.customerId);
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export interface RevisionWithEquipment extends Revision {
isLatest?: boolean;
equipment: {
id: string;
name: string;
address: string;
type: { id: string; name: string; color?: string };
customer?: { id: string; name: string } | null;
};
}
export const revisionsApi = {
getAll: (filters: RevisionFilters = {}) =>
getPaginated<RevisionWithEquipment>(`/revisions?${buildQueryString(filters)}`),
getById: (id: string) =>
get<RevisionWithEquipment>(`/revisions/${id}`),
create: (data: CreateRevisionData) =>
post<RevisionWithEquipment>('/revisions', data),
update: (id: string, data: UpdateRevisionData) =>
put<RevisionWithEquipment>(`/revisions/${id}`, data),
delete: (id: string) =>
del<void>(`/revisions/${id}`),
getStats: () =>
get<RevisionStats>('/revisions/stats'),
getSchedule: (filters: ScheduleFilters = {}) =>
getPaginated<RevisionScheduleItem>(`/revisions/schedule?${buildScheduleQueryString(filters)}`),
skip: (data: SkipRevisionData) =>
post<RevisionWithEquipment>('/revisions/skip', data),
};

View File

@@ -64,6 +64,7 @@ export const settingsApi = {
// System Settings
getSystemSettings: () => get<SystemSetting[]>('/settings/system'),
getSystemSetting: (key: string) => get<SystemSetting>(`/settings/system/${key}`),
updateSystemSetting: (key: string, value: unknown) => put<SystemSetting>(`/settings/system/${key}`, { value }),
// Users (admin)

View File

@@ -121,6 +121,7 @@ export interface Equipment {
partNumber?: string;
serialNumber?: string;
installDate?: string;
revisionCycleStart?: string;
warrantyEnd?: string;
warrantyStatus?: string;
description?: string;
@@ -128,6 +129,7 @@ export interface Equipment {
active: boolean;
createdAt: string;
updatedAt: string;
revisionSchedules?: Array<{ revisionType: RevisionType }>;
_count?: {
revisions: number;
};
@@ -138,6 +140,7 @@ export interface Revision {
equipmentId: string;
typeId: string;
type: RevisionType;
status: 'performed' | 'skipped';
performedDate: string;
nextDueDate?: string;
performedById: string;
@@ -145,9 +148,22 @@ export interface Revision {
findings?: string;
result?: string;
notes?: string;
skipReason?: string;
createdAt: string;
}
export interface RevisionScheduleItem {
equipmentId: string;
equipmentName: string;
equipmentAddress: string;
customer: { id: string; name: string } | null;
revisionType: { id: string; name: string; color?: string; intervalDays: number };
dueDate: string;
daysUntil: number;
lastPerformedDate: string | null;
label: string;
}
// RMA
export interface RMA {
id: string;