[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:
@ -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="*"
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user