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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
615
backend/src/controllers/revisions.controller.ts
Normal file
615
backend/src/controllers/revisions.controller.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
23
backend/src/routes/revisions.routes.ts
Normal file
23
backend/src/routes/revisions.routes.ts
Normal 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;
|
||||
184
backend/src/utils/revisionSchedule.ts
Normal file
184
backend/src/utils/revisionSchedule.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user