Autosave: 20260505-060240

This commit is contained in:
Flatlogic Bot 2026-05-05 06:02:39 +00:00
parent 7587f1c3a4
commit 57f98695be
22 changed files with 3742 additions and 276 deletions

View File

@ -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 = [
{

View File

@ -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,
),
};
}

View File

@ -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;
}
},
};

View File

@ -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: {

View File

@ -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:

View File

@ -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',
];

View File

@ -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:

View File

@ -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;
}
}
};

View File

@ -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;
}
}
};

View File

@ -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';

View File

@ -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,
},
{

View File

@ -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'

View File

@ -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'

View File

@ -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',

File diff suppressed because it is too large Load Diff

View File

@ -1,166 +1,246 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import type { ReactElement } from 'react';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const heroMetrics = [
{ label: 'Cabang aktif', value: '4 outlet' },
{ label: 'Skala tim', value: '±250 karyawan' },
{ label: 'Core flow', value: 'Absensi → Payroll → KPI' },
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const modules = [
{
title: 'Absensi mobile web',
text: 'Check-in/out via HP dengan GPS radius outlet, selfie wajib, dan status hadir/telat/izin/sakit/dinas.',
},
{
title: 'Payroll generator FnB',
text: 'Hitung gaji pokok, makan-transport, lembur, service charge, bonus omzet, KPI, dan deduction dalam satu draft payroll.',
},
{
title: 'KPI 5 indikator',
text: 'Absensi, disiplin, produktivitas, attitude, dan SOP dengan skor final untuk tunjangan kinerja yang transparan.',
},
{
title: 'WA-ready operations',
text: 'Struktur payslip, status delivery, dan notifikasi payroll sudah siap diperluas ke WhatsApp pada iterasi berikutnya.',
},
];
const title = 'HRIS FnB Multi Cabang'
const roles = [
{
title: 'HR / Superadmin',
text: 'Global dashboard, master data, setting komponen gaji, generate & approve payroll, serta reporting lintas outlet.',
},
{
title: 'Manager Outlet',
text: 'Input lembur, izin/sakit/dinas, omzet cabang, KPI, catatan, SP, dan data karyawan untuk outlet masing-masing.',
},
{
title: 'Finance',
text: 'Salary checking, validasi payroll outlet, input pinjaman, dan monitoring pembayaran gaji.',
},
{
title: 'Employee Self Service',
text: 'Absensi pribadi, riwayat kehadiran, slip gaji, dan update data diri dalam pengalaman mobile-friendly.',
},
];
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const workflowSteps = [
'Karyawan melakukan absensi mobile dengan GPS + foto selfie.',
'Manager melengkapi input operasional: izin, sakit, lembur, omzet, dan KPI.',
'Finance memeriksa pinjaman serta validasi payroll outlet.',
'HR membuka Payroll Workbench untuk preview, generate draft, review, lalu approve payslip.',
];
export default function LandingPage() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('HRIS FnB Multi Cabang')}</title>
<meta
name="description"
content="HRIS FnB multi cabang untuk absensi GPS + selfie, payroll otomatis, KPI, dan operasional HR yang lebih rapi."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your HRIS FnB Multi Cabang app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="min-h-screen bg-[#F4F7FB] text-slate-900">
<div className="relative overflow-hidden bg-slate-950 text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(20,184,166,0.28),_transparent_36%),radial-gradient(circle_at_top_right,_rgba(59,130,246,0.22),_transparent_30%),linear-gradient(135deg,_#020617,_#0F172A_45%,_#042F2E)]" />
<div className="relative mx-auto max-w-7xl px-6 py-6 lg:px-8">
<header className="flex flex-col gap-4 border-b border-white/10 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold uppercase tracking-[0.35em] text-teal-200">HRIS FnB Multi Cabang</div>
<div className="mt-2 text-sm text-slate-300">
Mobile-friendly web app for attendance, payroll, KPI, and branch operations.
</div>
</div>
<nav className="flex flex-wrap items-center gap-3">
<Link
href="/login"
className="inline-flex items-center rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-white transition hover:border-white/30 hover:bg-white/5"
>
Login
</Link>
<Link
href="/dashboard"
className="inline-flex items-center rounded-full bg-teal-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-teal-300"
>
Admin Interface
</Link>
</nav>
</header>
<div className="grid gap-12 py-16 lg:grid-cols-[1.1fr,0.9fr] lg:items-center">
<div>
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-teal-100">
Initial MVP slice already inside the admin app
</span>
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl">
Payroll closure yang lebih cepat untuk operasional FnB multi outlet.
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">
Dirancang untuk HR, Manager, Finance, dan Karyawan: absensi GPS + selfie, payroll otomatis,
KPI 5 indikator, service charge, bonus omzet, pinjaman, dan payslip yang siap dipakai di workflow harian.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Link
href="/login"
className="inline-flex items-center rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-slate-100"
>
Masuk ke aplikasi
</Link>
<Link
href="/dashboard"
className="inline-flex items-center rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:border-white/30 hover:bg-white/5"
>
Buka admin dashboard
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{heroMetrics.map((metric) => (
<div
key={metric.label}
className="rounded-3xl border border-white/10 bg-white/10 px-4 py-5 backdrop-blur"
>
<div className="text-xs uppercase tracking-[0.2em] text-slate-300">{metric.label}</div>
<div className="mt-3 text-2xl font-semibold text-white">{metric.value}</div>
</div>
))}
</div>
</div>
<CardBox className="border-0 bg-white/95 shadow-2xl">
<div className="rounded-[28px] bg-[linear-gradient(135deg,_#0F172A,_#1E293B_55%,_#0F766E)] p-6 text-white">
<div className="text-sm font-semibold uppercase tracking-[0.24em] text-teal-100">Highlighted first workflow</div>
<h2 className="mt-4 text-3xl font-semibold">Payroll Workbench</h2>
<p className="mt-4 text-sm leading-7 text-slate-200">
HR dapat memilih outlet + periode, melihat preview payroll dari data attendance/KPI/omzet yang sudah ada,
lalu generate draft payroll dan langsung lanjut ke detail period, payroll items, serta payslip.
</p>
<div className="mt-6 grid gap-3 text-sm text-slate-100">
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
Preview THP, overtime, bonus omzet, service charge, dan deduction sebelum approve.
</div>
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
Warning state untuk attendance/KPI yang masih kosong agar tim review lebih cepat.
</div>
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
Generate draft payroll run + payslip records lalu buka detail screen yang sudah tersedia.
</div>
</div>
</div>
</CardBox>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
<main className="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<section>
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-teal-600">Core modules</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-950 md:text-4xl">
Built around the real FnB HR workflow, not generic admin pages.
</h2>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{modules.map((module) => (
<CardBox key={module.title} className="border border-slate-200 shadow-sm">
<div className="text-lg font-semibold text-slate-950">{module.title}</div>
<p className="mt-3 text-sm leading-7 text-slate-500">{module.text}</p>
</CardBox>
))}
</div>
</section>
<section className="mt-16 grid gap-6 lg:grid-cols-[0.9fr,1.1fr]">
<CardBox className="border border-slate-200 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-teal-600">Role-based operations</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-950">Setiap role dapat layar kerja yang relevan.</h2>
<div className="mt-8 space-y-4">
{roles.map((role) => (
<div key={role.title} className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="text-lg font-semibold text-slate-950">{role.title}</div>
<p className="mt-2 text-sm leading-7 text-slate-500">{role.text}</p>
</div>
))}
</div>
</CardBox>
<CardBox className="border border-slate-200 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-teal-600">Operational flow</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-950">From attendance capture to payroll release.</h2>
<div className="mt-8 space-y-5">
{workflowSteps.map((step, index) => (
<div key={step} className="flex gap-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-lg font-semibold text-white">
{index + 1}
</div>
<p className="pt-1 text-sm leading-7 text-slate-600">{step}</p>
</div>
))}
</div>
</CardBox>
</section>
<section className="mt-16">
<CardBox className="overflow-hidden border-0 bg-[linear-gradient(135deg,_#0F172A,_#0F766E_55%,_#14B8A6)] text-white shadow-2xl">
<div className="grid gap-8 lg:grid-cols-[1.15fr,0.85fr] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-teal-100">Ready for the next iteration</p>
<h2 className="mt-3 text-3xl font-semibold md:text-4xl">Lanjutkan dari payroll draft ke approval, payslip delivery, dan WhatsApp notifications.</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-100 md:text-base">
Seed app CRUD tetap dipakai sebagai pondasi. Lapisan domain berikutnya bisa menambah approval workflow,
employee self-service attendance screen, dan dashboard KPI/outlet yang lebih spesifik.
</p>
</div>
<div className="flex flex-wrap gap-4 lg:justify-end">
<Link
href="/login"
className="inline-flex items-center rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-slate-100"
>
Login sekarang
</Link>
<Link
href="/dashboard"
className="inline-flex items-center rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:border-white/35 hover:bg-white/5"
>
Masuk ke admin interface
</Link>
</div>
</div>
</CardBox>
</section>
</main>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -161,6 +161,7 @@ const EditJob_positionsPage = () => {
payroll_weight: '',
shift_schedule: '',
@ -519,6 +520,17 @@ const EditJob_positionsPage = () => {
<FormField
label="ShiftSchedule"
help="Pisahkan jam shift dengan koma. Contoh: 07:00, 12:00, 15:00"
>
<Field
as="textarea"
name="shift_schedule"
placeholder="07:00, 12:00, 15:00"
/>
</FormField>
<FormField label='organizations' labelFor='organizations'>
<Field
name='organizations'

View File

@ -35,6 +35,7 @@ const Job_positionsTablesPage = () => {
const [filters] = useState([{label: 'PositionCode', title: 'code'},{label: 'PositionName', title: 'name'},
{label: 'ShiftSchedule', title: 'shift_schedule'},
{label: 'PayrollWeight', title: 'payroll_weight', number: 'true'},

View File

@ -98,6 +98,7 @@ const initialValues = {
payroll_weight: '',
shift_schedule: '',
@ -348,6 +349,17 @@ const Job_positionsNew = () => {
<FormField
label="ShiftSchedule"
help="Pisahkan jam shift dengan koma. Contoh: 07:00, 12:00, 15:00"
>
<Field
as="textarea"
name="shift_schedule"
placeholder="07:00, 12:00, 15:00"
/>
</FormField>
<FormField label="organizations" labelFor="organizations">
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>

View File

@ -251,6 +251,11 @@ const Job_positionsView = () => {
<p className={'block font-bold mb-2'}>PayrollWeight</p>
<p>{job_positions?.payroll_weight || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>ShiftSchedule</p>
<p>{job_positions?.shift_schedule || 'No data'}</p>
</div>

View File

@ -0,0 +1,886 @@
import {
mdiAccountGroup,
mdiAlertCircleOutline,
mdiCashRegister,
mdiChartBoxOutline,
mdiClockOutline,
mdiCurrencyUsd,
mdiFileDocumentOutline,
mdiRefresh,
mdiStoreMarker,
} from '@mdi/js';
import axios from 'axios';
import dayjs from 'dayjs';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import BaseDivider from '../components/BaseDivider';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type OutletOption = {
id: string;
code: string;
name: string;
address?: string;
gps_radius_m?: number;
};
type RecentRun = {
id: string;
name: string;
status: string;
period_start: string;
period_end: string;
generated_at: string;
employeeCount: number;
totalTakeHomePay: number;
outlet?: {
id: string;
name: string;
code: string;
} | null;
generated_by_user?: string | null;
};
type PreviewEmployee = {
id: string;
employee_code?: string;
full_name: string;
workdays: number;
late_count: number;
kpi_score: number;
overtime_hours_total: number;
take_home_pay: number;
attendance_record_count: number;
has_kpi_score: boolean;
job_position?: {
name?: string;
} | null;
};
type PreviewResponse = {
config: {
periodMonth: string;
periodStart: string;
periodEnd: string;
standardWorkdays: number;
maxKpiAllowance: number;
latePenaltyAmount: number;
distributionMethod: 'equal' | 'weighted_by_position';
};
outlet: {
id: string;
name: string;
code: string;
address?: string;
gps_radius_m?: number;
};
readiness: {
blockers: string[];
warnings: string[];
employeeCount: number;
missingAttendanceCount: number;
missingKpiCount: number;
existingRunCount: number;
latestExistingRun?: {
id: string;
name: string;
status: string;
generated_at: string;
generated_by_user?: string | null;
} | null;
};
totals: {
projectedTakeHomePay: number;
averageTakeHomePay: number;
totalBaseSalary: number;
totalMealTransport: number;
totalPositionAllowance: number;
totalKpiAllowance: number;
totalRevenueBonus: number;
totalServiceCharge: number;
totalOvertime: number;
totalDeductions: number;
totalOvertimeHours: number;
grossRevenue: number;
serviceChargePool: number;
bonusPool: number;
bonusRate: number;
bonusTierLabel: string;
};
employees: PreviewEmployee[];
};
type GenerateResponse = {
success: boolean;
payrollPeriod: {
id: string;
name: string;
status: string;
generated_at: string;
};
generatedCount: number;
payslipCount: number;
totals: PreviewResponse['totals'];
warnings: string[];
};
type WorkbenchResponse = {
outlets: OutletOption[];
recentRuns: RecentRun[];
};
type WorkbenchForm = {
outletId: string;
periodMonth: string;
standardWorkdays: number;
maxKpiAllowance: number;
latePenaltyAmount: number;
distributionMethod: 'equal' | 'weighted_by_position';
};
const initialForm: WorkbenchForm = {
outletId: '',
periodMonth: dayjs().subtract(1, 'month').format('YYYY-MM'),
standardWorkdays: 26,
maxKpiAllowance: 500000,
latePenaltyAmount: 1000,
distributionMethod: 'weighted_by_position',
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(Number.isFinite(value) ? value : 0);
const formatShortDate = (value?: string | null) => {
if (!value) {
return '—';
}
return dayjs(value).format('DD MMM YYYY');
};
const formatMonthLabel = (value: string) => {
if (!value) {
return 'Belum dipilih';
}
return dayjs(`${value}-01`).format('MMMM YYYY');
};
const getStatusBadgeClasses = (status?: string) => {
switch (status) {
case 'paid':
return 'bg-emerald-100 text-emerald-700 border border-emerald-200';
case 'finance_approved':
case 'approved':
return 'bg-sky-100 text-sky-700 border border-sky-200';
case 'hr_approved':
case 'generated':
return 'bg-violet-100 text-violet-700 border border-violet-200';
case 'draft':
default:
return 'bg-slate-100 text-slate-700 border border-slate-200';
}
};
const getErrorMessage = (error: unknown) => {
if (axios.isAxiosError(error)) {
if (typeof error.response?.data === 'string' && error.response.data) {
return error.response.data;
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Something went wrong while loading payroll data.';
};
const StatCard = ({
icon,
label,
value,
hint,
}: {
icon: string;
label: string;
value: string;
hint: string;
}) => (
<div className="rounded-2xl border border-slate-200/80 bg-white/90 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500">{label}</span>
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-slate-950 text-white">
<BaseIcon path={icon} size={20} />
</span>
</div>
<div className="text-2xl font-semibold text-slate-950">{value}</div>
<div className="mt-2 text-sm text-slate-500">{hint}</div>
</div>
);
const PayrollWorkbenchPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const canGenerate = hasPermission(currentUser, 'CREATE_PAYROLL_PERIODS');
const [workbench, setWorkbench] = useState<WorkbenchResponse>({ outlets: [], recentRuns: [] });
const [form, setForm] = useState<WorkbenchForm>(initialForm);
const [preview, setPreview] = useState<PreviewResponse | null>(null);
const [generatedRun, setGeneratedRun] = useState<GenerateResponse | null>(null);
const [isLoadingWorkbench, setIsLoadingWorkbench] = useState(true);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [previewDirty, setPreviewDirty] = useState(false);
const selectedOutlet = useMemo(
() => workbench.outlets.find((outlet) => outlet.id === form.outletId),
[form.outletId, workbench.outlets],
);
const fetchWorkbench = useCallback(async () => {
setIsLoadingWorkbench(true);
setErrorMessage('');
try {
const { data } = await axios.get<WorkbenchResponse>('payroll_periods/workbench');
setWorkbench(data);
setForm((current) => {
if (current.outletId || !data.outlets.length) {
return current;
}
return {
...current,
outletId: data.outlets[0].id,
};
});
} catch (error) {
setErrorMessage(getErrorMessage(error));
} finally {
setIsLoadingWorkbench(false);
}
}, []);
const fetchPreview = useCallback(async (payload: WorkbenchForm) => {
if (!payload.outletId || !payload.periodMonth) {
return;
}
setIsLoadingPreview(true);
setErrorMessage('');
try {
const search = new URLSearchParams({
outletId: payload.outletId,
periodMonth: payload.periodMonth,
standardWorkdays: String(payload.standardWorkdays),
maxKpiAllowance: String(payload.maxKpiAllowance),
latePenaltyAmount: String(payload.latePenaltyAmount),
distributionMethod: payload.distributionMethod,
});
const { data } = await axios.get<PreviewResponse>(`payroll_periods/workbench/preview?${search.toString()}`);
setPreview(data);
setPreviewDirty(false);
} catch (error) {
setPreview(null);
setErrorMessage(getErrorMessage(error));
} finally {
setIsLoadingPreview(false);
}
}, []);
useEffect(() => {
void fetchWorkbench();
}, [fetchWorkbench]);
useEffect(() => {
if (!form.outletId) {
return;
}
void fetchPreview(form);
}, [fetchPreview, form.outletId, form.periodMonth]);
const handleFieldChange = <K extends keyof WorkbenchForm>(field: K, value: WorkbenchForm[K]) => {
setGeneratedRun(null);
setForm((current) => ({
...current,
[field]: value,
}));
if (field === 'outletId' || field === 'periodMonth') {
setPreviewDirty(false);
return;
}
setPreviewDirty(true);
};
const handleGenerate = async () => {
setIsGenerating(true);
setErrorMessage('');
try {
const { data } = await axios.post<GenerateResponse>('payroll_periods/workbench/generate', {
data: form,
});
setGeneratedRun(data);
await fetchWorkbench();
await fetchPreview(form);
} catch (error) {
setGeneratedRun(null);
setErrorMessage(getErrorMessage(error));
} finally {
setIsGenerating(false);
}
};
const topEmployees = preview?.employees.slice(0, 6) || [];
return (
<>
<Head>
<title>{getPageTitle('Payroll Workbench')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCashRegister} title="Payroll Workbench" main>
<BaseButton href="/payroll_periods/payroll_periods-list" color="info" label="Payroll periods" />
</SectionTitleLineWithButton>
<CardBox className="overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-slate-900 to-teal-700 text-white shadow-2xl">
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
<div>
<span className="inline-flex items-center rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-teal-100">
HRIS FnB Monthly Payroll Run
</span>
<h2 className="mt-4 text-3xl font-semibold leading-tight md:text-4xl">
Generate payroll drafts from attendance, KPI, overtime, omzet, and service charge in one flow.
</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-200 md:text-base">
This first iteration gives HR and Finance a focused command center: preview payroll assumptions,
spot missing data, generate a new draft run, then jump directly into existing payslip and payroll detail screens.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<BaseButton href="/payroll_items/payroll_items-list" color="info" outline label="Payroll items" />
<BaseButton href="/payslips/payslips-list" color="info" outline label="Payslips" />
<BaseButton href="/employees/employees-list" color="whiteDark" outline label="Employees" />
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/10 p-5 backdrop-blur">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-teal-100">Current setup</p>
<p className="mt-2 text-2xl font-semibold text-white">{formatMonthLabel(form.periodMonth)}</p>
</div>
<span className="inline-flex rounded-full border border-white/15 px-3 py-1 text-xs font-medium text-slate-200">
{selectedOutlet ? `${selectedOutlet.code}${selectedOutlet.name}` : 'Choose outlet'}
</span>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="flex items-center gap-3 text-sm text-slate-200">
<BaseIcon path={mdiAccountGroup} className="text-teal-200" />
Employees in preview
</div>
<div className="mt-2 text-3xl font-semibold text-white">{preview?.readiness.employeeCount || 0}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="flex items-center gap-3 text-sm text-slate-200">
<BaseIcon path={mdiFileDocumentOutline} className="text-teal-200" />
Recent payroll runs
</div>
<div className="mt-2 text-3xl font-semibold text-white">{workbench.recentRuns.length}</div>
</div>
</div>
<p className="mt-5 text-sm leading-6 text-slate-200">
{canGenerate
? 'You have permission to preview and generate a new payroll draft.'
: 'You can preview payroll assumptions here, but generating a draft requires CREATE_PAYROLL_PERIODS permission.'}
</p>
</div>
</div>
</CardBox>
{errorMessage ? (
<div className="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{errorMessage}
</div>
) : null}
{generatedRun ? (
<div className="mt-6 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="inline-flex rounded-full border border-emerald-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">
Payroll draft generated
</div>
<h3 className="mt-3 text-2xl font-semibold text-slate-950">{generatedRun.payrollPeriod.name}</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{generatedRun.generatedCount} payroll item(s) and {generatedRun.payslipCount} draft payslip(s) were created with a projected THP of{' '}
<span className="font-semibold text-slate-950">
{formatCurrency(generatedRun.totals.projectedTakeHomePay)}
</span>
.
</p>
</div>
<div className="flex flex-wrap gap-3">
<BaseButton
href={`/payroll_periods/payroll_periods-view/?id=${generatedRun.payrollPeriod.id}`}
color="info"
label="Open payroll detail"
/>
<BaseButton href="/payslips/payslips-list" color="info" outline label="Review payslips" />
</div>
</div>
</div>
) : null}
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr,1.05fr]">
<CardBox className="border border-slate-200 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-slate-950">Generate payroll draft</h3>
<p className="mt-2 text-sm leading-6 text-slate-500">
Set the payroll month, outlet, and the few assumptions that are still manual in this first release.
</p>
</div>
<button
type="button"
onClick={() => void fetchPreview(form)}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-950"
>
<BaseIcon path={mdiRefresh} size={16} />
Refresh preview
</button>
</div>
<BaseDivider />
{isLoadingWorkbench ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
Loading outlet and payroll context
</div>
) : !workbench.outlets.length ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
No outlet is available yet. Create outlets first so payroll can be generated per branch.
<div className="mt-4">
<BaseButton href="/outlets/outlets-list" color="info" label="Open outlets" />
</div>
</div>
) : (
<>
<FormField label="Outlet cabang">
<select
value={form.outletId}
onChange={(event) => handleFieldChange('outletId', event.target.value)}
>
{workbench.outlets.map((outlet) => (
<option value={outlet.id} key={outlet.id}>
{outlet.code} {outlet.name}
</option>
))}
</select>
</FormField>
<FormField label="Payroll month" help="Default is previous month, so HR can close last months payroll quickly.">
<input
type="month"
value={form.periodMonth}
onChange={(event) => handleFieldChange('periodMonth', event.target.value)}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Standard workdays">
<input
type="number"
min={1}
value={form.standardWorkdays}
onChange={(event) =>
handleFieldChange('standardWorkdays', Number(event.target.value || 26))
}
/>
</FormField>
<FormField label="Late penalty / minute" help="Default rule: Rp1.000 untuk setiap menit keterlambatan.">
<input
type="number"
min={0}
step={1000}
value={form.latePenaltyAmount}
onChange={(event) =>
handleFieldChange('latePenaltyAmount', Number(event.target.value || 0))
}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Max KPI allowance">
<input
type="number"
min={0}
step={10000}
value={form.maxKpiAllowance}
onChange={(event) =>
handleFieldChange('maxKpiAllowance', Number(event.target.value || 0))
}
/>
</FormField>
<FormField label="Bonus & service-charge split">
<select
value={form.distributionMethod}
onChange={(event) =>
handleFieldChange(
'distributionMethod',
event.target.value as WorkbenchForm['distributionMethod'],
)
}
>
<option value="weighted_by_position">Weighted by job position</option>
<option value="equal">Equal split per employee</option>
</select>
</FormField>
</div>
{selectedOutlet ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="flex items-center gap-2 font-medium text-slate-950">
<BaseIcon path={mdiStoreMarker} size={16} className="text-teal-600" />
{selectedOutlet.name}
</div>
<p className="mt-2 leading-6">
{selectedOutlet.address || 'Address is not filled yet.'} · GPS radius{' '}
<span className="font-semibold text-slate-950">{selectedOutlet.gps_radius_m || 0}m</span>
</p>
</div>
) : null}
{previewDirty ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Assumptions changed. Refresh the preview before sharing the numbers with HR or Finance.
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<BaseButton
onClick={() => void fetchPreview(form)}
color="info"
label={isLoadingPreview ? 'Refreshing preview…' : 'Preview payroll draft'}
disabled={isLoadingPreview || !form.outletId}
/>
<BaseButton
onClick={handleGenerate}
color="info"
outline
label={isGenerating ? 'Generating draft…' : 'Generate payroll draft'}
disabled={isGenerating || !canGenerate || !form.outletId}
/>
</div>
</>
)}
</CardBox>
<div className="space-y-6">
<CardBox className="border border-slate-200 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-slate-950">Payroll preview</h3>
<p className="mt-2 text-sm leading-6 text-slate-500">
A thin but real payroll projection using the current attendance, KPI, overtime, omzet, service-charge, and loan data.
</p>
</div>
<span className="inline-flex rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
{preview ? `${formatMonthLabel(preview.config.periodMonth)}${preview.outlet.name}` : 'Waiting for preview'}
</span>
</div>
<BaseDivider />
{isLoadingPreview && !preview ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
Building payroll preview
</div>
) : preview ? (
<>
{preview.readiness.blockers.length ? (
<div className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{preview.readiness.blockers[0]}
</div>
) : null}
{preview.readiness.warnings.length ? (
<div className="mb-5 space-y-3 rounded-3xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-800">
<BaseIcon path={mdiAlertCircleOutline} size={16} />
Review flags before approving the final payroll
</div>
<ul className="space-y-2 text-sm leading-6 text-amber-700">
{preview.readiness.warnings.map((warning) => (
<li key={warning}> {warning}</li>
))}
</ul>
{preview.readiness.latestExistingRun?.id ? (
<div className="text-sm">
<Link
href={`/payroll_periods/payroll_periods-view/?id=${preview.readiness.latestExistingRun.id}`}
className="font-semibold text-amber-900 underline underline-offset-4"
>
Open the latest existing payroll run
</Link>
</div>
) : null}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={mdiAccountGroup}
label="Employees"
value={String(preview.readiness.employeeCount)}
hint={`${preview.readiness.missingAttendanceCount} missing attendance • ${preview.readiness.missingKpiCount} missing KPI`}
/>
<StatCard
icon={mdiCurrencyUsd}
label="Projected THP"
value={formatCurrency(preview.totals.projectedTakeHomePay)}
hint={`Avg ${formatCurrency(preview.totals.averageTakeHomePay)} per employee`}
/>
<StatCard
icon={mdiChartBoxOutline}
label="Outlet revenue"
value={formatCurrency(preview.totals.grossRevenue)}
hint={`${preview.totals.bonusTierLabel} • bonus pool ${formatCurrency(preview.totals.bonusPool)}`}
/>
<StatCard
icon={mdiClockOutline}
label="Overtime + SC"
value={`${preview.totals.totalOvertimeHours.toFixed(1)}h`}
hint={`${formatCurrency(preview.totals.totalOvertime)} overtime • ${formatCurrency(preview.totals.totalServiceCharge)} service charge`}
/>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-slate-950">Earnings mix</div>
<div className="mt-4 space-y-3 text-sm text-slate-600">
<div className="flex items-center justify-between">
<span>Base salary</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalBaseSalary)}</span>
</div>
<div className="flex items-center justify-between">
<span>Meal & transport</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalMealTransport)}</span>
</div>
<div className="flex items-center justify-between">
<span>Position allowance</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalPositionAllowance)}</span>
</div>
<div className="flex items-center justify-between">
<span>KPI allowance</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalKpiAllowance)}</span>
</div>
<div className="flex items-center justify-between">
<span>Revenue bonus</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalRevenueBonus)}</span>
</div>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-slate-950">Deductions & distribution</div>
<div className="mt-4 space-y-3 text-sm text-slate-600">
<div className="flex items-center justify-between">
<span>Total deductions</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.totalDeductions)}</span>
</div>
<div className="flex items-center justify-between">
<span>Service charge pool</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.totals.serviceChargePool)}</span>
</div>
<div className="flex items-center justify-between">
<span>Distribution mode</span>
<span className="font-semibold text-slate-950">
{preview.config.distributionMethod === 'equal' ? 'Equal split' : 'Weighted by position'}
</span>
</div>
<div className="flex items-center justify-between">
<span>Late penalty / minute</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.config.latePenaltyAmount)} / min</span>
</div>
<div className="flex items-center justify-between">
<span>KPI allowance cap</span>
<span className="font-semibold text-slate-950">{formatCurrency(preview.config.maxKpiAllowance)}</span>
</div>
</div>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
Choose an outlet to load the payroll preview.
</div>
)}
</CardBox>
<CardBox className="border border-slate-200 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-slate-950">Employee payout snapshot</h3>
<p className="mt-2 text-sm leading-6 text-slate-500">
A fast read of the top employees in the current preview, before you open the full payroll detail screens.
</p>
</div>
<BaseButton href="/employees/employees-list" color="info" outline label="Employee master data" />
</div>
<BaseDivider />
{!topEmployees.length ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
No employees are available for this preview yet.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-left text-sm">
<thead>
<tr className="text-slate-500">
<th className="pb-3 pr-4 font-medium">Employee</th>
<th className="pb-3 pr-4 font-medium">Workdays</th>
<th className="pb-3 pr-4 font-medium">KPI</th>
<th className="pb-3 pr-4 font-medium">Overtime</th>
<th className="pb-3 text-right font-medium">Projected THP</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{topEmployees.map((employee) => (
<tr key={employee.id}>
<td className="py-4 pr-4">
<div className="font-semibold text-slate-950">{employee.full_name}</div>
<div className="text-xs text-slate-500">
{employee.employee_code || 'No code'}
{employee.job_position?.name ? `${employee.job_position.name}` : ''}
</div>
</td>
<td className="py-4 pr-4 text-slate-600">{employee.workdays}</td>
<td className="py-4 pr-4 text-slate-600">
{employee.has_kpi_score ? employee.kpi_score.toFixed(1) : 'Missing'}
</td>
<td className="py-4 pr-4 text-slate-600">{employee.overtime_hours_total.toFixed(1)}h</td>
<td className="py-4 text-right font-semibold text-slate-950">
{formatCurrency(employee.take_home_pay)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBox>
</div>
</div>
<CardBox className="mt-6 border border-slate-200 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-slate-950">Recent payroll runs</h3>
<p className="mt-2 text-sm leading-6 text-slate-500">
Reuse the existing list and detail pages after generation. This table gives HR a faster overview of recent periods.
</p>
</div>
<BaseButton href="/payroll_periods/payroll_periods-list" color="info" outline label="Open full list" />
</div>
<BaseDivider />
{!workbench.recentRuns.length ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm text-slate-500">
No payroll periods have been generated yet.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-left text-sm">
<thead>
<tr className="text-slate-500">
<th className="pb-3 pr-4 font-medium">Payroll run</th>
<th className="pb-3 pr-4 font-medium">Period</th>
<th className="pb-3 pr-4 font-medium">Outlet</th>
<th className="pb-3 pr-4 font-medium">Employees</th>
<th className="pb-3 pr-4 font-medium">THP total</th>
<th className="pb-3 pr-4 font-medium">Status</th>
<th className="pb-3 text-right font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{workbench.recentRuns.map((run) => (
<tr key={run.id}>
<td className="py-4 pr-4">
<div className="font-semibold text-slate-950">{run.name}</div>
<div className="text-xs text-slate-500">
Generated {formatShortDate(run.generated_at)}
{run.generated_by_user ? `${run.generated_by_user}` : ''}
</div>
</td>
<td className="py-4 pr-4 text-slate-600">
{formatShortDate(run.period_start)} {formatShortDate(run.period_end)}
</td>
<td className="py-4 pr-4 text-slate-600">{run.outlet?.name || '—'}</td>
<td className="py-4 pr-4 text-slate-600">{run.employeeCount}</td>
<td className="py-4 pr-4 font-semibold text-slate-950">
{formatCurrency(run.totalTakeHomePay)}
</td>
<td className="py-4 pr-4">
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${getStatusBadgeClasses(run.status)}`}>
{run.status.replaceAll('_', ' ')}
</span>
</td>
<td className="py-4 text-right">
<Link
href={`/payroll_periods/payroll_periods-view/?id=${run.id}`}
className="font-semibold text-teal-700 transition hover:text-teal-900"
>
Open detail
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBox>
</SectionMain>
</>
);
};
PayrollWorkbenchPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_PAYROLL_PERIODS">{page}</LayoutAuthenticated>;
};
export default PayrollWorkbenchPage;

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';