Employee Master & Org
Business view
Before you can pay, schedule, or manage anyone, you need a real record of who they are. This is that foundation.
Every user in the clinic gets an employee record — one per person — holding the things HR actually needs but the login account never captured:
- Employment: department, position, manager (the reporting line that draws
the org chart), job grade, employee type (full-time, etc.), job title, and hire / termination / contract-end dates.
- Identity & compliance: national ID / iqama, passport, and professional
license numbers — each with an expiry date that feeds the daily reminder.
- Emergency contact and banking (bank, account number, IBAN, SWIFT) — the
banking is what the WPS file pays into.
You organize people with departments (each has a head) and positions (optionally under a department), and the reporting lines render as a live org chart (reporting tree or by-department).
Records are created lazily — a new user gets a record the first time HR opens their profile — and the old free-text User.job is backfilled into the new job title. Nothing has to be migrated by hand.
Technical view
Data model (packages/prisma/schema.prisma)
EmployeeRecord—schema.prisma:4302. One-to-one withUser(userId
@unique). Holds departmentId, positionId, jobTitle, employeeType (EmployeeType default FULL_TIME), managerId (scalar reporting line), hireDate / terminationDate / contractEndDate (all @db.Date), the compliance IDs + expiries (nationalId+nationalIdExpiry, passportNumber+passportExpiry, licenseNumber+licenseExpiry), emergency contact, banking (bankName, bankAccountNumber, iban, swiftCode), gradeId → JobGrade, and notes. Indexed by company, department, and manager.
Department—schema.prisma:4265. Company-scoped,nameunique per
company, optional managerId, relations to positions and employees.
Position—schema.prisma:4283. Company-scoped, optionaldepartmentId.JobGrade—schema.prisma:4567.code(G1…),level, and
minSalary / midSalary / maxSalary bands.
File.expiryDate— added atschema.prisma:3402(plusdocumentType) so
uploaded employee documents (contracts, certificates) carry an expiry too.
Mutations (resolvers/mutations/actions/hr/employeeMasterMutations.js)
All gated by MANAGE_HR. Two shared guards: assertInCompany (:8) tenant-scopes any referenced id or throws; duplicateNameGuard (:15) maps Prisma P2002 to a friendly hr.duplicateName.
upsertDepartment(:25) /deleteDepartment(:43) — validates the
manager belongs to the company; delete relies on ON DELETE SET NULL, so positions and employee records detach rather than block.
upsertPosition(:52) /deletePosition(:69) — same pattern,
validates the department is in-company.
updateEmployeeRecord(:84) — the core per-employee upsert, keyed on
userId (:108). Validates the target user and each referenced department/position/manager/grade are in-company. Blocks self-management: if (args.manager === args.user) throw hr.cannotManageSelf (:91). Text fields (RECORD_TEXT_FIELDS, :78) are trimmed with empty→null; date fields (RECORD_DATE_FIELDS, :77) parsed to Date or null; undefined args are skipped, giving partial-update semantics. On create it seeds jobTitle from the legacy user.job (:113).
Honest note: the master-mutation file's header mentions masking, but the masking is not implemented here — it happens read-side (see below and Security & Permissions).
Lazy provisioning & masking (read side)
The employeeProfile query (resolvers/queries/actions/payroll/payrollQueries.js:38) is where a missing record is lazily created (:72) and where sensitive fields are masked (:86): unless the viewer is the employee or holds DO_ALL/MANAGE_HR/MANAGE_SALARY_HUB, the fields bankName, bankAccountNumber, iban, swiftCode, nationalId, passportNumber are set to null.
Org chart & directory (resolvers/queries/actions/hr/)
orgChart(hrBoard.js:196) — a flat list of active staff with
managerId/dept/position/jobTitle; the client (OrgChart.js:69) builds the tree, cycle-safe.
departments(hrQueries.js:299) — includes_countof positions/employees
and hydrates each department's manager via a company-scoped lookup.
positions(hrQueries.js:319) — optional department filter, exposes
employeesCount.
Web UI (packages/clinic-web/src/components/dashboard/SalaryHub/)
- Directory:
HrPeople.js:16(route/hr/people). - Profile:
EmployeeProfilePage.js:119(/people/profile/:id) and the
quick-edit EmployeeProfileDrawer.js:141.
- Master form:
EmployeeMasterForm.js:49(sends only changed fields). - Departments/positions:
DepartmentsPositions.js(a Settings sub-tab). - Org chart:
OrgChart.js:69(route/hr/org).
Validation (permissions/inputRules.js)
updateEmployeeRecordInput (:3837) — all fields optional/nullable with length caps (jobTitle ≤100, IDs ≤50, notes ≤2000) and an IBAN regex ^[A-Za-z]{2}[0-9A-Za-z]{13,32}$.