Files
hotelsync/src/pages/RoomCategoriesPage.tsx

470 lines
21 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, useRef, useEffect } from 'react'
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, Loader2 } from 'lucide-react'
import { cn } from '../lib/utils'
import type { RoomCategory } from '../types'
import { useAmenities } from '../contexts/AmenitiesContext'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { CategoryApi } from '../lib/api'
// ── Mock data ─────────────────────────────────────────────────────────────────
const CATEGORY_COLORS = [
'#4F46E5', '#059669', '#2563EB', '#7C3AED',
'#DC2626', '#D97706', '#DB2777', '#475569',
]
function fromApi(c: CategoryApi): RoomCategory {
return {
id: c.id,
hotelId: c.hotel_id,
name: c.name,
description: c.description,
color: c.color,
amenities: c.amenities,
photos: c.photos,
}
}
// ── Category Form Modal ────────────────────────────────────────────────────────
interface CategoryFormProps {
category?: RoomCategory
onClose: () => void
onSave: (cat: RoomCategory) => Promise<void>
}
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const isEdit = !!category
const { amenities: allAmenities } = useAmenities()
const [tab, setTab] = useState<'main' | 'photos'>('main')
const [name, setName] = useState(category?.name ?? '')
const [description, setDescription] = useState(category?.description ?? '')
const [color, setColor] = useState(category?.color ?? '#4F46E5')
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
const [photoIdx, setPhotoIdx] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const [dragging, setDragging] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const [uploading, setUploading] = useState(false)
const uploadFiles = async (files: FileList | null) => {
const list = Array.from(files ?? []).filter(f => f.type.startsWith('image/'))
if (!list.length) return
setUploading(true)
try {
const urls = await Promise.all(list.map(f => api.upload.photo(f)))
setPhotos(prev => {
const next = [...prev, ...urls]
setPhotoIdx(next.length - 1)
return next
})
} catch {
setSaveError('Ошибка загрузки фото')
} finally {
setUploading(false)
}
}
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
uploadFiles(e.target.files)
e.target.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
uploadFiles(e.dataTransfer.files)
}
const removePhoto = (i: number) => {
setPhotos(prev => prev.filter((_, idx) => idx !== i))
setPhotoIdx(p => Math.max(0, p - 1))
}
const handleSave = async () => {
if (!name.trim() || saving) return
setSaving(true)
setSaveError('')
try {
await onSave({
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? '',
name: name.trim(),
description,
color,
amenities,
photos,
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-2xl bg-white dark:bg-slate-800 rounded-2xl shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{isEdit ? `Редактировать: ${category.name}` : 'Новая категория'}
</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500">
<XIcon size={18} />
</button>
</div>
{/* Tab bar */}
<div className="px-6 pt-3 flex gap-1 p-1 shrink-0">
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-full">
{(['main', 'photos'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors',
tab === t
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400',
)}
>
{t === 'main' ? 'Основное' : `Фото${photos.length > 0 ? ` (${photos.length})` : ''}`}
</button>
))}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{tab === 'main' && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название категории *</label>
<input type="text" className="input" placeholder="Стандарт" value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Цвет категории</label>
<div className="flex items-center gap-2 flex-wrap">
{CATEGORY_COLORS.map(c => (
<button
key={c}
onClick={() => setColor(c)}
className={cn(
'w-8 h-8 rounded-full border-2 transition-transform',
color === c ? 'border-slate-900 dark:border-slate-100 scale-110' : 'border-transparent',
)}
style={{ background: c }}
/>
))}
<input type="color" value={color} onChange={e => setColor(e.target.value)} className="w-8 h-8 rounded-full cursor-pointer" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание</label>
<p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
<textarea
className="input resize-none w-full"
rows={4}
placeholder="Опишите категорию для гостей..."
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Удобства в номере</label>
<span className="text-xs text-slate-400">Справочник удобств на странице «Номера»</span>
</div>
<div className="flex flex-wrap gap-2">
{allAmenities.map(a => {
const sel = amenities.includes(a)
return (
<button
key={a}
type="button"
onClick={() => toggleAmenity(a)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
sel
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{a}
</button>
)
})}
</div>
{amenities.length > 0 && (
<p className="text-xs text-slate-400 mt-1.5">Выбрано: {amenities.length}</p>
)}
</div>
</>
)}
{tab === 'photos' && (
<>
{photos.length > 0 ? (
<div className="space-y-3">
<div
className={cn(
'relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700 transition-colors',
dragging && 'ring-2 ring-brand-500 ring-offset-2',
)}
style={{ height: 220 }}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" />
{dragging && (
<div className="absolute inset-0 bg-brand-600/40 flex items-center justify-center">
<p className="text-white font-semibold text-sm">Отпустите для добавления</p>
</div>
)}
<button
onClick={() => removePhoto(photoIdx)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center"
>
<XIcon size={13} />
</button>
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-0.5 rounded-full">
{photoIdx + 1} / {photos.length}
</div>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button key={i} onClick={() => setPhotoIdx(i)}
className={cn('w-14 h-14 rounded-lg overflow-hidden border-2 shrink-0', i === photoIdx ? 'border-brand-500' : 'border-transparent')}>
<img src={p} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
</div>
) : (
<div
className={cn(
'flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed transition-colors cursor-pointer',
dragging
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-600'
: 'border-slate-300 dark:border-slate-600 text-slate-400 hover:border-slate-400',
)}
onClick={() => fileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<ImagePlus size={28} className="mb-2 opacity-60" />
<p className="text-sm font-medium">{dragging ? 'Отпустите файлы' : 'Перетащите фото или нажмите'}</p>
<p className="text-xs mt-0.5 opacity-60">JPG, PNG, WEBP</p>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
<button onClick={() => fileRef.current?.click()} className="btn-secondary w-full justify-center" disabled={uploading}>
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
{uploading ? 'Загрузка...' : 'Загрузить фото'}
</button>
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700">
{saveError && (
<p className="text-xs text-red-500 mb-3">{saveError}</p>
)}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary" disabled={saving}>Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!name.trim() || saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : null}
{isEdit ? 'Сохранить' : 'Создать категорию'}
</button>
</div>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function RoomCategoriesPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [categories, setCategories] = useState<RoomCategory[]>([])
const [loading, setLoading] = useState(true)
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false)
useEffect(() => {
if (!slug) return
api.categories.list(slug)
.then(rows => setCategories(rows.map(fromApi)))
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const openCreate = () => { setEditingCat(undefined); setFormOpen(true) }
const openEdit = (cat: RoomCategory) => { setEditingCat(cat); setFormOpen(true) }
const handleSave = async (cat: RoomCategory) => {
if (!slug) return
const payload = {
name: cat.name, description: cat.description,
color: cat.color, amenities: cat.amenities, photos: cat.photos,
}
const isNew = !categories.find(c => c.id === cat.id)
const saved = isNew
? await api.categories.create(slug, payload)
: await api.categories.update(slug, cat.id, payload)
const converted = fromApi(saved)
setCategories(prev => isNew ? [...prev, converted] : prev.map(c => c.id === cat.id ? converted : c))
setFormOpen(false)
}
const handleDelete = async (id: string) => {
if (!slug) return
await api.categories.delete(slug, id)
setCategories(prev => prev.filter(c => c.id !== id))
}
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 */}
<div className="flex items-center justify-between">
<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 className="btn-primary" onClick={openCreate}>
<Plus size={15} />Добавить категорию
</button>
</div>
{/* Explanation */}
<div className="card p-4 border-indigo-200 dark:border-indigo-700/50 bg-indigo-50/50 dark:bg-indigo-900/10">
<div className="flex items-start gap-3">
<Tag size={16} className="text-indigo-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Как работают категории</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Категории позволяют объединять номера в группы (Стандарт, Делюкс, Люкс).
В виджете бронирования гость выбирает категорию, а не конкретный номер.
Категория содержит описание и фотогалерею, которые видят гости на сайте.
</p>
</div>
</div>
</div>
{/* Categories list */}
<div className="space-y-4">
{categories.map(cat => {
return (
<div key={cat.id} className="card p-5">
<div className="flex items-start gap-4">
{/* Color indicator + photo */}
<div
className="w-16 h-16 rounded-xl shrink-0 flex items-center justify-center overflow-hidden"
style={{ background: cat.photos.length > 0 ? undefined : cat.color + '20', borderColor: cat.color, borderWidth: 2 }}
>
{cat.photos.length > 0
? <img src={cat.photos[0]} alt="" className="w-full h-full object-cover" />
: <Tag size={24} style={{ color: cat.color }} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">{cat.name}</h3>
</div>
{cat.description && (
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2 line-clamp-2">{cat.description}</p>
)}
{/* Amenities */}
{cat.amenities.length > 0 && (
<div className="flex flex-wrap gap-1">
{cat.amenities.map(a => (
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
{a}
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<button
onClick={() => openEdit(cat)}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-brand-400 text-slate-500 hover:text-brand-600 transition-colors"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-red-400 text-slate-500 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<button
onClick={() => openEdit(cat)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors whitespace-nowrap"
>
<span className="hidden sm:inline">Номера</span>
<ChevronRight size={12} />
</button>
</div>
</div>
</div>
)
})}
{categories.length === 0 && (
<div className="card p-10 text-center">
<Tag size={36} className="mx-auto mb-3 text-slate-300" />
<p className="font-medium text-slate-600 dark:text-slate-400">Категории не созданы</p>
<p className="text-sm text-slate-400 mt-1">Создайте первую категорию для группировки номеров</p>
<button className="btn-primary mt-4" onClick={openCreate}>
<Plus size={14} />Создать категорию
</button>
</div>
)}
</div>
{formOpen && (
<CategoryForm
category={editingCat}
onClose={() => setFormOpen(false)}
onSave={handleSave}
/>
)}
</div>
)
}