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

@@ -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);

View File

@@ -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
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

View File

@@ -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 />} />

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,10 +198,33 @@ 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 => (
{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={() => { onHkStatusChange(room.id, item.status); onClose() }}
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,
@@ -180,9 +234,77 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
{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 && (
<>

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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
View 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>
)
}