Files
hotelsync/src/pages/ChecklistSettingsPage.tsx
HotelSync 3c1a6ccebf feat: checklists, minibar and deposit modules
- 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>
2026-04-02 19:24:53 +03:00

333 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}