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:
6
backend/migrations/025_task_category.sql
Normal file
6
backend/migrations/025_task_category.sql
Normal file
@@ -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);
|
||||
@@ -16,7 +16,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// ── GET /api/hotels/:slug/housekeeping ─────────────────────────────────────
|
||||
fastify.get<SlugParam & { Querystring: { status?: string; date?: string } }>(
|
||||
fastify.get<SlugParam & { Querystring: { status?: string; date?: string; category?: string } }>(
|
||||
'/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<SlugParam & { Body: {
|
||||
room_id?: string; type: string; priority?: string
|
||||
assignee_id?: string; notes?: string; due_date?: string
|
||||
assignee_id?: string; notes?: string; due_date?: string; category?: string
|
||||
} }>(
|
||||
'/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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||
<Route path="/discounts" element={<DiscountsPage />} />
|
||||
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
||||
<Route path="/technical" element={<TechnicalPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -215,10 +215,11 @@ export const api = {
|
||||
|
||||
// ── Housekeeping ──────────────────────────────────────────────────────────
|
||||
housekeeping: {
|
||||
list: (slug: string, params?: { date?: string; status?: string }) => {
|
||||
list: (slug: string, params?: { date?: string; status?: string; category?: string }) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.date) qs.set('date', params.date)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
if (params?.category) qs.set('category', params.category)
|
||||
const q = qs.toString()
|
||||
return req<HousekeepingTask[]>('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
|
||||
},
|
||||
@@ -554,6 +555,7 @@ function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
|
||||
export interface HkPayload {
|
||||
room_id?: string; type?: string; priority?: string
|
||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface HkSettings {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
|
||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||
import type { Room, Booking } from '../types'
|
||||
import type { HkPriority } from '../components/rooms/RoomContextMenu'
|
||||
import type { RentalBooking } from '../data/rentalData'
|
||||
|
||||
export type BookingLock = { checkIn: string; checkOut: string; lockedBy: string }
|
||||
@@ -153,18 +154,18 @@ export function CalendarPage() {
|
||||
send({ type: 'unlock', roomId })
|
||||
}, [send])
|
||||
|
||||
const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial<Room>) => {
|
||||
const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial<Room>, priority?: HkPriority) => {
|
||||
try {
|
||||
const updated = await api.rooms.update(slug, roomId, patch)
|
||||
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
|
||||
// When manually marking room as dirty → auto-create a housekeeping task
|
||||
if (patch.housekeepingStatus === 'dirty') {
|
||||
if (patch.housekeepingStatus === 'dirty' || patch.housekeepingStatus === 'cleaning') {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const task = await api.housekeeping.create(slug, {
|
||||
room_id: roomId,
|
||||
type: 'regular',
|
||||
priority: 'medium',
|
||||
priority: priority ?? 'medium',
|
||||
due_date: today,
|
||||
category: 'housekeeping',
|
||||
})
|
||||
send({ type: 'housekeeping_task_created', task: task as unknown as Record<string, unknown> })
|
||||
}
|
||||
@@ -173,6 +174,23 @@ export function CalendarPage() {
|
||||
}
|
||||
}, [slug, send])
|
||||
|
||||
const handleMaintenanceTaskCreate = useCallback(async (roomId: string, description: string, priority: HkPriority) => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const task = await api.housekeeping.create(slug, {
|
||||
room_id: roomId,
|
||||
type: 'maintenance',
|
||||
priority,
|
||||
notes: description,
|
||||
due_date: today,
|
||||
category: 'maintenance',
|
||||
})
|
||||
send({ type: 'housekeeping_task_created', task: task as unknown as Record<string, unknown> })
|
||||
} catch (err) {
|
||||
console.error('Failed to create maintenance task:', err)
|
||||
}
|
||||
}, [slug, send])
|
||||
|
||||
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
||||
try {
|
||||
const created = await api.rental.createBooking(slug, {
|
||||
@@ -208,6 +226,7 @@ export function CalendarPage() {
|
||||
onBookingUpdate={handleUpdate}
|
||||
onBookingBulkUpdate={handleBulkUpdate}
|
||||
onRoomUpdate={handleRoomUpdate}
|
||||
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
|
||||
fadingBookingIds={fadingBookings}
|
||||
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function HousekeepingPage() {
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
Promise.all([
|
||||
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') }),
|
||||
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd'), category: 'housekeeping' }),
|
||||
api.housekeeping.getSettings(slug).catch(() => null),
|
||||
]).then(([t, s]) => {
|
||||
setTasks(t)
|
||||
|
||||
300
src/pages/TechnicalPage.tsx
Normal file
300
src/pages/TechnicalPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Wrench, Clock, CheckCircle2, AlertTriangle, Trash2 } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||
import type { HousekeepingTask } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Modal } from '../components/ui/Modal'
|
||||
|
||||
const PRIORITY_LABELS: Record<string, { label: string; color: string }> = {
|
||||
urgent: { label: 'Срочно', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' },
|
||||
high: { label: 'Высокий', color: 'text-orange-600 bg-orange-50 dark:bg-orange-900/20 dark:text-orange-400 border border-orange-200 dark:border-orange-800' },
|
||||
medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' },
|
||||
low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' },
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||
pending: { label: 'Ожидает', icon: Clock, color: 'text-slate-500' },
|
||||
in_progress: { label: 'В работе', icon: Wrench, color: 'text-blue-600' },
|
||||
done: { label: 'Завершено', icon: CheckCircle2, color: 'text-emerald-600' },
|
||||
cancelled: { label: 'Отменено', icon: AlertTriangle, color: 'text-slate-400' },
|
||||
}
|
||||
|
||||
export function TechnicalPage() {
|
||||
const { user, session } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
|
||||
const [filterStatus, setFilterStatus] = useState<string>('active')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!slug) return
|
||||
api.housekeeping.list(slug, { category: 'maintenance' })
|
||||
.then(setTasks)
|
||||
.catch(console.error)
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||
if (msg.type === 'housekeeping_task_created') {
|
||||
const task = msg.task as unknown as HousekeepingTask
|
||||
if ((task as unknown as Record<string, unknown>).category === 'maintenance') {
|
||||
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev])
|
||||
}
|
||||
} else if (msg.type === 'housekeeping_updated') {
|
||||
const task = msg.task as unknown as HousekeepingTask
|
||||
if ((task as unknown as Record<string, unknown>).category === 'maintenance') {
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? task : t))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||||
|
||||
const handleStatusChange = async (taskId: string, status: string) => {
|
||||
try {
|
||||
const updated = await api.housekeeping.update(slug, taskId, { status })
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!confirm('Удалить задачу?')) return
|
||||
try {
|
||||
await api.housekeeping.delete(slug, taskId)
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = tasks.filter(t => {
|
||||
const s = t.status as string
|
||||
if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled'
|
||||
if (filterStatus === 'done') return s === 'done'
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Технические задачи</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Заявки для технического персонала</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Создать задачу</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{[
|
||||
{ id: 'active', label: 'Активные' },
|
||||
{ id: 'done', label: 'Выполненные' },
|
||||
{ id: 'all', label: 'Все' },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilterStatus(f.id)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||
filterStatus === f.id
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<Wrench size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Нет технических задач</p>
|
||||
</div>
|
||||
) : filtered.map(task => {
|
||||
const taskRecord = task as unknown as Record<string, string>
|
||||
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium']
|
||||
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
||||
const SIcon = sMeta.icon
|
||||
const roomLabel = taskRecord.roomNumber
|
||||
? `Номер ${taskRecord.roomNumber}`
|
||||
: 'Без номера'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="card p-4 flex items-start gap-3"
|
||||
>
|
||||
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
|
||||
<SIcon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 flex-1">
|
||||
{task.notes ?? task.type ?? 'Задача'}
|
||||
</span>
|
||||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
|
||||
{pMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{roomLabel}</span>
|
||||
{task.dueDate && (
|
||||
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
||||
)}
|
||||
{taskRecord.assigneeName && (
|
||||
<span>→ {taskRecord.assigneeName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Status dropdown */}
|
||||
<select
|
||||
value={task.status ?? 'pending'}
|
||||
onChange={e => handleStatusChange(task.id, e.target.value)}
|
||||
className="text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
<option value="pending">Ожидает</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="done">Выполнено</option>
|
||||
<option value="cancelled">Отменено</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDelete(task.id)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<TaskCreateModal
|
||||
slug={slug}
|
||||
onClose={() => setShowModal(false)}
|
||||
onCreated={task => {
|
||||
setTasks(prev => [task, ...prev])
|
||||
setShowModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskCreateModal({
|
||||
slug, onClose, onCreated,
|
||||
}: {
|
||||
slug: string
|
||||
onClose: () => void
|
||||
onCreated: (t: HousekeepingTask) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
notes: '',
|
||||
priority: 'medium',
|
||||
room_id: '',
|
||||
due_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
assignee_id: '',
|
||||
})
|
||||
const [rooms, setRooms] = useState<{ id: string; number: string }[]>([])
|
||||
const [users, setUsers] = useState<{ id: string; name: string }[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.rooms.list(slug).then(r => setRooms(r.map(rm => ({ id: rm.id, number: rm.number })))).catch(() => {})
|
||||
api.users.list(slug).then(u => setUsers(u)).catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.notes.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const task = await api.housekeeping.create(slug, {
|
||||
room_id: form.room_id || undefined,
|
||||
type: 'maintenance',
|
||||
priority: form.priority,
|
||||
notes: form.notes,
|
||||
due_date: form.due_date || undefined,
|
||||
assignee_id: form.assignee_id || undefined,
|
||||
category: 'maintenance',
|
||||
})
|
||||
onCreated(task)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="Новая техническая задача">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание задачи *</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={3}
|
||||
placeholder="Опишите проблему или задачу..."
|
||||
value={form.notes}
|
||||
onChange={e => setForm(p => ({ ...p, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приоритет</label>
|
||||
<select className="input" value={form.priority} onChange={e => setForm(p => ({ ...p, priority: e.target.value }))}>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="medium">Средний</option>
|
||||
<option value="low">Низкий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||||
<input type="date" className="input" value={form.due_date} onChange={e => setForm(p => ({ ...p, due_date: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Номер (если нужен)</label>
|
||||
<select className="input" value={form.room_id} onChange={e => setForm(p => ({ ...p, room_id: e.target.value }))}>
|
||||
<option value="">— Не выбран —</option>
|
||||
{rooms.map(r => <option key={r.id} value={r.id}>Номер {r.number}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Исполнитель</label>
|
||||
<select className="input" value={form.assignee_id} onChange={e => setForm(p => ({ ...p, assignee_id: e.target.value }))}>
|
||||
<option value="">— Не назначен —</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} disabled={saving || !form.notes.trim()} className="btn-primary">
|
||||
{saving ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user