feat: add category field to housekeeping tasks + TechnicalPage for maintenance tasks

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:28:42 +03:00
parent 9be51e5d07
commit 3bb7262b59
10 changed files with 508 additions and 48 deletions

View File

@@ -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<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
onRoomUpdate?: (roomId: string, patch: Partial<Room>) => void
onRoomUpdate?: (roomId: string, patch: Partial<Room>, priority?: HkPriority) => void
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority) => void
fadingBookingIds?: Set<string>
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}
/>
)}

View File

@@ -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) {
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
<NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />
<NavItem to="/technical" icon={Zap} label="Тех. задачи" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
{isModuleActive('channel-manager') && (
<NavItem

View File

@@ -1,19 +1,22 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays } from 'lucide-react'
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap } from 'lucide-react'
import { format } from 'date-fns'
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
import { cn } from '../../lib/utils'
const today = () => 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: <ClipboardList size={14} />, 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<HTMLDivElement>(null)
const [subForm, setSubForm] = useState<SubForm>(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<HousekeepingStatus | null>(null)
const [techDesc, setTechDesc] = useState('')
const [techPriority, setTechPriority] = useState<HkPriority>('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
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Статус номера</p>
{/* Available */}
<button
onClick={() => handleApplyStatus('available')}
className={cn(
@@ -103,7 +138,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{room.status === 'available' && subForm === null && <span className="ml-auto text-[10px] text-slate-400"></span>}
</button>
{/* Maintenance */}
<button
onClick={() => handleApplyStatus('maintenance')}
className={cn(
@@ -117,7 +151,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{subForm !== 'maintenance' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
</button>
{/* Maintenance date form */}
{subForm === 'maintenance' && (
<DateRangeForm
dateFrom={dateFrom} setDateFrom={setDateFrom}
@@ -132,7 +165,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
/>
)}
{/* Blocked */}
<button
onClick={() => handleApplyStatus('blocked')}
className={cn(
@@ -146,7 +178,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{subForm !== 'blocked' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
</button>
{/* Blocked date form */}
{subForm === 'blocked' && (
<DateRangeForm
dateFrom={dateFrom} setDateFrom={setDateFrom}
@@ -167,23 +198,114 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{/* Housekeeping */}
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Уборка</p>
{HK_ITEMS.map(item => (
<button
key={item.status}
onClick={() => { onHkStatusChange(room.id, item.status); onClose() }}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
item.color,
room.housekeepingStatus === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
{item.icon}
{item.label}
{room.housekeepingStatus === item.status && <span className="ml-auto text-[10px] text-slate-400"></span>}
</button>
))}
{subForm === 'hk_priority' ? (
<div className="mx-1 mb-1 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-1">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">Срочность</p>
<button onClick={() => setSubForm(null)} className="p-0.5 rounded text-slate-400 hover:text-slate-600">
<XIcon size={12} />
</button>
</div>
{PRIORITY_ITEMS.map(p => (
<button
key={p.value}
onClick={() => handleHkWithPriority(p.value)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
p.color,
)}
>
<AlertTriangle size={12} />
{p.label}
</button>
))}
</div>
) : (
HK_ITEMS.map(item => (
<button
key={item.status}
onClick={() => handleHkClick(item.status)}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
item.color,
room.housekeepingStatus === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
{item.icon}
{item.label}
{room.housekeepingStatus === item.status && <span className="ml-auto text-[10px] text-slate-400"></span>}
{(item.status === 'dirty' || item.status === 'cleaning') && <ChevronRight size={12} className="ml-auto text-slate-400 opacity-50" />}
</button>
))
)}
</div>
{onMaintenanceTask && (
<>
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Тех. задача</p>
{subForm === 'tech_task' ? (
<div className="mx-1 mb-1 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-2">
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">Описание проблемы</p>
<textarea
autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 resize-none"
rows={2}
placeholder="Опишите задачу..."
value={techDesc}
onChange={e => setTechDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleTechSubmit() } }}
/>
<div>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mb-1">Приоритет</p>
<div className="grid grid-cols-2 gap-1">
{PRIORITY_ITEMS.map(p => (
<button
key={p.value}
onClick={() => setTechPriority(p.value)}
className={cn(
'px-2 py-1 rounded-lg text-[11px] font-medium transition-colors border',
techPriority === p.value
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{p.label}
</button>
))}
</div>
</div>
<div className="flex gap-1.5 pt-0.5">
<button
onClick={handleTechSubmit}
disabled={!techDesc.trim()}
className="flex-1 py-1 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors disabled:opacity-40"
>
Создать задачу
</button>
<button
onClick={() => setSubForm(null)}
className="px-2 py-1 rounded-lg text-xs text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<XIcon size={12} />
</button>
</div>
</div>
) : (
<button
onClick={() => setSubForm('tech_task')}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 text-orange-600 dark:text-orange-400"
>
<Zap size={14} />
Создать тех. задачу
<ChevronRight size={12} className="ml-auto text-slate-400" />
</button>
)}
</div>
</>
)}
{onEdit && (
<>
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />