[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.
|
||||
|
||||
### 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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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-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 { 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
|
||||
}
|
||||
@ -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 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
|
||||
];
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user