- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits - Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture) - YooKassa service for hold/capture/cancel payments - Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage - HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons - BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings - Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight } from 'lucide-react'
|
||
import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { cn } from '../lib/utils'
|
||
|
||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||
checkout: 'После выезда',
|
||
daily: 'Ежедневная',
|
||
deep: 'Генеральная',
|
||
}
|
||
|
||
function InlineEdit({ value, onSave, onCancel }: {
|
||
value: string
|
||
onSave: (v: string) => void
|
||
onCancel: () => void
|
||
}) {
|
||
const [v, setV] = useState(value)
|
||
return (
|
||
<div className="flex items-center gap-1.5 flex-1">
|
||
<input
|
||
autoFocus
|
||
value={v}
|
||
onChange={e => setV(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') onSave(v.trim())
|
||
if (e.key === 'Escape') onCancel()
|
||
}}
|
||
className="input flex-1 py-1 text-sm"
|
||
/>
|
||
<button onClick={() => onSave(v.trim())} className="p-1 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||
<Check size={14} />
|
||
</button>
|
||
<button onClick={onCancel} className="p-1 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ChecklistSettingsPage() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const [templates, setTemplates] = useState<ChecklistTemplate[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// New template form
|
||
const [newName, setNewName] = useState('')
|
||
const [newType, setNewType] = useState('')
|
||
const [adding, setAdding] = useState(false)
|
||
|
||
// Editing states
|
||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
||
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||
const [newItemText, setNewItemText] = useState<Record<string, string>>({})
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
api.checklists.listTemplates(slug)
|
||
.then(t => setTemplates(t))
|
||
.catch(() => setError('Не удалось загрузить шаблоны'))
|
||
.finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
const addTemplate = async () => {
|
||
if (!newName.trim()) return
|
||
setAdding(true)
|
||
try {
|
||
const tpl = await api.checklists.createTemplate(slug, {
|
||
name: newName.trim(),
|
||
taskType: newType || undefined,
|
||
})
|
||
setTemplates(prev => [...prev, tpl])
|
||
setNewName('')
|
||
setNewType('')
|
||
} catch {
|
||
setError('Не удалось создать шаблон')
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
const updateTemplateName = async (id: string, name: string) => {
|
||
try {
|
||
const updated = await api.checklists.updateTemplate(slug, id, { name })
|
||
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: updated.name } : t))
|
||
} catch { /* ignore */ }
|
||
setEditingTemplateId(null)
|
||
}
|
||
|
||
const toggleActive = async (t: ChecklistTemplate) => {
|
||
try {
|
||
const updated = await api.checklists.updateTemplate(slug, t.id, { isActive: !t.isActive })
|
||
setTemplates(prev => prev.map(x => x.id === t.id ? { ...x, isActive: updated.isActive } : x))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const deleteTemplate = async (id: string) => {
|
||
if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return
|
||
try {
|
||
await api.checklists.deleteTemplate(slug, id)
|
||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const addItem = async (templateId: string) => {
|
||
const text = newItemText[templateId]?.trim()
|
||
if (!text) return
|
||
try {
|
||
const item = await api.checklists.addItem(slug, templateId, { text })
|
||
setTemplates(prev => prev.map(t => t.id === templateId
|
||
? { ...t, items: [...t.items, item] }
|
||
: t,
|
||
))
|
||
setNewItemText(prev => ({ ...prev, [templateId]: '' }))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const updateItemText = async (templateId: string, item: ChecklistItem, text: string) => {
|
||
try {
|
||
const updated = await api.checklists.updateItem(slug, templateId, item.id, { text })
|
||
setTemplates(prev => prev.map(t => t.id === templateId
|
||
? { ...t, items: t.items.map(i => i.id === item.id ? { ...i, text: updated.text } : i) }
|
||
: t,
|
||
))
|
||
} catch { /* ignore */ }
|
||
setEditingItemId(null)
|
||
}
|
||
|
||
const deleteItem = async (templateId: string, itemId: string) => {
|
||
try {
|
||
await api.checklists.deleteItem(slug, templateId, itemId)
|
||
setTemplates(prev => prev.map(t => t.id === templateId
|
||
? { ...t, items: t.items.filter(i => i.id !== itemId) }
|
||
: t,
|
||
))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const moveItem = async (templateId: string, itemId: string, dir: 'up' | 'down') => {
|
||
const tpl = templates.find(t => t.id === templateId)
|
||
if (!tpl) return
|
||
const idx = tpl.items.findIndex(i => i.id === itemId)
|
||
if (dir === 'up' && idx === 0) return
|
||
if (dir === 'down' && idx === tpl.items.length - 1) return
|
||
|
||
const newItems = [...tpl.items]
|
||
const swapIdx = dir === 'up' ? idx - 1 : idx + 1
|
||
;[newItems[idx], newItems[swapIdx]] = [newItems[swapIdx], newItems[idx]]
|
||
|
||
// Update sort_order for both
|
||
const a = newItems[idx]
|
||
const b = newItems[swapIdx]
|
||
const sortA = a.sortOrder
|
||
const sortB = b.sortOrder
|
||
|
||
setTemplates(prev => prev.map(t => t.id === templateId ? { ...t, items: newItems } : t))
|
||
|
||
try {
|
||
await Promise.all([
|
||
api.checklists.updateItem(slug, templateId, a.id, { sortOrder: sortA }),
|
||
api.checklists.updateItem(slug, templateId, b.id, { sortOrder: sortB }),
|
||
])
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
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-6 max-w-3xl">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||
<ListChecks size={22} className="text-brand-600" />
|
||
Шаблоны чек-листов
|
||
</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
Настройте пункты проверки для задач уборки. Шаблон применяется автоматически по типу задачи.
|
||
</p>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add template form */}
|
||
<div className="card p-4 space-y-3">
|
||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Новый шаблон</h3>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={newName}
|
||
onChange={e => setNewName(e.target.value)}
|
||
placeholder="Название шаблона"
|
||
className="input flex-1"
|
||
onKeyDown={e => e.key === 'Enter' && addTemplate()}
|
||
/>
|
||
<select
|
||
value={newType}
|
||
onChange={e => setNewType(e.target.value)}
|
||
className="input w-44"
|
||
>
|
||
<option value="">Любой тип</option>
|
||
<option value="checkout">После выезда</option>
|
||
<option value="daily">Ежедневная</option>
|
||
<option value="deep">Генеральная</option>
|
||
</select>
|
||
<button
|
||
onClick={addTemplate}
|
||
disabled={!newName.trim() || adding}
|
||
className="btn-primary flex items-center gap-1.5"
|
||
>
|
||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Templates list */}
|
||
<div className="space-y-4">
|
||
{templates.length === 0 && (
|
||
<div className="text-center py-12 text-slate-400 dark:text-slate-500">
|
||
Нет шаблонов. Создайте первый шаблон чек-листа.
|
||
</div>
|
||
)}
|
||
{templates.map(tpl => (
|
||
<div key={tpl.id} className={cn(
|
||
'border rounded-xl overflow-hidden',
|
||
tpl.isActive
|
||
? 'border-slate-200 dark:border-slate-700'
|
||
: 'border-dashed border-slate-200 dark:border-slate-700 opacity-60',
|
||
)}>
|
||
{/* Template header */}
|
||
<div className="flex items-center gap-2 px-4 py-3 bg-slate-50 dark:bg-slate-800/50">
|
||
{editingTemplateId === tpl.id ? (
|
||
<InlineEdit
|
||
value={tpl.name}
|
||
onSave={name => updateTemplateName(tpl.id, name)}
|
||
onCancel={() => setEditingTemplateId(null)}
|
||
/>
|
||
) : (
|
||
<>
|
||
<span className="font-semibold text-slate-800 dark:text-slate-200 flex-1">{tpl.name}</span>
|
||
{tpl.taskType && (
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||
{TASK_TYPE_LABELS[tpl.taskType] ?? tpl.taskType}
|
||
</span>
|
||
)}
|
||
{!tpl.taskType && (
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||
Любой тип
|
||
</span>
|
||
)}
|
||
<button onClick={() => setEditingTemplateId(tpl.id)} className="p-1.5 rounded hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-400">
|
||
<Pencil size={13} />
|
||
</button>
|
||
<button onClick={() => toggleActive(tpl)} className={cn('p-1.5 rounded text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600', tpl.isActive ? 'text-brand-600' : 'text-slate-400')}>
|
||
{tpl.isActive ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||
</button>
|
||
<button onClick={() => deleteTemplate(tpl.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Items */}
|
||
<div className="p-4 bg-white dark:bg-slate-900 space-y-1.5">
|
||
{tpl.items.map((item, idx) => (
|
||
<div key={item.id} className="flex items-center gap-2 group">
|
||
<span className="text-slate-400 text-xs w-5 text-right shrink-0">{idx + 1}.</span>
|
||
{editingItemId === item.id ? (
|
||
<InlineEdit
|
||
value={item.text}
|
||
onSave={text => updateItemText(tpl.id, item, text)}
|
||
onCancel={() => setEditingItemId(null)}
|
||
/>
|
||
) : (
|
||
<>
|
||
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{item.text}</span>
|
||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button onClick={() => moveItem(tpl.id, item.id, 'up')} disabled={idx === 0} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||
<ChevronUp size={13} />
|
||
</button>
|
||
<button onClick={() => moveItem(tpl.id, item.id, 'down')} disabled={idx === tpl.items.length - 1} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||
<ChevronDown size={13} />
|
||
</button>
|
||
<button onClick={() => setEditingItemId(item.id)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||
<Pencil size={13} />
|
||
</button>
|
||
<button onClick={() => deleteItem(tpl.id, item.id)} className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{/* Add item */}
|
||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-slate-100 dark:border-slate-700">
|
||
<input
|
||
value={newItemText[tpl.id] ?? ''}
|
||
onChange={e => setNewItemText(prev => ({ ...prev, [tpl.id]: e.target.value }))}
|
||
placeholder="Новый пункт..."
|
||
className="input flex-1 py-1.5 text-sm"
|
||
onKeyDown={e => e.key === 'Enter' && addItem(tpl.id)}
|
||
/>
|
||
<button
|
||
onClick={() => addItem(tpl.id)}
|
||
disabled={!newItemText[tpl.id]?.trim()}
|
||
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1"
|
||
>
|
||
<Plus size={14} />
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|