feat: rental drafts, room modal cleanup, tariff auto-code

- New hotels get 3 rental object templates (tennis/sauna/conference) as drafts (is_active=false)
- RentalPage: draft templates shown separately with Activate/Delete buttons
- Migration 047: adds is_active column to rental_objects
- RoomModal: hide bed type/status/housekeeping in create mode; optional price (from category); restructured Places tab with extra beds constructor
- TariffsPage: auto-generate tariff code from name initials if code field is empty
- types: ExtraPlace.beds for extra place bed configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 11:19:54 +03:00
parent 1970afdd13
commit f6d3c78dd0
9 changed files with 201 additions and 60 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, CalendarDays, X, Save } from 'lucide-react'
import { Plus, Pencil, Trash2, CalendarDays, X, Save, Sparkles } from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { cn, formatCurrency } from '../lib/utils'
@@ -267,6 +267,7 @@ function fromObjApi(o: RentalObjectApi): RentalObject {
openHour: o.openHour, closeHour: o.closeHour,
maxHoursPerSlot: o.maxHoursPerSlot ?? undefined,
bufferMinutes: o.bufferMinutes ?? undefined,
isActive: o.isActive,
}
}
@@ -334,6 +335,12 @@ export function RentalPage() {
if (selectedObj?.id === id) setSelectedObj(null)
}
const handleActivateObject = async (id: string) => {
if (!slug) return
const saved = await api.rental.updateObject(slug, id, { is_active: true })
setObjects(prev => prev.map(o => o.id === id ? fromObjApi(saved) : o))
}
const handleBookingSave = async (b: RentalBooking) => {
if (!slug) return
const payload = {
@@ -357,13 +364,16 @@ export function RentalPage() {
.filter(b => b.status === 'confirmed')
.reduce((s, b) => s + b.totalAmount, 0)
const activeObjects = objects.filter(o => o.isActive !== false)
const draftObjects = objects.filter(o => o.isActive === false)
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">{objects.length} объектов · управление и расписание</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{activeObjects.length} объектов · управление и расписание</p>
</div>
<button onClick={() => setCreateModal(true)} className="btn-primary">
<Plus size={15} />
@@ -374,7 +384,7 @@ export function RentalPage() {
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Объектов', value: objects.length, color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Объектов', value: activeObjects.length, color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Броней сегодня', value: bookings.filter(b => b.date === today).length, color: 'text-brand-600 dark:text-brand-400' },
{ label: 'Всего броней', value: bookings.filter(b => b.status === 'confirmed').length, color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Общая выручка', value: formatCurrency(totalRevenue), color: 'text-emerald-600 dark:text-emerald-400' },
@@ -390,7 +400,7 @@ export function RentalPage() {
{/* Objects list */}
<div className="space-y-3">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Объекты аренды</p>
{objects.map(obj => {
{activeObjects.map(obj => {
const objBookings = bookings.filter(b => b.objectId === obj.id && b.status === 'confirmed')
const todayB = objBookings.filter(b => b.date === today)
const isSelected = selectedObj?.id === obj.id
@@ -443,7 +453,7 @@ export function RentalPage() {
)
})}
{objects.length === 0 && (
{activeObjects.length === 0 && draftObjects.length === 0 && (
<div className="card p-8 text-center text-slate-400">
<p className="text-sm">Нет объектов аренды</p>
<button onClick={() => setCreateModal(true)} className="btn-secondary text-xs mt-3">
@@ -451,6 +461,43 @@ export function RentalPage() {
</button>
</div>
)}
{/* Draft templates */}
{draftObjects.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<Sparkles size={13} className="text-amber-500" />
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Шаблоны</p>
</div>
{draftObjects.map(obj => (
<div key={obj.id} className="rounded-xl border border-dashed border-slate-300 dark:border-slate-600 p-3 bg-slate-50 dark:bg-slate-800/50">
<div className="flex items-center gap-3">
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center text-lg shrink-0 text-white opacity-60', obj.color)}>
{obj.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 truncate">{obj.name}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Черновик · не активен</p>
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={() => handleActivateObject(obj.id)}
className="text-xs px-2.5 py-1 rounded-lg bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50 font-medium transition-colors"
>
Активировать
</button>
<button
onClick={() => handleDeleteObject(obj.id)}
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-600 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Right: bookings for selected object */}

View File

@@ -166,6 +166,18 @@ function TariffModal({
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
// Авто-генерация кода из названия (если поле кода пустое)
const autoCode = (name: string) =>
name.trim().split(/\s+/).map(w => w[0]?.toUpperCase() ?? '').join('').slice(0, 6) || ''
const handleNameChange = (v: string) => {
setForm(prev => ({
...prev,
name: v,
code: prev.code || !tariff ? autoCode(v) : prev.code,
}))
}
const toggleInclusion = (id: string) =>
setForm(prev => ({
...prev,
@@ -204,9 +216,13 @@ function TariffModal({
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name && form.code) onSave(form) }}
onClick={() => {
if (!form.name) return
const code = form.code || autoCode(form.name) || `T${Date.now().toString(36).toUpperCase().slice(-4)}`
onSave({ ...form, code })
}}
className="btn-primary"
disabled={!form.name || !form.code}
disabled={!form.name}
>
{tariff ? 'Сохранить' : 'Создать тариф'}
</button>
@@ -221,13 +237,13 @@ function TariffModal({
Название тарифа *
</label>
<input type="text" className="input" placeholder="Стандарт с завтраком"
value={form.name} onChange={e => set('name', e.target.value)} />
value={form.name} onChange={e => handleNameChange(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Код *
Код <span className="text-slate-400 font-normal text-xs">(необязательно)</span>
</label>
<input type="text" className="input font-mono uppercase" placeholder="STD-BB"
<input type="text" className="input font-mono uppercase" placeholder="авто"
value={form.code}
onChange={e => set('code', e.target.value.toUpperCase())} />
</div>