[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S36 Plan G-O2 Phòng họp Mig 36 + BE CRUD + FE 2 app
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s

Phase 10.2 G-O2 Phòng họp BookingCalendar — Mig 36 schema + BE CQRS + FE 2 app
mirror cookie-cutter G-H2 HrmConfig pattern. Standalone không depend workflow.

BE schema (Mig 36 — em main solo Step 4):
- 4 Domain new: MeetingRoom catalog + MeetingBooking header + MeetingBookingAttendee
  join table N-to-N (NOT JSON per Investigator verdict) + Enums (MeetingBookingStatus
  3-state: Confirmed/Cancelled/Completed)
- 3 EF Config: UNIQUE Code + composite index (RoomId, StartAt) range query + UNIQUE
  composite (BookingId, UserId) join
- FK strategy: Room→Restrict (preserve history) + Booking→Cascade attendees +
  User→Restrict (denorm FullName+Email tránh cascade wipe)
- Mig 36 3-file rule + ApplicationDbContextModelSnapshot updated + apply Dev+Design DB

BE CQRS (~584 LOC — Implementer Case 2):
- MeetingFeatures.cs 479 LOC 9 handler: 4 Room CRUD + 5 Booking (List + GetById +
  Create + Update + Cancel)
- SERIALIZABLE transaction overlap check via EXISTS query — throw 409 Conflict
  "Phòng đã được đặt trong khoảng thời gian này"
- MeetingRoomsController 49 LOC + MeetingBookingsController 56 LOC — class-level
  [Authorize] + Roles="Admin" for write
- Application.csproj +Microsoft.EntityFrameworkCore.Relational package (em main fix
  IsolationLevel overload — Implementer gotcha #53 4th truncation diagnose mid-task)
- MenuKeys.cs +4 const (Off_PhongHop sub-group + View/Manage/Book leaf)
- DbInitializer +SeedMeetingRoomsAsync 4 sample (PH-A Phòng họp lớn cap=20 + PH-B
  cap=8 + PHG-501 Giám đốc cap=6 + ONL-1 Online Zoom cap=50) — NOT gated DemoSeed
  per gotcha #51 INFRASTRUCTURE seed

FE 2 app (~1770 LOC × 2 — Implementer Case 2):
- types/meeting.ts × 2 SHA256 IDENTICAL (ce0ad9c6d017cde2) — DTO interface mirror
- MeetingCalendarPage.tsx × 2 SHA256 IDENTICAL (d6d160ae1e4f2285) ~530 LOC — custom
  HTML 7-day grid 8h-20h slot, NO FullCalendar dep (~80 KB bundle saved per
  Investigator verdict alternative)
- MeetingRoomsPage.tsx × 2 SHA256 IDENTICAL (ba35a7ef379a5e9c) ~270 LOC — admin
  catalog CRUD table + Dialog
- 4-place mirror Pattern 16-bis 7× cumulative: types + page + App.tsx route +
  menuKeys + Layout staticMap 3 entry (gotcha #50 silent sidebar drop prevention)

Verify:
- dotnet build SolutionErp.slnx PASS 0 error 2 pre-existing DocxRenderer warning
- dotnet test 130/130 PASS baseline preserve (58 Domain + 72 Infra)
- npm build × 2 app PASS 0 TS error (fe-admin 16.91s bundle 1490 KB / fe-user 8.56s
  bundle 1404 KB, +23 KB gzip both)

Pattern reinforced cumulative S36:
- Pattern 12-bis cross-module mirror 10× (PE → Contract V2 → Hrm → Office)
- Pattern 16-bis 4-place mirror cross-app 7×
- Smart Friend Implementer truncation gotcha #53 4th — mitigation tight brief WORK
  (FE 2 app no truncation, BE truncate diagnose mid only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 15:06:12 +07:00
parent 8afdc1e826
commit f45090b654
30 changed files with 8573 additions and 0 deletions

View File

@ -23,6 +23,8 @@ import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
function App() {
return (
@ -63,6 +65,9 @@ function App() {
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
<Route path="/directory" element={<InternalDirectoryPage />} />
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}
<Route path="/meeting-calendar" element={<MeetingCalendarPage />} />
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -86,6 +86,12 @@ function resolvePath(key: string): string | null {
// [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',
// [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36).
// Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss.
// View leaf + Book leaf đều trỏ calendar (user perspective); Manage trỏ catalog.
Off_PhongHop_View: '/meeting-calendar',
Off_PhongHop_Book: '/meeting-calendar',
Off_PhongHop_Manage: '/meeting-rooms',
}
if (staticMap[key]) return staticMap[key]

View File

@ -43,6 +43,11 @@ export const MenuKeys = {
// 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',
// Văn phòng số — Phòng họp (Phase 10.2 G-O2 — Mig 36 Session 36, 2026-05-28)
OffPhongHop: 'Off_PhongHop',
OffPhongHopView: 'Off_PhongHop_View',
OffPhongHopManage: 'Off_PhongHop_Manage',
OffPhongHopBook: 'Off_PhongHop_Book',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,693 @@
// Đặt phòng họp (Meeting Calendar) — Phase 10.2 G-O2 (S36 2026-05-28).
// Custom HTML 7-day week grid (8h20h, 1h slot) — KHÔNG cài FullCalendar dep
// để giữ bundle size minimal (Investigator verdict pre-flight S36).
// Booking blocks render absolute positioning theo startAt/endAt.
// File này MIRROR SHA256 identical với fe-user counterpart.
// Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Select } from '@/components/ui/Select'
import { Dialog } from '@/components/ui/Dialog'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
MeetingBookingStatus,
type AttendeeInput,
type MeetingBookingCreateInput,
type MeetingBookingDto,
type MeetingRoomDto,
} from '@/types/meeting'
// Calendar constants — 12 slot 1h, 8h20h.
const HOUR_START = 8
const HOUR_END = 20
const SLOT_COUNT = HOUR_END - HOUR_START
const SLOT_PX = 56 // height of each 1h row
const DAYS_OF_WEEK = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'] as const
// ===== Date helpers =====
function startOfWeek(d: Date): Date {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
const day = x.getDay() // 0=Sun, 1=Mon...
const diff = day === 0 ? -6 : 1 - day // shift to Monday
x.setDate(x.getDate() + diff)
return x
}
function addDays(d: Date, days: number): Date {
const x = new Date(d)
x.setDate(x.getDate() + days)
return x
}
function formatDateRange(weekStart: Date): string {
const end = addDays(weekStart, 6)
const sameMonth = weekStart.getMonth() === end.getMonth()
if (sameMonth) {
return `${weekStart.getDate()}${end.getDate()}/${weekStart.getMonth() + 1}/${weekStart.getFullYear()}`
}
return `${weekStart.getDate()}/${weekStart.getMonth() + 1}${end.getDate()}/${end.getMonth() + 1}/${weekStart.getFullYear()}`
}
function formatTime(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
// Trả về datetime-local string 'YYYY-MM-DDTHH:mm' từ Date (giữ local tz).
function toLocalDateTimeString(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// Booking nằm trong day d nếu (startAt < endOfDay) && (endAt > startOfDay).
function bookingOverlapsDay(b: MeetingBookingDto, day: Date): boolean {
const startOfDay = new Date(day)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(day)
endOfDay.setHours(23, 59, 59, 999)
const bStart = new Date(b.startAt)
const bEnd = new Date(b.endAt)
return bStart < endOfDay && bEnd > startOfDay
}
// Compute top + height (px) cho 1 booking trong day cụ thể.
function computeBlockStyle(b: MeetingBookingDto, day: Date): { top: number; height: number } {
const bStart = new Date(b.startAt)
const bEnd = new Date(b.endAt)
const dayStart = new Date(day)
dayStart.setHours(HOUR_START, 0, 0, 0)
const dayEnd = new Date(day)
dayEnd.setHours(HOUR_END, 0, 0, 0)
const clampedStart = bStart < dayStart ? dayStart : bStart
const clampedEnd = bEnd > dayEnd ? dayEnd : bEnd
const topHours = (clampedStart.getTime() - dayStart.getTime()) / (1000 * 60 * 60)
const durationHours = (clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60)
return {
top: Math.max(0, topHours * SLOT_PX),
height: Math.max(20, durationHours * SLOT_PX),
}
}
// Status badge color
function statusBadgeClass(status: number): string {
if (status === MeetingBookingStatus.Cancelled) return 'bg-slate-100 text-slate-500 line-through'
if (status === MeetingBookingStatus.Completed) return 'bg-emerald-100 text-emerald-700'
return 'bg-brand-100 text-brand-700' // Confirmed
}
function statusLabel(status: number): string {
if (status === MeetingBookingStatus.Cancelled) return 'Đã hủy'
if (status === MeetingBookingStatus.Completed) return 'Đã hoàn tất'
return 'Xác nhận'
}
// ===== Page =====
type UserOption = { id: string; fullName: string; email: string }
export function MeetingCalendarPage() {
const auth = useAuth()
const qc = useQueryClient()
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
const [roomId, setRoomId] = useState<string>('')
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<Partial<MeetingBookingCreateInput>>({})
const [createAttendees, setCreateAttendees] = useState<AttendeeInput[]>([])
const [detailId, setDetailId] = useState<string | null>(null)
const rooms = useQuery({
queryKey: ['meeting-rooms', 'active'],
queryFn: async () =>
(await api.get<MeetingRoomDto[]>('/meeting-rooms', { params: { isActiveOnly: true } })).data,
})
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart])
const bookings = useQuery({
queryKey: ['meeting-bookings', { roomId, weekStart: weekStart.toISOString() }],
queryFn: async () =>
(await api.get<MeetingBookingDto[]>('/meeting-bookings', {
params: {
roomId: roomId || undefined,
startDate: weekStart.toISOString(),
endDate: weekEnd.toISOString(),
},
})).data,
})
const users = useQuery({
queryKey: ['users-meeting-attendees'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
enabled: createOpen || detailId !== null,
})
const createBooking = useMutation({
mutationFn: async (payload: MeetingBookingCreateInput) => {
await api.post('/meeting-bookings', payload)
},
onSuccess: () => {
toast.success('Đã tạo booking')
qc.invalidateQueries({ queryKey: ['meeting-bookings'] })
setCreateOpen(false)
setCreateForm({})
setCreateAttendees([])
},
onError: err => toast.error(getErrorMessage(err)),
})
const cancelBooking = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/meeting-bookings/${id}`)
},
onSuccess: () => {
toast.success('Đã hủy booking')
qc.invalidateQueries({ queryKey: ['meeting-bookings'] })
setDetailId(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreateForSlot(day: Date, hour: number) {
const start = new Date(day)
start.setHours(hour, 0, 0, 0)
const end = new Date(day)
end.setHours(hour + 1, 0, 0, 0)
setCreateForm({
roomId: roomId || rooms.data?.[0]?.id || '',
startAt: toLocalDateTimeString(start),
endAt: toLocalDateTimeString(end),
title: '',
description: '',
note: '',
})
setCreateAttendees([])
setCreateOpen(true)
}
function openCreateBlank() {
const now = new Date()
const next = new Date(now)
next.setHours(now.getHours() + 1, 0, 0, 0)
const end = new Date(next)
end.setHours(end.getHours() + 1)
setCreateForm({
roomId: roomId || rooms.data?.[0]?.id || '',
startAt: toLocalDateTimeString(next),
endAt: toLocalDateTimeString(end),
title: '',
description: '',
note: '',
})
setCreateAttendees([])
setCreateOpen(true)
}
function submitCreate(e: FormEvent) {
e.preventDefault()
if (!createForm.roomId || !createForm.startAt || !createForm.endAt || !createForm.title?.trim()) {
toast.error('Vui lòng nhập đầy đủ thông tin')
return
}
// datetime-local strings không có tz suffix → convert sang ISO UTC
const payload: MeetingBookingCreateInput = {
roomId: createForm.roomId,
startAt: new Date(createForm.startAt).toISOString(),
endAt: new Date(createForm.endAt).toISOString(),
title: createForm.title.trim(),
description: createForm.description?.trim() || null,
note: createForm.note?.trim() || null,
attendees: createAttendees,
}
createBooking.mutate(payload)
}
const detailBooking = useMemo(
() => bookings.data?.find(b => b.id === detailId) ?? null,
[bookings.data, detailId],
)
// Filter bookings cho từng day column
const bookingsByDay = useMemo(() => {
const map = new Map<number, MeetingBookingDto[]>()
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i)
const dayBookings = (bookings.data ?? []).filter(b => bookingOverlapsDay(b, day))
map.set(i, dayBookings)
}
return map
}, [bookings.data, weekStart])
const totalThisWeek = bookings.data?.length ?? 0
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Đt phòng họp
</span>
}
description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
actions={
<Button onClick={openCreateBlank}>
<Plus className="h-4 w-4" />
Tạo Booking
</Button>
}
/>
{/* Filter bar */}
<div className="mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white p-3 shadow-sm sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(addDays(weekStart, -7)))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
Hôm nay
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(addDays(weekStart, 7)))}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-2 text-sm font-medium text-slate-700">{formatDateRange(weekStart)}</span>
</div>
<div className="flex-1" />
<Select
value={roomId}
onChange={e => setRoomId(e.target.value)}
className="sm:w-64"
>
<option value="">Tất cả phòng họp</option>
{(rooms.data ?? []).map(r => (
<option key={r.id} value={r.id}>
{r.code} {r.name} ({r.capacity} chỗ)
</option>
))}
</Select>
</div>
{/* Calendar grid */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="min-w-[900px]">
{/* Header row: 7 days */}
<div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50">
<div className="border-r border-slate-200 px-2 py-2 text-[11px] uppercase tracking-wider text-slate-500"></div>
{DAYS_OF_WEEK.map((d, i) => {
const day = addDays(weekStart, i)
const isToday = day.toDateString() === new Date().toDateString()
return (
<div
key={d}
className={cn(
'border-r border-slate-200 px-2 py-2 text-center last:border-r-0',
isToday && 'bg-brand-50',
)}
>
<div className="text-[11px] uppercase tracking-wider text-slate-500">{d}</div>
<div className={cn('text-sm font-semibold', isToday ? 'text-brand-700' : 'text-slate-900')}>
{day.getDate()}/{day.getMonth() + 1}
</div>
</div>
)
})}
</div>
{/* Time slots column + day columns (relative parent for absolute booking blocks) */}
<div className="grid grid-cols-[60px_repeat(7,minmax(0,1fr))]">
{/* Time labels column */}
<div className="border-r border-slate-200 bg-slate-50/50">
{Array.from({ length: SLOT_COUNT }).map((_, i) => (
<div
key={i}
className="flex items-start justify-end border-b border-slate-100 px-2 py-1 text-[10px] text-slate-400"
style={{ height: `${SLOT_PX}px` }}
>
{String(HOUR_START + i).padStart(2, '0')}:00
</div>
))}
</div>
{/* 7 day columns */}
{Array.from({ length: 7 }).map((_, dayIdx) => {
const day = addDays(weekStart, dayIdx)
const dayBookings = bookingsByDay.get(dayIdx) ?? []
return (
<div
key={dayIdx}
className="relative border-r border-slate-200 last:border-r-0"
style={{ height: `${SLOT_COUNT * SLOT_PX}px` }}
>
{/* Hour slot click targets */}
{Array.from({ length: SLOT_COUNT }).map((_, hourIdx) => (
<button
key={hourIdx}
onClick={() => openCreateForSlot(day, HOUR_START + hourIdx)}
className="block w-full border-b border-slate-100 transition hover:bg-brand-50/40"
style={{ height: `${SLOT_PX}px` }}
title={`Tạo booking lúc ${String(HOUR_START + hourIdx).padStart(2, '0')}:00`}
/>
))}
{/* Absolute booking blocks */}
{dayBookings.map(b => {
const { top, height } = computeBlockStyle(b, day)
const isOwn = b.bookedByUserId === auth.user?.id
return (
<button
key={b.id}
onClick={() => setDetailId(b.id)}
className={cn(
'absolute left-1 right-1 overflow-hidden rounded-md border px-2 py-1 text-left text-[11px] shadow-sm transition hover:shadow',
statusBadgeClass(b.status),
isOwn ? 'border-brand-400 ring-1 ring-brand-200' : 'border-slate-200',
)}
style={{ top: `${top}px`, height: `${height}px` }}
>
<div className="truncate font-semibold leading-tight">{b.title}</div>
<div className="truncate text-[10px] opacity-80">
{formatTime(b.startAt)}{formatTime(b.endAt)} · {b.roomCode}
</div>
{height >= 56 && (
<div className="truncate text-[10px] opacity-70">{b.bookedByFullName}</div>
)}
</button>
)
})}
</div>
)
})}
</div>
</div>
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap gap-3 text-[11px] text-slate-500">
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-brand-100" /> Xác nhận</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-emerald-100" /> Hoàn tất</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-slate-100" /> Đã hủy</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded border border-brand-400" /> Booking của tôi</span>
</div>
{/* Create Dialog */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Tạo Booking mới"
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
<Button onClick={(e: FormEvent) => submitCreate(e)} disabled={createBooking.isPending}>
{createBooking.isPending ? 'Đang tạo…' : 'Tạo'}
</Button>
</>
}
>
<form onSubmit={submitCreate} className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>Phòng họp *</Label>
<Select
value={createForm.roomId ?? ''}
onChange={e => setCreateForm(s => ({ ...s, roomId: e.target.value }))}
required
>
<option value=""> Chọn phòng </option>
{(rooms.data ?? []).map(r => (
<option key={r.id} value={r.id}>
{r.code} {r.name} ({r.capacity} chỗ)
</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label>Tiêu đ *</Label>
<Input
value={createForm.title ?? ''}
onChange={e => setCreateForm(s => ({ ...s, title: e.target.value }))}
placeholder="VD: Họp planning Q3 2026"
required
/>
</div>
<div className="space-y-1">
<Label>Bắt đu *</Label>
<Input
type="datetime-local"
value={createForm.startAt ?? ''}
onChange={e => setCreateForm(s => ({ ...s, startAt: e.target.value }))}
required
/>
</div>
<div className="space-y-1">
<Label>Kết thúc *</Label>
<Input
type="datetime-local"
value={createForm.endAt ?? ''}
onChange={e => setCreateForm(s => ({ ...s, endAt: e.target.value }))}
required
/>
</div>
</div>
<div className="space-y-1">
<Label> tả</Label>
<Textarea
rows={2}
value={createForm.description ?? ''}
onChange={e => setCreateForm(s => ({ ...s, description: e.target.value }))}
placeholder="Nội dung họp, agenda..."
/>
</div>
<div className="space-y-1">
<Label>Ghi chú</Label>
<Input
value={createForm.note ?? ''}
onChange={e => setCreateForm(s => ({ ...s, note: e.target.value }))}
placeholder="Ghi chú nội bộ"
/>
</div>
<div className="space-y-1">
<Label>Người tham dự ({createAttendees.length})</Label>
<AttendeePicker
users={users.data ?? []}
selected={createAttendees}
onChange={setCreateAttendees}
/>
</div>
</form>
</Dialog>
{/* Detail Dialog */}
<Dialog
open={detailBooking !== null}
onClose={() => setDetailId(null)}
title="Chi tiết Booking"
size="lg"
footer={
detailBooking && detailBooking.bookedByUserId === auth.user?.id
&& detailBooking.status === MeetingBookingStatus.Confirmed
? (
<>
<Button variant="outline" onClick={() => setDetailId(null)}>Đóng</Button>
<Button
variant="danger"
onClick={() => {
if (confirm('Hủy booking này?')) cancelBooking.mutate(detailBooking.id)
}}
disabled={cancelBooking.isPending}
>
<Trash2 className="h-4 w-4" />
Hủy booking
</Button>
</>
) : (
<Button variant="outline" onClick={() => setDetailId(null)}>Đóng</Button>
)
}
>
{detailBooking && (
<div className="space-y-3 text-sm">
<div>
<h3 className="text-base font-semibold text-slate-900">{detailBooking.title}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
{statusLabel(detailBooking.status)}
</span>
<span className="text-slate-500">
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span>
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-2 rounded-md bg-slate-50 p-3 sm:grid-cols-2">
<div className="flex items-center gap-2 text-slate-700">
<MapPin className="h-4 w-4 text-slate-400" />
<span>{detailBooking.roomCode} {detailBooking.roomName}</span>
</div>
<div className="flex items-center gap-2 text-slate-700">
<Clock className="h-4 w-4 text-slate-400" />
<span>
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
{' '}
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</span>
</div>
</div>
{detailBooking.description && (
<div>
<Label className="text-xs text-slate-500"> tả</Label>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
</div>
)}
{detailBooking.note && (
<div>
<Label className="text-xs text-slate-500">Ghi chú</Label>
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
</div>
)}
<div>
<Label className="flex items-center gap-1 text-xs text-slate-500">
<UsersIcon className="h-3.5 w-3.5" />
Người tham dự ({detailBooking.attendees.length})
</Label>
{detailBooking.attendees.length === 0 ? (
<p className="mt-1 text-xs text-slate-400"> Không người tham dự </p>
) : (
<ul className="mt-1 divide-y divide-slate-100 rounded-md border border-slate-200">
{detailBooking.attendees.map(a => (
<li key={a.userId} className="flex items-start gap-2 px-3 py-2 text-xs">
<UsersIcon className="mt-0.5 h-3.5 w-3.5 text-slate-400" />
<div className="flex-1">
<div className="font-medium text-slate-700">{a.fullName}</div>
{a.email && <div className="text-[11px] text-slate-500">{a.email}</div>}
{a.notes && <div className="mt-0.5 text-[11px] italic text-slate-500">{a.notes}</div>}
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</Dialog>
</div>
)
}
// ===== AttendeePicker subcomponent =====
function AttendeePicker({
users,
selected,
onChange,
}: {
users: UserOption[]
selected: AttendeeInput[]
onChange: (next: AttendeeInput[]) => void
}) {
const [search, setSearch] = useState('')
const selectedIds = useMemo(() => new Set(selected.map(a => a.userId)), [selected])
const filtered = useMemo(() => {
if (!search.trim()) return users.slice(0, 50)
const s = search.toLowerCase()
return users
.filter(u => u.fullName.toLowerCase().includes(s) || u.email.toLowerCase().includes(s))
.slice(0, 50)
}, [users, search])
function toggle(u: UserOption) {
if (selectedIds.has(u.id)) {
onChange(selected.filter(a => a.userId !== u.id))
} else {
onChange([...selected, { userId: u.id, notes: null }])
}
}
function removeAt(id: string) {
onChange(selected.filter(a => a.userId !== id))
}
return (
<div className="rounded-md border border-slate-200">
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 border-b border-slate-200 bg-slate-50 p-2">
{selected.map(a => {
const u = users.find(x => x.id === a.userId)
return (
<span
key={a.userId}
className="inline-flex items-center gap-1 rounded-full bg-brand-100 px-2 py-0.5 text-[11px] text-brand-700"
>
{u?.fullName ?? a.userId}
<button
type="button"
onClick={() => removeAt(a.userId)}
className="rounded-full text-brand-600 hover:bg-brand-200"
>
<X className="h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
{/* Search + list */}
<div className="p-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm theo tên / email…"
className="mb-2 h-8 text-xs"
/>
<div className="max-h-40 overflow-y-auto rounded border border-slate-100">
{filtered.length === 0 ? (
<div className="p-3 text-center text-[11px] text-slate-400">Không user khớp.</div>
) : (
filtered.map(u => {
const isSel = selectedIds.has(u.id)
return (
<button
key={u.id}
type="button"
onClick={() => toggle(u)}
className={cn(
'block w-full px-2 py-1.5 text-left text-xs transition',
isSel ? 'bg-brand-50 text-brand-700' : 'hover:bg-slate-50',
)}
>
<div className="font-medium">{u.fullName}</div>
<div className="text-[10px] text-slate-500">{u.email}</div>
</button>
)
})
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,320 @@
// Phòng họp (Meeting Rooms catalog) — Phase 10.2 G-O2 (S36 2026-05-28).
// Catalog CRUD admin-only — Pattern Master/Catalogs/CatalogsPage table CRUD.
// File này MIRROR SHA256 identical với fe-user counterpart.
// Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Building2, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { MeetingRoomCreateInput, MeetingRoomDto, MeetingRoomUpdateInput } from '@/types/meeting'
type FormState = {
id?: string
code: string
name: string
capacity: number | ''
location: string
equipment: string
isActive: boolean
}
const EMPTY_FORM: FormState = {
code: '',
name: '',
capacity: '',
location: '',
equipment: '',
isActive: true,
}
export function MeetingRoomsPage() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [showInactive, setShowInactive] = useState(false)
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const isEdit = !!form.id
const list = useQuery({
queryKey: ['meeting-rooms', { search, showInactive }],
queryFn: async () =>
(await api.get<MeetingRoomDto[]>('/meeting-rooms', {
params: {
search: search || undefined,
isActiveOnly: showInactive ? undefined : true,
},
})).data,
})
const save = useMutation({
mutationFn: async () => {
if (!form.code.trim() || !form.name.trim() || form.capacity === '' || Number(form.capacity) <= 0) {
throw new Error('Vui lòng nhập đầy đủ Mã, Tên, Sức chứa (> 0)')
}
if (isEdit) {
const body: MeetingRoomUpdateInput & { id: string } = {
id: form.id!,
code: form.code.trim(),
name: form.name.trim(),
capacity: Number(form.capacity),
location: form.location.trim() || null,
equipment: form.equipment.trim() || null,
isActive: form.isActive,
}
await api.put(`/meeting-rooms/${form.id}`, body)
} else {
const body: MeetingRoomCreateInput = {
code: form.code.trim(),
name: form.name.trim(),
capacity: Number(form.capacity),
location: form.location.trim() || null,
equipment: form.equipment.trim() || null,
}
await api.post('/meeting-rooms', body)
}
},
onSuccess: () => {
toast.success(isEdit ? 'Đã lưu phòng họp' : 'Đã thêm phòng họp')
qc.invalidateQueries({ queryKey: ['meeting-rooms'] })
setOpen(false)
setForm(EMPTY_FORM)
},
onError: err => toast.error(getErrorMessage(err)),
})
const remove = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/meeting-rooms/${id}`)
},
onSuccess: () => {
toast.success('Đã tắt phòng họp (IsActive=false)')
qc.invalidateQueries({ queryKey: ['meeting-rooms'] })
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreate() {
setForm(EMPTY_FORM)
setOpen(true)
}
function openEdit(row: MeetingRoomDto) {
setForm({
id: row.id,
code: row.code,
name: row.name,
capacity: row.capacity,
location: row.location ?? '',
equipment: row.equipment ?? '',
isActive: row.isActive,
})
setOpen(true)
}
const filtered = useMemo(() => list.data ?? [], [list.data])
const total = filtered.length
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Phòng họp
</span>
}
description={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
actions={
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Thêm phòng họp
</Button>
}
/>
{/* Filter row */}
<div className="mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white p-3 shadow-sm sm:flex-row sm:items-center">
<div className="relative max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm theo mã / tên / vị trí…"
className="pl-8"
/>
</div>
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={showInactive}
onChange={e => setShowInactive(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Hiện cả phòng đã tắt
</label>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">Tên</th>
<th className="px-3 py-2 text-left">Sức chứa</th>
<th className="px-3 py-2 text-left">Vị trí</th>
<th className="px-3 py-2 text-left">Thiết bị</th>
<th className="px-3 py-2 text-left">Trạng thái</th>
<th className="w-20 px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.isLoading && (
<tr><td colSpan={7} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)}
{!list.isLoading && total === 0 && (
<tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa phòng họp bấm Thêm đ tạo mới.</td></tr>
)}
{filtered.map(row => (
<tr key={row.id} className={cn('hover:bg-slate-50', !row.isActive && 'opacity-60')}>
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
<td className="px-3 py-2 font-medium text-slate-800">{row.name}</td>
<td className="px-3 py-2 text-xs text-slate-600">{row.capacity} chỗ</td>
<td className="px-3 py-2 text-xs text-slate-600">{row.location ?? '—'}</td>
<td className="px-3 py-2 text-xs text-slate-600">{row.equipment ?? '—'}</td>
<td className="px-3 py-2">
{row.isActive ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Đã tắt</span>
)}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => openEdit(row)}
title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
{row.isActive && (
<button
onClick={() => {
if (confirm(`Tắt phòng "${row.name}"? (IsActive=false — booking cũ vẫn giữ.)`)) {
remove.mutate(row.id)
}
}}
title="Tắt phòng"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
disabled={remove.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={`${isEdit ? 'Sửa' : 'Thêm'} phòng họp`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
<Button
onClick={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
disabled={save.isPending}
>
{save.isPending ? 'Đang lưu…' : (isEdit ? 'Lưu' : 'Thêm')}
</Button>
</>
}
>
<form
onSubmit={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<div className="space-y-1">
<Label> *</Label>
<Input
value={form.code}
onChange={e => setForm(s => ({ ...s, code: e.target.value }))}
placeholder="VD: ROOM-A1"
required
maxLength={50}
/>
</div>
<div className="space-y-1">
<Label>Tên *</Label>
<Input
value={form.name}
onChange={e => setForm(s => ({ ...s, name: e.target.value }))}
placeholder="VD: Phòng họp tầng 3"
required
maxLength={200}
/>
</div>
<div className="space-y-1">
<Label>Sức chứa *</Label>
<Input
type="number"
min={1}
value={form.capacity}
onChange={e => setForm(s => ({ ...s, capacity: e.target.value === '' ? '' : Number(e.target.value) }))}
placeholder="10"
required
/>
</div>
<div className="space-y-1">
<Label>Vị trí</Label>
<Input
value={form.location}
onChange={e => setForm(s => ({ ...s, location: e.target.value }))}
placeholder="VD: Tầng 3, Toà nhà A"
maxLength={200}
/>
</div>
<div className="col-span-1 space-y-1 sm:col-span-2">
<Label>Thiết bị</Label>
<Textarea
rows={2}
value={form.equipment}
onChange={e => setForm(s => ({ ...s, equipment: e.target.value }))}
placeholder="VD: Máy chiếu, bảng trắng, loa Bluetooth..."
/>
</div>
{isEdit && (
<div className="col-span-1 space-y-1 sm:col-span-2">
<Label>Trạng thái</Label>
<div className="flex items-center gap-2 pt-1">
<input
type="checkbox"
checked={form.isActive}
onChange={e => setForm(s => ({ ...s, isActive: e.target.checked }))}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">Đang dùng (bỏ tick đ tắt booking vẫn giữ)</span>
</div>
</div>
)}
</form>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,88 @@
// Phòng họp + Booking (Phase 10.2 G-O2 — Mig 36, S36 2026-05-28).
// Mirror BE Application/Office/MeetingFeatures.cs DTOs.
// File này MIRROR SHA256 identical với fe-user/src/types/meeting.ts.
// 3-state lifecycle Booking — mirror BE MeetingBookingStatus enum (Domain/Office).
export const MeetingBookingStatus = {
Confirmed: 1,
Cancelled: 2,
Completed: 3,
} as const
export type MeetingBookingStatusValue = (typeof MeetingBookingStatus)[keyof typeof MeetingBookingStatus]
// ===== MeetingRoom (catalog) =====
export interface MeetingRoomDto {
id: string
code: string
name: string
capacity: number
location: string | null
equipment: string | null
isActive: boolean
createdAt: string
}
export interface MeetingRoomCreateInput {
code: string
name: string
capacity: number
location?: string | null
equipment?: string | null
}
export interface MeetingRoomUpdateInput extends MeetingRoomCreateInput {
isActive: boolean
}
// ===== MeetingBooking (booking + attendees N-to-N join) =====
export interface MeetingBookingAttendeeDto {
userId: string
fullName: string
email: string | null
notes: string | null
}
export interface MeetingBookingDto {
id: string
roomId: string
roomCode: string
roomName: string
bookedByUserId: string
bookedByFullName: string
startAt: string
endAt: string
title: string
description: string | null
status: MeetingBookingStatusValue
note: string | null
createdAt: string
attendees: MeetingBookingAttendeeDto[]
}
export interface AttendeeInput {
userId: string
notes?: string | null
}
export interface MeetingBookingCreateInput {
roomId: string
startAt: string
endAt: string
title: string
description?: string | null
note?: string | null
attendees: AttendeeInput[]
}
export interface MeetingBookingUpdateInput {
id: string
roomId: string
startAt: string
endAt: string
title: string
description?: string | null
note?: string | null
attendees: AttendeeInput[]
}