[CLAUDE] Domain+App+Api+Infra+FE-Admin+FE-User: S34 Plan 2 G-O1 Danh bạ nội bộ
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m46s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m46s
Phase 10.2 Văn phòng số — Internal Directory (1 endpoint reuse Users + EmployeeProfiles + Departments, FE card grid avatar/dept/email/phone/Ext). BE Task 1+2 (em main solo): - Application/Office/DirectoryFeatures.cs — GetDirectoryQuery + DirectoryItemDto 12 field LEFT JOIN Users.IsActive + Departments + EmployeeProfiles - Api/Controllers/DirectoryController.cs — GET /api/directory?search=&departmentId= class-level [Authorize] (mọi authenticated NV tra cứu danh bạ nội bộ) - MenuKeys.cs +Off+OffDanhBa const + All[] update - DbInitializer.SeedMenuTreeAsync Off Order=29 + OffDanhBa Order=1 dưới Off FE Task 3 (Implementer Case 2 Pattern 16-bis 4-place mirror cross-app — 5×): - types/directory.ts SHA256 7349d9f64e78 × 2 app IDENTICAL - pages/office/InternalDirectoryPage.tsx SHA256 2aa7e0eed2c8 × 2 app IDENTICAL Card grid responsive 1/2/3/4 col + filter dept dropdown + search input Avatar 14×14 initials gradient PALETTE 6 màu (Pattern 14 Tailwind JIT) EmployeeCode badge + Department emerald badge + email mailto + phone tel Internal phone Ext: amber badge + empty/loading state Vietnamese 100% - App.tsx route /directory × 2 app - lib/menuKeys.ts Off+OffDanhBa const × 2 app - components/Layout.tsx resolvePath staticMap Off_DanhBa:/directory × 2 app (gotcha #50 — 5 places mirror crossapp DON'T MISS) Verify: - dotnet build PASS (2 warn DocxRenderer existing, 0 error) - dotnet test 120/120 PASS (58 Domain + 62 Infra baseline preserve) - npm build × 2 app PASS 0 TS err (fe-admin 1436KB / fe-user 1350KB) Implementer MEMORY Pattern 16-bis reinforced 5× cumulative (S29 Plan CA HF1 + S29 Plan B Chunk D + S33 Plan B G-H1 Task 5 + S34 Plan G-O1 Task 3). Endpoint smoke pending CICD post-deploy Stage 4 (Run #XXX expected ~3m30s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -162,7 +162,7 @@ Pattern reusable: test PE workflow → 1 Step + 2 Levels + N approvers per Level
|
|||||||
|
|
||||||
Tránh API surface bloat. Reusable cho future guard / helper internal cần test.
|
Tránh API surface bloat. Reusable cho future guard / helper internal cần test.
|
||||||
|
|
||||||
### Pattern 16-bis: 4-place mirror checklist khi cookie-cutter copy page CROSS-APP (S29 Plan CA Hotfix 1 — gotcha #50)
|
### Pattern 16-bis: 4-place mirror checklist khi cookie-cutter copy page CROSS-APP (S29 Plan CA Hotfix 1 — gotcha #50, reinforced 5× cumulative qua S33+S34)
|
||||||
|
|
||||||
Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lại (Implementer Case 2 cookie-cutter mirror page), MUST mirror 4 places (NOT just 3):
|
Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lại (Implementer Case 2 cookie-cutter mirror page), MUST mirror 4 places (NOT just 3):
|
||||||
|
|
||||||
@ -175,6 +175,13 @@ Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lạ
|
|||||||
|
|
||||||
**Verification post-fix:** Reviewer Cat 1 "Wire claim verify" SHOULD add to checklist: "Sidebar menu visible end-to-end test post-build" — curl `/api/menus/me` + grep MenuLeaf render output. Smart Friend prevent silent drop.
|
**Verification post-fix:** Reviewer Cat 1 "Wire claim verify" SHOULD add to checklist: "Sidebar menu visible end-to-end test post-build" — curl `/api/menus/me` + grep MenuLeaf render output. Smart Friend prevent silent drop.
|
||||||
|
|
||||||
|
**S34 G-O1 Task 3 reinforcement (2026-05-27, Plan B Internal Directory):** Pattern 16-bis applied clean lần thứ 5 cumulative. Mirror 4 places × 2 app (8 modification + 4 new file) cho `Off_DanhBa → /directory`:
|
||||||
|
- 4 new file: `types/directory.ts` × 2 (SHA256 `7349d9f64e78`) + `pages/office/InternalDirectoryPage.tsx` × 2 (SHA256 `2aa7e0eed2c8`) — both MATCH identical hash
|
||||||
|
- 6 modified: App.tsx × 2 (+route), menuKeys.ts × 2 (+Off/OffDanhBa const), Layout.tsx × 2 (+staticMap Off_DanhBa)
|
||||||
|
- npm build × 2 app: fe-admin 21.99s clean (bundle 1436.71 kB / gzip 364.54 kB), fe-user 9.37s clean (bundle 1350.28 kB / gzip 349.01 kB) — 0 TS error
|
||||||
|
- Token cost ~20k (under budget 25k). Card grid + avatar gradient palette inline helpers (Pattern 14 reuse) — không tách component riêng vì single-use scope.
|
||||||
|
- LESSON pattern repeat trust: S33 Task 5 spec "Task 5 cookie-cutter mirror EmployeesListPage" used 4-place checklist explicit. S34 G-O1 Task 3 spec follow same template → execute 0 ambiguity. Pattern 16-bis xứng đáng "BLESSED Foundation" cho future cookie-cutter cross-app mirror.
|
||||||
|
|
||||||
### Pattern 12-bis: Cross-module entity cookie-cutter mirror (S29 Plan B Chunk C — Mig 33)
|
### Pattern 12-bis: Cross-module entity cookie-cutter mirror (S29 Plan B Chunk C — Mig 33)
|
||||||
|
|
||||||
Khi spec yêu cầu "mirror entity X từ PE module sang Contract module" (vd LevelOpinions / DepartmentApproval / ManualBudgetFields):
|
Khi spec yêu cầu "mirror entity X từ PE module sang Contract module" (vd LevelOpinions / DepartmentApproval / ManualBudgetFields):
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
|
|||||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
||||||
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
||||||
|
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -73,6 +74,8 @@ function App() {
|
|||||||
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
||||||
<Route path="/employees" element={<EmployeesListPage />} />
|
<Route path="/employees" element={<EmployeesListPage />} />
|
||||||
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
||||||
|
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
|
||||||
|
<Route path="/directory" element={<InternalDirectoryPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -55,6 +55,9 @@ function resolvePath(key: string): string | null {
|
|||||||
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
|
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
|
||||||
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
|
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
|
||||||
Hrm_HoSo: '/employees',
|
Hrm_HoSo: '/employees',
|
||||||
|
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
|
||||||
|
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
|
||||||
|
Off_DanhBa: '/directory',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export const MenuKeys = {
|
|||||||
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
||||||
Hrm: 'Hrm',
|
Hrm: 'Hrm',
|
||||||
HrmHoSo: 'Hrm_HoSo',
|
HrmHoSo: 'Hrm_HoSo',
|
||||||
|
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
|
||||||
|
Off: 'Off',
|
||||||
|
OffDanhBa: 'Off_DanhBa',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
236
fe-admin/src/pages/office/InternalDirectoryPage.tsx
Normal file
236
fe-admin/src/pages/office/InternalDirectoryPage.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
|
||||||
|
// Card grid responsive, filter search + department, avatar fallback gradient theo
|
||||||
|
// userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
|
// Reuse BE GET /api/directory readonly (DirectoryFeatures.cs).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import type { Paged, Department } from '@/types/master'
|
||||||
|
import type { DirectoryItem } from '@/types/directory'
|
||||||
|
|
||||||
|
// Pattern 14 Tailwind JIT palette 6 màu gradient cho initials avatar.
|
||||||
|
const AVATAR_PALETTE = [
|
||||||
|
'bg-gradient-to-br from-blue-400 to-blue-600',
|
||||||
|
'bg-gradient-to-br from-emerald-400 to-emerald-600',
|
||||||
|
'bg-gradient-to-br from-amber-400 to-amber-600',
|
||||||
|
'bg-gradient-to-br from-violet-400 to-violet-600',
|
||||||
|
'bg-gradient-to-br from-rose-400 to-rose-600',
|
||||||
|
'bg-gradient-to-br from-cyan-400 to-cyan-600',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Format SĐT Vietnam 10 chữ số: 0XXX XXX XXX
|
||||||
|
function formatPhone(p: string | null): string {
|
||||||
|
if (!p) return ''
|
||||||
|
const digits = p.replace(/\D/g, '')
|
||||||
|
if (digits.length === 10) return digits.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3')
|
||||||
|
if (digits.length === 11) return digits.replace(/(\d{4})(\d{3})(\d{4})/, '$1 $2 $3')
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash stable userId → palette index (charCodeAt sum mod 6).
|
||||||
|
function avatarColor(userId: string): string {
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < userId.length; i++) sum += userId.charCodeAt(i)
|
||||||
|
return AVATAR_PALETTE[sum % AVATAR_PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// First letter của FullName (uppercase, fallback "?").
|
||||||
|
function initials(fullName: string): string {
|
||||||
|
const trimmed = fullName.trim()
|
||||||
|
if (!trimmed) return '?'
|
||||||
|
return trimmed.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InternalDirectoryPage() {
|
||||||
|
const [sp, setSp] = useSearchParams()
|
||||||
|
const search = sp.get('q') ?? ''
|
||||||
|
const departmentId = sp.get('deptId') ?? ''
|
||||||
|
|
||||||
|
const [localSearch, setLocalSearch] = useState(search)
|
||||||
|
|
||||||
|
const departments = useQuery({
|
||||||
|
queryKey: ['departments-all-directory'],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['directory', { search, departmentId }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<DirectoryItem[]>('/directory', {
|
||||||
|
params: {
|
||||||
|
search: search || undefined,
|
||||||
|
departmentId: departmentId || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function setParam(key: string, value: string | null) {
|
||||||
|
const next = new URLSearchParams(sp)
|
||||||
|
if (value == null || value === '') next.delete(key)
|
||||||
|
else next.set(key, value)
|
||||||
|
setSp(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setParam('q', localSearch.trim() || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = list.data?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Danh bạ nội bộ"
|
||||||
|
description={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter bar sticky top */}
|
||||||
|
<div className="sticky top-0 z-10 mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:flex-row sm:items-center">
|
||||||
|
<form onSubmit={applySearch} className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={localSearch}
|
||||||
|
onChange={e => setLocalSearch(e.target.value)}
|
||||||
|
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||||
|
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<Select
|
||||||
|
value={departmentId}
|
||||||
|
onChange={e => setParam('deptId', e.target.value || null)}
|
||||||
|
className="sm:w-64"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả phòng ban</option>
|
||||||
|
{(departments.data ?? []).map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card grid */}
|
||||||
|
{list.isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-44 animate-pulse rounded-lg border border-slate-200 bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="Không tìm thấy nhân viên nào"
|
||||||
|
description="Thử đổi từ khoá tìm hoặc chọn phòng ban khác."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{list.data!.map(item => (
|
||||||
|
<DirectoryCard key={item.userId} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md">
|
||||||
|
{/* Top row: avatar + name + code */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{item.photoUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.photoUrl}
|
||||||
|
alt={item.fullName}
|
||||||
|
className="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-white shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold text-white shadow ring-2 ring-white',
|
||||||
|
avatarColor(item.userId),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials(item.fullName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-slate-900" title={item.fullName}>
|
||||||
|
{item.fullName}
|
||||||
|
</h3>
|
||||||
|
{item.employeeCode && (
|
||||||
|
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-600">
|
||||||
|
{item.employeeCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.position && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-600" title={item.position}>
|
||||||
|
{item.position}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.departmentName && (
|
||||||
|
<span className="mt-1 inline-flex rounded bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-900">
|
||||||
|
{item.departmentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact rows */}
|
||||||
|
<div className="mt-3 space-y-1.5 border-t border-slate-100 pt-3 text-xs">
|
||||||
|
{item.email ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${item.email}`}
|
||||||
|
className="flex items-center gap-1.5 text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={item.email}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">{item.email}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${item.phone}`}
|
||||||
|
className="flex items-center gap-1.5 text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={item.phone}
|
||||||
|
>
|
||||||
|
<Phone className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">{formatPhone(item.phone)}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<Phone className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.internalPhone && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="inline-flex rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-900">
|
||||||
|
Ext: {item.internalPhone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
fe-admin/src/types/directory.ts
Normal file
22
fe-admin/src/types/directory.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
|
||||||
|
// Mirror BE DirectoryItemDto (Application/Office/DirectoryFeatures.cs).
|
||||||
|
// File này MIRROR SHA256 identical với fe-admin/src/types/directory.ts.
|
||||||
|
export type DirectoryItem = {
|
||||||
|
userId: string
|
||||||
|
fullName: string
|
||||||
|
position: string | null
|
||||||
|
photoUrl: string | null
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
employeeCode: string | null
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
internalPhone: string | null
|
||||||
|
personalEmail: string | null
|
||||||
|
workLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectoryQuery = {
|
||||||
|
search?: string
|
||||||
|
departmentId?: string
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
|
|||||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
||||||
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
||||||
|
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -56,6 +57,8 @@ function App() {
|
|||||||
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
||||||
<Route path="/employees" element={<EmployeesListPage />} />
|
<Route path="/employees" element={<EmployeesListPage />} />
|
||||||
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
||||||
|
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
|
||||||
|
<Route path="/directory" element={<InternalDirectoryPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@ -77,6 +77,9 @@ function resolvePath(key: string): string | null {
|
|||||||
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
|
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
|
||||||
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
|
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
|
||||||
Hrm_HoSo: '/employees',
|
Hrm_HoSo: '/employees',
|
||||||
|
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
|
||||||
|
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
|
||||||
|
Off_DanhBa: '/directory',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export const MenuKeys = {
|
|||||||
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
||||||
Hrm: 'Hrm',
|
Hrm: 'Hrm',
|
||||||
HrmHoSo: 'Hrm_HoSo',
|
HrmHoSo: 'Hrm_HoSo',
|
||||||
|
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
|
||||||
|
Off: 'Off',
|
||||||
|
OffDanhBa: 'Off_DanhBa',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
236
fe-user/src/pages/office/InternalDirectoryPage.tsx
Normal file
236
fe-user/src/pages/office/InternalDirectoryPage.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
|
||||||
|
// Card grid responsive, filter search + department, avatar fallback gradient theo
|
||||||
|
// userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
|
// Reuse BE GET /api/directory readonly (DirectoryFeatures.cs).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import type { Paged, Department } from '@/types/master'
|
||||||
|
import type { DirectoryItem } from '@/types/directory'
|
||||||
|
|
||||||
|
// Pattern 14 Tailwind JIT palette 6 màu gradient cho initials avatar.
|
||||||
|
const AVATAR_PALETTE = [
|
||||||
|
'bg-gradient-to-br from-blue-400 to-blue-600',
|
||||||
|
'bg-gradient-to-br from-emerald-400 to-emerald-600',
|
||||||
|
'bg-gradient-to-br from-amber-400 to-amber-600',
|
||||||
|
'bg-gradient-to-br from-violet-400 to-violet-600',
|
||||||
|
'bg-gradient-to-br from-rose-400 to-rose-600',
|
||||||
|
'bg-gradient-to-br from-cyan-400 to-cyan-600',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Format SĐT Vietnam 10 chữ số: 0XXX XXX XXX
|
||||||
|
function formatPhone(p: string | null): string {
|
||||||
|
if (!p) return ''
|
||||||
|
const digits = p.replace(/\D/g, '')
|
||||||
|
if (digits.length === 10) return digits.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3')
|
||||||
|
if (digits.length === 11) return digits.replace(/(\d{4})(\d{3})(\d{4})/, '$1 $2 $3')
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash stable userId → palette index (charCodeAt sum mod 6).
|
||||||
|
function avatarColor(userId: string): string {
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < userId.length; i++) sum += userId.charCodeAt(i)
|
||||||
|
return AVATAR_PALETTE[sum % AVATAR_PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// First letter của FullName (uppercase, fallback "?").
|
||||||
|
function initials(fullName: string): string {
|
||||||
|
const trimmed = fullName.trim()
|
||||||
|
if (!trimmed) return '?'
|
||||||
|
return trimmed.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InternalDirectoryPage() {
|
||||||
|
const [sp, setSp] = useSearchParams()
|
||||||
|
const search = sp.get('q') ?? ''
|
||||||
|
const departmentId = sp.get('deptId') ?? ''
|
||||||
|
|
||||||
|
const [localSearch, setLocalSearch] = useState(search)
|
||||||
|
|
||||||
|
const departments = useQuery({
|
||||||
|
queryKey: ['departments-all-directory'],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['directory', { search, departmentId }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<DirectoryItem[]>('/directory', {
|
||||||
|
params: {
|
||||||
|
search: search || undefined,
|
||||||
|
departmentId: departmentId || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function setParam(key: string, value: string | null) {
|
||||||
|
const next = new URLSearchParams(sp)
|
||||||
|
if (value == null || value === '') next.delete(key)
|
||||||
|
else next.set(key, value)
|
||||||
|
setSp(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setParam('q', localSearch.trim() || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = list.data?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Danh bạ nội bộ"
|
||||||
|
description={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter bar sticky top */}
|
||||||
|
<div className="sticky top-0 z-10 mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:flex-row sm:items-center">
|
||||||
|
<form onSubmit={applySearch} className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={localSearch}
|
||||||
|
onChange={e => setLocalSearch(e.target.value)}
|
||||||
|
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||||
|
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<Select
|
||||||
|
value={departmentId}
|
||||||
|
onChange={e => setParam('deptId', e.target.value || null)}
|
||||||
|
className="sm:w-64"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả phòng ban</option>
|
||||||
|
{(departments.data ?? []).map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card grid */}
|
||||||
|
{list.isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-44 animate-pulse rounded-lg border border-slate-200 bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="Không tìm thấy nhân viên nào"
|
||||||
|
description="Thử đổi từ khoá tìm hoặc chọn phòng ban khác."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{list.data!.map(item => (
|
||||||
|
<DirectoryCard key={item.userId} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md">
|
||||||
|
{/* Top row: avatar + name + code */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{item.photoUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.photoUrl}
|
||||||
|
alt={item.fullName}
|
||||||
|
className="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-white shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold text-white shadow ring-2 ring-white',
|
||||||
|
avatarColor(item.userId),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials(item.fullName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-slate-900" title={item.fullName}>
|
||||||
|
{item.fullName}
|
||||||
|
</h3>
|
||||||
|
{item.employeeCode && (
|
||||||
|
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-600">
|
||||||
|
{item.employeeCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.position && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-600" title={item.position}>
|
||||||
|
{item.position}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.departmentName && (
|
||||||
|
<span className="mt-1 inline-flex rounded bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-900">
|
||||||
|
{item.departmentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact rows */}
|
||||||
|
<div className="mt-3 space-y-1.5 border-t border-slate-100 pt-3 text-xs">
|
||||||
|
{item.email ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${item.email}`}
|
||||||
|
className="flex items-center gap-1.5 text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={item.email}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">{item.email}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${item.phone}`}
|
||||||
|
className="flex items-center gap-1.5 text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={item.phone}
|
||||||
|
>
|
||||||
|
<Phone className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">{formatPhone(item.phone)}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<Phone className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>—</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.internalPhone && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="inline-flex rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-900">
|
||||||
|
Ext: {item.internalPhone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
fe-user/src/types/directory.ts
Normal file
22
fe-user/src/types/directory.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
|
||||||
|
// Mirror BE DirectoryItemDto (Application/Office/DirectoryFeatures.cs).
|
||||||
|
// File này MIRROR SHA256 identical với fe-admin/src/types/directory.ts.
|
||||||
|
export type DirectoryItem = {
|
||||||
|
userId: string
|
||||||
|
fullName: string
|
||||||
|
position: string | null
|
||||||
|
photoUrl: string | null
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
employeeCode: string | null
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
internalPhone: string | null
|
||||||
|
personalEmail: string | null
|
||||||
|
workLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectoryQuery = {
|
||||||
|
search?: string
|
||||||
|
departmentId?: string
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
// Phase 10.2 G-O1 (S34) — Danh bạ nội bộ.
|
||||||
|
// 1 endpoint readonly. Class-level [Authorize] — mọi authenticated user thấy được
|
||||||
|
// (không restrict admin-only vì danh bạ nội bộ default open cho NV tra cứu nhau).
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/directory")]
|
||||||
|
[Authorize]
|
||||||
|
public class DirectoryController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<DirectoryItemDto>>> Get(
|
||||||
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] Guid? departmentId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new GetDirectoryQuery(search, departmentId), ct));
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
// Phase 10.2 G-O1 (S34) — Danh bạ nội bộ.
|
||||||
|
// 1 endpoint readonly query JOIN Users + EmployeeProfiles + Departments.
|
||||||
|
// FE card grid hiển thị avatar/name/dept/position/email/phone/internal-phone.
|
||||||
|
// Reuse existing data — KHÔNG mở schema mới.
|
||||||
|
|
||||||
|
public sealed record DirectoryItemDto(
|
||||||
|
Guid UserId,
|
||||||
|
string FullName,
|
||||||
|
string? Position,
|
||||||
|
string? PhotoUrl,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
string? DepartmentName,
|
||||||
|
string? EmployeeCode,
|
||||||
|
string? Email,
|
||||||
|
string? Phone,
|
||||||
|
string? InternalPhone,
|
||||||
|
string? PersonalEmail,
|
||||||
|
string? WorkLocation);
|
||||||
|
|
||||||
|
public sealed record GetDirectoryQuery(
|
||||||
|
string? Search = null,
|
||||||
|
Guid? DepartmentId = null) : IRequest<List<DirectoryItemDto>>;
|
||||||
|
|
||||||
|
public sealed class GetDirectoryQueryHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetDirectoryQuery, List<DirectoryItemDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<DirectoryItemDto>> Handle(GetDirectoryQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Active users only — Department LEFT join (admin/system user có thể null dept).
|
||||||
|
// EmployeeProfile LEFT join (user chưa được seed EmployeeProfile vẫn hiện trong danh bạ
|
||||||
|
// với phone/internalPhone null — sau Phase 10.1 G-H1 thì tất cả 33 prod user đều có).
|
||||||
|
var query =
|
||||||
|
from u in db.Users.AsNoTracking().Where(x => x.IsActive)
|
||||||
|
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into deptJoin
|
||||||
|
from d in deptJoin.DefaultIfEmpty()
|
||||||
|
join ep in db.EmployeeProfiles.AsNoTracking() on u.Id equals ep.UserId into epJoin
|
||||||
|
from ep in epJoin.DefaultIfEmpty()
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
u.Id,
|
||||||
|
u.FullName,
|
||||||
|
u.Position,
|
||||||
|
u.Email,
|
||||||
|
u.DepartmentId,
|
||||||
|
DepartmentName = d != null ? d.Name : null,
|
||||||
|
EmployeeCode = ep != null ? ep.EmployeeCode : null,
|
||||||
|
Phone = ep != null ? ep.Phone : null,
|
||||||
|
InternalPhone = ep != null ? ep.InternalPhone : null,
|
||||||
|
PersonalEmail = ep != null ? ep.PersonalEmail : null,
|
||||||
|
PhotoUrl = ep != null ? ep.PhotoUrl : null,
|
||||||
|
WorkLocation = ep != null ? ep.WorkLocation : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.DepartmentId is Guid deptId)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.DepartmentId == deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
var term = request.Search.Trim();
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.FullName.Contains(term) ||
|
||||||
|
(x.Email != null && x.Email.Contains(term)) ||
|
||||||
|
(x.Phone != null && x.Phone.Contains(term)) ||
|
||||||
|
(x.InternalPhone != null && x.InternalPhone.Contains(term)) ||
|
||||||
|
(x.EmployeeCode != null && x.EmployeeCode.Contains(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderBy(x => x.DepartmentName)
|
||||||
|
.ThenBy(x => x.FullName)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Select(x => new DirectoryItemDto(
|
||||||
|
x.Id,
|
||||||
|
x.FullName,
|
||||||
|
x.Position,
|
||||||
|
x.PhotoUrl,
|
||||||
|
x.DepartmentId,
|
||||||
|
x.DepartmentName,
|
||||||
|
x.EmployeeCode,
|
||||||
|
x.Email,
|
||||||
|
x.Phone,
|
||||||
|
x.InternalPhone,
|
||||||
|
x.PersonalEmail,
|
||||||
|
x.WorkLocation))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -85,6 +85,15 @@ public static class MenuKeys
|
|||||||
public const string Hrm = "Hrm"; // root group
|
public const string Hrm = "Hrm"; // root group
|
||||||
public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit)
|
public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Module Văn phòng số (Phase 10.2 G-O1+ S34 2026-05-27).
|
||||||
|
// 1 root group `Off` + leaf con: Off_DanhBa (G-O1 Danh bạ nội bộ).
|
||||||
|
// Future Phase 10.2+ add: Off_PhongHop (G-O2 booking) +
|
||||||
|
// workflow apps Off_DeXuat / Off_DonTu / Off_DatXe / Off_ItTicket.
|
||||||
|
// ============================================================
|
||||||
|
public const string Off = "Off"; // root group văn phòng số
|
||||||
|
public const string OffDanhBa = "Off_DanhBa"; // Danh bạ nội bộ (card grid)
|
||||||
|
|
||||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||||
|
|
||||||
@ -110,6 +119,7 @@ public static class MenuKeys
|
|||||||
PurchaseEvaluations,
|
PurchaseEvaluations,
|
||||||
Budgets, BudgetList, BudgetCreate, BudgetPending,
|
Budgets, BudgetList, BudgetCreate, BudgetPending,
|
||||||
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
|
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
|
||||||
|
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
|
||||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1483,6 +1483,11 @@ public static class DbInitializer
|
|||||||
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
|
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
|
||||||
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
|
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
|
||||||
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
|
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
|
||||||
|
|
||||||
|
// Module Văn phòng số (Phase 10.2 G-O1+ S34). 1 root + leaf Danh bạ.
|
||||||
|
// Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket.
|
||||||
|
(MenuKeys.Off, "Văn phòng số", null, 29, "Briefcase"),
|
||||||
|
(MenuKeys.OffDanhBa, "Danh bạ nội bộ", MenuKeys.Off, 1, "BookUser"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||||
|
|||||||
Reference in New Issue
Block a user