Connect frontend to real API — rooms, bookings, housekeeping, calendar
- src/lib/api.ts: central API client with JWT auto-refresh, snake_case→camelCase transform - src/contexts/AuthContext.tsx: real login via POST /api/auth/login - src/pages/RoomsPage.tsx: load rooms from API, create/update via API - src/pages/BookingsPage.tsx: load bookings + rooms from API - src/pages/HousekeepingPage.tsx: load today's tasks from API, update status via API - src/pages/CalendarPage.tsx: load rooms + bookings from API - src/types/index.ts: fix HousekeepingTask.priority to match DB (medium/urgent) - backend/src/routes/rooms.ts: update to use new column names (max_guests, base_rate) + all new fields - backend/src/routes/bookings.ts: update price_per_night→base_rate, add paid_amount to PATCH - backend/migrations/003_fix_constraints.sql: fix rooms.status values, add paid_amount, inquiry status, other source - public/robots.txt + index.html: noindex for SPA inner pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon } from 'lucide-react'
|
||||
import { MOCK_HK_TASKS } from '../data/mockData'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2 } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { HousekeepingTask } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { useNotifications } from '../contexts/NotificationsContext'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={cn('relative w-10 h-5.5 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||
>
|
||||
<div className={cn('absolute top-0.5 w-4.5 h-4.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||||
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -20,18 +22,21 @@ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||
type Column = 'pending' | 'in_progress' | 'done'
|
||||
|
||||
const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [
|
||||
{ id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' },
|
||||
{ id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' },
|
||||
{ id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
]
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
normal: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
high: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
medium: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' }
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: 'Экстренно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
@@ -40,34 +45,33 @@ const TYPE_COLORS = {
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
cleaning: 'Уборка',
|
||||
inspection: 'Проверка',
|
||||
maintenance: 'Ремонт',
|
||||
cleaning: 'Уборка', inspection: 'Проверка', maintenance: 'Ремонт',
|
||||
}
|
||||
|
||||
export function HousekeepingPage() {
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
|
||||
const [planSaved, setPlanSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') })
|
||||
.then(setTasks)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
// Cleaning plan settings
|
||||
const [plans, setPlans] = useState({
|
||||
checkoutAuto: true,
|
||||
checkoutPriority: 'high' as 'high' | 'normal',
|
||||
checkoutInspection: true,
|
||||
|
||||
dailyEnabled: true,
|
||||
dailyIntervalDays: 1,
|
||||
dailyStartFromDay: 1,
|
||||
|
||||
onDemandEnabled: true,
|
||||
onDemandPriority: 'normal' as 'high' | 'normal' | 'low',
|
||||
|
||||
deepCleanEnabled: false,
|
||||
deepCleanEveryDays: 7,
|
||||
|
||||
inspectionAfterClean: true,
|
||||
autoAssign: false,
|
||||
checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium',
|
||||
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
|
||||
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
||||
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
||||
inspectionAfterClean: true, autoAssign: false,
|
||||
})
|
||||
|
||||
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
||||
@@ -78,29 +82,23 @@ export function HousekeepingPage() {
|
||||
setTimeout(() => setPlanSaved(false), 2000)
|
||||
}
|
||||
|
||||
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === id
|
||||
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
|
||||
: t,
|
||||
))
|
||||
const updateStatus = async (id: string, status: HousekeepingTask['status']) => {
|
||||
try {
|
||||
const updated = await api.housekeeping.update(slug, id, { status })
|
||||
setTasks(prev => prev.map(t => t.id === id ? updated : t))
|
||||
} catch (err) {
|
||||
console.error('Failed to update task:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const { addNotification } = useNotifications()
|
||||
|
||||
const addMaintenanceReport = (
|
||||
id: string,
|
||||
note: string,
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
) => {
|
||||
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
|
||||
const task = tasks.find(t => t.id === id)
|
||||
const roomBlocked = severity === 'high'
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === id
|
||||
? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked }
|
||||
: t,
|
||||
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
|
||||
))
|
||||
|
||||
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
|
||||
addNotification({
|
||||
type: 'maintenance',
|
||||
@@ -115,6 +113,14 @@ export function HousekeepingPage() {
|
||||
const total = tasks.length
|
||||
const done = tasks.filter(t => t.status === 'done').length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
@@ -140,8 +146,8 @@ export function HousekeepingPage() {
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 dark:border-slate-700">
|
||||
{([
|
||||
{ id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks },
|
||||
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
|
||||
{ id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks },
|
||||
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
|
||||
]).map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -161,40 +167,46 @@ export function HousekeepingPage() {
|
||||
|
||||
{/* ── Tasks tab ── */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const Icon = col.icon
|
||||
return (
|
||||
<div key={col.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={col.color} />
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-300">{col.label}</h3>
|
||||
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{colTasks.map(task => (
|
||||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
|
||||
))}
|
||||
{colTasks.length === 0 && (
|
||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
|
||||
<>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||||
Задач на сегодня нет
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const Icon = col.icon
|
||||
return (
|
||||
<div key={col.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={col.color} />
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-300">{col.label}</h3>
|
||||
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{colTasks.map(task => (
|
||||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
|
||||
))}
|
||||
{colTasks.length === 0 && (
|
||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Plans tab ── */}
|
||||
{activeTab === 'plans' && (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
|
||||
{/* Checkout cleaning */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -210,7 +222,7 @@ export function HousekeepingPage() {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет задачи</label>
|
||||
<div className="flex gap-2">
|
||||
{([['high', 'Срочно'], ['normal', 'Обычный']] as const).map(([v, l]) => (
|
||||
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setP('checkoutPriority', v)}
|
||||
@@ -237,14 +249,11 @@ export function HousekeepingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily cleaning */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Ежедневная уборка</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Регулярная уборка в номерах с проживающими гостями
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Регулярная уборка в номерах с проживающими гостями</p>
|
||||
</div>
|
||||
<Toggle on={plans.dailyEnabled} onChange={() => setP('dailyEnabled', !plans.dailyEnabled)} />
|
||||
</div>
|
||||
@@ -252,9 +261,7 @@ export function HousekeepingPage() {
|
||||
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
Каждые N дней
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Каждые N дней</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[1, 2, 3, 7].map(n => (
|
||||
<button
|
||||
@@ -273,32 +280,25 @@ export function HousekeepingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
Начинать с дня заезда №
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Начинать с дня заезда №</label>
|
||||
<input
|
||||
type="number" min={1} max={10}
|
||||
className="input w-20 text-sm"
|
||||
value={plans.dailyStartFromDay}
|
||||
onChange={e => setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Уборка начнётся на {plans.dailyStartFromDay}-й день проживания
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Уборка начнётся на {plans.dailyStartFromDay}-й день</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* On-demand cleaning */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка по запросу гостя</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Гость может запросить уборку через QR-код или мобильное приложение
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Гость может запросить уборку через QR-код</p>
|
||||
</div>
|
||||
<Toggle on={plans.onDemandEnabled} onChange={() => setP('onDemandEnabled', !plans.onDemandEnabled)} />
|
||||
</div>
|
||||
@@ -306,7 +306,7 @@ export function HousekeepingPage() {
|
||||
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет</label>
|
||||
<div className="flex gap-2">
|
||||
{([['high', 'Срочно'], ['normal', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
|
||||
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setP('onDemandPriority', v)}
|
||||
@@ -325,14 +325,11 @@ export function HousekeepingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deep cleaning */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Генеральная уборка</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Глубокая уборка с чисткой мебели, мытьём окон и полной сменой постельного белья
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Глубокая уборка с чисткой мебели, мытьём окон</p>
|
||||
</div>
|
||||
<Toggle on={plans.deepCleanEnabled} onChange={() => setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
|
||||
</div>
|
||||
@@ -350,27 +347,21 @@ export function HousekeepingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inspection after cleaning */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Проверка после уборки</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
После завершения любой уборки создавать задачу инспекции для старшей горничной
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">После завершения любой уборки создавать задачу инспекции</p>
|
||||
</div>
|
||||
<Toggle on={plans.inspectionAfterClean} onChange={() => setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto assign */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Автоматически назначать ответственную горничную по расписанию смен (без этой опции — задачи назначаются вручную)
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать ответственную горничную по расписанию смен</p>
|
||||
</div>
|
||||
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
||||
</div>
|
||||
@@ -429,8 +420,8 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={PRIORITY_COLORS[task.priority]}>
|
||||
{PRIORITY_LABELS[task.priority]}
|
||||
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
|
||||
{PRIORITY_LABELS[task.priority] ?? task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -472,7 +463,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Maintenance report form */}
|
||||
{reportOpen && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -483,8 +473,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Severity selector */}
|
||||
<div className="flex gap-1.5">
|
||||
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
|
||||
<button
|
||||
@@ -501,19 +489,17 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{severity === 'high' && (
|
||||
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
|
||||
<AlertTriangle size={11} className="shrink-0" />
|
||||
Номер будет закрыт для бронирования до устранения поломки
|
||||
</p>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
autoFocus
|
||||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
|
||||
rows={2}
|
||||
placeholder="Опишите неисправность (напр. Не работает кондиционер, сломана ручка двери...)"
|
||||
placeholder="Опишите неисправность..."
|
||||
value={reportText}
|
||||
onChange={e => setReportText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
|
||||
@@ -533,7 +519,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 pt-1">
|
||||
{task.status === 'pending' && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user