[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
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:
@ -30,6 +30,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 (
|
||||
@ -80,6 +82,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="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
|
||||
@ -64,6 +64,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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
693
fe-admin/src/pages/office/MeetingCalendarPage.tsx
Normal file
693
fe-admin/src/pages/office/MeetingCalendarPage.tsx
Normal 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 (8h–20h, 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, 8h–20h.
|
||||
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>Mô 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">Mô 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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
320
fe-admin/src/pages/office/MeetingRoomsPage.tsx
Normal file
320
fe-admin/src/pages/office/MeetingRoomsPage.tsx
Normal 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">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>
|
||||
)
|
||||
}
|
||||
88
fe-admin/src/types/meeting.ts
Normal file
88
fe-admin/src/types/meeting.ts
Normal 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[]
|
||||
}
|
||||
@ -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="*"
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
693
fe-user/src/pages/office/MeetingCalendarPage.tsx
Normal file
693
fe-user/src/pages/office/MeetingCalendarPage.tsx
Normal 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 (8h–20h, 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, 8h–20h.
|
||||
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>Mô 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">Mô 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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
320
fe-user/src/pages/office/MeetingRoomsPage.tsx
Normal file
320
fe-user/src/pages/office/MeetingRoomsPage.tsx
Normal 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">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>
|
||||
)
|
||||
}
|
||||
88
fe-user/src/types/meeting.ts
Normal file
88
fe-user/src/types/meeting.ts
Normal 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[]
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
// Phase 10.2 G-O2 (S36) — Phòng họp Booking API.
|
||||
// Class-level [Authorize]: mọi user đăng nhập được book (multi-tenant office use case).
|
||||
// Authorization owner-or-admin enforced TRONG handler (UpdateMeetingBookingHandler +
|
||||
// CancelMeetingBookingHandler) — controller không restrict role.
|
||||
// Pattern 12-bis cross-module mirror HrmConfigsController.cs (S35 cumulative 10×).
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meeting-bookings")]
|
||||
[Authorize]
|
||||
public class MeetingBookingsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<List<MeetingBookingDto>> List(
|
||||
[FromQuery] Guid? roomId,
|
||||
[FromQuery] DateTime? startDate,
|
||||
[FromQuery] DateTime? endDate,
|
||||
[FromQuery] bool myOnly,
|
||||
CancellationToken ct)
|
||||
=> await mediator.Send(new GetMeetingBookingsQuery(roomId, startDate, endDate, myOnly), ct);
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<MeetingBookingDto>> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var dto = await mediator.Send(new GetMeetingBookingByIdQuery(id), ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMeetingBookingCommand body, CancellationToken ct)
|
||||
{
|
||||
var id = await mediator.Send(body, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMeetingBookingCommand body, CancellationToken ct)
|
||||
{
|
||||
if (id != body.Id) return BadRequest("Id mismatch");
|
||||
await mediator.Send(body, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new CancelMeetingBookingCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
// Phase 10.2 G-O2 (S36) — Phòng họp catalog API.
|
||||
// Class-level [Authorize]: mọi user đăng nhập được List (FE Booking page cần load room dropdown).
|
||||
// Per-action [Authorize(Roles = "Admin")] cho Create/Update/Delete: chỉ admin quản lý catalog.
|
||||
// Pattern 12-bis cross-module mirror HrmConfigsController.cs (S35 cumulative 10×).
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meeting-rooms")]
|
||||
[Authorize]
|
||||
public class MeetingRoomsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<List<MeetingRoomDto>> List(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool? isActiveOnly,
|
||||
CancellationToken ct)
|
||||
=> await mediator.Send(new GetMeetingRoomsQuery(search, isActiveOnly), ct);
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMeetingRoomCommand body, CancellationToken ct)
|
||||
{
|
||||
var id = await mediator.Send(body, ct);
|
||||
return CreatedAtAction(nameof(List), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMeetingRoomCommand body, CancellationToken ct)
|
||||
{
|
||||
if (id != body.Id) return BadRequest("Id mismatch");
|
||||
await mediator.Send(body, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteMeetingRoomCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.Office;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.Common.Interfaces;
|
||||
@ -103,5 +104,12 @@ public interface IApplicationDbContext
|
||||
DbSet<ShiftPattern> ShiftPatterns { get; }
|
||||
DbSet<OtPolicy> OtPolicies { get; }
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
|
||||
// Overlap check qua SERIALIZABLE tx + EXISTS handler (clean-room SOL,
|
||||
// mirror NamGroup TblBookingResource S50 pattern). FullCalendar v6 MIT FE.
|
||||
DbSet<MeetingRoom> MeetingRooms { get; }
|
||||
DbSet<MeetingBooking> MeetingBookings { get; }
|
||||
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
479
src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs
Normal file
479
src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs
Normal file
@ -0,0 +1,479 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking CQRS.
|
||||
// Pattern 12-bis cross-module mirror Hrm/HrmConfigFeatures.cs (S35 cumulative 10×).
|
||||
// Pattern 16-bis foundation 4-place modification: entity (done) + config (done)
|
||||
// + controller (separate file) + menu/DbInit (DbInitializer SeedMenuTreeAsync extend).
|
||||
//
|
||||
// Endpoints expose qua MeetingRoomsController + MeetingBookingsController:
|
||||
// GET /api/meeting-rooms — list (filter search + active)
|
||||
// POST /api/meeting-rooms — create [Admin]
|
||||
// PUT /api/meeting-rooms/{id} — update [Admin]
|
||||
// DELETE /api/meeting-rooms/{id} — soft disable IsActive=false [Admin]
|
||||
// (NOT IsDeleted vì FK Restrict Booking)
|
||||
// GET /api/meeting-bookings — list (room + date range + my filter)
|
||||
// GET /api/meeting-bookings/{id} — detail Include Attendees
|
||||
// POST /api/meeting-bookings — create (SERIALIZABLE tx overlap check)
|
||||
// PUT /api/meeting-bookings/{id} — update (SERIALIZABLE tx exclude self)
|
||||
// DELETE /api/meeting-bookings/{id} — cancel Status=Cancelled (NOT IsDeleted — preserve history)
|
||||
//
|
||||
// Investigator verdict S36 pre-flight chốt:
|
||||
// - Overlap check qua System.Data.IsolationLevel.Serializable tx + EXISTS AnyAsync
|
||||
// - Attendees join table N-to-N (MeetingBookingAttendee), NOT JSON array
|
||||
// - 3-state Status enum (Confirmed=1 / Cancelled=2 / Completed=3)
|
||||
|
||||
// ===== DTOs =====
|
||||
|
||||
public record MeetingRoomDto(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record MeetingBookingAttendeeDto(
|
||||
Guid UserId,
|
||||
string FullName,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
|
||||
public record MeetingBookingDto(
|
||||
Guid Id,
|
||||
Guid RoomId,
|
||||
string RoomCode,
|
||||
string RoomName,
|
||||
Guid BookedByUserId,
|
||||
string BookedByFullName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
int Status,
|
||||
string? Note,
|
||||
DateTime CreatedAt,
|
||||
List<MeetingBookingAttendeeDto> Attendees);
|
||||
|
||||
public record AttendeeInput(Guid UserId, string? Notes);
|
||||
|
||||
// ===== Region 1: MeetingRoom CRUD =====
|
||||
|
||||
public record GetMeetingRoomsQuery(string? Search = null, bool? IsActiveOnly = null) : IRequest<List<MeetingRoomDto>>;
|
||||
public class GetMeetingRoomsHandler(IApplicationDbContext db) : IRequestHandler<GetMeetingRoomsQuery, List<MeetingRoomDto>>
|
||||
{
|
||||
public async Task<List<MeetingRoomDto>> Handle(GetMeetingRoomsQuery req, CancellationToken ct)
|
||||
{
|
||||
var q = db.MeetingRooms.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (req.IsActiveOnly == true) q = q.Where(x => x.IsActive);
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.ToLower();
|
||||
q = q.Where(x =>
|
||||
x.Code.ToLower().Contains(s) ||
|
||||
x.Name.ToLower().Contains(s) ||
|
||||
(x.Location != null && x.Location.ToLower().Contains(s)));
|
||||
}
|
||||
return await q.OrderBy(x => x.Code)
|
||||
.Select(x => new MeetingRoomDto(
|
||||
x.Id, x.Code, x.Name, x.Capacity, x.Location, x.Equipment, x.IsActive, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateMeetingRoomCommand(
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment) : IRequest<Guid>;
|
||||
public class CreateMeetingRoomValidator : AbstractValidator<CreateMeetingRoomCommand>
|
||||
{
|
||||
public CreateMeetingRoomValidator()
|
||||
{
|
||||
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Location).MaximumLength(200);
|
||||
RuleFor(x => x.Equipment).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
public class CreateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<CreateMeetingRoomCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
if (await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
|
||||
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
|
||||
var entity = new MeetingRoom
|
||||
{
|
||||
Code = req.Code,
|
||||
Name = req.Name,
|
||||
Capacity = req.Capacity,
|
||||
Location = req.Location,
|
||||
Equipment = req.Equipment,
|
||||
IsActive = true,
|
||||
};
|
||||
db.MeetingRooms.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateMeetingRoomCommand(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment,
|
||||
bool IsActive) : IRequest;
|
||||
public class UpdateMeetingRoomValidator : AbstractValidator<UpdateMeetingRoomCommand>
|
||||
{
|
||||
public UpdateMeetingRoomValidator()
|
||||
{
|
||||
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Location).MaximumLength(200);
|
||||
RuleFor(x => x.Equipment).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
public class UpdateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<UpdateMeetingRoomCommand>
|
||||
{
|
||||
public async Task Handle(UpdateMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingRoom", req.Id);
|
||||
if (entity.Code != req.Code
|
||||
&& await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && x.Id != req.Id && !x.IsDeleted, ct))
|
||||
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
|
||||
entity.Code = req.Code;
|
||||
entity.Name = req.Name;
|
||||
entity.Capacity = req.Capacity;
|
||||
entity.Location = req.Location;
|
||||
entity.Equipment = req.Equipment;
|
||||
entity.IsActive = req.IsActive;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteMeetingRoomCommand(Guid Id) : IRequest;
|
||||
public class DeleteMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<DeleteMeetingRoomCommand>
|
||||
{
|
||||
public async Task Handle(DeleteMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingRoom", req.Id);
|
||||
// FK Restrict Booking → NOT soft delete (IsDeleted=true), chỉ set IsActive=false.
|
||||
// Preserve booking history + future query filter chỉ load room đang active.
|
||||
// Admin có thể toggle IsActive=true lại nếu muốn re-enable.
|
||||
entity.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Region 2: MeetingBooking CRUD =====
|
||||
// Overlap check pattern SERIALIZABLE tx + EXISTS:
|
||||
// - BeginTransactionAsync(IsolationLevel.Serializable, ct)
|
||||
// - AnyAsync(b.RoomId == cmd.RoomId && b.Status == Confirmed && !b.IsDeleted
|
||||
// && b.StartAt < cmd.EndAt && b.EndAt > cmd.StartAt)
|
||||
// - Update: exclude self (b.Id != cmd.Id)
|
||||
// SERIALIZABLE đảm bảo 2 concurrent insert overlap không cùng commit (range lock).
|
||||
|
||||
public record GetMeetingBookingsQuery(
|
||||
Guid? RoomId = null,
|
||||
DateTime? StartDate = null,
|
||||
DateTime? EndDate = null,
|
||||
bool MyOnly = false) : IRequest<List<MeetingBookingDto>>;
|
||||
|
||||
public class GetMeetingBookingsHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<GetMeetingBookingsQuery, List<MeetingBookingDto>>
|
||||
{
|
||||
public async Task<List<MeetingBookingDto>> Handle(GetMeetingBookingsQuery req, CancellationToken ct)
|
||||
{
|
||||
var q = db.MeetingBookings.AsNoTracking()
|
||||
.Include(x => x.Room)
|
||||
.Include(x => x.Attendees)
|
||||
.Where(x => !x.IsDeleted);
|
||||
|
||||
if (req.RoomId.HasValue) q = q.Where(x => x.RoomId == req.RoomId.Value);
|
||||
if (req.StartDate.HasValue) q = q.Where(x => x.EndAt >= req.StartDate.Value);
|
||||
if (req.EndDate.HasValue) q = q.Where(x => x.StartAt <= req.EndDate.Value);
|
||||
|
||||
if (req.MyOnly && currentUser.UserId is Guid uid)
|
||||
{
|
||||
// "My bookings" = đã đặt OR được mời (Attendee).
|
||||
q = q.Where(x => x.BookedByUserId == uid || x.Attendees.Any(a => a.UserId == uid));
|
||||
}
|
||||
|
||||
return await q.OrderBy(x => x.StartAt)
|
||||
.Select(x => new MeetingBookingDto(
|
||||
x.Id,
|
||||
x.RoomId,
|
||||
x.Room.Code,
|
||||
x.Room.Name,
|
||||
x.BookedByUserId,
|
||||
x.BookedByFullName,
|
||||
x.StartAt,
|
||||
x.EndAt,
|
||||
x.Title,
|
||||
x.Description,
|
||||
(int)x.Status,
|
||||
x.Note,
|
||||
x.CreatedAt,
|
||||
x.Attendees
|
||||
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record GetMeetingBookingByIdQuery(Guid Id) : IRequest<MeetingBookingDto?>;
|
||||
public class GetMeetingBookingByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetMeetingBookingByIdQuery, MeetingBookingDto?>
|
||||
{
|
||||
public async Task<MeetingBookingDto?> Handle(GetMeetingBookingByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
return await db.MeetingBookings.AsNoTracking()
|
||||
.Include(x => x.Room)
|
||||
.Include(x => x.Attendees)
|
||||
.Where(x => x.Id == req.Id && !x.IsDeleted)
|
||||
.Select(x => new MeetingBookingDto(
|
||||
x.Id,
|
||||
x.RoomId,
|
||||
x.Room.Code,
|
||||
x.Room.Name,
|
||||
x.BookedByUserId,
|
||||
x.BookedByFullName,
|
||||
x.StartAt,
|
||||
x.EndAt,
|
||||
x.Title,
|
||||
x.Description,
|
||||
(int)x.Status,
|
||||
x.Note,
|
||||
x.CreatedAt,
|
||||
x.Attendees
|
||||
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
|
||||
.ToList()))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateMeetingBookingCommand(
|
||||
Guid RoomId,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? Note,
|
||||
List<AttendeeInput> Attendees) : IRequest<Guid>;
|
||||
|
||||
public class CreateMeetingBookingValidator : AbstractValidator<CreateMeetingBookingCommand>
|
||||
{
|
||||
public CreateMeetingBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.RoomId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Description).MaximumLength(1000);
|
||||
RuleFor(x => x.Note).MaximumLength(500);
|
||||
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
|
||||
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
|
||||
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<CreateMeetingBookingCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
// Verify Room exists + IsActive (NotFoundException nếu fail)
|
||||
var roomActive = await db.MeetingRooms
|
||||
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
|
||||
if (!roomActive)
|
||||
throw new NotFoundException("MeetingRoom", req.RoomId);
|
||||
|
||||
// SERIALIZABLE transaction — range lock prevent 2 concurrent insert overlap commit.
|
||||
// EXISTS query overlap detect: tồn tại booking Confirmed cùng Room khoảng giao thời.
|
||||
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
|
||||
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||||
|
||||
var overlap = await db.MeetingBookings.AnyAsync(b =>
|
||||
b.RoomId == req.RoomId &&
|
||||
b.Status == MeetingBookingStatus.Confirmed &&
|
||||
!b.IsDeleted &&
|
||||
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
|
||||
ct);
|
||||
if (overlap)
|
||||
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
|
||||
|
||||
// Resolve attendee FullName + Email từ Users JOIN (denorm).
|
||||
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
|
||||
var attendeeUsers = await db.Users.AsNoTracking()
|
||||
.Where(u => attendeeIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToListAsync(ct);
|
||||
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
|
||||
|
||||
var entity = new MeetingBooking
|
||||
{
|
||||
RoomId = req.RoomId,
|
||||
BookedByUserId = userId,
|
||||
BookedByFullName = currentUser.FullName ?? string.Empty,
|
||||
StartAt = req.StartAt,
|
||||
EndAt = req.EndAt,
|
||||
Title = req.Title,
|
||||
Description = req.Description,
|
||||
Note = req.Note,
|
||||
Status = MeetingBookingStatus.Confirmed,
|
||||
Attendees = req.Attendees
|
||||
.Where(a => attendeeLookup.ContainsKey(a.UserId)) // skip user không tồn tại
|
||||
.Select(a => new MeetingBookingAttendee
|
||||
{
|
||||
UserId = a.UserId,
|
||||
FullName = attendeeLookup[a.UserId].FullName,
|
||||
Email = attendeeLookup[a.UserId].Email,
|
||||
Notes = a.Notes,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
db.MeetingBookings.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateMeetingBookingCommand(
|
||||
Guid Id,
|
||||
Guid RoomId,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? Note,
|
||||
List<AttendeeInput> Attendees) : IRequest;
|
||||
|
||||
public class UpdateMeetingBookingValidator : AbstractValidator<UpdateMeetingBookingCommand>
|
||||
{
|
||||
public UpdateMeetingBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.RoomId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Description).MaximumLength(1000);
|
||||
RuleFor(x => x.Note).MaximumLength(500);
|
||||
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
|
||||
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
|
||||
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<UpdateMeetingBookingCommand>
|
||||
{
|
||||
public async Task Handle(UpdateMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
var entity = await db.MeetingBookings
|
||||
.Include(x => x.Attendees)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingBooking", req.Id);
|
||||
|
||||
// Authorization: owner OR Admin role
|
||||
var isOwner = entity.BookedByUserId == userId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép sửa booking.");
|
||||
|
||||
// Verify Room exists + IsActive
|
||||
var roomActive = await db.MeetingRooms
|
||||
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
|
||||
if (!roomActive)
|
||||
throw new NotFoundException("MeetingRoom", req.RoomId);
|
||||
|
||||
// SERIALIZABLE transaction — overlap check exclude SELF (b.Id != req.Id).
|
||||
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
|
||||
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||||
|
||||
var overlap = await db.MeetingBookings.AnyAsync(b =>
|
||||
b.Id != req.Id &&
|
||||
b.RoomId == req.RoomId &&
|
||||
b.Status == MeetingBookingStatus.Confirmed &&
|
||||
!b.IsDeleted &&
|
||||
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
|
||||
ct);
|
||||
if (overlap)
|
||||
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
|
||||
|
||||
// Update header fields
|
||||
entity.RoomId = req.RoomId;
|
||||
entity.StartAt = req.StartAt;
|
||||
entity.EndAt = req.EndAt;
|
||||
entity.Title = req.Title;
|
||||
entity.Description = req.Description;
|
||||
entity.Note = req.Note;
|
||||
// Status preserve (Cancel via separate command).
|
||||
|
||||
// Recreate Attendees: delete existing + re-add (idempotent simpler than diff).
|
||||
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
|
||||
var attendeeUsers = await db.Users.AsNoTracking()
|
||||
.Where(u => attendeeIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToListAsync(ct);
|
||||
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
|
||||
|
||||
// Remove old (Cascade FK on Booking.Attendees handles cleanup via collection)
|
||||
db.MeetingBookingAttendees.RemoveRange(entity.Attendees);
|
||||
entity.Attendees = req.Attendees
|
||||
.Where(a => attendeeLookup.ContainsKey(a.UserId))
|
||||
.Select(a => new MeetingBookingAttendee
|
||||
{
|
||||
BookingId = entity.Id,
|
||||
UserId = a.UserId,
|
||||
FullName = attendeeLookup[a.UserId].FullName,
|
||||
Email = attendeeLookup[a.UserId].Email,
|
||||
Notes = a.Notes,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CancelMeetingBookingCommand(Guid Id) : IRequest;
|
||||
public class CancelMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<CancelMeetingBookingCommand>
|
||||
{
|
||||
public async Task Handle(CancelMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
var entity = await db.MeetingBookings
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingBooking", req.Id);
|
||||
|
||||
var isOwner = entity.BookedByUserId == userId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");
|
||||
|
||||
// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
|
||||
entity.Status = MeetingBookingStatus.Cancelled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,8 @@
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||
<!-- Relational cần cho IsolationLevel overload BeginTransactionAsync (S36 G-O2 SERIALIZABLE overlap) -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -99,6 +99,11 @@ public static class MenuKeys
|
||||
// ============================================================
|
||||
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)
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking calendar.
|
||||
public const string OffPhongHop = "Off_PhongHop"; // sub-group phòng họp
|
||||
public const string OffPhongHopView = "Off_PhongHop_View"; // Xem lịch (FullCalendar)
|
||||
public const string OffPhongHopManage = "Off_PhongHop_Manage"; // Quản lý phòng (Admin CRUD MeetingRoom)
|
||||
public const string OffPhongHopBook = "Off_PhongHop_Book"; // Đặt phòng (Create/Update/Cancel Booking)
|
||||
|
||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||
@ -127,6 +132,7 @@ public static class MenuKeys
|
||||
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
|
||||
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
|
||||
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
|
||||
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
|
||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||
];
|
||||
|
||||
12
src/Backend/SolutionErp.Domain/Office/Enums.cs
Normal file
12
src/Backend/SolutionErp.Domain/Office/Enums.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.2 G-O2 — Phòng họp BookingCalendar enum.
|
||||
// Reference NamGroup TblBookingResource TrangThai 4-state (NamGroup S50)
|
||||
// — SOL adapt 3-state, drop "Rejected" (use Cancelled instead).
|
||||
|
||||
public enum MeetingBookingStatus
|
||||
{
|
||||
Confirmed = 1, // Đã xác nhận (default sau Create)
|
||||
Cancelled = 2, // Đã huỷ
|
||||
Completed = 3, // Đã kết thúc (auto-set khi EndAt < Now via job/manual)
|
||||
}
|
||||
29
src/Backend/SolutionErp.Domain/Office/MeetingBooking.cs
Normal file
29
src/Backend/SolutionErp.Domain/Office/MeetingBooking.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36) — Booking phòng họp.
|
||||
// Overlap check qua SERIALIZABLE tx + EXISTS query trong handler
|
||||
// (NOT DB constraint vì datetime range not native SQL Server).
|
||||
// Reference NamGroup TblBookingResource S50 — SOL adapt 3-state status.
|
||||
public class MeetingBooking : AuditableEntity
|
||||
{
|
||||
public Guid RoomId { get; set; }
|
||||
public MeetingRoom Room { get; set; } = null!; // FK Restrict — không xoá Room còn booking active
|
||||
|
||||
public Guid BookedByUserId { get; set; } // User đặt phòng (Creator semantics)
|
||||
public string BookedByFullName { get; set; } = string.Empty; // denorm tránh cascade
|
||||
|
||||
public DateTime StartAt { get; set; } // UTC datetime2
|
||||
public DateTime EndAt { get; set; } // UTC datetime2. EndAt > StartAt (validator)
|
||||
|
||||
public string Title { get; set; } = string.Empty; // "Họp giao ban tuần", "Phỏng vấn ứng viên"
|
||||
public string? Description { get; set; } // Nội dung chi tiết / agenda
|
||||
|
||||
public MeetingBookingStatus Status { get; set; } = MeetingBookingStatus.Confirmed;
|
||||
|
||||
public string? Note { get; set; } // Ghi chú thêm (vd "Cần chuẩn bị nước uống")
|
||||
|
||||
// Attendees nav — collection join entity (NOT JSON, theo Investigator verdict S36)
|
||||
public List<MeetingBookingAttendee> Attendees { get; set; } = new();
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36) — Attendee join table N-to-N.
|
||||
// Composite PK (BookingId, UserId) — không support add user 2 lần cùng booking.
|
||||
// FK Cascade Booking (xoá Booking → wipe Attendees) + Restrict User
|
||||
// (admin không xoá user còn invite — denorm name preserve).
|
||||
// Reference Investigator verdict: prefer join table over JSON array
|
||||
// để index query Notification push + reuse Users API multi-select FE.
|
||||
public class MeetingBookingAttendee : BaseEntity // NO IsDeleted — composite PK delete entire row
|
||||
{
|
||||
public Guid BookingId { get; set; }
|
||||
public MeetingBooking Booking { get; set; } = null!;
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
public string FullName { get; set; } = string.Empty; // denorm tránh JOIN cho Notification push
|
||||
public string? Email { get; set; } // denorm cho email outbox future
|
||||
|
||||
public string? Notes { get; set; } // "Vắng buổi sáng", "Tham gia online"
|
||||
}
|
||||
23
src/Backend/SolutionErp.Domain/Office/MeetingRoom.cs
Normal file
23
src/Backend/SolutionErp.Domain/Office/MeetingRoom.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp catalog.
|
||||
// Reference NamGroup TblResource 11 cols (S50 design) — SOL adapt clean-room
|
||||
// 3 entity: MeetingRoom (catalog) + MeetingBooking (header) + MeetingBookingAttendee (join).
|
||||
// AuditableEntity inherit chuẩn SOL Clean Arch.
|
||||
public class MeetingRoom : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty; // UNIQUE — "PH-A", "PH-B", "PHG-501"
|
||||
public string Name { get; set; } = string.Empty; // "Phòng họp tầng 5", "Phòng họp lớn"
|
||||
|
||||
// Sức chứa tối đa (số người). 0 = không giới hạn (mở rộng tự do).
|
||||
public int Capacity { get; set; }
|
||||
|
||||
public string? Location { get; set; } // "Tầng 5, Toà A" / "Trực tuyến Zoom"
|
||||
|
||||
// Trang thiết bị có sẵn (free text): "Máy chiếu, Bảng trắng, Wifi" — info-only.
|
||||
public string? Equipment { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true; // Soft-disable không xoá (preserve booking history)
|
||||
}
|
||||
@ -11,6 +11,7 @@ using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.Office;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence;
|
||||
@ -96,6 +97,11 @@ public class ApplicationDbContext
|
||||
public DbSet<ShiftPattern> ShiftPatterns => Set<ShiftPattern>();
|
||||
public DbSet<OtPolicy> OtPolicies => Set<OtPolicy>();
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
|
||||
public DbSet<MeetingRoom> MeetingRooms => Set<MeetingRoom>();
|
||||
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();
|
||||
public DbSet<MeetingBookingAttendee> MeetingBookingAttendees => Set<MeetingBookingAttendee>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 36 G-O2 (S36) — Attendee join table N-to-N.
|
||||
// Composite UNIQUE (BookingId, UserId) — không add user 2 lần cùng booking.
|
||||
// Inherit BaseEntity (Guid Id auto) + composite UNIQUE constraint via index.
|
||||
public class MeetingBookingAttendeeConfiguration : IEntityTypeConfiguration<MeetingBookingAttendee>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MeetingBookingAttendee> e)
|
||||
{
|
||||
e.ToTable("MeetingBookingAttendees");
|
||||
|
||||
e.Property(x => x.FullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Email).HasMaxLength(200);
|
||||
e.Property(x => x.Notes).HasMaxLength(200);
|
||||
|
||||
// UNIQUE composite — 1 user / 1 booking
|
||||
e.HasIndex(x => new { x.BookingId, x.UserId }).IsUnique();
|
||||
// UserId lookup "my invites"
|
||||
e.HasIndex(x => x.UserId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 36 G-O2 (S36) — Booking phòng họp.
|
||||
// FK Restrict cho Room (preserve history) + Restrict User Drafter (denorm name).
|
||||
// Index composite (RoomId, StartAt) cho range query overlap check + calendar fetch.
|
||||
public class MeetingBookingConfiguration : IEntityTypeConfiguration<MeetingBooking>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MeetingBooking> e)
|
||||
{
|
||||
e.ToTable("MeetingBookings");
|
||||
|
||||
e.Property(x => x.BookedByFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Title).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Description).HasMaxLength(1000);
|
||||
e.Property(x => x.Note).HasMaxLength(500);
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
|
||||
e.HasOne(x => x.Room)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.RoomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasMany(x => x.Attendees)
|
||||
.WithOne(a => a.Booking)
|
||||
.HasForeignKey(a => a.BookingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Range query + overlap check (RoomId + StartAt)
|
||||
e.HasIndex(x => new { x.RoomId, x.StartAt });
|
||||
// BookedByUserId lookup "my bookings"
|
||||
e.HasIndex(x => x.BookedByUserId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 36 G-O2 (S36) — Phòng họp catalog. Standalone no FK.
|
||||
// Mirror LeaveType pattern (no HasQueryFilter — handler explicit Where(!IsDeleted)).
|
||||
public class MeetingRoomConfiguration : IEntityTypeConfiguration<MeetingRoom>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MeetingRoom> e)
|
||||
{
|
||||
e.ToTable("MeetingRooms");
|
||||
|
||||
e.Property(x => x.Code).HasMaxLength(20).IsRequired();
|
||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Location).HasMaxLength(200);
|
||||
e.Property(x => x.Equipment).HasMaxLength(500);
|
||||
|
||||
e.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.Office;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence;
|
||||
@ -95,6 +96,9 @@ public static class DbInitializer
|
||||
// Plan G-H2 (Mig 35 S34 2026-05-27) — Cấu hình HRM 4 catalog. Infrastructure
|
||||
// data (NOT gated DemoSeed flag) — Workflow Apps reference cần data ngày 1.
|
||||
await SeedHrmConfigsAsync(db, logger);
|
||||
// Plan G-O2 (Mig 36 S36 2026-05-28) — Phòng họp 4 sample room. Infrastructure
|
||||
// data (NOT gated DemoSeed flag, gotcha #51) — admin có thể edit/delete sau.
|
||||
await SeedMeetingRoomsAsync(db, logger);
|
||||
await SeedMenuTreeAsync(db, logger);
|
||||
await SeedAdminPermissionsAsync(db, roleManager, logger);
|
||||
await SeedDemoMasterDataAsync(db, logger);
|
||||
@ -1498,6 +1502,11 @@ public static class DbInitializer
|
||||
// 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"),
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28). Sub-group "Phòng họp" + 3 leaf.
|
||||
(MenuKeys.OffPhongHop, "Phòng họp", MenuKeys.Off, 2, "CalendarRange"),
|
||||
(MenuKeys.OffPhongHopView, "Xem lịch", MenuKeys.OffPhongHop, 1, "CalendarDays"),
|
||||
(MenuKeys.OffPhongHopManage, "Quản lý phòng", MenuKeys.OffPhongHop, 2, "Building2"),
|
||||
(MenuKeys.OffPhongHopBook, "Đặt phòng", MenuKeys.OffPhongHop, 3, "CalendarPlus"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
@ -2156,4 +2165,49 @@ public static class DbInitializer
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("SeedHrmConfigsAsync: seeded 5 LeaveTypes + 10 Holidays 2026 + 3 ShiftPatterns + 1 OtPolicy default.");
|
||||
}
|
||||
|
||||
// Plan G-O2 (Mig 36 — S36 2026-05-28). 4 sample MeetingRoom seed.
|
||||
// Infrastructure data (NOT gated DemoSeed flag, gotcha #51) — Booking calendar
|
||||
// cần dropdown phòng ngày 1. Admin có thể edit/delete/disable qua FE Manage page.
|
||||
// Idempotent: skip nếu đã có row.
|
||||
private static async Task SeedMeetingRoomsAsync(ApplicationDbContext db, ILogger logger)
|
||||
{
|
||||
if (await db.MeetingRooms.AnyAsync())
|
||||
{
|
||||
logger.LogInformation("SeedMeetingRoomsAsync: skip — đã có MeetingRoom.");
|
||||
return;
|
||||
}
|
||||
|
||||
db.MeetingRooms.AddRange(
|
||||
new MeetingRoom
|
||||
{
|
||||
Code = "PH-A", Name = "Phòng họp lớn",
|
||||
Capacity = 20, Location = "Tầng 5, Toà A",
|
||||
Equipment = "Máy chiếu, Bảng trắng, Wifi, Webcam 4K",
|
||||
IsActive = true,
|
||||
},
|
||||
new MeetingRoom
|
||||
{
|
||||
Code = "PH-B", Name = "Phòng họp nhỏ",
|
||||
Capacity = 8, Location = "Tầng 3, Toà A",
|
||||
Equipment = "TV 55 inch, Bảng trắng",
|
||||
IsActive = true,
|
||||
},
|
||||
new MeetingRoom
|
||||
{
|
||||
Code = "PHG-501", Name = "Phòng Giám đốc",
|
||||
Capacity = 6, Location = "Tầng 5, Toà A",
|
||||
Equipment = "TV 65 inch, Bàn họp dài, Tủ tài liệu",
|
||||
IsActive = true,
|
||||
},
|
||||
new MeetingRoom
|
||||
{
|
||||
Code = "ONL-1", Name = "Trực tuyến Zoom",
|
||||
Capacity = 50, Location = "Online",
|
||||
Equipment = "License Zoom Pro, link cố định",
|
||||
IsActive = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("SeedMeetingRoomsAsync: seeded 4 sample meeting rooms (PH-A/PH-B/PHG-501/ONL-1).");
|
||||
}
|
||||
}
|
||||
|
||||
5170
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528074125_AddMeetingRooms.Designer.cs
generated
Normal file
5170
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528074125_AddMeetingRooms.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMeetingRooms : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MeetingRooms",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Capacity = table.Column<int>(type: "int", nullable: false),
|
||||
Location = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Equipment = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MeetingRooms", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MeetingBookings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RoomId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
BookedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
BookedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
StartAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MeetingBookings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MeetingBookings_MeetingRooms_RoomId",
|
||||
column: x => x.RoomId,
|
||||
principalTable: "MeetingRooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MeetingBookingAttendees",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
BookingId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
FullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MeetingBookingAttendees", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MeetingBookingAttendees_MeetingBookings_BookingId",
|
||||
column: x => x.BookingId,
|
||||
principalTable: "MeetingBookings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MeetingBookingAttendees_BookingId_UserId",
|
||||
table: "MeetingBookingAttendees",
|
||||
columns: new[] { "BookingId", "UserId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MeetingBookingAttendees_UserId",
|
||||
table: "MeetingBookingAttendees",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MeetingBookings_BookedByUserId",
|
||||
table: "MeetingBookings",
|
||||
column: "BookedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MeetingBookings_RoomId_StartAt",
|
||||
table: "MeetingBookings",
|
||||
columns: new[] { "RoomId", "StartAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MeetingRooms_Code",
|
||||
table: "MeetingRooms",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MeetingBookingAttendees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MeetingBookings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MeetingRooms");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3448,6 +3448,181 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("BookedByFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("BookedByUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<DateTime>("EndAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<Guid>("RoomId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("StartAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookedByUserId");
|
||||
|
||||
b.HasIndex("RoomId", "StartAt");
|
||||
|
||||
b.ToTable("MeetingBookings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("BookingId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("BookingId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MeetingBookingAttendees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingRoom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Equipment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MeetingRooms", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -4690,6 +4865,28 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Room");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Office.MeetingBooking", "Booking")
|
||||
.WithMany("Attendees")
|
||||
.HasForeignKey("BookingId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Booking");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
||||
@ -4924,6 +5121,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Permissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||
{
|
||||
b.Navigation("Attendees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||
{
|
||||
b.Navigation("Approvals");
|
||||
|
||||
Reference in New Issue
Block a user