From 57f98695be8b1e684cf5493074a80ffed22f261f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 5 May 2026 06:02:39 +0000 Subject: [PATCH] Autosave: 20260505-060240 --- backend/src/db/api/attendance_logs.js | 44 +- backend/src/db/api/job_positions.js | 47 +- ...5100000-add-job-position-shift-schedule.js | 43 + backend/src/db/models/job_positions.js | 13 +- backend/src/routes/attendance_logs.js | 18 +- backend/src/routes/job_positions.js | 5 +- backend/src/routes/payroll_periods.js | 19 +- backend/src/services/attendance_logs.js | 671 ++++++++++- backend/src/services/payroll_periods.js | 824 +++++++++++++- frontend/src/components/AsideMenuLayer.tsx | 4 +- .../configureJob_positionsCols.tsx | 15 + frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 16 + frontend/src/pages/clock-in.tsx | 1005 +++++++++++++++++ frontend/src/pages/index.tsx | 368 +++--- .../job_positions/job_positions-edit.tsx | 12 + .../job_positions/job_positions-list.tsx | 1 + .../pages/job_positions/job_positions-new.tsx | 12 + .../job_positions/job_positions-view.tsx | 5 + frontend/src/pages/payroll-workbench.tsx | 886 +++++++++++++++ frontend/src/pages/search.tsx | 4 +- 22 files changed, 3742 insertions(+), 276 deletions(-) create mode 100644 backend/src/db/migrations/20260505100000-add-job-position-shift-schedule.js create mode 100644 frontend/src/pages/clock-in.tsx create mode 100644 frontend/src/pages/payroll-workbench.tsx diff --git a/backend/src/db/api/attendance_logs.js b/backend/src/db/api/attendance_logs.js index 8e04ebb..5398add 100644 --- a/backend/src/db/api/attendance_logs.js +++ b/backend/src/db/api/attendance_logs.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -16,8 +15,7 @@ module.exports = class Attendance_logsDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - const attendance_logs = await db.attendance_logs.create( + const attendance_logs = await db.attendance_logs.create( { id: data.id || undefined, @@ -37,22 +35,22 @@ module.exports = class Attendance_logsDBApi { , check_in_lat: data.check_in_lat - || + ?? null , check_in_lng: data.check_in_lng - || + ?? null , check_out_lat: data.check_out_lat - || + ?? null , check_out_lng: data.check_out_lng - || + ?? null , @@ -62,7 +60,7 @@ module.exports = class Attendance_logsDBApi { , late_minutes: data.late_minutes - || + ?? null , @@ -129,8 +127,7 @@ module.exports = class Attendance_logsDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method + // Prepare data - wrapping individual data transformations in a map() method const attendance_logsData = data.map((item, index) => ({ id: item.id || undefined, @@ -150,22 +147,22 @@ module.exports = class Attendance_logsDBApi { , check_in_lat: item.check_in_lat - || + ?? null , check_in_lng: item.check_in_lng - || + ?? null , check_out_lat: item.check_out_lat - || + ?? null , check_out_lng: item.check_out_lng - || + ?? null , @@ -175,7 +172,7 @@ module.exports = class Attendance_logsDBApi { , late_minutes: item.late_minutes - || + ?? null , @@ -232,9 +229,7 @@ module.exports = class Attendance_logsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const attendance_logs = await db.attendance_logs.findByPk(id, {}, {transaction}); + const attendance_logs = await db.attendance_logs.findByPk(id, {}, {transaction}); @@ -339,8 +334,7 @@ module.exports = class Attendance_logsDBApi { static async deleteByIds(ids, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - const attendance_logs = await db.attendance_logs.findAll({ + const attendance_logs = await db.attendance_logs.findAll({ where: { id: { [Op.in]: ids, @@ -368,8 +362,7 @@ module.exports = class Attendance_logsDBApi { static async remove(id, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - - const attendance_logs = await db.attendance_logs.findByPk(id, options); + const attendance_logs = await db.attendance_logs.findByPk(id, options); await attendance_logs.update({ deletedBy: currentUser.id @@ -386,8 +379,7 @@ module.exports = class Attendance_logsDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; - - const attendance_logs = await db.attendance_logs.findOne( + const attendance_logs = await db.attendance_logs.findOne( { where }, { transaction }, ); @@ -476,10 +468,6 @@ module.exports = class Attendance_logsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { diff --git a/backend/src/db/api/job_positions.js b/backend/src/db/api/job_positions.js index b546d82..d16ac02 100644 --- a/backend/src/db/api/job_positions.js +++ b/backend/src/db/api/job_positions.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -16,8 +14,7 @@ module.exports = class Job_positionsDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - const job_positions = await db.job_positions.create( + const job_positions = await db.job_positions.create( { id: data.id || undefined, @@ -39,6 +36,11 @@ module.exports = class Job_positionsDBApi { payroll_weight: data.payroll_weight || + null + , + + shift_schedule: data.shift_schedule + || null , @@ -70,8 +72,7 @@ module.exports = class Job_positionsDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method + // Prepare data - wrapping individual data transformations in a map() method const job_positionsData = data.map((item, index) => ({ id: item.id || undefined, @@ -94,6 +95,11 @@ module.exports = class Job_positionsDBApi { payroll_weight: item.payroll_weight || null + , + + shift_schedule: item.shift_schedule + || + null , importHash: item.importHash || null, @@ -114,9 +120,7 @@ module.exports = class Job_positionsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const job_positions = await db.job_positions.findByPk(id, {}, {transaction}); + const job_positions = await db.job_positions.findByPk(id, {}, {transaction}); @@ -134,6 +138,7 @@ module.exports = class Job_positionsDBApi { if (data.payroll_weight !== undefined) updatePayload.payroll_weight = data.payroll_weight; + if (data.shift_schedule !== undefined) updatePayload.shift_schedule = data.shift_schedule; updatePayload.updatedById = currentUser.id; @@ -171,8 +176,7 @@ module.exports = class Job_positionsDBApi { static async deleteByIds(ids, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - - const job_positions = await db.job_positions.findAll({ + const job_positions = await db.job_positions.findAll({ where: { id: { [Op.in]: ids, @@ -200,8 +204,7 @@ module.exports = class Job_positionsDBApi { static async remove(id, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - - const job_positions = await db.job_positions.findByPk(id, options); + const job_positions = await db.job_positions.findByPk(id, options); await job_positions.update({ deletedBy: currentUser.id @@ -218,8 +221,7 @@ module.exports = class Job_positionsDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; - - const job_positions = await db.job_positions.findOne( + const job_positions = await db.job_positions.findOne( { where }, { transaction }, ); @@ -297,10 +299,6 @@ module.exports = class Job_positionsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { @@ -361,6 +359,17 @@ module.exports = class Job_positionsDBApi { }; } + if (filter.shift_schedule) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'job_positions', + 'shift_schedule', + filter.shift_schedule, + ), + }; + } + diff --git a/backend/src/db/migrations/20260505100000-add-job-position-shift-schedule.js b/backend/src/db/migrations/20260505100000-add-job-position-shift-schedule.js new file mode 100644 index 0000000..5064637 --- /dev/null +++ b/backend/src/db/migrations/20260505100000-add-job-position-shift-schedule.js @@ -0,0 +1,43 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const table = await queryInterface.describeTable('job_positions'); + + if (!table.shift_schedule) { + await queryInterface.addColumn( + 'job_positions', + 'shift_schedule', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const table = await queryInterface.describeTable('job_positions'); + + if (table.shift_schedule) { + await queryInterface.removeColumn('job_positions', 'shift_schedule', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/job_positions.js b/backend/src/db/models/job_positions.js index bfcae47..798e679 100644 --- a/backend/src/db/models/job_positions.js +++ b/backend/src/db/models/job_positions.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const job_positions = sequelize.define( 'job_positions', @@ -43,6 +37,13 @@ payroll_weight: { + }, + +shift_schedule: { + type: DataTypes.TEXT, + + + }, importHash: { diff --git a/backend/src/routes/attendance_logs.js b/backend/src/routes/attendance_logs.js index f487343..de15311 100644 --- a/backend/src/routes/attendance_logs.js +++ b/backend/src/routes/attendance_logs.js @@ -5,9 +5,6 @@ const Attendance_logsService = require('../services/attendance_logs'); const Attendance_logsDBApi = require('../db/api/attendance_logs'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -273,6 +270,21 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.get('/clock-in', wrapAsync(async (req, res) => { + const payload = await Attendance_logsService.getClockInContext( + req.currentUser, + req.query.timezoneOffsetMinutes, + ); + + res.status(200).send(payload); +})); + +router.post('/clock-in', wrapAsync(async (req, res) => { + const payload = await Attendance_logsService.clockIn(req.body, req.currentUser); + + res.status(200).send(payload); +})); + /** * @swagger * /api/attendance_logs: diff --git a/backend/src/routes/job_positions.js b/backend/src/routes/job_positions.js index 3df30d2..b445631 100644 --- a/backend/src/routes/job_positions.js +++ b/backend/src/routes/job_positions.js @@ -5,9 +5,6 @@ const Job_positionsService = require('../services/job_positions'); const Job_positionsDBApi = require('../db/api/job_positions'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -299,7 +296,7 @@ router.get('/', wrapAsync(async (req, res) => { ); if (filetype && filetype === 'csv') { const fields = ['id','code','name', - 'payroll_weight', + 'shift_schedule','payroll_weight', ]; diff --git a/backend/src/routes/payroll_periods.js b/backend/src/routes/payroll_periods.js index 6733d1c..6847bfd 100644 --- a/backend/src/routes/payroll_periods.js +++ b/backend/src/routes/payroll_periods.js @@ -5,9 +5,6 @@ const Payroll_periodsService = require('../services/payroll_periods'); const Payroll_periodsDBApi = require('../db/api/payroll_periods'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -20,6 +17,22 @@ const { router.use(checkCrudPermissions('payroll_periods')); +router.get('/workbench', wrapAsync(async (req, res) => { + const payload = await Payroll_periodsService.getWorkbench(req.currentUser); + res.status(200).send(payload); +})); + +router.get('/workbench/preview', wrapAsync(async (req, res) => { + const payload = await Payroll_periodsService.previewWorkbench(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/workbench/generate', wrapAsync(async (req, res) => { + const payload = await Payroll_periodsService.generateWorkbench(req.body.data || req.body, req.currentUser); + res.status(200).send(payload); +})); + + /** * @swagger * components: diff --git a/backend/src/services/attendance_logs.js b/backend/src/services/attendance_logs.js index 9ee553d..a31cf2d 100644 --- a/backend/src/services/attendance_logs.js +++ b/backend/src/services/attendance_logs.js @@ -1,36 +1,643 @@ const db = require('../db/models'); const Attendance_logsDBApi = require('../db/api/attendance_logs'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const { Op } = db.Sequelize; +const CLOCK_IN_WINDOW_BEFORE_MINUTES = 60; +const CLOCK_IN_WINDOW_AFTER_MINUTES = 60; +const LATE_PENALTY_PER_MINUTE = 1000; +function createHttpError(message, code = 400) { + const error = new Error(message); + error.code = code; + return error; +} +function normalizeTimezoneOffset(value) { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + return 0; + } + + return Math.max(-840, Math.min(840, Math.trunc(parsed))); +} + +function isValidDate(value) { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +function parseDecimal(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function buildLocalDayRange(referenceDate, timezoneOffsetMinutes) { + const offsetMs = timezoneOffsetMinutes * 60 * 1000; + const shiftedReference = new Date(referenceDate.getTime() - offsetMs); + const startShifted = new Date( + Date.UTC( + shiftedReference.getUTCFullYear(), + shiftedReference.getUTCMonth(), + shiftedReference.getUTCDate(), + 0, + 0, + 0, + 0, + ), + ); + const endShifted = new Date( + Date.UTC( + shiftedReference.getUTCFullYear(), + shiftedReference.getUTCMonth(), + shiftedReference.getUTCDate() + 1, + 0, + 0, + 0, + 0, + ), + ); + + return { + start: new Date(startShifted.getTime() + offsetMs), + end: new Date(endShifted.getTime() + offsetMs), + }; +} + +function serializeFile(file) { + if (!file) { + return null; + } + + const plain = file.get ? file.get({ plain: true }) : file; + + return { + id: plain.id, + name: plain.name, + sizeInBytes: plain.sizeInBytes, + privateUrl: plain.privateUrl, + publicUrl: plain.publicUrl, + }; +} + +function serializeOutlet(outlet) { + if (!outlet) { + return null; + } + + const plain = outlet.get ? outlet.get({ plain: true }) : outlet; + + return { + id: plain.id, + code: plain.code ?? null, + name: plain.name ?? null, + address: plain.address ?? null, + gps_lat: parseDecimal(plain.gps_lat), + gps_lng: parseDecimal(plain.gps_lng), + gps_radius_m: plain.gps_radius_m ?? null, + is_active: Boolean(plain.is_active), + }; +} + +function serializeEmployee(employee) { + if (!employee) { + return null; + } + + const plain = employee.get ? employee.get({ plain: true }) : employee; + + return { + id: plain.id, + employee_code: plain.employee_code ?? null, + full_name: plain.full_name ?? null, + phone: plain.phone ?? null, + userId: plain.userId ?? null, + outletId: plain.outletId ?? null, + job_positionId: plain.job_positionId ?? null, + }; +} + +function serializeJobPosition(jobPosition) { + if (!jobPosition) { + return null; + } + + const plain = jobPosition.get ? jobPosition.get({ plain: true }) : jobPosition; + const shiftTimes = parseShiftSchedule(plain.shift_schedule).map((shift) => shift.label); + + return { + id: plain.id, + code: plain.code ?? null, + name: plain.name ?? null, + payroll_weight: plain.payroll_weight ?? null, + is_active: Boolean(plain.is_active), + shift_schedule: plain.shift_schedule ?? null, + shift_times: shiftTimes, + }; +} + +function getShiftedReference(referenceDate, timezoneOffsetMinutes) { + return new Date(referenceDate.getTime() - timezoneOffsetMinutes * 60 * 1000); +} + +function buildLocalDateTime(referenceDate, timezoneOffsetMinutes, hour, minute) { + const shiftedReference = getShiftedReference(referenceDate, timezoneOffsetMinutes); + + return new Date( + Date.UTC( + shiftedReference.getUTCFullYear(), + shiftedReference.getUTCMonth(), + shiftedReference.getUTCDate(), + hour, + minute, + 0, + 0, + ) + timezoneOffsetMinutes * 60 * 1000, + ); +} + +function formatLocalTime(referenceDate, timezoneOffsetMinutes) { + if (!referenceDate) { + return null; + } + + const shiftedReference = getShiftedReference(referenceDate, timezoneOffsetMinutes); + return `${String(shiftedReference.getUTCHours()).padStart(2, '0')}:${String( + shiftedReference.getUTCMinutes(), + ).padStart(2, '0')}`; +} + +function parseShiftSchedule(value) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + const normalizedValue = value.replace(/\bdan\b/gi, ',').replace(/\band\b/gi, ','); + const uniqueShiftMap = new Map(); + + normalizedValue + .split(/[\n,;|]+/) + .map((token) => token.trim()) + .filter(Boolean) + .forEach((token) => { + const normalizedToken = token.replace(/\./g, ':').replace(/\s+/g, ''); + const match = normalizedToken.match(/^(\d{1,2})(?::(\d{1,2}))?$/); + + if (!match) { + return; + } + + const hour = Number(match[1]); + const minute = match[2] === undefined ? 0 : Number(match[2]); + + if ( + !Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return; + } + + const label = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + uniqueShiftMap.set(label, { + label, + hour, + minute, + totalMinutes: hour * 60 + minute, + }); + }); + + return Array.from(uniqueShiftMap.values()).sort((left, right) => left.totalMinutes - right.totalMinutes); +} + +function buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes) { + const startsAt = buildLocalDateTime(referenceDate, timezoneOffsetMinutes, shift.hour, shift.minute); + const windowStartAt = new Date( + startsAt.getTime() - CLOCK_IN_WINDOW_BEFORE_MINUTES * 60 * 1000, + ); + const windowEndAt = new Date( + startsAt.getTime() + CLOCK_IN_WINDOW_AFTER_MINUTES * 60 * 1000, + ); + const diffMs = referenceDate.getTime() - startsAt.getTime(); + const lateMinutes = diffMs > 0 ? Math.ceil(diffMs / 60000) : 0; + + return { + ...shift, + startsAt, + windowStartAt, + windowEndAt, + diffMs, + lateMinutes, + latePenaltyAmount: lateMinutes * LATE_PENALTY_PER_MINUTE, + status: lateMinutes > 0 ? 'telat' : 'hadir', + }; +} + +function serializeShiftOccurrence(occurrence, timezoneOffsetMinutes, withinWindow = true) { + if (!occurrence) { + return null; + } + + return { + label: occurrence.label, + time: occurrence.label, + startsAt: occurrence.startsAt.toISOString(), + windowStartAt: occurrence.windowStartAt.toISOString(), + windowEndAt: occurrence.windowEndAt.toISOString(), + windowLabel: `${formatLocalTime(occurrence.windowStartAt, timezoneOffsetMinutes)} - ${formatLocalTime( + occurrence.windowEndAt, + timezoneOffsetMinutes, + )}`, + lateMinutes: occurrence.lateMinutes, + latePenaltyAmount: occurrence.latePenaltyAmount, + status: occurrence.status, + withinWindow, + }; +} + +function resolveShiftMatch(shifts, referenceDate, timezoneOffsetMinutes, options = {}) { + const { allowOutsideWindow = false } = options; + const occurrences = shifts.map((shift) => buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes)); + const sortedByDistance = [...occurrences].sort( + (left, right) => Math.abs(left.diffMs) - Math.abs(right.diffMs) || left.totalMinutes - right.totalMinutes, + ); + const withinWindow = sortedByDistance.find( + (occurrence) => + referenceDate.getTime() >= occurrence.windowStartAt.getTime() && + referenceDate.getTime() <= occurrence.windowEndAt.getTime(), + ); + + if (withinWindow) { + return { + occurrence: withinWindow, + withinWindow: true, + occurrences, + }; + } + + if (allowOutsideWindow && sortedByDistance.length) { + return { + occurrence: sortedByDistance[0], + withinWindow: false, + occurrences, + }; + } + + return { + occurrence: null, + withinWindow: false, + occurrences, + }; +} + +function resolveNextShiftOccurrence(shifts, referenceDate, timezoneOffsetMinutes) { + return shifts + .map((shift) => buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes)) + .filter((occurrence) => occurrence.windowStartAt.getTime() > referenceDate.getTime()) + .sort((left, right) => left.windowStartAt.getTime() - right.windowStartAt.getTime())[0] || null; +} + +function buildRosterContext(jobPosition, referenceDate, timezoneOffsetMinutes, attendanceLog = null) { + const serializedJobPosition = serializeJobPosition(jobPosition); + const shifts = parseShiftSchedule(serializedJobPosition?.shift_schedule); + const activeShiftMatch = resolveShiftMatch(shifts, referenceDate, timezoneOffsetMinutes); + const activeShift = serializeShiftOccurrence( + activeShiftMatch.occurrence, + timezoneOffsetMinutes, + activeShiftMatch.withinWindow, + ); + const nextShiftOccurrence = resolveNextShiftOccurrence(shifts, referenceDate, timezoneOffsetMinutes); + const nextShift = serializeShiftOccurrence(nextShiftOccurrence, timezoneOffsetMinutes, false); + + let assignedShift = null; + + if (attendanceLog?.check_in_at) { + const recordedCheckInAt = new Date(attendanceLog.check_in_at); + + if (isValidDate(recordedCheckInAt)) { + const assignedShiftMatch = resolveShiftMatch(shifts, recordedCheckInAt, timezoneOffsetMinutes, { + allowOutsideWindow: true, + }); + assignedShift = serializeShiftOccurrence( + assignedShiftMatch.occurrence, + timezoneOffsetMinutes, + assignedShiftMatch.withinWindow, + ); + } + } + + let blockedReason = null; + + if (!shifts.length) { + blockedReason = 'Job position/divisi ini belum punya jadwal shift. Isi contoh: 07:00, 12:00, 15:00.'; + } else if (!activeShiftMatch.occurrence) { + const shiftList = shifts.map((shift) => shift.label).join(', '); + + if (nextShiftOccurrence) { + blockedReason = `Absensi hanya bisa ${CLOCK_IN_WINDOW_BEFORE_MINUTES} menit sebelum sampai ${CLOCK_IN_WINDOW_AFTER_MINUTES} menit sesudah shift dimulai. Window terdekat untuk shift ${nextShiftOccurrence.label}: ${formatLocalTime(nextShiftOccurrence.windowStartAt, timezoneOffsetMinutes)} - ${formatLocalTime(nextShiftOccurrence.windowEndAt, timezoneOffsetMinutes)}.`; + } else { + blockedReason = `Semua window absensi untuk hari ini sudah lewat. Jadwal shift hari ini: ${shiftList}.`; + } + } + + return { + latePenaltyPerMinute: LATE_PENALTY_PER_MINUTE, + windowBeforeMinutes: CLOCK_IN_WINDOW_BEFORE_MINUTES, + windowAfterMinutes: CLOCK_IN_WINDOW_AFTER_MINUTES, + shiftScheduleRaw: serializedJobPosition?.shift_schedule ?? null, + shiftTimes: shifts.map((shift) => shift.label), + activeShift, + nextShift, + assignedShift, + blockedReason, + }; +} + +function getClockInSetupError(employee, roster) { + if (!employee) { + return 'Akun ini belum terhubung ke data employee.'; + } + + if (!employee.outlet) { + return 'Employee ini belum terhubung ke outlet.'; + } + + if (!employee.job_position) { + return 'Employee ini belum terhubung ke job position/divisi.'; + } + + if (!roster.shiftTimes.length) { + return 'Job position/divisi ini belum punya jadwal shift.'; + } + + return null; +} + +function serializeAttendanceLog(attendanceLog) { + if (!attendanceLog) { + return null; + } + + const plain = attendanceLog.get ? attendanceLog.get({ plain: true }) : attendanceLog; + + return { + id: plain.id, + work_date: plain.work_date ? new Date(plain.work_date).toISOString() : null, + check_in_at: plain.check_in_at ? new Date(plain.check_in_at).toISOString() : null, + check_out_at: plain.check_out_at ? new Date(plain.check_out_at).toISOString() : null, + check_in_lat: parseDecimal(plain.check_in_lat), + check_in_lng: parseDecimal(plain.check_in_lng), + check_out_lat: parseDecimal(plain.check_out_lat), + check_out_lng: parseDecimal(plain.check_out_lng), + status: plain.status ?? null, + late_minutes: plain.late_minutes ?? null, + gps_valid: Boolean(plain.gps_valid), + remarks: plain.remarks ?? null, + employee: serializeEmployee(plain.employee), + outlet: serializeOutlet(plain.outlet), + check_in_photo: Array.isArray(plain.check_in_photo) + ? plain.check_in_photo.map(serializeFile).filter(Boolean) + : [], + check_out_photo: Array.isArray(plain.check_out_photo) + ? plain.check_out_photo.map(serializeFile).filter(Boolean) + : [], + }; +} + +function attendanceLogInclude() { + return [ + { + model: db.employees, + as: 'employee', + required: false, + }, + { + model: db.outlets, + as: 'outlet', + required: false, + }, + { + model: db.file, + as: 'check_in_photo', + required: false, + }, + { + model: db.file, + as: 'check_out_photo', + required: false, + }, + ]; +} + +async function getEmployeeForCurrentUser(currentUser, transaction) { + const where = { + userId: currentUser.id, + }; + + const organizationsId = currentUser?.organizationsId || currentUser?.organizations?.id || null; + + if (!currentUser?.app_role?.globalAccess && organizationsId) { + where.organizationsId = organizationsId; + } + + return db.employees.findOne({ + where, + include: [ + { + model: db.outlets, + as: 'outlet', + required: false, + }, + { + model: db.job_positions, + as: 'job_position', + required: false, + }, + ], + order: [['createdAt', 'ASC']], + transaction, + }); +} + +async function findTodayAttendanceLog(employeeId, referenceDate, timezoneOffsetMinutes, transaction) { + const { start, end } = buildLocalDayRange(referenceDate, timezoneOffsetMinutes); + + return db.attendance_logs.findOne({ + where: { + employeeId, + work_date: { + [Op.gte]: start, + [Op.lt]: end, + }, + }, + include: attendanceLogInclude(), + order: [ + ['work_date', 'DESC'], + ['createdAt', 'DESC'], + ], + transaction, + }); +} module.exports = class Attendance_logsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await Attendance_logsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); + await Attendance_logsDBApi.create(data, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async getClockInContext(currentUser, timezoneOffsetMinutesInput) { + const timezoneOffsetMinutes = normalizeTimezoneOffset(timezoneOffsetMinutesInput); + const employee = await getEmployeeForCurrentUser(currentUser); + const todayLog = employee + ? await findTodayAttendanceLog(employee.id, new Date(), timezoneOffsetMinutes) + : null; + const roster = buildRosterContext(employee?.job_position, new Date(), timezoneOffsetMinutes, todayLog); + const setupError = getClockInSetupError(employee, roster); + const clockInBlockedReason = todayLog?.check_in_at + ? 'Anda sudah clock in untuk hari ini.' + : setupError || roster.blockedReason; + + return { + employee: serializeEmployee(employee), + outlet: serializeOutlet(employee?.outlet), + jobPosition: serializeJobPosition(employee?.job_position), + todayLog: serializeAttendanceLog(todayLog), + canClockIn: Boolean(employee?.outlet) && !clockInBlockedReason, + setupError, + clockInBlockedReason, + roster, + timezoneOffsetMinutes, + }; + } + + static async clockIn(payload, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const timezoneOffsetMinutes = normalizeTimezoneOffset(payload.timezoneOffsetMinutes); + const requestedCheckInAt = payload.clientTimestamp + ? new Date(payload.clientTimestamp) + : new Date(); + const checkInAt = isValidDate(requestedCheckInAt) + ? requestedCheckInAt + : new Date(); + const latitude = parseDecimal(payload.latitude); + const longitude = parseDecimal(payload.longitude); + const remarks = typeof payload.remarks === 'string' && payload.remarks.trim() + ? payload.remarks.trim() + : null; + const checkInPhoto = Array.isArray(payload.check_in_photo) + ? payload.check_in_photo.filter(Boolean) + : []; + + if (latitude === null || longitude === null) { + throw createHttpError('GPS wajib diambil sebelum clock in.'); + } + + if (!checkInPhoto.length) { + throw createHttpError('Foto selfie wajib diunggah sebelum clock in.'); + } + + const employee = await getEmployeeForCurrentUser(currentUser, transaction); + const todayLog = employee + ? await findTodayAttendanceLog(employee.id, checkInAt, timezoneOffsetMinutes, transaction) + : null; + const roster = buildRosterContext(employee?.job_position, checkInAt, timezoneOffsetMinutes, todayLog); + const setupError = getClockInSetupError(employee, roster); + + if (setupError) { + throw createHttpError(setupError); + } + + if (todayLog?.check_in_at) { + throw createHttpError('Anda sudah clock in untuk hari ini.'); + } + + if (!roster.activeShift) { + throw createHttpError(roster.blockedReason || 'Tidak ada shift aktif untuk waktu absensi ini.'); + } + + const data = { + employee: employee.id, + outlet: employee.outlet.id, + organizations: + employee.organizationsId || + currentUser?.organizationsId || + currentUser?.organizations?.id || + null, + work_date: checkInAt, + check_in_at: checkInAt, + check_in_lat: latitude, + check_in_lng: longitude, + status: roster.activeShift.lateMinutes > 0 ? 'telat' : 'hadir', + late_minutes: roster.activeShift.lateMinutes, + gps_valid: true, + remarks, + check_in_photo: checkInPhoto, + check_out_photo: Array.isArray(todayLog?.check_out_photo) + ? todayLog.check_out_photo.map(serializeFile).filter(Boolean) + : [], + }; + + const attendanceLogRecord = todayLog + ? await Attendance_logsDBApi.update(todayLog.id, data, { + currentUser, + transaction, + }) + : await Attendance_logsDBApi.create(data, { + currentUser, + transaction, + }); + + const freshAttendanceLog = await db.attendance_logs.findByPk(attendanceLogRecord.id, { + include: attendanceLogInclude(), + transaction, + }); + const responseRoster = buildRosterContext( + employee?.job_position, + checkInAt, + timezoneOffsetMinutes, + freshAttendanceLog, + ); + + await transaction.commit(); + + return { + success: true, + action: todayLog ? 'updated' : 'created', + attendanceLog: serializeAttendanceLog(freshAttendanceLog), + jobPosition: serializeJobPosition(employee?.job_position), + roster: responseRoster, + timezoneOffsetMinutes, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +645,7 @@ module.exports = class Attendance_logsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +656,13 @@ module.exports = class Attendance_logsService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Attendance_logsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,15 +675,13 @@ module.exports = class Attendance_logsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let attendance_logs = await Attendance_logsDBApi.findBy( - {id}, - {transaction}, + const attendance_logs = await Attendance_logsDBApi.findBy( + { id }, + { transaction }, ); if (!attendance_logs) { - throw new ValidationError( - 'attendance_logsNotFound', - ); + throw new ValidationError('attendance_logsNotFound'); } const updatedAttendance_logs = await Attendance_logsDBApi.update( @@ -90,12 +695,11 @@ module.exports = class Attendance_logsService { await transaction.commit(); return updatedAttendance_logs; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -117,13 +721,10 @@ module.exports = class Attendance_logsService { const transaction = await db.sequelize.transaction(); try { - await Attendance_logsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); + await Attendance_logsDBApi.remove(id, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { @@ -131,8 +732,4 @@ module.exports = class Attendance_logsService { throw error; } } - - }; - - diff --git a/backend/src/services/payroll_periods.js b/backend/src/services/payroll_periods.js index 3089466..b8625ba 100644 --- a/backend/src/services/payroll_periods.js +++ b/backend/src/services/payroll_periods.js @@ -1,15 +1,608 @@ const db = require('../db/models'); const Payroll_periodsDBApi = require('../db/api/payroll_periods'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const { randomUUID } = require('crypto'); +const { Op } = db.Sequelize; +const BONUS_TIERS = [ + { minimum: 200000000, rate: 0.05, label: '5% tier (> 200 jt)' }, + { minimum: 150000000, rate: 0.03, label: '3% tier (> 150 jt)' }, + { minimum: 100000000, rate: 0.02, label: '2% tier (> 100 jt)' }, +]; +function createHttpError(message, code = 400) { + const error = new Error(message); + error.code = code; + return error; +} +function toNumber(value) { + if (value === null || value === undefined || value === '') { + return 0; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +} + +function roundMoney(value) { + return Number(toNumber(value).toFixed(2)); +} + +function addDays(date, days) { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + days); + return result; +} + +function buildPeriodRange(periodMonth) { + if (!/^\d{4}-\d{2}$/.test(periodMonth || '')) { + throw createHttpError('Period month must use YYYY-MM format.'); + } + + const [yearText, monthText] = periodMonth.split('-'); + const year = Number(yearText); + const monthIndex = Number(monthText) - 1; + + if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || monthIndex < 0 || monthIndex > 11) { + throw createHttpError('Invalid period month.'); + } + + const periodStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0)); + const periodEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999)); + + return { periodStart, periodEnd }; +} + +function getScopeWhere(currentUser) { + if (currentUser?.app_role?.globalAccess || !currentUser?.organizationsId) { + return {}; + } + + return { organizationsId: currentUser.organizationsId }; +} + +function getDistributionWeight(employee, distributionMethod) { + if (distributionMethod === 'weighted_by_position') { + const configuredWeight = toNumber(employee?.job_position?.payroll_weight); + return configuredWeight > 0 ? configuredWeight : 1; + } + + return 1; +} + +function getRevenueTier(totalRevenue) { + return BONUS_TIERS.find((tier) => totalRevenue >= tier.minimum) || null; +} + +function serializeDate(value) { + if (!value) { + return null; + } + + return new Date(value).toISOString(); +} + +function normalizeWorkbenchInput(payload = {}) { + const outletId = payload.outletId; + const periodMonth = payload.periodMonth; + const standardWorkdays = Math.max(1, Math.round(toNumber(payload.standardWorkdays || 26))); + const maxKpiAllowance = Math.max(0, toNumber(payload.maxKpiAllowance || 0)); + const latePenaltyAmount = Math.max(0, toNumber(payload.latePenaltyAmount ?? 1000)); + const distributionMethod = + payload.distributionMethod === 'equal' ? 'equal' : 'weighted_by_position'; + + if (!outletId) { + throw createHttpError('Outlet is required.'); + } + + if (!periodMonth) { + throw createHttpError('Period month is required.'); + } + + const { periodStart, periodEnd } = buildPeriodRange(periodMonth); + + return { + outletId, + periodMonth, + periodStart, + periodEnd, + standardWorkdays, + maxKpiAllowance, + latePenaltyAmount, + distributionMethod, + }; +} + +function groupCounts(items, keySelector, valueSelector = () => 1) { + const grouped = new Map(); + + items.forEach((item) => { + const key = keySelector(item); + const currentValue = grouped.get(key) || 0; + grouped.set(key, currentValue + valueSelector(item)); + }); + + return grouped; +} + +async function buildPayrollPreview(input, currentUser) { + const config = normalizeWorkbenchInput(input); + const scopeWhere = getScopeWhere(currentUser); + const kpiScoreWindowEnd = addDays(config.periodEnd, 15); + const kpiScoreWindowStart = addDays(config.periodStart, -7); + + const outlet = await db.outlets.findOne({ + where: { + id: config.outletId, + is_active: true, + ...scopeWhere, + }, + }); + + if (!outlet) { + throw createHttpError('Outlet not found or unavailable for your workspace.', 404); + } + + const existingRuns = await db.payroll_periods.findAll({ + where: { + outletId: config.outletId, + period_start: config.periodStart, + period_end: config.periodEnd, + ...scopeWhere, + }, + include: [ + { + model: db.users, + as: 'generated_by_user', + required: false, + }, + ], + order: [ + ['generated_at', 'DESC'], + ['createdAt', 'DESC'], + ], + }); + + const employees = await db.employees.findAll({ + where: { + outletId: config.outletId, + is_active: true, + ...scopeWhere, + employment_status: { + [Op.ne]: 'resigned', + }, + [Op.and]: [ + { + [Op.or]: [{ join_date: null }, { join_date: { [Op.lte]: config.periodEnd } }], + }, + { + [Op.or]: [{ resign_date: null }, { resign_date: { [Op.gte]: config.periodStart } }], + }, + ], + }, + include: [ + { + model: db.job_positions, + as: 'job_position', + required: false, + }, + ], + order: [['full_name', 'ASC']], + }); + + if (!employees.length) { + return { + config: { + ...config, + periodStart: serializeDate(config.periodStart), + periodEnd: serializeDate(config.periodEnd), + }, + outlet: { + id: outlet.id, + name: outlet.name, + code: outlet.code, + address: outlet.address, + }, + readiness: { + blockers: ['No active employees were found for the selected outlet.'], + warnings: [], + employeeCount: 0, + missingAttendanceCount: 0, + missingKpiCount: 0, + existingRunCount: existingRuns.length, + latestExistingRun: existingRuns[0] + ? { + id: existingRuns[0].id, + name: existingRuns[0].name, + status: existingRuns[0].status, + generated_at: serializeDate(existingRuns[0].generated_at), + } + : null, + }, + totals: { + projectedTakeHomePay: 0, + averageTakeHomePay: 0, + totalBaseSalary: 0, + totalMealTransport: 0, + totalPositionAllowance: 0, + totalKpiAllowance: 0, + totalRevenueBonus: 0, + totalServiceCharge: 0, + totalOvertime: 0, + totalDeductions: 0, + totalOvertimeHours: 0, + grossRevenue: 0, + serviceChargePool: 0, + bonusPool: 0, + bonusRate: 0, + bonusTierLabel: 'No omzet bonus tier reached yet', + }, + employees: [], + }; + } + + const employeeIds = employees.map((employee) => employee.id); + + const [attendanceLogs, overtimeLogs, kpiScores, outletRevenues, serviceChargePools, loanAccounts] = + await Promise.all([ + db.attendance_logs.findAll({ + where: { + employeeId: { + [Op.in]: employeeIds, + }, + outletId: config.outletId, + work_date: { + [Op.between]: [config.periodStart, config.periodEnd], + }, + ...scopeWhere, + }, + }), + db.overtime_logs.findAll({ + where: { + employeeId: { + [Op.in]: employeeIds, + }, + outletId: config.outletId, + work_date: { + [Op.between]: [config.periodStart, config.periodEnd], + }, + approval_status: 'approved', + ...scopeWhere, + }, + }), + db.kpi_scores.findAll({ + where: { + employeeId: { + [Op.in]: employeeIds, + }, + outletId: config.outletId, + ...scopeWhere, + [Op.or]: [ + { + scored_at: { + [Op.between]: [kpiScoreWindowStart, kpiScoreWindowEnd], + }, + }, + { + scored_at: null, + }, + ], + }, + include: [ + { + model: db.kpi_periods, + as: 'kpi_period', + required: false, + }, + ], + order: [ + ['scored_at', 'DESC'], + ['updatedAt', 'DESC'], + ], + }), + db.outlet_revenues.findAll({ + where: { + outletId: config.outletId, + revenue_date: { + [Op.between]: [config.periodStart, config.periodEnd], + }, + ...scopeWhere, + }, + }), + db.service_charge_pools.findAll({ + where: { + outletId: config.outletId, + ...scopeWhere, + status: { + [Op.ne]: 'archived', + }, + period_start: { + [Op.lte]: config.periodEnd, + }, + period_end: { + [Op.gte]: config.periodStart, + }, + }, + }), + db.loan_accounts.findAll({ + where: { + employeeId: { + [Op.in]: employeeIds, + }, + outletId: config.outletId, + ...scopeWhere, + status: 'active', + [Op.and]: [ + { + [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: config.periodEnd } }], + }, + { + [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: config.periodStart } }], + }, + ], + }, + }), + ]); + + const attendanceByEmployee = new Map(); + attendanceLogs.forEach((log) => { + const existing = attendanceByEmployee.get(log.employeeId) || []; + existing.push(log); + attendanceByEmployee.set(log.employeeId, existing); + }); + + const overtimeHoursByEmployee = groupCounts( + overtimeLogs, + (item) => item.employeeId, + (item) => toNumber(item.hours), + ); + + const loanInstallmentByEmployee = groupCounts( + loanAccounts, + (item) => item.employeeId, + (item) => toNumber(item.installment_amount), + ); + + const kpiScoresByEmployee = new Map(); + kpiScores.forEach((score) => { + const periodMatches = + score.kpi_period && + score.kpi_period.period_start && + score.kpi_period.period_end && + new Date(score.kpi_period.period_start) <= config.periodEnd && + new Date(score.kpi_period.period_end) >= config.periodStart; + + const scoredAt = score.scored_at ? new Date(score.scored_at) : null; + const scoredWithinWindow = + !scoredAt || (scoredAt >= kpiScoreWindowStart && scoredAt <= kpiScoreWindowEnd); + + if ((periodMatches || scoredWithinWindow) && !kpiScoresByEmployee.has(score.employeeId)) { + kpiScoresByEmployee.set(score.employeeId, score); + } + }); + + const totalGrossRevenue = roundMoney( + outletRevenues.reduce((total, revenue) => total + toNumber(revenue.gross_revenue || revenue.net_revenue), 0), + ); + const totalServiceChargePool = roundMoney( + serviceChargePools.reduce((total, pool) => total + toNumber(pool.total_amount), 0), + ); + const revenueTier = getRevenueTier(totalGrossRevenue); + const totalRevenueBonusPool = roundMoney(totalGrossRevenue * (revenueTier?.rate || 0)); + + const totalDistributionWeight = employees.reduce( + (total, employee) => total + getDistributionWeight(employee, config.distributionMethod), + 0, + ); + + const employeeRows = employees.map((employee) => { + const employeeAttendance = attendanceByEmployee.get(employee.id) || []; + const sickDays = employeeAttendance.filter((entry) => entry.status === 'sakit').length; + const leaveDays = employeeAttendance.filter((entry) => entry.status === 'izin').length; + const absentDays = employeeAttendance.filter((entry) => entry.status === 'tidak_hadir').length; + const lateEntries = employeeAttendance.filter((entry) => entry.status === 'telat'); + const lateCount = lateEntries.length; + const lateMinutesTotal = lateEntries.reduce( + (total, entry) => total + Math.max(0, Math.round(toNumber(entry.late_minutes))), + 0, + ); + + const workdays = Math.max(0, config.standardWorkdays - sickDays - leaveDays); + const baseSalaryMonthly = roundMoney(toNumber(employee.base_salary_monthly)); + const dailyRate = config.standardWorkdays > 0 ? roundMoney(baseSalaryMonthly / config.standardWorkdays) : 0; + const baseSalaryTotal = roundMoney(dailyRate * workdays); + + const mealTransportDaily = + roundMoney(toNumber(employee.meal_allowance_daily) + toNumber(employee.transport_allowance_daily)); + const mealTransportTotal = roundMoney(mealTransportDaily * workdays); + + const shouldProratePositionAllowance = + Boolean(employee.prorate_position_allowance_under_20_days) && workdays < 20; + const positionAllowanceFixed = roundMoney(toNumber(employee.position_allowance_fixed)); + const positionAllowanceTotal = shouldProratePositionAllowance + ? roundMoney((positionAllowanceFixed * workdays) / config.standardWorkdays) + : positionAllowanceFixed; + + const kpiScore = roundMoney(toNumber(kpiScoresByEmployee.get(employee.id)?.final_score)); + const kpiAllowanceTotal = roundMoney((kpiScore / 100) * config.maxKpiAllowance); + + const overtimeHoursTotal = roundMoney(overtimeHoursByEmployee.get(employee.id) || 0); + const overtimeRate = roundMoney(dailyRate / 8); + const overtimeTotal = roundMoney(overtimeHoursTotal * overtimeRate); + + const employeeWeight = getDistributionWeight(employee, config.distributionMethod); + const distributionRatio = totalDistributionWeight > 0 ? employeeWeight / totalDistributionWeight : 0; + const revenueBonusTotal = roundMoney(totalRevenueBonusPool * distributionRatio); + const serviceChargeTotal = roundMoney(totalServiceChargePool * distributionRatio); + + const absentDeduction = roundMoney(absentDays * dailyRate); + const lateDeduction = roundMoney(lateMinutesTotal * config.latePenaltyAmount); + const loanDeduction = roundMoney(loanInstallmentByEmployee.get(employee.id) || 0); + const deductionTotal = roundMoney(absentDeduction + lateDeduction + loanDeduction); + + const takeHomePay = roundMoney( + baseSalaryTotal + + mealTransportTotal + + positionAllowanceTotal + + kpiAllowanceTotal + + revenueBonusTotal + + serviceChargeTotal + + overtimeTotal - + deductionTotal, + ); + + return { + id: employee.id, + employee_code: employee.employee_code, + full_name: employee.full_name, + employment_status: employee.employment_status, + bank_name: employee.bank_name, + bank_account_name: employee.bank_account_name, + bank_account_number: employee.bank_account_number, + job_position: employee.job_position + ? { + id: employee.job_position.id, + name: employee.job_position.name, + payroll_weight: toNumber(employee.job_position.payroll_weight), + } + : null, + workdays, + sick_days: sickDays, + leave_days: leaveDays, + absent_days: absentDays, + late_count: lateCount, + late_minutes_total: lateMinutesTotal, + base_salary_monthly: baseSalaryMonthly, + daily_rate: dailyRate, + base_salary_total: baseSalaryTotal, + meal_transport_total: mealTransportTotal, + position_allowance_total: positionAllowanceTotal, + kpi_score: kpiScore, + kpi_allowance_total: kpiAllowanceTotal, + overtime_hours_total: overtimeHoursTotal, + overtime_rate: overtimeRate, + overtime_total: overtimeTotal, + revenue_bonus_total: revenueBonusTotal, + service_charge_total: serviceChargeTotal, + absent_deduction: absentDeduction, + late_deduction: lateDeduction, + loan_deduction: loanDeduction, + deduction_total: deductionTotal, + take_home_pay: takeHomePay, + attendance_record_count: employeeAttendance.length, + has_kpi_score: kpiScoresByEmployee.has(employee.id), + has_attendance: employeeAttendance.length > 0, + }; + }); + + const missingAttendanceCount = employeeRows.filter((row) => !row.has_attendance).length; + const missingKpiCount = employeeRows.filter((row) => !row.has_kpi_score).length; + + const warnings = []; + if (existingRuns.length) { + warnings.push( + `${existingRuns.length} payroll run(s) already exist for this outlet and period. Generating again will create a new revision.`, + ); + } + if (missingAttendanceCount) { + warnings.push( + `${missingAttendanceCount} employee(s) do not have attendance data for this month. Draft values use default workdays and should be reviewed.`, + ); + } + if (missingKpiCount) { + warnings.push( + `${missingKpiCount} employee(s) are missing KPI scores. Their KPI allowance is currently projected as Rp 0.`, + ); + } + if (!totalGrossRevenue) { + warnings.push('No outlet revenue was found for this period, so omzet bonus is projected as Rp 0.'); + } + if (!totalServiceChargePool) { + warnings.push('No service charge pool was found for this period.'); + } + + const totals = employeeRows.reduce( + (accumulator, row) => ({ + projectedTakeHomePay: roundMoney(accumulator.projectedTakeHomePay + row.take_home_pay), + totalBaseSalary: roundMoney(accumulator.totalBaseSalary + row.base_salary_total), + totalMealTransport: roundMoney(accumulator.totalMealTransport + row.meal_transport_total), + totalPositionAllowance: roundMoney( + accumulator.totalPositionAllowance + row.position_allowance_total, + ), + totalKpiAllowance: roundMoney(accumulator.totalKpiAllowance + row.kpi_allowance_total), + totalRevenueBonus: roundMoney(accumulator.totalRevenueBonus + row.revenue_bonus_total), + totalServiceCharge: roundMoney(accumulator.totalServiceCharge + row.service_charge_total), + totalOvertime: roundMoney(accumulator.totalOvertime + row.overtime_total), + totalDeductions: roundMoney(accumulator.totalDeductions + row.deduction_total), + totalOvertimeHours: roundMoney(accumulator.totalOvertimeHours + row.overtime_hours_total), + }), + { + projectedTakeHomePay: 0, + totalBaseSalary: 0, + totalMealTransport: 0, + totalPositionAllowance: 0, + totalKpiAllowance: 0, + totalRevenueBonus: 0, + totalServiceCharge: 0, + totalOvertime: 0, + totalDeductions: 0, + totalOvertimeHours: 0, + }, + ); + + return { + config: { + ...config, + periodStart: serializeDate(config.periodStart), + periodEnd: serializeDate(config.periodEnd), + }, + outlet: { + id: outlet.id, + name: outlet.name, + code: outlet.code, + address: outlet.address, + gps_radius_m: outlet.gps_radius_m, + }, + readiness: { + blockers: [], + warnings, + employeeCount: employeeRows.length, + missingAttendanceCount, + missingKpiCount, + existingRunCount: existingRuns.length, + latestExistingRun: existingRuns[0] + ? { + id: existingRuns[0].id, + name: existingRuns[0].name, + status: existingRuns[0].status, + generated_at: serializeDate(existingRuns[0].generated_at), + generated_by_user: existingRuns[0].generated_by_user + ? `${existingRuns[0].generated_by_user.firstName || ''} ${ + existingRuns[0].generated_by_user.lastName || '' + }`.trim() + : null, + } + : null, + }, + totals: { + ...totals, + averageTakeHomePay: employeeRows.length + ? roundMoney(totals.projectedTakeHomePay / employeeRows.length) + : 0, + grossRevenue: totalGrossRevenue, + serviceChargePool: totalServiceChargePool, + bonusPool: totalRevenueBonusPool, + bonusRate: revenueTier?.rate || 0, + bonusTierLabel: revenueTier?.label || 'No omzet bonus tier reached yet', + }, + employees: employeeRows, + }; +} module.exports = class Payroll_periodsService { static async create(data, currentUser) { @@ -28,9 +621,9 @@ module.exports = class Payroll_periodsService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +631,7 @@ module.exports = class Payroll_periodsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +642,13 @@ module.exports = class Payroll_periodsService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Payroll_periodsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,15 +661,13 @@ module.exports = class Payroll_periodsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let payroll_periods = await Payroll_periodsDBApi.findBy( - {id}, - {transaction}, + const payroll_periods = await Payroll_periodsDBApi.findBy( + { id }, + { transaction }, ); if (!payroll_periods) { - throw new ValidationError( - 'payroll_periodsNotFound', - ); + throw new ValidationError('payroll_periodsNotFound'); } const updatedPayroll_periods = await Payroll_periodsDBApi.update( @@ -90,12 +681,11 @@ module.exports = class Payroll_periodsService { await transaction.commit(); return updatedPayroll_periods; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +722,197 @@ module.exports = class Payroll_periodsService { } } - + static async getWorkbench(currentUser) { + const scopeWhere = getScopeWhere(currentUser); + + const outlets = await db.outlets.findAll({ + where: { + is_active: true, + ...scopeWhere, + }, + order: [['name', 'ASC']], + }); + + const recentPayrollPeriods = await db.payroll_periods.findAll({ + where: scopeWhere, + include: [ + { + model: db.outlets, + as: 'outlet', + required: false, + }, + { + model: db.users, + as: 'generated_by_user', + required: false, + }, + ], + order: [ + ['generated_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 6, + }); + + const recentRuns = await Promise.all( + recentPayrollPeriods.map(async (period) => { + const [employeeCount, totalTakeHomePay] = await Promise.all([ + db.payroll_items.count({ + where: { + payroll_periodId: period.id, + ...scopeWhere, + }, + }), + db.payroll_items.sum('take_home_pay', { + where: { + payroll_periodId: period.id, + ...scopeWhere, + }, + }), + ]); + + return { + id: period.id, + name: period.name, + status: period.status, + period_start: serializeDate(period.period_start), + period_end: serializeDate(period.period_end), + generated_at: serializeDate(period.generated_at), + outlet: period.outlet + ? { + id: period.outlet.id, + name: period.outlet.name, + code: period.outlet.code, + } + : null, + generated_by_user: period.generated_by_user + ? `${period.generated_by_user.firstName || ''} ${period.generated_by_user.lastName || ''}`.trim() + : null, + employeeCount, + totalTakeHomePay: roundMoney(totalTakeHomePay), + }; + }), + ); + + return { + outlets: outlets.map((outlet) => ({ + id: outlet.id, + code: outlet.code, + name: outlet.name, + address: outlet.address, + gps_radius_m: outlet.gps_radius_m, + })), + recentRuns, + }; + } + + static async previewWorkbench(input, currentUser) { + return buildPayrollPreview(input, currentUser); + } + + static async generateWorkbench(input, currentUser) { + const preview = await buildPayrollPreview(input, currentUser); + + if (preview.readiness.blockers.length) { + throw createHttpError(preview.readiness.blockers[0]); + } + + const transaction = await db.sequelize.transaction(); + + try { + const revisionNumber = preview.readiness.existingRunCount + 1; + const periodId = randomUUID(); + const periodNameBase = `Payroll ${preview.config.periodMonth} • ${preview.outlet.name}`; + const periodName = + revisionNumber > 1 ? `${periodNameBase} (Rev ${revisionNumber})` : periodNameBase; + const generatedAt = new Date(); + + await db.payroll_periods.create( + { + id: periodId, + name: periodName, + period_start: preview.config.periodStart, + period_end: preview.config.periodEnd, + standard_workdays: preview.config.standardWorkdays, + status: 'generated', + generated_at: generatedAt, + outletId: preview.outlet.id, + generated_by_userId: currentUser.id, + organizationsId: currentUser.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const payrollItemsPayload = preview.employees.map((employee) => ({ + id: randomUUID(), + payroll_periodId: periodId, + employeeId: employee.id, + outletId: preview.outlet.id, + organizationsId: currentUser.organizationsId || null, + workdays: employee.workdays, + sick_days: employee.sick_days, + leave_days: employee.leave_days, + absent_days: employee.absent_days, + late_count: employee.late_count, + late_minutes_total: employee.late_minutes_total, + base_salary_monthly: employee.base_salary_monthly, + daily_rate: employee.daily_rate, + base_salary_total: employee.base_salary_total, + meal_transport_total: employee.meal_transport_total, + position_allowance_total: employee.position_allowance_total, + kpi_score: employee.kpi_score, + kpi_allowance_total: employee.kpi_allowance_total, + overtime_hours_total: employee.overtime_hours_total, + overtime_rate: employee.overtime_rate, + overtime_total: employee.overtime_total, + revenue_bonus_total: employee.revenue_bonus_total, + service_charge_total: employee.service_charge_total, + deduction_total: employee.deduction_total, + take_home_pay: employee.take_home_pay, + status: 'draft', + adjustment_notes: preview.readiness.warnings.join(' | ') || null, + createdById: currentUser.id, + updatedById: currentUser.id, + })); + + await db.payroll_items.bulkCreate(payrollItemsPayload, { transaction }); + + const payslipsPayload = payrollItemsPayload.map((item, index) => ({ + id: randomUUID(), + payroll_itemId: item.id, + organizationsId: currentUser.organizationsId || null, + slip_number: `SLIP-${preview.config.periodMonth}-${String(index + 1).padStart(3, '0')}`, + issued_at: generatedAt, + delivery_status: 'not_sent', + delivery_notes: 'Draft payslip created from Payroll Workbench.', + createdById: currentUser.id, + updatedById: currentUser.id, + })); + + await db.payslips.bulkCreate(payslipsPayload, { transaction }); + + await transaction.commit(); + + return { + success: true, + payrollPeriod: { + id: periodId, + name: periodName, + status: 'generated', + generated_at: serializeDate(generatedAt), + period_start: preview.config.periodStart, + period_end: preview.config.periodEnd, + }, + generatedCount: payrollItemsPayload.length, + payslipCount: payslipsPayload.length, + totals: preview.totals, + warnings: preview.readiness.warnings, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } }; - - diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 2c6ddf0..3cdfaff 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/Job_positions/configureJob_positionsCols.tsx b/frontend/src/components/Job_positions/configureJob_positionsCols.tsx index d45464d..35ab145 100644 --- a/frontend/src/components/Job_positions/configureJob_positionsCols.tsx +++ b/frontend/src/components/Job_positions/configureJob_positionsCols.tsx @@ -69,6 +69,21 @@ export const loadColumns = async ( editable: hasUpdatePermission, + }, + + { + field: 'shift_schedule', + headerName: 'ShiftSchedule', + flex: 1, + minWidth: 180, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + + editable: hasUpdatePermission, + + }, { diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index db1d850..1486dca 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -80,6 +80,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiMapMarkerCheck' in icon ? icon['mdiMapMarkerCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_ATTENDANCE_LOGS' }, + { + href: '/clock-in', + label: 'Clock in', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCellphoneMarker' in icon ? icon['mdiCellphoneMarker' as keyof typeof icon] : icon.mdiMapMarkerCheck ?? icon.mdiTable, + permissions: 'CREATE_ATTENDANCE_LOGS' + }, { href: '/attendance_requests/attendance_requests-list', label: 'Attendance requests', @@ -152,6 +160,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiCalendarMonth' in icon ? icon['mdiCalendarMonth' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_PAYROLL_PERIODS' }, + { + href: '/payroll-workbench', + label: 'Payroll workbench', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCashRegister' in icon ? icon['mdiCashRegister' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PAYROLL_PERIODS' + }, { href: '/payroll_items/payroll_items-list', label: 'Payroll items', diff --git a/frontend/src/pages/clock-in.tsx b/frontend/src/pages/clock-in.tsx new file mode 100644 index 0000000..6122554 --- /dev/null +++ b/frontend/src/pages/clock-in.tsx @@ -0,0 +1,1005 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import Head from 'next/head'; +import React, { + ChangeEvent, + ReactElement, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import FileUploader from '../components/Uploaders/UploadService'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type ClockInEmployee = { + id: string; + employee_code: string | null; + full_name: string | null; + phone: string | null; + userId: string | null; + outletId: string | null; + job_positionId: string | null; +}; + +type ClockInOutlet = { + id: string; + code: string | null; + name: string | null; + address: string | null; + gps_lat: number | null; + gps_lng: number | null; + gps_radius_m: number | null; + is_active: boolean; +}; + +type ClockInJobPosition = { + id: string; + code: string | null; + name: string | null; + payroll_weight: number | null; + is_active: boolean; + shift_schedule: string | null; + shift_times: string[]; +}; + +type ClockInFile = { + id: string; + name: string; + sizeInBytes: number; + privateUrl: string; + publicUrl: string; + new?: boolean; +}; + +type ClockInLog = { + id: string; + work_date: string | null; + check_in_at: string | null; + check_out_at: string | null; + check_in_lat: number | null; + check_in_lng: number | null; + check_out_lat: number | null; + check_out_lng: number | null; + status: string | null; + late_minutes: number | null; + gps_valid: boolean; + remarks: string | null; + employee: ClockInEmployee | null; + outlet: ClockInOutlet | null; + check_in_photo: ClockInFile[]; + check_out_photo: ClockInFile[]; +}; + +type RosterShift = { + label: string; + time: string; + startsAt: string; + windowStartAt: string; + windowEndAt: string; + windowLabel: string; + lateMinutes: number; + latePenaltyAmount: number; + status: string; + withinWindow: boolean; +}; + +type ClockInRoster = { + latePenaltyPerMinute: number; + windowBeforeMinutes: number; + windowAfterMinutes: number; + shiftScheduleRaw: string | null; + shiftTimes: string[]; + activeShift: RosterShift | null; + nextShift: RosterShift | null; + assignedShift: RosterShift | null; + blockedReason: string | null; +}; + +type ClockInContext = { + employee: ClockInEmployee | null; + outlet: ClockInOutlet | null; + jobPosition: ClockInJobPosition | null; + todayLog: ClockInLog | null; + canClockIn: boolean; + setupError: string | null; + clockInBlockedReason: string | null; + roster: ClockInRoster; + timezoneOffsetMinutes: number; +}; + +type ClockInResponse = { + success: boolean; + action: 'created' | 'updated'; + attendanceLog: ClockInLog; + jobPosition: ClockInJobPosition | null; + roster: ClockInRoster; + timezoneOffsetMinutes: number; +}; + +type LocationState = { + latitude: number; + longitude: number; + accuracy: number | null; + capturedAt: string; +}; + +const pageIcon = + 'mdiCellphoneMarker' in icon + ? icon['mdiCellphoneMarker' as keyof typeof icon] + : icon.mdiMapMarkerCheck ?? icon.mdiTable; +const gpsIcon = + 'mdiCrosshairsGps' in icon + ? icon['mdiCrosshairsGps' as keyof typeof icon] + : icon.mdiMapMarkerCheck ?? icon.mdiTable; +const selfieIcon = + 'mdiCameraOutline' in icon + ? icon['mdiCameraOutline' as keyof typeof icon] + : icon.mdiUpload ?? icon.mdiTable; +const successIcon = + 'mdiCheckCircleOutline' in icon + ? icon['mdiCheckCircleOutline' as keyof typeof icon] + : icon.mdiCheckCircle ?? icon.mdiTable; +const warningIcon = + 'mdiAlertCircleOutline' in icon + ? icon['mdiAlertCircleOutline' as keyof typeof icon] + : icon.mdiAlert ?? icon.mdiTable; +const refreshIcon = icon.mdiReload ?? icon.mdiTable; +const detailIcon = + 'mdiOpenInNew' in icon + ? icon['mdiOpenInNew' as keyof typeof icon] + : icon.mdiArrowRightBoldCircleOutline ?? icon.mdiTable; +const calendarIcon = + 'mdiCalendarClock' in icon + ? icon['mdiCalendarClock' as keyof typeof icon] + : icon.mdiCalendar ?? icon.mdiTable; +const shiftIcon = + 'mdiClockOutline' in icon + ? icon['mdiClockOutline' as keyof typeof icon] + : icon.mdiClock ?? icon.mdiTable; +const officeIcon = + 'mdiStoreMarker' in icon + ? icon['mdiStoreMarker' as keyof typeof icon] + : icon.mdiStore ?? icon.mdiTable; +const teamIcon = + 'mdiAccountTieOutline' in icon + ? icon['mdiAccountTieOutline' as keyof typeof icon] + : icon.mdiAccount ?? icon.mdiTable; + +const inputClassName = + 'w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-teal-500 focus:ring focus:ring-teal-200'; + +const formatDateTime = (value?: string | null) => { + if (!value) { + return '—'; + } + + return dayjs(value).format('DD MMM YYYY • HH:mm'); +}; + +const formatTime = (value?: string | null) => { + if (!value) { + return '—'; + } + + return dayjs(value).format('HH:mm'); +}; + +const formatCurrency = (value?: number | null) => + new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0, + }).format(Number.isFinite(value) ? Number(value) : 0); + +const getErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + if (typeof error.response?.data === 'string' && error.response.data) { + return error.response.data; + } + + if ( + error.response?.data && + typeof error.response.data === 'object' && + error.response.data !== null && + 'message' in error.response.data && + typeof error.response.data.message === 'string' + ) { + return error.response.data.message; + } + + return error.message; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Terjadi kesalahan saat memproses clock in.'; +}; + +const getStatusBadgeClasses = (status?: string | null) => { + if (status === 'telat') { + return 'border border-amber-200 bg-amber-50 text-amber-700'; + } + + if (status === 'hadir') { + return 'border border-emerald-200 bg-emerald-50 text-emerald-700'; + } + + return 'border border-slate-200 bg-slate-50 text-slate-700'; +}; + +const getChecklistBadgeClasses = (ok: boolean) => + ok ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'; + +const ClockInPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const canReadAttendanceLogs = hasPermission(currentUser, 'READ_ATTENDANCE_LOGS'); + const timezoneOffsetMinutes = useMemo(() => new Date().getTimezoneOffset(), []); + + const [context, setContext] = useState(null); + const [result, setResult] = useState(null); + const [remarks, setRemarks] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(''); + const [location, setLocation] = useState(null); + const [manualLatitude, setManualLatitude] = useState(''); + const [manualLongitude, setManualLongitude] = useState(''); + const [pageError, setPageError] = useState(''); + const [submitError, setSubmitError] = useState(''); + const [locationError, setLocationError] = useState(''); + const [isLoadingContext, setIsLoadingContext] = useState(true); + const [isLocating, setIsLocating] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const todayLog = context?.todayLog || result?.attendanceLog || null; + const roster = context?.roster || result?.roster || null; + const jobPosition = context?.jobPosition || result?.jobPosition || null; + const hasClockedIn = Boolean(todayLog?.check_in_at); + const lateMinutes = typeof todayLog?.late_minutes === 'number' ? todayLog.late_minutes : 0; + const latePenaltyAmount = lateMinutes * (roster?.latePenaltyPerMinute || 0); + const employeeLabel = + context?.employee?.full_name || + currentUser?.employees_user?.[0]?.full_name || + currentUser?.firstName || + currentUser?.email || + 'Belum terhubung'; + const outletLabel = context?.outlet?.name || 'Belum ada outlet'; + const availableShiftLabel = roster?.shiftTimes.length ? roster.shiftTimes.join(', ') : 'Belum diatur'; + + useEffect(() => { + if (!selectedFile) { + setPreviewUrl(''); + return undefined; + } + + const nextPreviewUrl = URL.createObjectURL(selectedFile); + setPreviewUrl(nextPreviewUrl); + + return () => { + URL.revokeObjectURL(nextPreviewUrl); + }; + }, [selectedFile]); + + const loadClockInContext = useCallback(async () => { + setIsLoadingContext(true); + setPageError(''); + + try { + const response = await axios.get('/attendance_logs/clock-in', { + params: { + timezoneOffsetMinutes, + }, + }); + + setContext(response.data); + } catch (error) { + console.error('Failed to load clock-in context:', error); + setPageError(getErrorMessage(error)); + } finally { + setIsLoadingContext(false); + } + }, [timezoneOffsetMinutes]); + + const requestLocation = useCallback(async () => { + if (typeof window === 'undefined' || !navigator.geolocation) { + setLocation(null); + setLocationError('Browser ini belum mendukung GPS otomatis. Isi koordinat manual untuk testing.'); + return; + } + + setIsLocating(true); + setLocationError(''); + + await new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (position) => { + setLocation({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + capturedAt: new Date().toISOString(), + }); + setManualLatitude(''); + setManualLongitude(''); + setIsLocating(false); + resolve(); + }, + (error) => { + console.error('Failed to get geolocation:', error); + setLocation(null); + setLocationError( + 'GPS tidak bisa diambil otomatis. Izinkan location di browser, atau isi koordinat manual untuk testing dev.', + ); + setIsLocating(false); + resolve(); + }, + { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 0, + }, + ); + }); + }, []); + + useEffect(() => { + void loadClockInContext(); + void requestLocation(); + }, [loadClockInContext, requestLocation]); + + const handleFileChange = (event: ChangeEvent) => { + const nextFile = event.target.files?.[0] ?? null; + setSelectedFile(nextFile); + }; + + const handleClockIn = async () => { + setSubmitError(''); + + if (!context) { + setSubmitError('Context clock in belum siap. Coba refresh halaman.'); + return; + } + + if (!context.canClockIn) { + setSubmitError( + context.clockInBlockedReason || context.setupError || 'Clock in belum bisa dilakukan sekarang.', + ); + return; + } + + if (!selectedFile) { + setSubmitError('Ambil atau unggah foto selfie terlebih dulu.'); + return; + } + + const latitude = location?.latitude ?? Number(manualLatitude); + const longitude = location?.longitude ?? Number(manualLongitude); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + setSubmitError('GPS belum tersedia. Klik “Ambil GPS lagi”, atau isi koordinat manual untuk testing dev.'); + return; + } + + setIsSubmitting(true); + + try { + const uploadedPhoto = (await FileUploader.upload( + 'attendance_logs/check_in_photo', + selectedFile, + { image: true }, + )) as ClockInFile; + + const response = await axios.post('/attendance_logs/clock-in', { + latitude, + longitude, + remarks, + check_in_photo: [uploadedPhoto], + clientTimestamp: new Date().toISOString(), + timezoneOffsetMinutes, + }); + + setResult(response.data); + setSelectedFile(null); + setRemarks(''); + await loadClockInContext(); + } catch (error) { + console.error('Clock-in request failed:', error); + setSubmitError(getErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + const checklistItems = [ + { + label: 'User terhubung ke employee', + ok: Boolean(context?.employee), + }, + { + label: 'Employee terhubung ke outlet', + ok: Boolean(context?.outlet), + }, + { + label: 'Employee terhubung ke divisi/job position', + ok: Boolean(jobPosition), + }, + { + label: 'Jadwal shift sudah dikonfigurasi', + ok: Boolean(roster?.shiftTimes.length), + }, + { + label: 'Sedang berada di window shift yang valid', + ok: Boolean(roster?.activeShift?.withinWindow), + }, + { + label: 'GPS tersedia', + ok: + typeof location?.latitude === 'number' || + (manualLatitude.trim() !== '' && manualLongitude.trim() !== ''), + }, + { + label: 'Foto selfie dipilih', + ok: Boolean(selectedFile), + }, + { + label: 'Belum ada clock in hari ini', + ok: !hasClockedIn, + }, + ]; + + return ( + <> + + {getPageTitle('Clock in')} + + + + + {''} + + +
+ +
+
+
+ + Flow Clock In Otomatis v2 +
+
+

+ Clock in otomatis dengan roster shift, GPS, selfie, dan denda telat per menit. +

+

+ Sistem akan otomatis mencocokkan absensi ke shift yang tersedia pada divisi user. + Clock in hanya boleh dilakukan 60 menit sebelum sampai 60 menit sesudah shift + dimulai. +

+
+
+ +
+
+
Employee
+
{employeeLabel}
+
+ {context?.employee?.employee_code || 'Employee code belum ada'} +
+
+
+
Outlet
+
{outletLabel}
+
+ {context?.outlet?.address || 'Hubungkan employee ke outlet untuk auto clock in'} +
+
+
+
Divisi & shift
+
{jobPosition?.name || 'Belum ada job position'}
+
{availableShiftLabel}
+
+
+
+
+ + {pageError ? ( +
+ {pageError} +
+ ) : null} + + {context?.setupError ? ( +
+ {context.setupError} Minta admin memastikan employee, outlet, dan job position sudah terhubung. +
+ ) : null} + + {!context?.setupError && context?.clockInBlockedReason && !hasClockedIn ? ( +
+ {context.clockInBlockedReason} +
+ ) : null} + + {result?.success ? ( +
+
+
+ +
+
+
Clock in berhasil disimpan.
+
+ {result.action === 'updated' ? 'Record hari ini diperbarui.' : 'Record baru dibuat.'}{' '} + Jam masuk: {formatDateTime(result.attendanceLog.check_in_at)}. + {result.roster.assignedShift + ? ` Shift terpilih otomatis: ${result.roster.assignedShift.label} (${result.roster.assignedShift.windowLabel}).` + : ''} + {typeof result.attendanceLog.late_minutes === 'number' + ? ` Keterlambatan: ${result.attendanceLog.late_minutes} menit.` + : ''} +
+ {canReadAttendanceLogs ? ( + + + + + ) : null} +
+
+
+ ) : null} + +
+
+ +
+
+
+
Status hari ini
+

+ {dayjs().format('dddd, DD MMMM YYYY')} +

+

+ Refresh halaman ini jika admin baru saja mengubah job position atau jadwal shift user. +

+
+ + + {hasClockedIn + ? todayLog?.status === 'telat' + ? 'Sudah clock in • telat' + : 'Sudah clock in' + : roster?.activeShift + ? `Shift aktif • ${roster.activeShift.label}` + : 'Belum clock in'} + +
+ +
+
+
+ + Jam masuk tercatat +
+
+ {todayLog?.check_in_at ? formatTime(todayLog.check_in_at) : '—'} +
+
+ {todayLog?.check_in_at + ? formatDateTime(todayLog.check_in_at) + : 'Belum ada record check in untuk hari ini.'} +
+
+ +
+
+ + Shift yang dipakai +
+
+ {roster?.assignedShift?.label || roster?.activeShift?.label || '—'} +
+
+ {roster?.assignedShift + ? `Window shift: ${roster.assignedShift.windowLabel}` + : roster?.nextShift + ? `Window berikutnya: ${roster.nextShift.windowLabel}` + : 'Belum ada shift yang cocok untuk waktu sekarang.'} +
+
+ +
+
+ + Keterlambatan & denda +
+
{lateMinutes} menit
+
+ Denda estimasi: {formatCurrency(latePenaltyAmount)} +
+
+
+ +
+
+
+ +

Konfigurasi roster divisi

+
+
+
+ Job position + + {jobPosition?.name || 'Belum diatur'} + +
+
+ Jam shift + + {availableShiftLabel} + +
+
+ Rule absensi + + {roster + ? `${roster.windowBeforeMinutes} menit sebelum s/d ${roster.windowAfterMinutes} menit sesudah shift` + : '—'} + +
+
+ Denda telat + + {formatCurrency(roster?.latePenaltyPerMinute)} / menit + +
+
+
+ +
+
+ +

Shift aktif / shift berikutnya

+
+
+ {roster?.activeShift ? ( + <> +
+
+ Shift aktif sekarang: {roster.activeShift.label} +
+
+ Window: {roster.activeShift.windowLabel} +
+
+ Status saat submit: {roster.activeShift.status} • estimasi telat{' '} + {roster.activeShift.lateMinutes} menit +
+
+ + ) : roster?.nextShift ? ( +
+
Belum masuk window shift.
+
+ Shift terdekat: {roster.nextShift.label} • window {roster.nextShift.windowLabel} +
+
+ ) : ( +
+ Tidak ada shift aktif saat ini. +
+ )} +
+
+
+
+
+ + +
+
+
+
+
+ +

GPS browser

+
+

+ Halaman ini akan mencoba mengambil GPS otomatis. Kalau browser HP memblokir, + Anda masih bisa isi koordinat manual untuk testing dev. +

+
+ { + void requestLocation(); + }} + disabled={isLocating} + /> +
+ +
+
+
Latitude
+
+ {location?.latitude ?? '—'} +
+
+
+
Longitude
+
+ {location?.longitude ?? '—'} +
+
+
+ +
+ {location + ? `Diambil ${formatDateTime(location.capturedAt)}${ + location.accuracy ? ` • akurasi ±${Math.round(location.accuracy)} m` : '' + }` + : 'Belum ada GPS yang berhasil diambil.'} +
+ + {locationError ? ( +
+ {locationError} +
+ ) : null} + + {locationError ? ( +
+
+ + setManualLatitude(event.target.value)} + placeholder="-6.200000" + /> +
+
+ + setManualLongitude(event.target.value)} + placeholder="106.816666" + /> +
+
+ ) : null} +
+ +
+
+ +

Foto selfie

+
+

+ Di HP, tombol ini akan membuka kamera depan kalau browser mendukung capture "user". +

+ +
+ + + {selectedFile ? ( +
+
File siap diunggah
+
{selectedFile.name}
+ {previewUrl ? ( + Preview selfie check in + ) : null} +
+ ) : ( +
+ Belum ada foto selfie yang dipilih. +
+ )} +
+
+ +
+ +