feat: add employee timing system, notification templates, and professor class plan sms

This commit is contained in:
2026-08-24 19:31:07 +03:30
parent 53fce86ad5
commit 282dd42422
18 changed files with 1152 additions and 175 deletions
@@ -0,0 +1,327 @@
// /components/employeeTimings/employeeTimingService.js
'use strict';
const EmployeeTiming = require('./employeeTimingModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const normalizeTime = (timeStr) => {
if (timeStr === undefined || timeStr === null) return '';
const trimmed = String(timeStr).trim();
if (!trimmed) return '';
// Check HH:mm format
const match = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/.exec(trimmed);
if (match) {
const hh = match[1].padStart(2, '0');
const mm = match[2];
return `${hh}:${mm}`;
}
return trimmed;
};
const normalizeDate = (rawDate) => {
if (!rawDate) return new Date();
const d = new Date(rawDate);
if (Number.isNaN(d.getTime())) {
throw new AppError('VALIDATION_FAILED', { field: 'date' }, 'تاریخ نامعتبر است');
}
return d;
};
const sanitizeNote = (note) => {
if (!note) return '';
return String(note).trim().slice(0, 1000);
};
const computeStatus = (entryTime, exitTime) => {
const hasEntry = Boolean(entryTime && String(entryTime).trim());
const hasExit = Boolean(exitTime && String(exitTime).trim());
if (hasEntry && hasExit) return 'complete';
if (hasEntry && !hasExit) return 'missing_exit';
if (!hasEntry && hasExit) return 'missing_entry';
return 'incomplete';
};
const computeDurationMinutes = (entryTime, exitTime) => {
if (!entryTime || !exitTime) return null;
const [inH, inM] = String(entryTime).split(':').map(Number);
const [outH, outM] = String(exitTime).split(':').map(Number);
if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) {
return null;
}
const startMinutes = inH * 60 + inM;
let endMinutes = outH * 60 + outM;
if (endMinutes < startMinutes) {
endMinutes += 24 * 60;
}
return endMinutes - startMinutes;
};
const computeDurationFormatted = (minutes) => {
if (minutes === null || minutes === undefined) return '—';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m} دقیقه`;
if (m === 0) return `${h} ساعت`;
return `${h} ساعت و ${m} دقیقه`;
};
const enrichRecord = (record) => {
const obj = record.toObject ? record.toObject({ virtuals: true }) : { ...record };
const entryTime = obj.entryTime || '';
const exitTime = obj.exitTime || '';
const status = computeStatus(entryTime, exitTime);
const hasWarning = status !== 'complete';
const durationMinutes = computeDurationMinutes(entryTime, exitTime);
const durationFormatted = computeDurationFormatted(durationMinutes);
let warningMessage = null;
if (status === 'missing_exit') warningMessage = 'ورود ثبت شده ولی خروج ثبت نشده است';
else if (status === 'missing_entry') warningMessage = 'خروج ثبت شده ولی ورود ثبت نشده است';
else if (status === 'incomplete') warningMessage = 'زمان ورود و خروج هیچ‌کدام ثبت نشده است';
return {
...obj,
status,
hasWarning,
warningMessage,
durationMinutes,
durationFormatted
};
};
const getAll = async (query = {}) => {
const page = parseInt(query.page, 10) || 1;
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.user || query.userId) {
filter.user = query.user || query.userId;
}
// Date range filter
if (query.startDate || query.endDate) {
filter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
filter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
filter.date.$lte = end;
}
}
// Search by user name / username / nationalIdCode
const searchTerm = getSearchTerm(query);
if (searchTerm) {
const escaped = escapeRegex(searchTerm);
const matchingUsers = await User.find({
$or: [
{ name: new RegExp(escaped, 'i') },
{ username: new RegExp(escaped, 'i') },
{ nationalIdCode: new RegExp(escaped, 'i') },
{ phoneNumber: new RegExp(escaped, 'i') }
]
}).select('_id').lean();
const userIds = matchingUsers.map(u => u._id);
if (filter.user) {
if (!userIds.some(id => String(id) === String(filter.user))) {
return {
items: [],
meta: calculateMeta(0, page, limit)
};
}
} else {
filter.user = { $in: userIds };
}
}
// Filter by status (complete, incomplete, missing_exit, missing_entry)
if (query.status) {
const s = String(query.status).trim();
if (s === 'complete') {
filter.entryTime = { $nin: ['', null] };
filter.exitTime = { $nin: ['', null] };
} else if (s === 'missing_exit') {
filter.entryTime = { $nin: ['', null] };
filter.$or = [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }];
} else if (s === 'missing_entry') {
filter.exitTime = { $nin: ['', null] };
filter.$or = [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }];
} else if (s === 'incomplete') {
filter.$or = [
{ entryTime: '' },
{ entryTime: null },
{ entryTime: { $exists: false } },
{ exitTime: '' },
{ exitTime: null },
{ exitTime: { $exists: false } }
];
}
}
const [records, total] = await Promise.all([
EmployeeTiming.find(filter)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username')
.sort({ date: -1, createdAt: -1 })
.skip(skip)
.limit(limit),
EmployeeTiming.countDocuments(filter)
]);
return {
items: records.map(enrichRecord),
meta: calculateMeta(total, page, limit)
};
};
const getSummary = async (query = {}) => {
const baseFilter = {};
if (query.user || query.userId) {
baseFilter.user = query.user || query.userId;
}
if (query.startDate || query.endDate) {
baseFilter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
baseFilter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
baseFilter.date.$lte = end;
}
}
const [total, complete, missingExit, missingEntry] = await Promise.all([
EmployeeTiming.countDocuments(baseFilter),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
exitTime: { $nin: ['', null] }
}),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
$or: [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }]
}),
EmployeeTiming.countDocuments({
...baseFilter,
exitTime: { $nin: ['', null] },
$or: [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }]
})
]);
const incomplete = total - complete;
return {
total,
complete,
incomplete,
missingExit,
missingEntry
};
};
const getOne = async (id) => {
const record = await EmployeeTiming.findById(id)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username');
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return enrichRecord(record);
};
const create = async (body, authorId) => {
if (!body.user && !body.userId) {
throw new AppError('VALIDATION_FAILED', { field: 'user' }, 'انتخاب کارمند / کاربر الزامی است');
}
const userId = body.user || body.userId;
const userExists = await User.findById(userId).select('_id name').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
const record = await EmployeeTiming.create({
user: userId,
date: normalizeDate(body.date),
entryTime: normalizeTime(body.entryTime),
exitTime: normalizeTime(body.exitTime),
note: sanitizeNote(body.note),
createdBy: authorId || undefined,
updatedBy: authorId || undefined
});
return getOne(record._id);
};
const update = async (id, body, authorId) => {
const record = await EmployeeTiming.findById(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
if (body.user !== undefined || body.userId !== undefined) {
const targetUserId = body.user || body.userId;
const userExists = await User.findById(targetUserId).select('_id').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
record.user = targetUserId;
}
if (body.date !== undefined) {
record.date = normalizeDate(body.date);
}
if (body.entryTime !== undefined) {
record.entryTime = normalizeTime(body.entryTime);
}
if (body.exitTime !== undefined) {
record.exitTime = normalizeTime(body.exitTime);
}
if (body.note !== undefined) {
record.note = sanitizeNote(body.note);
}
if (authorId) {
record.updatedBy = authorId;
}
await record.save();
return getOne(record._id);
};
const remove = async (id) => {
const record = await EmployeeTiming.findByIdAndDelete(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return { success: true };
};
module.exports = {
getAll,
getSummary,
getOne,
create,
update,
remove,
computeStatus,
computeDurationMinutes,
computeDurationFormatted
};