Server-backed
defaultStatus, nameComposition — đọc/ghi qua /api/settings
(singleton c_contact_settings), dùng chung mọi user.
pages/contacts/** + components/contacts/** │ gọi composable (không tự $fetch) ▼app/composables/use-contact*.ts ← CRUD, field registry, form logic, settings │ $fetch (cookie-auth, cùng origin) ▼server/api/** ← BFF: assertPermission({module:'contacts', action}) + customEndpoint proxy │ @odp/sdk userApi.request() ▼backend/extensions/contacts (ODP API) ← xem [Kiến trúc tổng thể](/contacts/ky-thuat/kien-truc/)useContactCollectionFields)shared_kindsshared_statusesuseExtensionStore từ host, fallback no-opuseContactCollectionFields thay vì useCollectionFields// NOTE: named `useContactCollectionFields` (not `useCollectionFields`) to avoid an// auto-import collision with `@odp/workflow`'s `useCollectionFields` (different return// shape) which otherwise shadows this one and breaks the contact field registry.Đây là ví dụ thực tế của rủi ro auto-import trong Nuxt 4 layer: hai package cùng đăng ký một tên composable, cái load sau âm thầm đè cái trước — không có lỗi build, chỉ có bug lúc chạy.
useContactFieldRegistry() fetch field metadata cho 4 collection (contacts, contact_profiles, contact_intakes, contact_settings) từ BFF, rồi áp một lớp normalize + tweak trước khi form dùng:
const FIELD_TWEAKS: Partial<Record<CollectionKey, Record<string, { interface?: string, options?: Record<string, any>, readonly?: boolean }>>> = { contacts: { // Read-only in forms: current organization is managed via Work History (employments) organization_id: { options: { template: "{{display_name}}" }, readonly: true }, date_of_birth: { interface: "date-picker" }, nationality: { interface: "select-country" }, country: { interface: "select-country" }, notes: { interface: "input-multiline" }, }, contact_profiles: { handling_notes: { interface: "input-multiline" }, },}interface: "date-picker" và "select-country" là component module-local đăng ký toàn cục qua app/plugins/interfaces.ts — không có sẵn ở core UI, module tự bổ sung.
server/api/contacts-fields/[collection].get.ts nhận tên rút gọn và map sang tên collection thật trước khi gọi backend:
const COLLECTION_MAP: Record<string, string> = { contacts: 'c_contacts', contact_profiles: 'c_contact_profiles', contact_intakes: 'c_contact_intakes', contact_settings: 'c_contact_settings',};Khác với Helpdesk (không có remap, dùng allowlist tên thật) — Contacts chọn cách map tên ngắn để client không phải gõ tiền tố c_contact_ khắp nơi.
Contacts theo đúng convention FormRoot + useFieldBuilder bắt buộc cho mọi form (không có <input>/<UFormField> tay). Layout tách khỏi field list:
Mỗi layout là mảng tab > section > tên field. useContactForm() build field thật cho tab đang active bằng cách ghép layout với field registry:
const activeFields = computed<Field[]>(() => { if (activeTab.value === "profile") { const profileFields = getAllFields("contact_profiles") return buildTabFields(PROFILE_FORM_TAB, profileFields) } const contactFields = getAllFields("contacts") const tab = activeLayout.value.tabs.find(t => t.id === activeTab.value) const built = buildTabFields(tab, contactFields) // Create: organization_id editable (seeds Work History on save). // Edit: hide entirely — managed via Work History. if (options.allowOrganizationSelect) return built.map(withEditableOrganization) return built.filter(f => f.field !== "organization_id")})activeLayout chọn PERSON_FORM_LAYOUT hay ORGANIZATION_FORM_LAYOUT dựa trên model.value.type — cùng một component form (ContactsContactForm) render layout khác nhau tuỳ kind.
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => { assertPermission(event, { module: "contacts", action: "read" }) const userApi = event.context.userApi const query = getQuery(event)
const result = await userApi.request(customEndpoint({ path: "/contacts/", method: "GET", params: query, }))
return result})Route counts tự bọc lại envelope tương tự Helpdesk (useContactsApi().counts() tolerate cả hai hình dạng { data } hoặc object trần — phòng trường hợp SDK unwrap tự động).
useContactsSettings() giữ 2 loại state khác hẳn nhau:
Server-backed
defaultStatus, nameComposition — đọc/ghi qua /api/settings
(singleton c_contact_settings), dùng chung mọi user.
Client-only (localStorage)
columnWidths, pinnedIds — plugin contacts-settings-persist.client.ts
đọc/ghi localStorage key contacts-settings, mỗi trình duyệt tự nhớ
riêng, không đồng bộ giữa các máy.
contactTypes (danh sách type hiển thị sidebar/filter) là hợp nhất giữa shared_types từ backend và bất kỳ type nào module khác register() qua useContactTypeRegistry() — backend thắng khi trùng key, key chỉ-contributed được nối thêm vào cuối.
useContactTypeRegistry/useExtensionStore cho module ngoài