Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57f98695be |
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -16,7 +15,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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,
|
id: data.id || undefined,
|
||||||
@ -37,22 +35,22 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
,
|
,
|
||||||
|
|
||||||
check_in_lat: data.check_in_lat
|
check_in_lat: data.check_in_lat
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_in_lng: data.check_in_lng
|
check_in_lng: data.check_in_lng
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_out_lat: data.check_out_lat
|
check_out_lat: data.check_out_lat
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_out_lng: data.check_out_lng
|
check_out_lng: data.check_out_lng
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
@ -62,7 +60,7 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
,
|
,
|
||||||
|
|
||||||
late_minutes: data.late_minutes
|
late_minutes: data.late_minutes
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
@ -129,7 +127,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
static async bulkImport(data, options) {
|
static async bulkImport(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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) => ({
|
const attendance_logsData = data.map((item, index) => ({
|
||||||
id: item.id || undefined,
|
id: item.id || undefined,
|
||||||
@ -150,22 +147,22 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
,
|
,
|
||||||
|
|
||||||
check_in_lat: item.check_in_lat
|
check_in_lat: item.check_in_lat
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_in_lng: item.check_in_lng
|
check_in_lng: item.check_in_lng
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_out_lat: item.check_out_lat
|
check_out_lat: item.check_out_lat
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
check_out_lng: item.check_out_lng
|
check_out_lng: item.check_out_lng
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
@ -175,7 +172,7 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
,
|
,
|
||||||
|
|
||||||
late_minutes: item.late_minutes
|
late_minutes: item.late_minutes
|
||||||
||
|
??
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
@ -232,8 +229,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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,7 +334,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
static async deleteByIds(ids, options) {
|
static async deleteByIds(ids, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const attendance_logs = await db.attendance_logs.findAll({
|
const attendance_logs = await db.attendance_logs.findAll({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
@ -368,7 +362,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
static async remove(id, options) {
|
static async remove(id, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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({
|
await attendance_logs.update({
|
||||||
@ -386,7 +379,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const attendance_logs = await db.attendance_logs.findOne(
|
const attendance_logs = await db.attendance_logs.findOne(
|
||||||
{ where },
|
{ where },
|
||||||
{ transaction },
|
{ transaction },
|
||||||
@ -476,10 +468,6 @@ module.exports = class Attendance_logsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -16,7 +14,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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,
|
id: data.id || undefined,
|
||||||
@ -39,6 +36,11 @@ module.exports = class Job_positionsDBApi {
|
|||||||
|
|
||||||
payroll_weight: data.payroll_weight
|
payroll_weight: data.payroll_weight
|
||||||
||
|
||
|
||||||
|
null
|
||||||
|
,
|
||||||
|
|
||||||
|
shift_schedule: data.shift_schedule
|
||||||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
@ -70,7 +72,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
static async bulkImport(data, options) {
|
static async bulkImport(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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) => ({
|
const job_positionsData = data.map((item, index) => ({
|
||||||
id: item.id || undefined,
|
id: item.id || undefined,
|
||||||
@ -94,6 +95,11 @@ module.exports = class Job_positionsDBApi {
|
|||||||
payroll_weight: item.payroll_weight
|
payroll_weight: item.payroll_weight
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
|
,
|
||||||
|
|
||||||
|
shift_schedule: item.shift_schedule
|
||||||
|
||
|
||||||
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
importHash: item.importHash || null,
|
||||||
@ -114,8 +120,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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.payroll_weight !== undefined) updatePayload.payroll_weight = data.payroll_weight;
|
||||||
|
|
||||||
|
if (data.shift_schedule !== undefined) updatePayload.shift_schedule = data.shift_schedule;
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
@ -171,7 +176,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
static async deleteByIds(ids, options) {
|
static async deleteByIds(ids, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const job_positions = await db.job_positions.findAll({
|
const job_positions = await db.job_positions.findAll({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
@ -200,7 +204,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
static async remove(id, options) {
|
static async remove(id, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
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({
|
await job_positions.update({
|
||||||
@ -218,7 +221,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const job_positions = await db.job_positions.findOne(
|
const job_positions = await db.job_positions.findOne(
|
||||||
{ where },
|
{ where },
|
||||||
{ transaction },
|
{ transaction },
|
||||||
@ -297,10 +299,6 @@ module.exports = class Job_positionsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const job_positions = sequelize.define(
|
const job_positions = sequelize.define(
|
||||||
'job_positions',
|
'job_positions',
|
||||||
@ -43,6 +37,13 @@ payroll_weight: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
shift_schedule: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
|
|||||||
@ -5,9 +5,6 @@ const Attendance_logsService = require('../services/attendance_logs');
|
|||||||
const Attendance_logsDBApi = require('../db/api/attendance_logs');
|
const Attendance_logsDBApi = require('../db/api/attendance_logs');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
@ -273,6 +270,21 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
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
|
* @swagger
|
||||||
* /api/attendance_logs:
|
* /api/attendance_logs:
|
||||||
|
|||||||
@ -5,9 +5,6 @@ const Job_positionsService = require('../services/job_positions');
|
|||||||
const Job_positionsDBApi = require('../db/api/job_positions');
|
const Job_positionsDBApi = require('../db/api/job_positions');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
@ -299,7 +296,7 @@ router.get('/', wrapAsync(async (req, res) => {
|
|||||||
);
|
);
|
||||||
if (filetype && filetype === 'csv') {
|
if (filetype && filetype === 'csv') {
|
||||||
const fields = ['id','code','name',
|
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 Payroll_periodsDBApi = require('../db/api/payroll_periods');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
@ -20,6 +17,22 @@ const {
|
|||||||
router.use(checkCrudPermissions('payroll_periods'));
|
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
|
* @swagger
|
||||||
* components:
|
* components:
|
||||||
|
|||||||
@ -1,36 +1,643 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Attendance_logsDBApi = require('../db/api/attendance_logs');
|
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 ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
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 {
|
module.exports = class Attendance_logsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await Attendance_logsDBApi.create(
|
await Attendance_logsDBApi.create(data, {
|
||||||
data,
|
|
||||||
{
|
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
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();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -38,7 +645,7 @@ module.exports = class Attendance_logsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
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) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
@ -49,13 +656,13 @@ module.exports = class Attendance_logsService {
|
|||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
});
|
||||||
|
|
||||||
await Attendance_logsDBApi.bulkImport(results, {
|
await Attendance_logsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -68,15 +675,13 @@ module.exports = class Attendance_logsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let attendance_logs = await Attendance_logsDBApi.findBy(
|
const attendance_logs = await Attendance_logsDBApi.findBy(
|
||||||
{ id },
|
{ id },
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!attendance_logs) {
|
if (!attendance_logs) {
|
||||||
throw new ValidationError(
|
throw new ValidationError('attendance_logsNotFound');
|
||||||
'attendance_logsNotFound',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAttendance_logs = await Attendance_logsDBApi.update(
|
const updatedAttendance_logs = await Attendance_logsDBApi.update(
|
||||||
@ -90,12 +695,11 @@ module.exports = class Attendance_logsService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedAttendance_logs;
|
return updatedAttendance_logs;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -117,13 +721,10 @@ module.exports = class Attendance_logsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Attendance_logsDBApi.remove(
|
await Attendance_logsDBApi.remove(id, {
|
||||||
id,
|
|
||||||
{
|
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -131,8 +732,4 @@ module.exports = class Attendance_logsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,608 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Payroll_periodsDBApi = require('../db/api/payroll_periods');
|
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 ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
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 {
|
module.exports = class Payroll_periodsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
@ -28,9 +621,9 @@ module.exports = class Payroll_periodsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -38,7 +631,7 @@ module.exports = class Payroll_periodsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
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) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
@ -49,13 +642,13 @@ module.exports = class Payroll_periodsService {
|
|||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
});
|
||||||
|
|
||||||
await Payroll_periodsDBApi.bulkImport(results, {
|
await Payroll_periodsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -68,15 +661,13 @@ module.exports = class Payroll_periodsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let payroll_periods = await Payroll_periodsDBApi.findBy(
|
const payroll_periods = await Payroll_periodsDBApi.findBy(
|
||||||
{ id },
|
{ id },
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!payroll_periods) {
|
if (!payroll_periods) {
|
||||||
throw new ValidationError(
|
throw new ValidationError('payroll_periodsNotFound');
|
||||||
'payroll_periodsNotFound',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPayroll_periods = await Payroll_periodsDBApi.update(
|
const updatedPayroll_periods = await Payroll_periodsDBApi.update(
|
||||||
@ -90,12 +681,11 @@ module.exports = class Payroll_periodsService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedPayroll_periods;
|
return updatedPayroll_periods;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
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 BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,21 @@ export const loadColumns = async (
|
|||||||
editable: hasUpdatePermission,
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
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,
|
icon: 'mdiMapMarkerCheck' in icon ? icon['mdiMapMarkerCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_ATTENDANCE_LOGS'
|
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',
|
href: '/attendance_requests/attendance_requests-list',
|
||||||
label: 'Attendance requests',
|
label: 'Attendance requests',
|
||||||
@ -152,6 +160,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: 'mdiCalendarMonth' in icon ? icon['mdiCalendarMonth' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiCalendarMonth' in icon ? icon['mdiCalendarMonth' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_PAYROLL_PERIODS'
|
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',
|
href: '/payroll_items/payroll_items-list',
|
||||||
label: 'Payroll items',
|
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 Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import type { ReactElement } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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 modules = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
{
|
||||||
src: undefined,
|
title: 'Absensi mobile web',
|
||||||
photographer: undefined,
|
text: 'Check-in/out via HP dengan GPS radius outlet, selfie wajib, dan status hadir/telat/izin/sakit/dinas.',
|
||||||
photographer_url: undefined,
|
},
|
||||||
})
|
{
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
title: 'Payroll generator FnB',
|
||||||
const [contentType, setContentType] = useState('video');
|
text: 'Hitung gaji pokok, makan-transport, lembur, service charge, bonus omzet, KPI, dan deduction dalam satu draft payroll.',
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
},
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
{
|
||||||
|
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
|
const workflowSteps = [
|
||||||
useEffect(() => {
|
'Karyawan melakukan absensi mobile dengan GPS + foto selfie.',
|
||||||
async function fetchData() {
|
'Manager melengkapi input operasional: izin, sakit, lembur, omzet, dan KPI.',
|
||||||
const image = await getPexelsImage();
|
'Finance memeriksa pinjaman serta validasi payroll outlet.',
|
||||||
const video = await getPexelsVideo();
|
'HR membuka Payroll Workbench untuk preview, generate draft, review, lalu approve payslip.',
|
||||||
setIllustrationImage(image);
|
];
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
export default function LandingPage() {
|
||||||
<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 (
|
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-[#F4F7FB] text-slate-900">
|
||||||
<div
|
<div className="relative overflow-hidden bg-slate-950 text-white">
|
||||||
className={`flex ${
|
<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)]" />
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="relative mx-auto max-w-7xl px-6 py-6 lg:px-8">
|
||||||
} min-h-screen w-full`}
|
<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"
|
||||||
>
|
>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
Login
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<Link
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
href="/dashboard"
|
||||||
? videoBlock(illustrationVideo)
|
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"
|
||||||
: null}
|
>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
Admin Interface
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
</Link>
|
||||||
<CardBoxComponentTitle title="Welcome to your HRIS FnB Multi Cabang app!"/>
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="grid gap-12 py-16 lg:grid-cols-[1.1fr,0.9fr] lg:items-center">
|
||||||
<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>
|
<div>
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<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">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
Initial MVP slice already inside the admin app
|
||||||
</div>
|
</span>
|
||||||
|
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl">
|
||||||
<BaseButtons>
|
Payroll closure yang lebih cepat untuk operasional FnB multi outlet.
|
||||||
<BaseButton
|
</h1>
|
||||||
href='/login'
|
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">
|
||||||
label='Login'
|
Dirancang untuk HR, Manager, Finance, dan Karyawan: absensi GPS + selfie, payroll otomatis,
|
||||||
color='info'
|
KPI 5 indikator, service charge, bonus omzet, pinjaman, dan payslip yang siap dipakai di workflow harian.
|
||||||
className='w-full'
|
</p>
|
||||||
/>
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
<Link
|
||||||
</BaseButtons>
|
href="/login"
|
||||||
</CardBox>
|
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"
|
||||||
</div>
|
>
|
||||||
</div>
|
Masuk ke aplikasi
|
||||||
</SectionFullScreen>
|
</Link>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<Link
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
href="/dashboard"
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
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"
|
||||||
Privacy Policy
|
>
|
||||||
|
Buka admin dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -161,6 +161,7 @@ const EditJob_positionsPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
payroll_weight: '',
|
payroll_weight: '',
|
||||||
|
shift_schedule: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -518,6 +519,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'>
|
<FormField label='organizations' labelFor='organizations'>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const Job_positionsTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'PositionCode', title: 'code'},{label: 'PositionName', title: 'name'},
|
const [filters] = useState([{label: 'PositionCode', title: 'code'},{label: 'PositionName', title: 'name'},
|
||||||
|
{label: 'ShiftSchedule', title: 'shift_schedule'},
|
||||||
{label: 'PayrollWeight', title: 'payroll_weight', number: 'true'},
|
{label: 'PayrollWeight', title: 'payroll_weight', number: 'true'},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,7 @@ const initialValues = {
|
|||||||
|
|
||||||
|
|
||||||
payroll_weight: '',
|
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">
|
<FormField label="organizations" labelFor="organizations">
|
||||||
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
|
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
|
||||||
|
|||||||
@ -252,6 +252,11 @@ const Job_positionsView = () => {
|
|||||||
<p>{job_positions?.payroll_weight || 'No data'}</p>
|
<p>{job_positions?.payroll_weight || 'No data'}</p>
|
||||||
</div>
|
</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 React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user