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:
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
102
frontend/src/hooks/useRevisionStatus.ts
Normal file
102
frontend/src/hooks/useRevisionStatus.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
318
frontend/src/pages/equipment/EquipmentDetail.tsx
Normal file
318
frontend/src/pages/equipment/EquipmentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
Vymazať
|
||||
Deaktivovať
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
318
frontend/src/pages/revisions/RevisionForm.tsx
Normal file
318
frontend/src/pages/revisions/RevisionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
658
frontend/src/pages/revisions/RevisionsList.tsx
Normal file
658
frontend/src/pages/revisions/RevisionsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
1
frontend/src/pages/revisions/index.ts
Normal file
1
frontend/src/pages/revisions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RevisionsList } from './RevisionsList';
|
||||
@@ -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}` : ''}`),
|
||||
};
|
||||
|
||||
111
frontend/src/services/revisions.api.ts
Normal file
111
frontend/src/services/revisions.api.ts
Normal 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),
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user