[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

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