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

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

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

View File

@@ -81,13 +81,15 @@ export const getDashboardToday = async (req: AuthRequest, res: Response): Promis
const userId = req.user!.userId;
const [myTasks, myProjects, recentRMAs] = await Promise.all([
// Tasks assigned to me that are not completed
const myTasksWhere = {
assignees: { some: { userId } },
status: { isFinal: false },
};
const [myTasks, myTasksTotal, myProjects, recentRMAs] = await Promise.all([
// Tasks assigned to me that are not completed (top 10 by priority)
prisma.task.findMany({
where: {
assignees: { some: { userId } },
status: { isFinal: false },
},
where: myTasksWhere,
include: {
status: true,
priority: true,
@@ -99,6 +101,9 @@ export const getDashboardToday = async (req: AuthRequest, res: Response): Promis
take: 10,
}),
// Total count of my active tasks (without limit)
prisma.task.count({ where: myTasksWhere }),
// My active projects
prisma.project.findMany({
where: {
@@ -133,6 +138,7 @@ export const getDashboardToday = async (req: AuthRequest, res: Response): Promis
successResponse(res, {
myTasks,
myTasksTotal,
myProjects,
recentRMAs,
});

View File

@@ -3,6 +3,13 @@ import prisma from '../config/database';
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { movePendingFilesToEntity } from './upload.controller';
import {
calculateNextDueDateFromInstall,
detectSkippedCycles,
getCoveringTypeIds,
calculateReminderDate,
MS_PER_DAY,
} from '../utils/revisionSchedule';
export const getEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
try {
@@ -14,19 +21,23 @@ export const getEquipment = async (req: AuthRequest, res: Response): Promise<voi
const typeId = getQueryString(req, 'typeId');
const customerId = getQueryString(req, 'customerId');
const where = {
const where: Record<string, unknown> = {
...(active !== undefined && { active: active === 'true' }),
...(typeId && { typeId }),
...(customerId && { customerId }),
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ serialNumber: { contains: search, mode: 'insensitive' as const } },
{ address: { contains: search, mode: 'insensitive' as const } },
],
}),
};
// Vyhľadávanie bez diakritiky pomocou unaccent
if (search) {
const matchingIds = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM "Equipment"
WHERE unaccent("name") ILIKE unaccent(${`%${search}%`})
OR unaccent(COALESCE("serialNumber", '')) ILIKE unaccent(${`%${search}%`})
OR unaccent("address") ILIKE unaccent(${`%${search}%`})
`;
where.id = { in: matchingIds.map(r => r.id) };
}
const [equipment, total] = await Promise.all([
prisma.equipment.findMany({
where,
@@ -37,6 +48,7 @@ export const getEquipment = async (req: AuthRequest, res: Response): Promise<voi
type: true,
customer: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
revisionSchedules: { include: { revisionType: true } },
_count: { select: { revisions: true } },
},
}),
@@ -62,7 +74,6 @@ export const getEquipmentById = async (req: AuthRequest, res: Response): Promise
createdBy: { select: { id: true, name: true, email: true } },
revisions: {
orderBy: { performedDate: 'desc' },
take: 5,
include: {
type: true,
performedBy: { select: { id: true, name: true } },
@@ -71,6 +82,10 @@ export const getEquipmentById = async (req: AuthRequest, res: Response): Promise
attachments: {
orderBy: { uploadedAt: 'desc' },
},
revisionSchedules: {
include: { revisionType: true },
orderBy: { revisionType: { intervalDays: 'asc' } },
},
tags: { include: { tag: true } },
},
});
@@ -89,6 +104,8 @@ export const getEquipmentById = async (req: AuthRequest, res: Response): Promise
export const createEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const revisionTypeIds: string[] = req.body.revisionTypeIds || [];
const equipment = await prisma.equipment.create({
data: {
name: req.body.name,
@@ -101,15 +118,22 @@ export const createEquipment = async (req: AuthRequest, res: Response): Promise<
partNumber: req.body.partNumber,
serialNumber: req.body.serialNumber,
installDate: req.body.installDate ? new Date(req.body.installDate) : null,
revisionCycleStart: req.body.revisionCycleStart ? new Date(req.body.revisionCycleStart) : null,
warrantyEnd: req.body.warrantyEnd ? new Date(req.body.warrantyEnd) : null,
warrantyStatus: req.body.warrantyStatus,
description: req.body.description,
notes: req.body.notes,
createdById: req.user!.userId,
...(revisionTypeIds.length > 0 && {
revisionSchedules: {
create: revisionTypeIds.map((rtId: string) => ({ revisionTypeId: rtId })),
},
}),
},
include: {
type: true,
customer: { select: { id: true, name: true } },
revisionSchedules: { include: { revisionType: true } },
},
});
@@ -156,16 +180,34 @@ export const updateEquipment = async (req: AuthRequest, res: Response): Promise<
if (req.body.installDate !== undefined) {
updateData.installDate = req.body.installDate ? new Date(req.body.installDate) : null;
}
if (req.body.revisionCycleStart !== undefined) {
updateData.revisionCycleStart = req.body.revisionCycleStart ? new Date(req.body.revisionCycleStart) : null;
}
if (req.body.warrantyEnd !== undefined) {
updateData.warrantyEnd = req.body.warrantyEnd ? new Date(req.body.warrantyEnd) : null;
}
// Sync revision schedules ak boli poskytnuté
if (req.body.revisionTypeIds !== undefined) {
const revisionTypeIds: string[] = req.body.revisionTypeIds || [];
await prisma.equipmentRevisionSchedule.deleteMany({ where: { equipmentId: id } });
if (revisionTypeIds.length > 0) {
await prisma.equipmentRevisionSchedule.createMany({
data: revisionTypeIds.map((rtId: string) => ({
equipmentId: id,
revisionTypeId: rtId,
})),
});
}
}
const equipment = await prisma.equipment.update({
where: { id },
data: updateData,
include: {
type: true,
customer: { select: { id: true, name: true } },
revisionSchedules: { include: { revisionType: true } },
},
});
@@ -248,11 +290,37 @@ export const createEquipmentRevision = async (req: AuthRequest, res: Response):
}
const performedDate = new Date(req.body.performedDate);
const nextDueDate = new Date(performedDate);
nextDueDate.setDate(nextDueDate.getDate() + revisionType.intervalDays);
let nextDueDate: Date;
const reminderDate = new Date(nextDueDate);
reminderDate.setDate(reminderDate.getDate() - revisionType.reminderDays);
const cycleAnchor = equipment.revisionCycleStart || equipment.installDate;
if (req.body.nextDueDate) {
nextDueDate = new Date(req.body.nextDueDate);
} else if (revisionType.intervalDays > 0 && cycleAnchor) {
nextDueDate = calculateNextDueDateFromInstall(
cycleAnchor, revisionType.intervalDays, performedDate
);
} else if (revisionType.intervalDays > 0) {
nextDueDate = new Date(performedDate);
nextDueDate.setDate(nextDueDate.getDate() + revisionType.intervalDays);
} else {
nextDueDate = performedDate;
}
const reminderDate = calculateReminderDate(nextDueDate, revisionType.reminderDays);
// Detekovať preskočené cykly - nájsť poslednú revíziu pre toto zariadenie
const lastRevision = await prisma.revision.findFirst({
where: { equipmentId: id },
orderBy: { performedDate: 'desc' },
select: { performedDate: true },
});
const skippedCycles = detectSkippedCycles(
cycleAnchor,
revisionType.intervalDays,
lastRevision?.performedDate || null,
performedDate
);
const revision = await prisma.revision.create({
data: {
@@ -279,7 +347,11 @@ export const createEquipmentRevision = async (req: AuthRequest, res: Response):
});
}
successResponse(res, revision, 'Revízia bola vytvorená.', 201);
const message = skippedCycles.length > 0
? `Revízia bola vytvorená. Upozornenie: ${skippedCycles.length} cyklus(y) boli preskočené!`
: 'Revízia bola vytvorená.';
successResponse(res, { ...revision, skippedCycles }, message, 201);
} catch (error) {
console.error('Error creating revision:', error);
errorResponse(res, 'Chyba pri vytváraní revízie.', 500);
@@ -317,3 +389,197 @@ export const getEquipmentReminders = async (req: AuthRequest, res: Response): Pr
errorResponse(res, 'Chyba pri načítaní upomienok.', 500);
}
};
/**
* Vráti nadchádzajúci revízny plán pre zariadenie.
* Pre každý priradený typ revízie vypočíta nasledujúce cyklové body s číslom cyklu.
* Ak sa cykly rôznych typov kryjú (napr. 4. štvrťročná = ročná), označí dlhší typ ako dominantný.
*/
export const getEquipmentSchedule = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const id = getParam(req, 'id');
const equipment = await prisma.equipment.findUnique({
where: { id },
include: {
revisionSchedules: {
include: { revisionType: true },
orderBy: { revisionType: { intervalDays: 'asc' } },
},
},
});
if (!equipment) {
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
return;
}
const cycleAnchor = equipment.revisionCycleStart || equipment.installDate;
if (!cycleAnchor || equipment.revisionSchedules.length === 0) {
successResponse(res, { schedules: [], upcomingDates: [] });
return;
}
const now = new Date();
const anchorTime = cycleAnchor.getTime();
const lookAheadDays = parseQueryInt(req.query.days, 365);
const lookAheadEnd = new Date(now);
lookAheadEnd.setDate(lookAheadEnd.getDate() + lookAheadDays);
// Pre každý revisionType nájsť posledný vykonaný a vypočítať nasledujúce cykly
interface CyclePoint { date: Date; cycleNumber: number }
const scheduleItems: Array<{
revisionType: typeof equipment.revisionSchedules[0]['revisionType'];
lastPerformed: Date | null;
nextDueDate: Date | null;
upcomingCycles: CyclePoint[];
}> = [];
const allTypeIds = equipment.revisionSchedules.map(s => ({
id: s.revisionType.id,
intervalDays: s.revisionType.intervalDays,
}));
for (const schedule of equipment.revisionSchedules) {
const rt = schedule.revisionType;
// Dlhší typ pokrýva kratší (ročná pokrýva štvrťročnú)
const coveringIds = getCoveringTypeIds(rt.intervalDays, allTypeIds);
const lastRevision = await prisma.revision.findFirst({
where: { equipmentId: id, typeId: { in: coveringIds } },
orderBy: { performedDate: 'desc' },
select: { performedDate: true, nextDueDate: true },
});
// Prepočítať nextDueDate z cyklového anchoru (nie z DB, kde staré dáta majú chybné hodnoty)
let computedNextDueDate: Date | null = null;
if (lastRevision && rt.intervalDays > 0) {
computedNextDueDate = calculateNextDueDateFromInstall(
new Date(anchorTime), rt.intervalDays, lastRevision.performedDate
);
}
const upcomingCycles: CyclePoint[] = [];
if (rt.intervalDays > 0) {
const intervalMs = rt.intervalDays * MS_PER_DAY;
const nowMs = now.getTime();
const endMs = lookAheadEnd.getTime();
// Priamy výpočet: anchor + n * interval (bez kumulatívneho driftu z setDate)
const startN = Math.max(1, Math.floor((nowMs - anchorTime) / intervalMs));
for (let n = startN; ; n++) {
const cycleDateMs = anchorTime + n * intervalMs;
if (cycleDateMs > endMs) break;
if (cycleDateMs > nowMs) {
upcomingCycles.push({ date: new Date(cycleDateMs), cycleNumber: n });
}
}
// Odstrániť cykly, ktoré sú už pokryté poslednou revíziou
if (computedNextDueDate && upcomingCycles.length > 0) {
const nextDueMs = computedNextDueDate.getTime();
const toleranceMs = MS_PER_DAY;
const firstValidIdx = upcomingCycles.findIndex(c => c.date.getTime() >= nextDueMs - toleranceMs);
if (firstValidIdx > 0) {
upcomingCycles.splice(0, firstValidIdx);
} else if (firstValidIdx === -1) {
upcomingCycles.length = 0;
}
}
}
scheduleItems.push({
revisionType: rt,
lastPerformed: lastRevision?.performedDate || null,
nextDueDate: computedNextDueDate || (upcomingCycles.length > 0 ? upcomingCycles[0].date : null),
upcomingCycles,
});
}
// Zostaviť výsledný zoznam nadchádzajúcich dátumov.
// Použiť LEN cyklové body najkratšieho intervalu.
// Pozícia v ročnom cykle určí label (napr. "3. Štvrťročná", "Ročná").
// Toto eliminuje problém s driftom nezávislých cyklov (4×90≠365).
const sortedItems = [...scheduleItems].sort(
(a, b) => a.revisionType.intervalDays - b.revisionType.intervalDays
);
const shortestItem = sortedItems[0];
const longestItem = sortedItems[sortedItems.length - 1];
const hasMultipleTypes = scheduleItems.length >= 2
&& shortestItem.revisionType.id !== longestItem.revisionType.id;
const allDates: Array<{
date: Date;
revisionTypes: Array<{
id: string;
name: string;
color?: string | null;
intervalDays: number;
cycleNumber: number;
}>;
label: string;
}> = [];
if (hasMultipleTypes) {
const shortRT = shortestItem.revisionType;
const longRT = longestItem.revisionType;
const stepsPerLongCycle = Math.round(longRT.intervalDays / shortRT.intervalDays);
for (const cycle of shortestItem.upcomingCycles) {
const dateMs = cycle.date.getTime();
const daysSinceAnchor = Math.round((dateMs - anchorTime) / MS_PER_DAY);
const prevLongDay = Math.floor(daysSinceAnchor / longRT.intervalDays) * longRT.intervalDays;
const daysInCycle = daysSinceAnchor - prevLongDay;
const pos = Math.round(daysInCycle / shortRT.intervalDays);
const isLongTypePosition = pos <= 0 || pos >= stepsPerLongCycle;
const activeType = isLongTypePosition ? longRT : shortRT;
const label = isLongTypePosition
? longRT.name
: `${pos}. ${shortRT.name}`;
allDates.push({
date: cycle.date,
revisionTypes: [{
id: activeType.id,
name: activeType.name,
color: activeType.color,
intervalDays: activeType.intervalDays,
cycleNumber: cycle.cycleNumber,
}],
label,
});
}
} else {
for (const cycle of shortestItem.upcomingCycles) {
allDates.push({
date: cycle.date,
revisionTypes: [{
id: shortestItem.revisionType.id,
name: shortestItem.revisionType.name,
color: shortestItem.revisionType.color,
intervalDays: shortestItem.revisionType.intervalDays,
cycleNumber: cycle.cycleNumber,
}],
label: shortestItem.revisionType.name,
});
}
}
allDates.sort((a, b) => a.date.getTime() - b.date.getTime());
successResponse(res, {
cycleAnchor,
schedules: scheduleItems.map((s) => ({
...s,
upcomingCycles: s.upcomingCycles.map((c) => ({ date: c.date, cycleNumber: c.cycleNumber })),
})),
upcomingDates: allDates,
});
} catch (error) {
console.error('Error fetching equipment schedule:', error);
errorResponse(res, 'Chyba pri načítaní revízneho plánu.', 500);
}
};

View File

@@ -0,0 +1,615 @@
import { Response } from 'express';
import prisma from '../config/database';
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import {
calculateNextDueDateFromInstall,
detectSkippedCycles,
computeFirstUpcomingCycleDate,
getCoveringTypeIds,
calculateReminderDate,
computeNextDueAndReminder,
mergeOverlappingItems,
MS_PER_DAY,
} from '../utils/revisionSchedule';
export const getRevisions = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const page = parseQueryInt(req.query.page, 1);
const limit = parseQueryInt(req.query.limit, 20);
const skip = (page - 1) * limit;
const search = getQueryString(req, 'search');
const equipmentId = getQueryString(req, 'equipmentId');
const typeId = getQueryString(req, 'typeId');
const customerId = getQueryString(req, 'customerId');
const dueSoon = getQueryString(req, 'dueSoon');
const overdue = getQueryString(req, 'overdue');
const status = getQueryString(req, 'status');
const now = new Date();
const where: Record<string, unknown> = {
...(status && { status }),
...(equipmentId && { equipmentId }),
...(typeId && { typeId }),
...(customerId && { equipment: { customerId } }),
};
// Vyhľadávanie bez diakritiky pomocou unaccent
if (search) {
const matchingIds = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT r.id FROM "Revision" r
LEFT JOIN "Equipment" e ON r."equipmentId" = e.id
WHERE unaccent(COALESCE(r."notes", '')) ILIKE unaccent(${`%${search}%`})
OR unaccent(COALESCE(r."findings", '')) ILIKE unaccent(${`%${search}%`})
OR unaccent(e."name") ILIKE unaccent(${`%${search}%`})
OR unaccent(e."address") ILIKE unaccent(${`%${search}%`})
`;
where.id = { in: matchingIds.map(r => r.id) };
}
// For overdue/dueSoon filters, only consider latest revision per equipment
if (dueSoon || overdue === 'true') {
const latestForFilter = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT ON ("equipmentId") id
FROM "Revision"
ORDER BY "equipmentId", "performedDate" DESC
`;
const latestFilterIds = latestForFilter.map(r => r.id);
if (overdue === 'true') {
where.id = { in: latestFilterIds };
where.nextDueDate = { lt: now };
} else if (dueSoon) {
const days = parseInt(dueSoon, 10) || 30;
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
where.id = { in: latestFilterIds };
where.nextDueDate = { gte: now, lte: futureDate };
}
}
// Get IDs of latest revision per equipment
const latestIds = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT ON ("equipmentId") id
FROM "Revision"
ORDER BY "equipmentId", "performedDate" DESC
`;
const latestIdSet = new Set(latestIds.map(r => r.id));
const [revisions, total] = await Promise.all([
prisma.revision.findMany({
where,
skip,
take: limit,
orderBy: { performedDate: 'desc' },
include: {
equipment: {
include: {
type: true,
customer: { select: { id: true, name: true } },
},
},
type: true,
performedBy: { select: { id: true, name: true } },
},
}),
prisma.revision.count({ where }),
]);
const revisionsWithLatest = revisions.map(r => ({
...r,
isLatest: latestIdSet.has(r.id),
}));
paginatedResponse(res, revisionsWithLatest, total, page, limit);
} catch (error) {
console.error('Error fetching revisions:', error);
errorResponse(res, 'Chyba pri načítaní revízií.', 500);
}
};
export const getRevision = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const id = getParam(req, 'id');
const revision = await prisma.revision.findUnique({
where: { id },
include: {
equipment: {
include: {
type: true,
customer: true,
},
},
type: true,
performedBy: { select: { id: true, name: true, email: true } },
},
});
if (!revision) {
errorResponse(res, 'Revízia nebola nájdená.', 404);
return;
}
successResponse(res, revision);
} catch (error) {
console.error('Error fetching revision:', error);
errorResponse(res, 'Chyba pri načítaní revízie.', 500);
}
};
export const createRevision = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const equipment = await prisma.equipment.findUnique({ where: { id: req.body.equipmentId } });
if (!equipment) {
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
return;
}
const revisionType = await prisma.revisionType.findUnique({
where: { id: req.body.typeId },
});
if (!revisionType) {
errorResponse(res, 'Typ revízie nebol nájdený.', 404);
return;
}
const performedDate = new Date(req.body.performedDate);
const cycleAnchor = equipment.revisionCycleStart || equipment.installDate;
const { nextDueDate, reminderDate } = await computeNextDueAndReminder({
equipmentId: equipment.id,
cycleAnchor,
performedDate,
typeIntervalDays: revisionType.intervalDays,
typeReminderDays: revisionType.reminderDays,
manualNextDueDate: req.body.nextDueDate,
});
// Detekovať preskočené cykly
const lastRevision = await prisma.revision.findFirst({
where: { equipmentId: req.body.equipmentId },
orderBy: { performedDate: 'desc' },
select: { performedDate: true },
});
const skippedCycles = detectSkippedCycles(
cycleAnchor,
revisionType.intervalDays,
lastRevision?.performedDate || null,
performedDate
);
const revision = await prisma.revision.create({
data: {
equipmentId: req.body.equipmentId,
typeId: req.body.typeId,
status: 'performed',
performedDate,
nextDueDate,
reminderDate,
performedById: req.user!.userId,
findings: req.body.findings,
result: req.body.result,
notes: req.body.notes,
},
include: {
equipment: {
include: {
type: true,
customer: { select: { id: true, name: true } },
},
},
type: true,
performedBy: { select: { id: true, name: true } },
},
});
if (req.logActivity) {
await req.logActivity('CREATE', 'Revision', revision.id, {
equipmentId: req.body.equipmentId,
type: revisionType.name,
});
}
const message = skippedCycles.length > 0
? `Revízia bola vytvorená. Upozornenie: ${skippedCycles.length} cyklus(y) boli preskočené!`
: 'Revízia bola vytvorená.';
successResponse(res, { ...revision, skippedCycles }, message, 201);
} catch (error) {
console.error('Error creating revision:', error);
errorResponse(res, 'Chyba pri vytváraní revízie.', 500);
}
};
export const updateRevision = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const id = getParam(req, 'id');
const existing = await prisma.revision.findUnique({ where: { id } });
if (!existing) {
errorResponse(res, 'Revízia nebola nájdená.', 404);
return;
}
const updateData: Record<string, unknown> = {};
if (req.body.typeId) {
updateData.typeId = req.body.typeId;
}
if (req.body.performedDate) {
updateData.performedDate = new Date(req.body.performedDate);
}
if (req.body.nextDueDate !== undefined) {
updateData.nextDueDate = req.body.nextDueDate ? new Date(req.body.nextDueDate) : null;
}
if (req.body.findings !== undefined) updateData.findings = req.body.findings;
if (req.body.result !== undefined) updateData.result = req.body.result;
if (req.body.notes !== undefined) updateData.notes = req.body.notes;
// Ak sa zmenil performedDate alebo typeId a nextDueDate nebol manuálne zadaný,
// prepočítať nextDueDate na základe cyklu od inštalácie (s najkratším intervalom)
if ((updateData.performedDate || updateData.typeId) && req.body.nextDueDate === undefined) {
const typeId = (updateData.typeId as string) || existing.typeId;
const revType = await prisma.revisionType.findUnique({ where: { id: typeId } });
if (revType && revType.intervalDays > 0) {
const equipment = await prisma.equipment.findUnique({ where: { id: existing.equipmentId } });
const perfDate = (updateData.performedDate as Date) || existing.performedDate;
const cycleAnchorUpdate = equipment?.revisionCycleStart || equipment?.installDate;
const { nextDueDate, reminderDate } = await computeNextDueAndReminder({
equipmentId: existing.equipmentId,
cycleAnchor: cycleAnchorUpdate || null,
performedDate: perfDate,
typeIntervalDays: revType.intervalDays,
typeReminderDays: revType.reminderDays,
});
updateData.nextDueDate = nextDueDate;
updateData.reminderDate = reminderDate;
}
}
// Prepočítať reminderDate ak sa zmenil len typeId (bez performedDate)
if (!updateData.reminderDate && updateData.typeId) {
const typeId = updateData.typeId as string;
const revType = await prisma.revisionType.findUnique({ where: { id: typeId } });
if (revType) {
const nextDate = (updateData.nextDueDate as Date) || existing.nextDueDate;
if (nextDate) {
updateData.reminderDate = calculateReminderDate(nextDate, revType.reminderDays);
}
}
}
const revision = await prisma.revision.update({
where: { id },
data: updateData,
include: {
equipment: {
include: {
type: true,
customer: { select: { id: true, name: true } },
},
},
type: true,
performedBy: { select: { id: true, name: true } },
},
});
if (req.logActivity) {
await req.logActivity('UPDATE', 'Revision', id, updateData);
}
successResponse(res, revision, 'Revízia bola aktualizovaná.');
} catch (error) {
console.error('Error updating revision:', error);
errorResponse(res, 'Chyba pri aktualizácii revízie.', 500);
}
};
export const deleteRevision = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const id = getParam(req, 'id');
const revision = await prisma.revision.findUnique({ where: { id } });
if (!revision) {
errorResponse(res, 'Revízia nebola nájdená.', 404);
return;
}
await prisma.revision.delete({ where: { id } });
if (req.logActivity) {
await req.logActivity('DELETE', 'Revision', id);
}
successResponse(res, null, 'Revízia bola vymazaná.');
} catch (error) {
console.error('Error deleting revision:', error);
errorResponse(res, 'Chyba pri mazaní revízie.', 500);
}
};
/**
* Načíta všetky equipment+type páry s ich nextDueDate.
* Spoločná logika pre schedule a stats.
*/
async function buildScheduleItems(filters?: {
typeId?: string;
customerId?: string;
search?: string;
}): Promise<Array<{
equipment: { id: string; name: string; address: string; customer: { id: string; name: string } | null };
revisionType: { id: string; name: string; color: string | null; intervalDays: number };
dueDate: Date;
lastPerformedDate: Date | null;
}>> {
const equipmentWhere: Record<string, unknown> = {
active: true,
revisionSchedules: { some: {} },
};
if (filters?.customerId) equipmentWhere.customerId = filters.customerId;
// Vyhľadávanie bez diakritiky pomocou unaccent
if (filters?.search) {
const matchingEqIds = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM "Equipment"
WHERE unaccent("name") ILIKE unaccent(${`%${filters.search}%`})
OR unaccent("address") ILIKE unaccent(${`%${filters.search}%`})
`;
equipmentWhere.id = { in: matchingEqIds.map(r => r.id) };
}
const equipment = await prisma.equipment.findMany({
where: equipmentWhere,
include: {
customer: { select: { id: true, name: true } },
revisionSchedules: {
include: { revisionType: true },
...(filters?.typeId ? { where: { revisionTypeId: filters.typeId } } : {}),
},
},
});
if (equipment.length === 0) return [];
const equipmentIds = equipment.map(e => e.id);
// Jedným dotazom načítať posledné revízie pre všetky equipment+type páry
const latestRevisions = await prisma.$queryRaw<Array<{
equipment_id: string;
type_id: string;
next_due_date: Date | null;
performed_date: Date;
}>>`
SELECT DISTINCT ON ("equipmentId", "typeId")
"equipmentId" as equipment_id,
"typeId" as type_id,
"nextDueDate" as next_due_date,
"performedDate" as performed_date
FROM "Revision"
WHERE "equipmentId" = ANY(${equipmentIds})
ORDER BY "equipmentId", "typeId", "performedDate" DESC
`;
const latestMap = new Map<string, { nextDueDate: Date | null; performedDate: Date }>();
for (const r of latestRevisions) {
latestMap.set(`${r.equipment_id}:${r.type_id}`, {
nextDueDate: r.next_due_date,
performedDate: r.performed_date,
});
}
const now = new Date();
const items: Array<{
equipment: { id: string; name: string; address: string; customer: { id: string; name: string } | null };
revisionType: { id: string; name: string; color: string | null; intervalDays: number };
dueDate: Date;
lastPerformedDate: Date | null;
}> = [];
for (const eq of equipment) {
const cycleAnchor = eq.revisionCycleStart || eq.installDate;
const allScheduleTypes = eq.revisionSchedules.map(s => ({
id: s.revisionType.id,
intervalDays: s.revisionType.intervalDays,
}));
for (const schedule of eq.revisionSchedules) {
const rt = schedule.revisionType;
// Dlhší typ pokrýva kratší (ročná pokrýva štvrťročnú)
const coveringTypeIds = getCoveringTypeIds(rt.intervalDays, allScheduleTypes);
let latest: { nextDueDate: Date | null; performedDate: Date } | undefined;
for (const covTypeId of coveringTypeIds) {
const candidate = latestMap.get(`${eq.id}:${covTypeId}`);
if (candidate && (!latest || candidate.performedDate > latest.performedDate)) {
latest = candidate;
}
}
let dueDate: Date | null = null;
if (latest) {
if (cycleAnchor && rt.intervalDays > 0) {
// Vždy prepočítať z cyklového anchoru a skutočného intervalu typu
dueDate = calculateNextDueDateFromInstall(cycleAnchor, rt.intervalDays, latest.performedDate);
} else if (latest.nextDueDate) {
// Fallback na uložený nextDueDate ak nemáme anchor
dueDate = new Date(latest.nextDueDate);
}
} else if (cycleAnchor && rt.intervalDays > 0) {
dueDate = computeFirstUpcomingCycleDate(cycleAnchor, rt.intervalDays, now);
}
if (!dueDate) continue;
items.push({
equipment: {
id: eq.id,
name: eq.name,
address: eq.address,
customer: eq.customer,
},
revisionType: {
id: rt.id,
name: rt.name,
color: rt.color,
intervalDays: rt.intervalDays,
},
dueDate,
lastPerformedDate: latest?.performedDate || null,
});
}
}
// Zlúčiť prekrývajúce sa termíny: ročná pokrýva štvrťročnú
return mergeOverlappingItems(
items,
item => item.equipment.id,
item => item.revisionType.intervalDays,
item => item.dueDate.getTime(),
);
}
export const getRevisionSchedule = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const view = getQueryString(req, 'view') || 'upcoming';
const typeId = getQueryString(req, 'typeId');
const customerId = getQueryString(req, 'customerId');
const search = getQueryString(req, 'search');
const page = parseQueryInt(req.query.page, 1);
const limit = parseQueryInt(req.query.limit, 25);
const allItems = await buildScheduleItems({ typeId: typeId || undefined, customerId: customerId || undefined, search: search || undefined });
const now = new Date();
const msPerDay = 24 * 60 * 60 * 1000;
// Filtrovať podľa view
const filtered = allItems.filter(item => {
if (view === 'overdue') return item.dueDate < now;
return item.dueDate >= now; // upcoming
});
// Zoradiť podľa dueDate
filtered.sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime());
// Stránkovanie
const total = filtered.length;
const skip = (page - 1) * limit;
const paged = filtered.slice(skip, skip + limit);
// Formátovať výstup
const data = paged.map(item => ({
equipmentId: item.equipment.id,
equipmentName: item.equipment.name,
equipmentAddress: item.equipment.address,
customer: item.equipment.customer,
revisionType: item.revisionType,
dueDate: item.dueDate.toISOString(),
daysUntil: Math.ceil((item.dueDate.getTime() - now.getTime()) / msPerDay),
lastPerformedDate: item.lastPerformedDate?.toISOString() || null,
label: item.revisionType.name,
}));
paginatedResponse(res, data, total, page, limit);
} catch (error) {
console.error('Error fetching revision schedule:', error);
errorResponse(res, 'Chyba pri načítaní revízneho plánu.', 500);
}
};
export const skipRevision = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { equipmentId, typeId, scheduledDate, skipReason } = req.body;
const equipment = await prisma.equipment.findUnique({ where: { id: equipmentId } });
if (!equipment) {
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
return;
}
const revisionType = await prisma.revisionType.findUnique({ where: { id: typeId } });
if (!revisionType) {
errorResponse(res, 'Typ revízie nebol nájdený.', 404);
return;
}
const scheduled = new Date(scheduledDate);
const cycleAnchor = equipment.revisionCycleStart || equipment.installDate;
const { nextDueDate, reminderDate } = await computeNextDueAndReminder({
equipmentId,
cycleAnchor,
performedDate: scheduled,
typeIntervalDays: revisionType.intervalDays,
typeReminderDays: revisionType.reminderDays,
});
const revision = await prisma.revision.create({
data: {
equipmentId,
typeId,
status: 'skipped',
performedDate: scheduled,
nextDueDate,
reminderDate,
performedById: req.user!.userId,
skipReason: skipReason || null,
},
include: {
equipment: {
include: {
type: true,
customer: { select: { id: true, name: true } },
},
},
type: true,
performedBy: { select: { id: true, name: true } },
},
});
if (req.logActivity) {
await req.logActivity('CREATE', 'Revision', revision.id, {
equipmentId,
type: revisionType.name,
action: 'skip',
reason: skipReason,
});
}
successResponse(res, revision, 'Revízia bola preskočená.', 201);
} catch (error) {
console.error('Error skipping revision:', error);
errorResponse(res, 'Chyba pri preskočení revízie.', 500);
}
};
export const getRevisionStats = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const now = new Date();
// Spočítať upcoming a overdue z agregovaného plánu
const allItems = await buildScheduleItems();
let upcoming = 0;
let overdue = 0;
for (const item of allItems) {
if (item.dueDate < now) overdue++;
else upcoming++;
}
const [performed, skipped] = await Promise.all([
prisma.revision.count({ where: { status: 'performed' } }),
prisma.revision.count({ where: { status: 'skipped' } }),
]);
successResponse(res, { upcoming, overdue, performed, skipped });
} catch (error) {
console.error('Error fetching revision stats:', error);
errorResponse(res, 'Chyba pri načítaní štatistík revízií.', 500);
}
};

View File

@@ -18,21 +18,36 @@ export const getTasks = async (req: AuthRequest, res: Response): Promise<void> =
const createdById = getQueryString(req, 'createdById');
const assigneeId = getQueryString(req, 'assigneeId');
const where = {
...(projectId && { projectId }),
...(statusId && { statusId }),
...(priorityId && { priorityId }),
...(createdById && { createdById }),
...(assigneeId && {
assignees: { some: { userId: assigneeId } },
}),
...(search && {
// ROOT a ADMIN vidia všetky úlohy, ostatní len svoje (priradené alebo vytvorené)
const userId = req.user!.userId;
const roleCode = req.user!.roleCode;
const isAdmin = roleCode === 'ROOT' || roleCode === 'ADMIN';
const conditions: object[] = [];
if (!isAdmin) {
conditions.push({
OR: [
{ assignees: { some: { userId } } },
{ createdById: userId },
],
});
}
if (projectId) conditions.push({ projectId });
if (statusId) conditions.push({ statusId });
if (priorityId) conditions.push({ priorityId });
if (createdById) conditions.push({ createdById });
if (assigneeId) conditions.push({ assignees: { some: { userId: assigneeId } } });
if (search) {
conditions.push({
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ description: { contains: search, mode: 'insensitive' as const } },
],
}),
};
});
}
const where = conditions.length > 0 ? { AND: conditions } : {};
const [tasks, total] = await Promise.all([
prisma.task.findMany({
@@ -95,6 +110,19 @@ export const getTask = async (req: AuthRequest, res: Response): Promise<void> =>
return;
}
// Kontrola prístupu - non-admin používatelia vidia len svoje úlohy
const roleCode = req.user!.roleCode;
const isAdmin = roleCode === 'ROOT' || roleCode === 'ADMIN';
if (!isAdmin) {
const userId = req.user!.userId;
const isAssignee = task.assignees.some(a => a.user.id === userId);
const isCreator = task.createdById === userId;
if (!isAssignee && !isCreator) {
errorResponse(res, 'Nemáte oprávnenie zobraziť túto úlohu.', 403);
return;
}
}
successResponse(res, task);
} catch (error) {
console.error('Error fetching task:', error);

View File

@@ -20,6 +20,9 @@ router.get('/:id', canRead('equipment'), equipmentController.getEquipmentById);
router.put('/:id', canUpdate('equipment'), equipmentController.updateEquipment);
router.delete('/:id', canDelete('equipment'), equipmentController.deleteEquipment);
// Schedule
router.get('/:id/schedule', canRead('equipment'), equipmentController.getEquipmentSchedule);
// Revisions
router.get('/:id/revisions', canRead('equipment'), equipmentController.getEquipmentRevisions);
router.post('/:id/revisions', canCreate('equipment'), equipmentController.createEquipmentRevision);

View File

@@ -11,6 +11,7 @@ import dashboardRoutes from './dashboard.routes';
import uploadRoutes from './upload.routes';
import zakazkyRoutes from './zakazky.routes';
import notificationRoutes from './notification.routes';
import revisionsRoutes from './revisions.routes';
const router = Router();
@@ -20,6 +21,7 @@ router.use('/customers', customersRoutes);
router.use('/projects', projectsRoutes);
router.use('/tasks', tasksRoutes);
router.use('/equipment', equipmentRoutes);
router.use('/revisions', revisionsRoutes);
router.use('/rma', rmaRoutes);
router.use('/settings', settingsRoutes);
router.use('/dashboard', dashboardRoutes);

View File

@@ -0,0 +1,23 @@
import { Router } from 'express';
import * as revisionsController from '../controllers/revisions.controller';
import { authenticate } from '../middleware/auth.middleware';
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
import { activityLogger } from '../middleware/activityLog.middleware';
import { validate } from '../middleware/validate.middleware';
import { revisionSchema, updateRevisionSchema, skipRevisionSchema } from '../utils/validators';
const router = Router();
router.use(authenticate);
router.use(activityLogger);
router.get('/', canRead('equipment'), revisionsController.getRevisions);
router.get('/stats', canRead('equipment'), revisionsController.getRevisionStats);
router.get('/schedule', canRead('equipment'), revisionsController.getRevisionSchedule);
router.post('/skip', canCreate('equipment'), validate(skipRevisionSchema), revisionsController.skipRevision);
router.get('/:id', canRead('equipment'), revisionsController.getRevision);
router.post('/', canCreate('equipment'), validate(revisionSchema), revisionsController.createRevision);
router.put('/:id', canUpdate('equipment'), validate(updateRevisionSchema), revisionsController.updateRevision);
router.delete('/:id', canDelete('equipment'), revisionsController.deleteRevision);
export default router;

View File

@@ -0,0 +1,184 @@
/**
* Zdieľané utility funkcie pre výpočet revíznych plánov.
* Konsoliduje logiku zdieľanú medzi equipment.controller a revisions.controller.
*/
import prisma from '../config/database';
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Vypočíta nasledujúci termín revízie na základe cyklu ukotveného na dátum inštalácie.
* Cyklus sa neposúva ani keď sa revízia vykoná po termíne.
*/
export function calculateNextDueDateFromInstall(
installDate: Date,
intervalDays: number,
performedDate: Date
): Date {
let cycleDate = new Date(installDate);
// Posúvame cyklové body dopredu, kým neprejdeme performedDate
while (cycleDate <= performedDate) {
cycleDate.setDate(cycleDate.getDate() + intervalDays);
}
// cycleDate je teraz prvý cyklový bod PO performedDate
const diffMs = cycleDate.getTime() - performedDate.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
// Ak je najbližší cyklový bod menej ako polovicu intervalu vzdialený,
// revízia pokrýva TENTO cyklový bod → nextDueDate je ten nasledujúci
if (diffDays < intervalDays / 2) {
cycleDate.setDate(cycleDate.getDate() + intervalDays);
}
return cycleDate;
}
/**
* Detekuje preskočené cykly medzi poslednou revíziou a novou.
* Vracia zoznam cyklových dátumov, ktoré boli preskočené.
*/
export function detectSkippedCycles(
installDate: Date | null,
intervalDays: number,
lastPerformedDate: Date | null,
newPerformedDate: Date
): Date[] {
if (!installDate || intervalDays <= 0) return [];
const startFrom = lastPerformedDate || installDate;
const missedCycles: Date[] = [];
let cycleDate = new Date(installDate);
// Posunieme sa na prvý cyklový bod po startFrom
while (cycleDate <= startFrom) {
cycleDate.setDate(cycleDate.getDate() + intervalDays);
}
// Zbierame cyklové body medzi lastPerformedDate a newPerformedDate
while (cycleDate < newPerformedDate) {
missedCycles.push(new Date(cycleDate));
cycleDate.setDate(cycleDate.getDate() + intervalDays);
}
// Posledný cyklový bod je ten, ktorý táto revízia pokrýva → odoberieme
if (missedCycles.length > 0) {
missedCycles.pop();
}
return missedCycles;
}
/**
* Vypočíta prvý nadchádzajúci cyklový bod po danom dátume.
*/
export function computeFirstUpcomingCycleDate(
anchor: Date,
intervalDays: number,
after: Date
): Date | null {
if (intervalDays <= 0) return null;
const intervalMs = intervalDays * MS_PER_DAY;
const anchorMs = anchor.getTime();
const afterMs = after.getTime();
const n = Math.ceil((afterMs - anchorMs) / intervalMs);
return new Date(anchorMs + Math.max(1, n) * intervalMs);
}
/**
* Pre daný typ revízie nájde ID typov, ktoré ho pokrývajú (vrátane seba samého).
* Napr. ročná revízia pokrýva aj štvrťročný cyklový bod.
*/
export function getCoveringTypeIds(
intervalDays: number,
allScheduleTypes: Array<{ id: string; intervalDays: number }>
): string[] {
return allScheduleTypes
.filter(t => t.intervalDays >= intervalDays)
.map(t => t.id);
}
/**
* Vypočíta dátum pripomienky z nextDueDate a počtu dní pred termínom.
*/
export function calculateReminderDate(nextDueDate: Date, reminderDays: number): Date {
const reminderDate = new Date(nextDueDate);
reminderDate.setDate(reminderDate.getDate() - reminderDays);
return reminderDate;
}
/**
* Nájde najkratší revízny interval spomedzi všetkých plánov zariadenia.
* Po ročnej revízii nasleduje štvrťročná, nie ďalšia ročná.
*/
export async function getShortestIntervalForEquipment(
equipmentId: string,
fallbackIntervalDays: number
): Promise<number> {
const schedules = await prisma.equipmentRevisionSchedule.findMany({
where: { equipmentId },
include: { revisionType: { select: { intervalDays: true } } },
});
const shortest = schedules
.map(s => s.revisionType.intervalDays)
.filter(d => d > 0)
.sort((a, b) => a - b)[0] || fallbackIntervalDays;
return Math.min(shortest, fallbackIntervalDays) || fallbackIntervalDays;
}
/**
* Vypočíta nextDueDate a reminderDate pre novú/aktualizovanú revíziu.
* Používa najkratší interval zariadenia (po ročnej → štvrťročná).
*/
export async function computeNextDueAndReminder(params: {
equipmentId: string;
cycleAnchor: Date | null;
performedDate: Date;
typeIntervalDays: number;
typeReminderDays: number;
manualNextDueDate?: string | null;
}): Promise<{ nextDueDate: Date; reminderDate: Date }> {
const { equipmentId, cycleAnchor, performedDate, typeIntervalDays, typeReminderDays, manualNextDueDate } = params;
let nextDueDate: Date;
if (manualNextDueDate) {
nextDueDate = new Date(manualNextDueDate);
} else {
const intervalForNextDue = await getShortestIntervalForEquipment(equipmentId, typeIntervalDays);
if (intervalForNextDue > 0 && cycleAnchor) {
nextDueDate = calculateNextDueDateFromInstall(cycleAnchor, intervalForNextDue, performedDate);
} else if (intervalForNextDue > 0) {
nextDueDate = new Date(performedDate);
nextDueDate.setDate(nextDueDate.getDate() + intervalForNextDue);
} else {
nextDueDate = performedDate;
}
}
const reminderDate = calculateReminderDate(nextDueDate, typeReminderDays);
return { nextDueDate, reminderDate };
}
/**
* Filtruje items tak, že odstráni kratšie typy ak sa ich termín kryje s dlhším typom.
* Ročná pokrýva štvrťročnú → štvrťročná sa nezobrazí.
*/
export function mergeOverlappingItems<T>(
items: T[],
getEquipmentId: (item: T) => string,
getIntervalDays: (item: T) => number,
getDueDateMs: (item: T) => number,
): T[] {
return items.filter(item => {
const longerMatch = items.find(other =>
getEquipmentId(other) === getEquipmentId(item) &&
getIntervalDays(other) > getIntervalDays(item) &&
Math.abs(getDueDateMs(other) - getDueDateMs(item)) <= (getIntervalDays(item) / 2) * MS_PER_DAY
);
return !longerMatch;
});
}

View File

@@ -84,10 +84,12 @@ export const equipmentSchema = z.object({
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
installDate: z.string().datetime().optional().or(z.literal('')),
revisionCycleStart: z.string().datetime().optional().or(z.literal('')),
warrantyEnd: z.string().datetime().optional().or(z.literal('')),
warrantyStatus: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
revisionTypeIds: z.array(z.string()).optional(),
});
// RMA validators
@@ -187,6 +189,33 @@ export const tagSchema = z.object({
active: z.boolean().optional(),
});
// Revision validators
export const revisionSchema = 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().or(z.literal('')),
findings: z.string().optional(),
result: z.string().optional(),
notes: z.string().optional(),
});
export const updateRevisionSchema = z.object({
typeId: z.string().optional(),
performedDate: z.string().optional(),
nextDueDate: z.string().optional().or(z.literal('')),
findings: z.string().optional(),
result: z.string().optional(),
notes: z.string().optional(),
});
export const skipRevisionSchema = z.object({
equipmentId: z.string().min(1, 'Zariadenie je povinné'),
typeId: z.string().min(1, 'Typ revízie je povinný'),
scheduledDate: z.string().min(1, 'Dátum je povinný'),
skipReason: z.string().optional(),
});
// Pagination
export const paginationSchema = z.object({
page: z.string().optional().transform((val) => (val ? parseInt(val, 10) : 1)),