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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user