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
|
role === 'super_admin' || userSlug === slug
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/housekeeping ─────────────────────────────────────
|
// ── 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',
|
'/api/hotels/:slug/housekeeping',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -31,9 +31,10 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const values: unknown[] = [hotelId]
|
const values: unknown[] = [hotelId]
|
||||||
let idx = 2
|
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 (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ }
|
||||||
if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); 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(
|
const { rows } = await db.query(
|
||||||
`SELECT t.*,
|
`SELECT t.*,
|
||||||
@@ -55,7 +56,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
// ── POST /api/hotels/:slug/housekeeping ────────────────────────────────────
|
// ── POST /api/hotels/:slug/housekeeping ────────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: {
|
fastify.post<SlugParam & { Body: {
|
||||||
room_id?: string; type: string; priority?: string
|
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',
|
'/api/hotels/:slug/housekeeping',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -67,13 +68,13 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO housekeeping_tasks
|
`INSERT INTO housekeeping_tasks
|
||||||
(hotel_id, room_id, type, priority, assignee_id, notes, due_date)
|
(hotel_id, room_id, type, priority, assignee_id, notes, due_date, category)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
[hotelId, room_id ?? null, type, priority,
|
[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])
|
return reply.code(201).send(rows[0])
|
||||||
},
|
},
|
||||||
@@ -91,7 +92,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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 updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { GuestReviewPage } from './pages/GuestReviewPage'
|
|||||||
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
||||||
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||||
import { TvWelcomePage } from './pages/TvWelcomePage'
|
import { TvWelcomePage } from './pages/TvWelcomePage'
|
||||||
|
import { TechnicalPage } from './pages/TechnicalPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -85,6 +86,7 @@ export default function App() {
|
|||||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||||
<Route path="/discounts" element={<DiscountsPage />} />
|
<Route path="/discounts" element={<DiscountsPage />} />
|
||||||
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
||||||
|
<Route path="/technical" element={<TechnicalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BookingModal } from '../bookings/BookingModal'
|
|||||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||||
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
||||||
import { RoomContextMenu } from '../rooms/RoomContextMenu'
|
import { RoomContextMenu } from '../rooms/RoomContextMenu'
|
||||||
|
import type { HkPriority } from '../rooms/RoomContextMenu'
|
||||||
|
|
||||||
const CELL_WIDTH = 52
|
const CELL_WIDTH = 52
|
||||||
const ROW_HEIGHT = 56
|
const ROW_HEIGHT = 56
|
||||||
@@ -27,7 +28,8 @@ interface BookingCalendarProps {
|
|||||||
onBookingCreate: (b: Partial<Booking>) => void
|
onBookingCreate: (b: Partial<Booking>) => void
|
||||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||||
onBookingBulkUpdate?: (updates: Array<{ id: string; data: 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>
|
fadingBookingIds?: Set<string>
|
||||||
rentalObjects?: RentalObject[]
|
rentalObjects?: RentalObject[]
|
||||||
rentalBookings?: RentalBooking[]
|
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'
|
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 [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
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 })
|
onRoomUpdate?.(roomId, { status, maintenanceFrom: from, maintenanceTo: to })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCtxHkChange = (roomId: string, status: HousekeepingStatus) => {
|
const handleCtxHkChange = (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => {
|
||||||
onRoomUpdate?.(roomId, { housekeepingStatus: status })
|
onRoomUpdate?.(roomId, { housekeepingStatus: status }, priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority) => {
|
||||||
|
onMaintenanceTaskCreate?.(roomId, description, priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cell hover price tooltip
|
// Cell hover price tooltip
|
||||||
@@ -957,6 +963,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
onClose={() => setCtxMenu(null)}
|
onClose={() => setCtxMenu(null)}
|
||||||
onStatusChange={handleCtxStatusChange}
|
onStatusChange={handleCtxStatusChange}
|
||||||
onHkStatusChange={handleCtxHkChange}
|
onHkStatusChange={handleCtxHkChange}
|
||||||
|
onMaintenanceTask={handleCtxMaintenanceTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NavLink, useNavigate } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
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'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
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="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
|
||||||
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
|
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
|
||||||
<NavItem to="/maintenance" icon={Wrench} 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} />
|
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
||||||
{isModuleActive('channel-manager') && (
|
{isModuleActive('channel-manager') && (
|
||||||
<NavItem
|
<NavItem
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { format } from 'date-fns'
|
||||||
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
const today = () => format(new Date(), 'yyyy-MM-dd')
|
const today = () => format(new Date(), 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
export type HkPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||||
|
|
||||||
interface RoomContextMenuProps {
|
interface RoomContextMenuProps {
|
||||||
room: Room
|
room: Room
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => 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
|
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' },
|
{ 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 ref = useRef<HTMLDivElement>(null)
|
||||||
const [subForm, setSubForm] = useState<SubForm>(null)
|
const [subForm, setSubForm] = useState<SubForm>(null)
|
||||||
const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ? room.maintenanceFrom.slice(0, 10) : today())
|
const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ? room.maintenanceFrom.slice(0, 10) : today())
|
||||||
const [dateTo, setDateTo] = useState(room.maintenanceTo ? room.maintenanceTo.slice(0, 10) : '')
|
const [dateTo, setDateTo] = useState(room.maintenanceTo ? room.maintenanceTo.slice(0, 10) : '')
|
||||||
const [indefinite, setIndefinite] = useState(false)
|
const [indefinite, setIndefinite] = useState(false)
|
||||||
|
const [pendingHkStatus, setPendingHkStatus] = useState<HousekeepingStatus | null>(null)
|
||||||
|
const [techDesc, setTechDesc] = useState('')
|
||||||
|
const [techPriority, setTechPriority] = useState<HkPriority>('medium')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -41,7 +54,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
|||||||
document.addEventListener('mousedown', handler)
|
document.addEventListener('mousedown', handler)
|
||||||
document.addEventListener('keydown', keyHandler)
|
document.addEventListener('keydown', keyHandler)
|
||||||
|
|
||||||
// Reposition if overflows viewport
|
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const rect = ref.current.getBoundingClientRect()
|
const rect = ref.current.getBoundingClientRect()
|
||||||
const overflowX = rect.right - window.innerWidth + 8
|
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('mousedown', handler)
|
||||||
document.removeEventListener('keydown', keyHandler)
|
document.removeEventListener('keydown', keyHandler)
|
||||||
}
|
}
|
||||||
}, [onClose, subForm]) // re-run reposition when subForm opens (menu height changes)
|
}, [onClose, subForm])
|
||||||
|
|
||||||
const handleApplyStatus = (status: RoomStatus) => {
|
const handleApplyStatus = (status: RoomStatus) => {
|
||||||
if (status === 'maintenance' || status === 'blocked') {
|
if (status === 'maintenance' || status === 'blocked') {
|
||||||
if (subForm === status) {
|
if (subForm === status) {
|
||||||
// confirm
|
|
||||||
onStatusChange(room.id, status, dateFrom || null, indefinite ? null : (dateTo || null))
|
onStatusChange(room.id, status, dateFrom || null, indefinite ? null : (dateTo || null))
|
||||||
onClose()
|
onClose()
|
||||||
} else {
|
} 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 menuX = Math.min(x, window.innerWidth - 260)
|
||||||
const menuY = y
|
const menuY = y
|
||||||
|
|
||||||
@@ -90,7 +126,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
|||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Статус номера</p>
|
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Статус номера</p>
|
||||||
|
|
||||||
{/* Available */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApplyStatus('available')}
|
onClick={() => handleApplyStatus('available')}
|
||||||
className={cn(
|
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>}
|
{room.status === 'available' && subForm === null && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Maintenance */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApplyStatus('maintenance')}
|
onClick={() => handleApplyStatus('maintenance')}
|
||||||
className={cn(
|
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" />}
|
{subForm !== 'maintenance' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Maintenance date form */}
|
|
||||||
{subForm === 'maintenance' && (
|
{subForm === 'maintenance' && (
|
||||||
<DateRangeForm
|
<DateRangeForm
|
||||||
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
||||||
@@ -132,7 +165,6 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blocked */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApplyStatus('blocked')}
|
onClick={() => handleApplyStatus('blocked')}
|
||||||
className={cn(
|
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" />}
|
{subForm !== 'blocked' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Blocked date form */}
|
|
||||||
{subForm === 'blocked' && (
|
{subForm === 'blocked' && (
|
||||||
<DateRangeForm
|
<DateRangeForm
|
||||||
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
dateFrom={dateFrom} setDateFrom={setDateFrom}
|
||||||
@@ -167,23 +198,114 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
|
|||||||
{/* Housekeeping */}
|
{/* Housekeeping */}
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Уборка</p>
|
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Уборка</p>
|
||||||
{HK_ITEMS.map(item => (
|
{subForm === 'hk_priority' ? (
|
||||||
<button
|
<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">
|
||||||
key={item.status}
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
onClick={() => { onHkStatusChange(room.id, item.status); onClose() }}
|
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">Срочность</p>
|
||||||
className={cn(
|
<button onClick={() => setSubForm(null)} className="p-0.5 rounded text-slate-400 hover:text-slate-600">
|
||||||
'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',
|
<XIcon size={12} />
|
||||||
item.color,
|
</button>
|
||||||
room.housekeepingStatus === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
|
</div>
|
||||||
)}
|
{PRIORITY_ITEMS.map(p => (
|
||||||
>
|
<button
|
||||||
{item.icon}
|
key={p.value}
|
||||||
{item.label}
|
onClick={() => handleHkWithPriority(p.value)}
|
||||||
{room.housekeepingStatus === item.status && <span className="ml-auto text-[10px] text-slate-400">✓</span>}
|
className={cn(
|
||||||
</button>
|
'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>
|
</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 && (
|
{onEdit && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
|
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
|
||||||
|
|||||||
@@ -215,10 +215,11 @@ export const api = {
|
|||||||
|
|
||||||
// ── Housekeeping ──────────────────────────────────────────────────────────
|
// ── Housekeeping ──────────────────────────────────────────────────────────
|
||||||
housekeeping: {
|
housekeeping: {
|
||||||
list: (slug: string, params?: { date?: string; status?: string }) => {
|
list: (slug: string, params?: { date?: string; status?: string; category?: string }) => {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params?.date) qs.set('date', params.date)
|
if (params?.date) qs.set('date', params.date)
|
||||||
if (params?.status) qs.set('status', params.status)
|
if (params?.status) qs.set('status', params.status)
|
||||||
|
if (params?.category) qs.set('category', params.category)
|
||||||
const q = qs.toString()
|
const q = qs.toString()
|
||||||
return req<HousekeepingTask[]>('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
|
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 {
|
export interface HkPayload {
|
||||||
room_id?: string; type?: string; priority?: string
|
room_id?: string; type?: string; priority?: string
|
||||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||||
|
category?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HkSettings {
|
export interface HkSettings {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
|
|||||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||||
import type { Room, Booking } from '../types'
|
import type { Room, Booking } from '../types'
|
||||||
|
import type { HkPriority } from '../components/rooms/RoomContextMenu'
|
||||||
import type { RentalBooking } from '../data/rentalData'
|
import type { RentalBooking } from '../data/rentalData'
|
||||||
|
|
||||||
export type BookingLock = { checkIn: string; checkOut: string; lockedBy: string }
|
export type BookingLock = { checkIn: string; checkOut: string; lockedBy: string }
|
||||||
@@ -153,18 +154,18 @@ export function CalendarPage() {
|
|||||||
send({ type: 'unlock', roomId })
|
send({ type: 'unlock', roomId })
|
||||||
}, [send])
|
}, [send])
|
||||||
|
|
||||||
const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial<Room>) => {
|
const handleRoomUpdate = useCallback(async (roomId: string, patch: Partial<Room>, priority?: HkPriority) => {
|
||||||
try {
|
try {
|
||||||
const updated = await api.rooms.update(slug, roomId, patch)
|
const updated = await api.rooms.update(slug, roomId, patch)
|
||||||
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
|
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' || patch.housekeepingStatus === 'cleaning') {
|
||||||
if (patch.housekeepingStatus === 'dirty') {
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const task = await api.housekeeping.create(slug, {
|
const task = await api.housekeeping.create(slug, {
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
type: 'regular',
|
type: 'regular',
|
||||||
priority: 'medium',
|
priority: priority ?? 'medium',
|
||||||
due_date: today,
|
due_date: today,
|
||||||
|
category: 'housekeeping',
|
||||||
})
|
})
|
||||||
send({ type: 'housekeeping_task_created', task: task as unknown as Record<string, unknown> })
|
send({ type: 'housekeeping_task_created', task: task as unknown as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
@@ -173,6 +174,23 @@ export function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [slug, send])
|
}, [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) => {
|
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
||||||
try {
|
try {
|
||||||
const created = await api.rental.createBooking(slug, {
|
const created = await api.rental.createBooking(slug, {
|
||||||
@@ -208,6 +226,7 @@ export function CalendarPage() {
|
|||||||
onBookingUpdate={handleUpdate}
|
onBookingUpdate={handleUpdate}
|
||||||
onBookingBulkUpdate={handleBulkUpdate}
|
onBookingBulkUpdate={handleBulkUpdate}
|
||||||
onRoomUpdate={handleRoomUpdate}
|
onRoomUpdate={handleRoomUpdate}
|
||||||
|
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
|
||||||
fadingBookingIds={fadingBookings}
|
fadingBookingIds={fadingBookings}
|
||||||
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||||
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function HousekeepingPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
Promise.all([
|
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),
|
api.housekeeping.getSettings(slug).catch(() => null),
|
||||||
]).then(([t, s]) => {
|
]).then(([t, s]) => {
|
||||||
setTasks(t)
|
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