[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

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:
pqhuy1987
2026-05-27 13:39:10 +07:00
parent 7b0781b94e
commit ea440da990
15 changed files with 676 additions and 1 deletions

View File

@ -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.
### 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):
@ -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.
**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)
Khi spec yêu cầu "mirror entity X từ PE module sang Contract module" (vd LevelOpinions / DepartmentApproval / ManualBudgetFields):

View File

@ -28,6 +28,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
function App() {
return (
@ -73,6 +74,8 @@ function App() {
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<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="/" element={<Navigate to="/dashboard" replace />} />
<Route

View File

@ -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
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
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]

View File

@ -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)
Hrm: 'Hrm',
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
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View 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>
)
}

View 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
}

View File

@ -21,6 +21,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
function App() {
return (
@ -56,6 +57,8 @@ function App() {
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<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="*"

View File

@ -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
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
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]

View File

@ -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)
Hrm: 'Hrm',
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
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View 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>
)
}

View 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
}

View File

@ -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));
}

View File

@ -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();
}
}

View File

@ -85,6 +85,15 @@ public static class MenuKeys
public const string Hrm = "Hrm"; // root group
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 =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -110,6 +119,7 @@ public static class MenuKeys
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
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,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -1483,6 +1483,11 @@ public static class DbInitializer
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
(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