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 };
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 6692e8b..e3feef0 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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
);
// Š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;
diff --git a/frontend/src/pages/equipment/EquipmentDetail.tsx b/frontend/src/pages/equipment/EquipmentDetail.tsx
new file mode 100644
index 0000000..dd62684
--- /dev/null
+++ b/frontend/src/pages/equipment/EquipmentDetail.tsx
@@ -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(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)?.revisions as Revision[] || [];
+ const revisionSchedules = (detail as Record)?.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 (
+
+ {/* Info o zariadení */}
+
+
+
+
+ Typ:
+ {equipment.type.name}
+
+ {equipment.customer && (
+
+
+ Zákazník:
+ {equipment.customer.name}
+
+ )}
+
+
+ Adresa:
+ {equipment.address}
+
+ {equipment.installDate && (
+
+
+ Dátum inštalácie:
+ {formatDate(equipment.installDate)}
+
+ )}
+ {equipment.revisionCycleStart && (
+
+
+ Východzia revízia:
+ {formatDate(equipment.revisionCycleStart)}
+
+ )}
+
+
+ {equipment.serialNumber && (
+
+ Sériové číslo:
+ {equipment.serialNumber}
+
+ )}
+ {equipment.brand && (
+
+ Značka:
+ {equipment.brand}
+
+ )}
+ {equipment.description && (
+
+ Popis:
+ {equipment.description}
+
+ )}
+ {revisionSchedules.length > 0 && (
+
+ Revízne typy:
+
+ {revisionSchedules.map((s) => (
+
+ {s.revisionType.name} ({formatInterval(s.revisionType.intervalDays)})
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Revízny plán - nadchádzajúce termíny */}
+ {schedule && schedule.upcomingDates.length > 0 && (
+
+
+ Revízny plán
+
+
+ {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 (
+
+
+
{formatDate(item.date)}
+
+ {StatusIcon && }
+
+ {daysUntil} dní
+
+
+
+
+
+
+ {item.label}
+
+
+
+ {onNewRevision && (
+
+ )}
+ {onSkipRevision && (
+
+ )}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Revízie */}
+
+
+
História revízií ({revisions.length})
+ {onNewRevision && (
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : revisions.length === 0 ? (
+
+ Pre toto zariadenie zatiaľ neboli vykonané žiadne revízie.
+
+ ) : (
+
+
+
+ Typ revízie
+ Vykonaná
+ Nasledujúci termín
+ Stav
+ Výsledok
+ Vykonal
+ Poznámka
+
+
+
+ {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 (
+
+ setExpandedRevision(isExpanded ? null : revision.id)}
+ >
+
+
+ {isExpanded
+ ?
+ :
+ }
+ {revision.type.name}
+
+
+ {formatDate(revision.performedDate)}
+
+ {revision.nextDueDate ? formatDate(revision.nextDueDate) : '-'}
+
+
+
+ {StatusIcon && }
+ {status.label}
+
+
+ {resultText}
+ {revision.performedBy?.name || '-'}
+ {notesText}
+
+ {isExpanded && (
+
+
+
+
+
+
Výsledok:
+
{resultText}
+
+
+
Zistenia:
+
{revision.findings || '-'}
+
+
+
Poznámka:
+
{notesText}
+
+ {revision.status === 'skipped' && revision.skipReason && (
+
+
Dôvod vynechania:
+
{revision.skipReason}
+
+ )}
+
+ {onEditRevision && (
+
+ )}
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/equipment/EquipmentForm.tsx b/frontend/src/pages/equipment/EquipmentForm.tsx
index 0b8fb03..e99468c 100644
--- a/frontend/src/pages/equipment/EquipmentForm.tsx
+++ b/frontend/src/pages/equipment/EquipmentForm.tsx
@@ -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([]);
+ const [selectedRevisionTypeIds, setSelectedRevisionTypeIds] = useState(
+ () => 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 (