Files
solution-erp/fe-user/src/pages/office/MeetingRoomsPage.tsx
pqhuy1987 f45090b654
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s
[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S36 Plan G-O2 Phòng họp Mig 36 + BE CRUD + FE 2 app
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>
2026-05-28 15:06:12 +07:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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