From f45090b65433e51adb81bcd98a1ec60704ea8c13 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 28 May 2026 15:06:12 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Domain+App+Infra+Api+FE-Admin+FE-Use?= =?UTF-8?q?r:=20S36=20Plan=20G-O2=20Ph=C3=B2ng=20h=E1=BB=8Dp=20Mig=2036=20?= =?UTF-8?q?+=20BE=20CRUD=20+=20FE=202=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fe-admin/src/App.tsx | 5 + fe-admin/src/components/Layout.tsx | 6 + fe-admin/src/lib/menuKeys.ts | 5 + .../src/pages/office/MeetingCalendarPage.tsx | 693 +++ .../src/pages/office/MeetingRoomsPage.tsx | 320 + fe-admin/src/types/meeting.ts | 88 + fe-user/src/App.tsx | 5 + fe-user/src/components/Layout.tsx | 6 + fe-user/src/lib/menuKeys.ts | 5 + .../src/pages/office/MeetingCalendarPage.tsx | 693 +++ fe-user/src/pages/office/MeetingRoomsPage.tsx | 320 + fe-user/src/types/meeting.ts | 88 + .../Controllers/MeetingBookingsController.cs | 56 + .../Controllers/MeetingRoomsController.cs | 49 + .../Interfaces/IApplicationDbContext.cs | 8 + .../Office/MeetingFeatures.cs | 479 ++ .../SolutionErp.Application.csproj | 2 + .../SolutionErp.Domain/Identity/MenuKeys.cs | 6 + .../SolutionErp.Domain/Office/Enums.cs | 12 + .../Office/MeetingBooking.cs | 29 + .../Office/MeetingBookingAttendee.cs | 21 + .../SolutionErp.Domain/Office/MeetingRoom.cs | 23 + .../Persistence/ApplicationDbContext.cs | 6 + .../MeetingBookingAttendeeConfiguration.cs | 25 + .../MeetingBookingConfiguration.cs | 37 + .../MeetingRoomConfiguration.cs | 22 + .../Persistence/DbInitializer.cs | 54 + ...20260528074125_AddMeetingRooms.Designer.cs | 5170 +++++++++++++++++ .../20260528074125_AddMeetingRooms.cs | 138 + .../ApplicationDbContextModelSnapshot.cs | 202 + 30 files changed, 8573 insertions(+) create mode 100644 fe-admin/src/pages/office/MeetingCalendarPage.tsx create mode 100644 fe-admin/src/pages/office/MeetingRoomsPage.tsx create mode 100644 fe-admin/src/types/meeting.ts create mode 100644 fe-user/src/pages/office/MeetingCalendarPage.tsx create mode 100644 fe-user/src/pages/office/MeetingRoomsPage.tsx create mode 100644 fe-user/src/types/meeting.ts create mode 100644 src/Backend/SolutionErp.Api/Controllers/MeetingBookingsController.cs create mode 100644 src/Backend/SolutionErp.Api/Controllers/MeetingRoomsController.cs create mode 100644 src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs create mode 100644 src/Backend/SolutionErp.Domain/Office/Enums.cs create mode 100644 src/Backend/SolutionErp.Domain/Office/MeetingBooking.cs create mode 100644 src/Backend/SolutionErp.Domain/Office/MeetingBookingAttendee.cs create mode 100644 src/Backend/SolutionErp.Domain/Office/MeetingRoom.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/MeetingBookingAttendeeConfiguration.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/MeetingBookingConfiguration.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/MeetingRoomConfiguration.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528074125_AddMeetingRooms.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528074125_AddMeetingRooms.cs diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index ad9035c..8e32666 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -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() { } /> {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} } /> + {/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */} + } /> + } /> } /> } /> 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(() => startOfWeek(new Date())) + const [roomId, setRoomId] = useState('') + + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState>({}) + const [createAttendees, setCreateAttendees] = useState([]) + + const [detailId, setDetailId] = useState(null) + + const rooms = useQuery({ + queryKey: ['meeting-rooms', 'active'], + queryFn: async () => + (await api.get('/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('/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>('/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() + 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 ( +
+ + + Đặt phòng họp + + } + description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`} + actions={ + + } + /> + + {/* Filter bar */} +
+
+ + + + {formatDateRange(weekStart)} +
+
+ +
+ + {/* Calendar grid */} +
+
+ {/* Header row: 7 days */} +
+
+ {DAYS_OF_WEEK.map((d, i) => { + const day = addDays(weekStart, i) + const isToday = day.toDateString() === new Date().toDateString() + return ( +
+
{d}
+
+ {day.getDate()}/{day.getMonth() + 1} +
+
+ ) + })} +
+ + {/* Time slots column + day columns (relative parent for absolute booking blocks) */} +
+ {/* Time labels column */} +
+ {Array.from({ length: SLOT_COUNT }).map((_, i) => ( +
+ {String(HOUR_START + i).padStart(2, '0')}:00 +
+ ))} +
+ + {/* 7 day columns */} + {Array.from({ length: 7 }).map((_, dayIdx) => { + const day = addDays(weekStart, dayIdx) + const dayBookings = bookingsByDay.get(dayIdx) ?? [] + return ( +
+ {/* Hour slot click targets */} + {Array.from({ length: SLOT_COUNT }).map((_, hourIdx) => ( + + ) + })} +
+ ) + })} +
+
+
+ + {/* Legend */} +
+ Xác nhận + Hoàn tất + Đã hủy + Booking của tôi +
+ + {/* Create Dialog */} + setCreateOpen(false)} + title="Tạo Booking mới" + size="lg" + footer={ + <> + + + + } + > +
+
+
+ + +
+
+ + setCreateForm(s => ({ ...s, title: e.target.value }))} + placeholder="VD: Họp planning Q3 2026" + required + /> +
+
+ + setCreateForm(s => ({ ...s, startAt: e.target.value }))} + required + /> +
+
+ + setCreateForm(s => ({ ...s, endAt: e.target.value }))} + required + /> +
+
+ +
+ +