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