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>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
// 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">Mã</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 có 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>Mã *</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 cũ vẫn giữ)</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|