From 3bb7262b590521ced8a3d114f88b192b87ea8970 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 23:28:42 +0300 Subject: [PATCH] feat: add category field to housekeeping tasks + TechnicalPage for maintenance tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 025 to add `category` column (housekeeping/maintenance) to housekeeping_tasks - Backend: filter by ?category= in GET, store category in POST, allow patching category - API client: HkPayload + housekeeping.list() now support category param - HousekeepingPage: filters by category=housekeeping so maintenance tasks don't appear - RoomContextMenu: priority sub-form for dirty/cleaning status, tech task creation inline form - BookingCalendar: accepts onMaintenanceTaskCreate prop, passes priority to onRoomUpdate - CalendarPage: handleRoomUpdate creates task for dirty+cleaning (with priority), new handleMaintenanceTaskCreate for category=maintenance tasks - TechnicalPage: new page for viewing/creating/updating maintenance tasks - App.tsx: /technical route added - Sidebar: Тех. задачи nav item added under Управление Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/025_task_category.sql | 6 + backend/src/routes/housekeeping.ts | 21 +- src/App.tsx | 2 + src/components/calendar/BookingCalendar.tsx | 15 +- src/components/layout/Sidebar.tsx | 3 +- src/components/rooms/RoomContextMenu.tsx | 176 ++++++++++-- src/lib/api.ts | 4 +- src/pages/CalendarPage.tsx | 27 +- src/pages/HousekeepingPage.tsx | 2 +- src/pages/TechnicalPage.tsx | 300 ++++++++++++++++++++ 10 files changed, 508 insertions(+), 48 deletions(-) create mode 100644 backend/migrations/025_task_category.sql create mode 100644 src/pages/TechnicalPage.tsx diff --git a/backend/migrations/025_task_category.sql b/backend/migrations/025_task_category.sql new file mode 100644 index 0000000..afb131b --- /dev/null +++ b/backend/migrations/025_task_category.sql @@ -0,0 +1,6 @@ +ALTER TABLE housekeeping_tasks + ADD COLUMN IF NOT EXISTS category VARCHAR(20) NOT NULL DEFAULT 'housekeeping'; + +-- Index for faster filtering +CREATE INDEX IF NOT EXISTS housekeeping_tasks_category_idx + ON housekeeping_tasks(hotel_id, category, created_at DESC); diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index 2ecdc32..aa1c511 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -16,7 +16,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { role === 'super_admin' || userSlug === slug // ── GET /api/hotels/:slug/housekeeping ───────────────────────────────────── - fastify.get( + fastify.get( '/api/hotels/:slug/housekeeping', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -31,9 +31,10 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const values: unknown[] = [hotelId] let idx = 2 - const { status, date } = request.query - if (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ } - if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ } + const { status, date, category } = request.query + if (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ } + if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ } + if (category) { conditions.push(`t.category = $${idx}`); values.push(category); idx++ } const { rows } = await db.query( `SELECT t.*, @@ -55,7 +56,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { // ── POST /api/hotels/:slug/housekeeping ──────────────────────────────────── fastify.post( '/api/hotels/:slug/housekeeping', { onRequest: [fastify.authenticate] }, @@ -67,13 +68,13 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { room_id, type, priority = 'medium', assignee_id, notes, due_date } = request.body + const { room_id, type, priority = 'medium', assignee_id, notes, due_date, category = 'housekeeping' } = request.body const { rows } = await db.query( `INSERT INTO housekeeping_tasks - (hotel_id, room_id, type, priority, assignee_id, notes, due_date) - VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + (hotel_id, room_id, type, priority, assignee_id, notes, due_date, category) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [hotelId, room_id ?? null, type, priority, - assignee_id ?? null, notes ?? null, due_date ?? null], + assignee_id ?? null, notes ?? null, due_date ?? null, category], ) return reply.code(201).send(rows[0]) }, @@ -91,7 +92,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date'] + const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 diff --git a/src/App.tsx b/src/App.tsx index bbc30a8..1f24699 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { ResetPasswordPage } from './pages/ResetPasswordPage' import { TvWelcomePage } from './pages/TvWelcomePage' +import { TechnicalPage } from './pages/TechnicalPage' export default function App() { return ( @@ -85,6 +86,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index eeef34e..dd0ccb9 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -11,6 +11,7 @@ import { BookingModal } from '../bookings/BookingModal' import { BookingDetailPanel } from '../bookings/BookingDetailPanel' import { RentalBookingModal } from '../rental/RentalBookingModal' import { RoomContextMenu } from '../rooms/RoomContextMenu' +import type { HkPriority } from '../rooms/RoomContextMenu' const CELL_WIDTH = 52 const ROW_HEIGHT = 56 @@ -27,7 +28,8 @@ interface BookingCalendarProps { onBookingCreate: (b: Partial) => void onBookingUpdate: (id: string, b: Partial) => void onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial }>) => void - onRoomUpdate?: (roomId: string, patch: Partial) => void + onRoomUpdate?: (roomId: string, patch: Partial, priority?: HkPriority) => void + onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority) => void fadingBookingIds?: Set rentalObjects?: RentalObject[] rentalBookings?: RentalBooking[] @@ -61,7 +63,7 @@ function getRoomTypeColor(type: string): string { return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' } -export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) { +export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) { const [startDate, setStartDate] = useState(() => startOfDay(new Date())) const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) @@ -82,8 +84,12 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook onRoomUpdate?.(roomId, { status, maintenanceFrom: from, maintenanceTo: to }) } - const handleCtxHkChange = (roomId: string, status: HousekeepingStatus) => { - onRoomUpdate?.(roomId, { housekeepingStatus: status }) + const handleCtxHkChange = (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => { + onRoomUpdate?.(roomId, { housekeepingStatus: status }, priority) + } + + const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority) => { + onMaintenanceTaskCreate?.(roomId, description, priority) } // Cell hover price tooltip @@ -957,6 +963,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook onClose={() => setCtxMenu(null)} onStatusChange={handleCtxStatusChange} onHkStatusChange={handleCtxHkChange} + onMaintenanceTask={handleCtxMaintenanceTask} /> )} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0671e7b..b7f974b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink, useNavigate } from 'react-router-dom' import { CalendarDays, BookOpen, BedDouble, Globe, Settings, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, - TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, + TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -172,6 +172,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { + {isModuleActive('channel-manager') && ( format(new Date(), 'yyyy-MM-dd') +export type HkPriority = 'urgent' | 'high' | 'medium' | 'low' + interface RoomContextMenuProps { room: Room x: number y: number onClose: () => void onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => void - onHkStatusChange: (roomId: string, status: HousekeepingStatus) => void + onHkStatusChange: (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => void + onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority) => void onEdit?: (room: Room) => void } @@ -24,14 +27,24 @@ const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNo { status: 'dirty', label: 'Убрать', icon: , color: 'text-red-500 dark:text-red-400' }, ] -type SubForm = 'maintenance' | 'blocked' | null +const PRIORITY_ITEMS: { value: HkPriority; label: string; color: string }[] = [ + { value: 'urgent', label: 'Срочно', color: 'text-red-600 dark:text-red-400' }, + { value: 'high', label: 'Высокий', color: 'text-orange-600 dark:text-orange-400' }, + { value: 'medium', label: 'Средний', color: 'text-yellow-600 dark:text-yellow-400' }, + { value: 'low', label: 'Низкий', color: 'text-slate-500 dark:text-slate-400' }, +] -export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onEdit }: RoomContextMenuProps) { +type SubForm = 'maintenance' | 'blocked' | 'hk_priority' | 'tech_task' | null + +export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onMaintenanceTask, onEdit }: RoomContextMenuProps) { const ref = useRef(null) const [subForm, setSubForm] = useState(null) const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ? room.maintenanceFrom.slice(0, 10) : today()) const [dateTo, setDateTo] = useState(room.maintenanceTo ? room.maintenanceTo.slice(0, 10) : '') const [indefinite, setIndefinite] = useState(false) + const [pendingHkStatus, setPendingHkStatus] = useState(null) + const [techDesc, setTechDesc] = useState('') + const [techPriority, setTechPriority] = useState('medium') useEffect(() => { const handler = (e: MouseEvent) => { @@ -41,7 +54,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu document.addEventListener('mousedown', handler) document.addEventListener('keydown', keyHandler) - // Reposition if overflows viewport if (ref.current) { const rect = ref.current.getBoundingClientRect() const overflowX = rect.right - window.innerWidth + 8 @@ -54,12 +66,11 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu document.removeEventListener('mousedown', handler) document.removeEventListener('keydown', keyHandler) } - }, [onClose, subForm]) // re-run reposition when subForm opens (menu height changes) + }, [onClose, subForm]) const handleApplyStatus = (status: RoomStatus) => { if (status === 'maintenance' || status === 'blocked') { if (subForm === status) { - // confirm onStatusChange(room.id, status, dateFrom || null, indefinite ? null : (dateTo || null)) onClose() } else { @@ -71,6 +82,31 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu } } + const handleHkClick = (status: HousekeepingStatus) => { + if (status === 'dirty' || status === 'cleaning') { + // Show priority sub-form + setPendingHkStatus(status) + setSubForm('hk_priority') + } else { + onHkStatusChange(room.id, status) + onClose() + } + } + + const handleHkWithPriority = (priority: HkPriority) => { + if (pendingHkStatus) { + onHkStatusChange(room.id, pendingHkStatus, priority) + } + onClose() + } + + const handleTechSubmit = () => { + if (onMaintenanceTask && techDesc.trim()) { + onMaintenanceTask(room.id, techDesc.trim(), techPriority) + } + onClose() + } + const menuX = Math.min(x, window.innerWidth - 260) const menuY = y @@ -90,7 +126,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu

Статус номера

- {/* Available */} - {/* Maintenance */} - {/* Maintenance date form */} {subForm === 'maintenance' && ( )} - {/* Blocked */} - {/* Blocked date form */} {subForm === 'blocked' && (

Уборка

- {HK_ITEMS.map(item => ( - - ))} + {subForm === 'hk_priority' ? ( +
+
+

Срочность

+ +
+ {PRIORITY_ITEMS.map(p => ( + + ))} +
+ ) : ( + HK_ITEMS.map(item => ( + + )) + )}
+ {onMaintenanceTask && ( + <> +
+
+

Тех. задача

+ {subForm === 'tech_task' ? ( +
+

Описание проблемы

+