Autosave: 20260505-060240
This commit is contained in:
parent
7587f1c3a4
commit
57f98695be
@ -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 = [
|
||||
|
||||
{
|
||||
|
||||
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
];
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
1005
frontend/src/pages/clock-in.tsx
Normal file
1005
frontend/src/pages/clock-in.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'},
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
886
frontend/src/pages/payroll-workbench.tsx
Normal file
886
frontend/src/pages/payroll-workbench.tsx
Normal 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 month’s 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;
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user