Frontend (Nuxt layer + BFF)
Sơ đồ tầng
Phần tiêu đề “Sơ đồ tầng”pages/hr/** + components/hr/** │ gọi composable (không tự $fetch) ▼app/composables/use-hr-api.ts ← raw $fetch wrapper cho MỌI route HR │app/composables/use-people.ts, use-departments.ts, use-leave.ts, … ← entity composable dùng use-hr-api() │ $fetch (cookie-auth, cùng origin) ▼server/api/hr/** ← BFF: assertPermission({module:'hr', action}) (+ stage-aware cho vài route) + customEndpoint proxy │ @odp/sdk userApi.request() ▼backend/extensions/hr (ODP API)Composable: 1 raw wrapper + nhiều entity composable
Phần tiêu đề “Composable: 1 raw wrapper + nhiều entity composable”Không như helpdesk (14 entity store độc lập, mỗi cái tự $fetch), HR gom toàn bộ lời gọi HTTP vào một composable duy nhất — use-hr-api.ts — rồi các composable “nghiệp vụ” phía trên gọi lại nó thay vì tự $fetch:
Thư mụcapp/composables/
- use-hr-api.ts raw $fetch wrapper — MỌI endpoint HR đi qua đây
- use-people.ts state danh sách người (talents/employees derive từ
stage), hire/reactivate/transition - use-departments.ts
- use-leave.ts
- use-holidays.ts
- use-employee-detail.ts provide/inject context cho layout chi tiết nhân viên (không gọi API)
- use-hr-settings.ts
- use-hr-policies.ts
- use-hr-field-registry.ts
- use-triggers.ts
- use-templates.ts
- use-providers.ts
- use-offboarding.ts
- use-recruitment-workflow.ts
use-employee-detail.ts là composable duy nhất không gọi use-hr-api() — nó chỉ là một cặp provide/inject (EmployeeDetailContext) để layout hr/employees/[id]/*.vue chia sẻ person hiện tại + isReadonly(tab) + onUpdated() xuống các tab con mà không phải truyền prop qua nhiều lớp.
unwrap() — chống lệch shape response
Phần tiêu đề “unwrap() — chống lệch shape response”use-hr-api.ts mở đầu bằng một helper nhỏ dùng lại ở gần như mọi hàm:
export function useHrApi() { const { $api } = useNuxtApp()
function unwrap<T>(res: T | { data: T }): T { return (res && typeof res === 'object' && 'data' in res) ? (res as any).data : res }Lý do cần unwrap(): một số response backend đã có envelope { data }, một số route BFF trả thẳng object (khi $api tự unwrap từ tầng SDK). Thay vì mỗi hàm tự đoán shape, mọi lời gọi đều bọc qua unwrap() — kể cả khi giá trị vào là T thuần (không có field data), hàm trả nguyên nó.
Danh sách người là trường hợp không dùng unwrap() cho top-level vì cần giữ meta riêng:
async function listPeople(query?: Record<string, string>) { const res = await $api<any>('/hr/people', { query }) return { data: unwrap(res.data ?? res), meta: res.meta ?? null }}Contact-as-master trong cùng composable
Phần tiêu đề “Contact-as-master trong cùng composable”Hai hàm dưới đây là cầu nối sang Tích hợp Contacts — nằm chung file với CRUD người thường, không tách riêng:
async function getPersonByContact(contactId: string | number) { return unwrap(await $api<Person | null | { data: Person | null }>(`/hr/people/by-contact/${contactId}`))}
async function convertFromContact(contactId: string | number, payload: { stage: string }) { return unwrap(await $api<Person | { data: Person }>('/hr/people/from-contact', { method: 'POST', body: { contact_id: Number(contactId), ...payload }, }))}BFF pattern: assertPermission + customEndpoint proxy — nhưng stage-aware
Phần tiêu đề “BFF pattern: assertPermission + customEndpoint proxy — nhưng stage-aware”Route BFF đơn giản (đọc) giống hệt helpdesk — chặn quyền cố định rồi proxy nguyên văn:
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => { assertPermission(event, { module: 'hr', action: 'read' }) const userApi = event.context.userApi const query = getQuery(event)
const result = await userApi.request(customEndpoint({ path: "/hr/people", method: "GET", params: query, }))
return result})Route tạo người thì khác — action quyền không cố định mà tính theo stage trong body, vì tạo một talent (đội Recruiter) và tạo một probation/nhân viên (đội HR) đòi 2 permission khác nhau (recruitment.write vs create — xem Thiết lập):
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => { const userApi = event.context.userApi const body = await readBody(event)
// Stage-aware: creating a recruitment-stage person needs recruitment.write const stage = body?.stage ?? 'talent' const isRecruitment = ['talent', 'interviewing', 'offer'].includes(stage) assertPermission(event, { module: 'hr', action: isRecruitment ? 'recruitment.write' : 'create' })
const result = await userApi.request(customEndpoint({ path: "/hr/people", method: "POST", body: JSON.stringify(body), }))
return result})server/api/hr/people/from-contact.post.ts lặp lại đúng khuôn này (mặc định stage = 'probation' thay vì 'talent', vì luồng tạo-từ-contact trong HR tab thường dùng cho employee). Backend routes.ts cũng chặn quyền stage-aware lần thứ hai ở tầng route thật (guardStage) — BFF không phải là ranh giới quyền duy nhất, nó chỉ tránh cho client gọi thẳng backend mà không qua kiểm tra.
imports.dirs khác biệt so với helpdesk
Phần tiêu đề “imports.dirs khác biệt so với helpdesk”nuxt.config.ts của HR khai auto-import 2 thư mục không chuẩn Nuxt 4 (app/data, app/config), phục vụ setup-wizard schema (hr-schema.ts) và layout form khai báo — cùng một khuôn với module Contacts:
export default defineNuxtConfig({ compatibilityDate: "2025-01-15", imports: { dirs: [ resolve(__dirname, "app/data"), resolve(__dirname, "app/config"), ], }, i18n: { locales: [ { code: "en", file: "en.json" }, { code: "vi", file: "vi.json" }, ], },})Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Tích hợp Contacts — choke point 2 chiều tạo
hr_peopletừ contact và ngược lại - Kiến trúc tổng thể — bức tranh 4 tầng
- Data model — toàn bộ collection
hr_*