Implements M0–M8 dari evolusi Absensi → HRIS. M9–M11 (Recruitment, Performance, Training) are not built — they are marked optional/LOW in the plan.
docker compose up -d # MySQL on host port 3307 (+ absensi_testing schema)
composer install
php artisan migrate
php artisan db:seed --class=HrisSeeder.env must point at the container:
DB_HOST=127.0.0.1
DB_PORT=3307
DB_DATABASE=absensi
DB_USERNAME=root
DB_PASSWORD=secret
Then grant yourself the top role:
php artisan tinker
>>> \App\Models\User::find(1)->assignRole('super_admin');HrisSeeder runs every reference-data seeder and is idempotent — safe to
re-run after an upgrade. It calls RolesAndPermissionsSeeder,
ApprovalFlowSeeder, LeaveTypeSeeder, DocumentTypeSeeder, TaxRateSeeder.
php artisan db:seed --class=DemoDataSeeder # refuses to run in production
php artisan serveSeeds a five-person company with a complete previous month of attendance,
approved and pending leave, a loan, and a full payroll run. Log in at
/admin/login with any of siti@ (super_admin), rina@ (hr_admin), budi@
(manager), ahmad@ / dewi@ (employee) demo.test, password password.
The previous month is used deliberately: a salary recap always measures against the whole month, so a part-way-through current month reads as mass absence.
The data is arranged to demonstrate the payroll fix side by side — Ahmad takes 3 days of approved paid leave, Dewi is absent 2 days with no request. Same attendance shortfall, opposite payroll outcome.
./vendor/bin/phpunit # 150 testsTests run against a separate absensi_testing schema (see phpunit.xml), so
they never touch development data.
backpack/crud without a PRO licence throws BackpackProRequiredException for
several things the docs show freely. Two that bit this build:
addFilter()is PRO. Filtering is done instead withApp\Traits\HasSimpleFilters, which reads plain GET parameters, applies them withaddClause(), and renders a filter bar (resources/views/vendor/backpack/crud/buttons/simple_filters.blade.php). Add filters through$this->applySimpleFilters([...]), neveraddFilter().- A CRUD model must use
CrudTrait. Spatie'sRole/Permissionmodels do not, soApp\Models\RoleandApp\Models\Permissionsubclass them purely to add the trait. Point CRUD controllers and relations at the app-local classes.
Also worth knowing: Backpack list rows load over AJAX from
POST /admin/<entity>/search. Fetching the list URL alone returns an empty
table shell — verify list data against the search endpoint.
Backpack authenticates admins on the backpack guard; roles are stored
against the web guard, which is also Laravel's default. This bites in
three predictable ways:
- Spatie's
@role/@canBlade directives read the default guard and are therefore false for a logged-in admin. In admin views usebackpack_user()->hasRole(...)/backpack_user()->can(...). $request->user()is null for admins. UseApp\Traits\ResolvesAuthenticatedUser, which theroleandpermissionmiddleware already do.CheckIfAdmindecides who may see/admin/*. A user with no roles at all is treated as an admin so accounts predating the roles upgrade are not locked out; accounts limited toemployeeare redirected to/my.
| Role | Scope |
|---|---|
super_admin |
Everything, incl. roles, permissions and approval-flow config |
hr_admin |
All HR operations. Cannot change roles, permissions or approval flows |
manager |
Team visibility + acting on approvals |
employee |
Self-service portal only |
Middleware: ->middleware('role:hr_admin,manager'), ->middleware('permission:leave.approve').
Roles & permissions (spatie/laravel-permission), approval engine, audit trail.
Tables: approval_flows, approval_flow_steps, approvals, approval_actions.
One active flow per module (leave, loan, overtime); each ordered step names
an approver by role, by the requester's manager, or by a specific user.
Make any model approvable:
class LeaveRequest extends Model
{
use \App\Traits\HasApproval;
public function approvalModule(): string { return 'leave'; }
public function onApprovalApproved($approval) { /* ... */ }
public function onApprovalRejected($approval) { /* ... */ }
public function onApprovalCancelled($approval) { /* ... */ }
}ApprovalService: submitForApproval, approve, reject, cancel,
getNextApprovers, getPendingForUser.
Every state change runs in a transaction with SELECT … FOR UPDATE on the
approval row and re-authorises against the step current at lock time, so two
approvers racing cannot both succeed. One live approval per record is enforced by
a unique index, not just application code.
Errors: \DomainException for caller mistakes (wrong approver, not pending,
blank reason, duplicate submit); \RuntimeException for misconfiguration (no
active flow, flow with no steps).
Attach App\Traits\Auditable to log create/update/delete into audit_logs.
Viewer at /admin/audit-log; pruned monthly via audit:prune --days=90.
departments (self-nesting, with a head), positions, plus new users columns:
department_id, position_id, employee_id, join_date, employment_status,
phone, address, manager_id.
Nesting is cycle-guarded: the CRUD refuses a parent that is the department
itself or one of its descendants, and descendants() terminates even if bad
data already contains a loop.
User::employed() scopes to active + probation — use it anywhere payroll or
headcount is involved, so resigned staff keep their history without appearing in
current figures.
Org chart: /admin/org-chart.
Tables: leave_types, leave_balances, leave_requests, leave_request_dates.
leave_balances.remaining is a generated column computed as
quota + carry_over - used. The original plan specified quota - used, which
silently discards carried-over days.
Before this module, every day an employee was not present counted as an unpaid
absence — approved holidays and sick days quietly docked pay. SalaryService
now distinguishes three cases:
| Case | Counts as absence | Deducted |
|---|---|---|
| Approved paid leave | no | no |
| Approved unpaid leave | no | yes |
| Unexplained absence | yes | yes |
Pending leave excuses nothing. See tests/Feature/SalaryLeaveIntegrationTest.php.
LeaveService skips weekends (per the user's schedule) and national holidays
when counting days, refuses overlapping requests, enforces
max_consecutive_days and requires_attachment, and checks quota twice — once
for fast feedback and again under a row lock at approval time.
Yearly setup (idempotent, scheduled 1 Jan):
php artisan leave:generate-balances --carry-over --max-carry=6Mid-year joiners get a prorated quota.
notifications + notification_preferences. Channels: database (always
written, it is the audit trail), email, WhatsApp. Delivery never throws — a mail
or gateway failure must not roll back the action that triggered it.
WhatsApp falls back to LogWhatsAppGateway (logs only) until FONNTE_TOKEN is
set, so nothing silently pretends to have sent.
Bell in the admin topbar; full list at /admin/notification and /my/notifications.
Scheduled: missing check-in 08:15, lateness 09:30, missing check-out 17:00 (weekdays); approval digest Mondays 08:00; expiring documents Mondays 07:30.
/my/*, sharing Backpack's login and guard — no second auth system. Blade +
Bootstrap 5 via CDN, no build step.
Dashboard, attendance history, payslips, leave (balance / request / cancel), loans, profile, password, notifications.
Every query is scoped to the authenticated user, and no route accepts a user id from the request. Employees may edit only contact details — name, department and employment status stay with HR.
Tables: employee_tax_profiles, ptkp_rates, pph21_brackets, bpjs_rates,
plus statutory columns on salary_recaps.
Rates are not hard-coded — PTKP, brackets and BPJS percentages are stored per year, because the government revises them and a historical recalculation must use the rates of its own period. If a year is missing, the most recent published year is used rather than contributing nothing.
TaxService:
calculateBPJS— Kesehatan (1%/4%, capped at 12,000,000), JHT (2%/3.7%, uncapped), JP (1%/2%, capped at 10,042,300), JKK and JKM (employer-only).calculatePPh21— annualised: gross × 12, less biaya jabatan (5%, capped at 6,000,000/yr), less employee JHT+JP, less PTKP; progressive brackets; ÷ 12. Employees without an NPWP get the 20% surcharge.calculateTHR— a full month after 12 months' service, prorated below that, nothing under one month.
Verify the seeded rates against current regulations before running real payroll.
TaxRateSeederreflects PMK 101/2016 and UU HPP No. 7/2021 as at the time of writing, and JKK is seeded at the lowest risk class (0.24%).
Reports: /admin/tax-report/annual (SPT basis), /admin/tax-report/bpjs.
document_types + employee_documents.
Files are stored on the local (private) disk, not public — identity
documents and contracts must not be reachable by guessing a URL. Downloads
stream through the app after an access check: HR sees everything, everyone else
only their own. Deleting a record deletes its file.
Per-type rules for allowed extensions, max size and whether an expiry date is
mandatory. Completeness checklist at /admin/employee-document/completeness;
expiry alerts via documents:notify-expiring --days=30.
branches + users.branch_id + presences.branch_id.
Geofencing is now per branch (coordinates and radius), replacing the
global config and the 100 m radius that was hard-coded in PresenceService.
Resolution order: the branch recorded on the presence row → the user's branch →
global app.office_lat/lng/radius. With no reference point configured anywhere,
scans are treated as on-site rather than flagging everyone.
A presence keeps the branch it was recorded against, so recalculating history after someone transfers office does not re-attribute it.
The migration creates one branch from existing config and attaches every user to it, so single-site behaviour is unchanged after the upgrade.
.envshippedLAT/LNGwith trailing semicolons (-6.8493328;). The old code hid this behind a(float)cast at the call site;config/app.phpnow casts once, centrally.
/admin/dashboard overrides Backpack's stock dashboard: today's attendance,
month payroll totals, a 12-month trend (Chart.js), top latecomers, leave this
week, headcount. Today's figures are cached for 5 minutes — call
DashboardService::flushCache() if a figure must be immediately fresh.
Reports: /admin/report/attendance, /report/salary, /report/loan,
/report/headcount, plus the leave and tax reports from M2/M5.
The dashboard counts people on approved leave separately from absentees, so leave no longer shows up as unexplained absence there either.
| Schedule | Command |
|---|---|
| weekdays 08:15 | notify:attendance --type=checkin |
| weekdays 09:30 | notify:attendance --type=late |
| weekdays 17:00 | notify:attendance --type=checkout |
| Mondays 07:30 | documents:notify-expiring --days=30 |
| Mondays 08:00 | notify:approval-digest |
| 1 Jan 01:00 | leave:generate-balances --carry-over --max-carry=6 |
| monthly | audit:prune --days=90 |
Found while building:
CheckIfAdminreturnedtruefor everyone, so any authenticated user could reach the whole admin panel once roles existed.hr_adminhad been seeded with all 44 permissions — identical tosuper_admin. Role/permission/approval-flow management is now super-admin only.PresenceService::calculateExtraTime()crashed on users without a schedule;calculateLate()andcalculateOvertime()already guarded this.- The stock
ExampleTestasserted/returns 200, but the app redirects to the QR scan page.
Found only by running the app against seeded data (the test suite was green throughout):
- Geofence never evaluated on insert.
presences.outsidedefaults to1andPresenceObserveronly recalculated coordinates on update, so any row created with its coordinates already attached — import, API, seeder — was permanently flagged off-site. Every seeded presence showed "Di Luar Radius" untilcreated()was fixed to evaluate the geofence too. approved_byrecorded the first approver, not the last. Theactions()relation carriesorderBy('step_order'), so appending->latest('acted_at')producedORDER BY step_order ASC, acted_at DESCand step 1 still won. On a manager→HR chain the manager was credited with the final approval. Fixed with->reorder(); same bug affectedrejection_reason. Both now have regression tests.