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

@@ -60,7 +60,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
type: room?.type ?? (categories[0]?.name ?? ''),
bedType: (room?.bedType ?? 'double') as BedType,
maxGuests: room?.maxGuests ?? 2,
baseRate: room?.baseRate ?? 5000,
baseRate: room?.baseRate != null ? String(room.baseRate) : '',
status: (room?.status ?? 'available') as RoomStatus,
housekeepingStatus: (room?.housekeepingStatus ?? 'clean') as HousekeepingStatus,
sortOrder: room?.sortOrder ?? 99,
@@ -84,8 +84,14 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
room?.beds ?? [{ type: 'double', count: 1 }]
)
const [extraPlace, setExtraPlace] = useState<ExtraPlace>(
room?.extraPlace ?? { enabled: false, count: 1, price: 1500 }
room?.extraPlace ?? { enabled: false, count: 1, price: 1500, beds: [] }
)
// Extra beds constructor helpers
const addExtraBed = () => setExtraPlace(p => ({ ...p, beds: [...(p.beds ?? []), { type: 'single', count: 1 }] }))
const removeExtraBed = (i: number) => setExtraPlace(p => ({ ...p, beds: (p.beds ?? []).filter((_, idx) => idx !== i) }))
const updateExtraBed = (i: number, patch: Partial<BedItem>) =>
setExtraPlace(p => ({ ...p, beds: (p.beds ?? []).map((b, idx) => idx === i ? { ...b, ...patch } : b) }))
const [childPolicy, setChildPolicy] = useState<ChildPolicy>(
room?.childPolicy ?? { enabled: false, freeUnderAge: 12, chargeFromAge: 12 }
)
@@ -151,6 +157,11 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
const handleSave = () => {
if (!form.number || numberTaken) return
// Derive extra place count from beds sum (or keep manual count if no beds)
const extraBeds = extraPlace.beds ?? []
const extraCount = extraBeds.length > 0
? extraBeds.reduce((s, b) => s + b.count, 0)
: extraPlace.count
onSave({
id: room?.id ?? `r-${Date.now()}`,
hotelId: room?.hotelId ?? 'hotel-1',
@@ -160,7 +171,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
type: form.type,
bedType: form.bedType,
maxGuests: form.maxGuests,
baseRate: form.baseRate,
baseRate: form.baseRate !== '' ? Number(form.baseRate) : 0,
status: form.status,
housekeepingStatus: form.housekeepingStatus,
amenities,
@@ -171,7 +182,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
description: form.description || undefined,
photos: photos.length > 0 ? photos : undefined,
beds: beds.length > 0 ? beds : undefined,
extraPlace: extraPlace.enabled ? extraPlace : undefined,
extraPlace: extraPlace.enabled ? { ...extraPlace, count: extraCount, beds: extraBeds.length > 0 ? extraBeds : undefined } : undefined,
childPolicy: childPolicy.enabled ? childPolicy : undefined,
earlyCheckinFee: form.earlyCheckinFee !== '' ? Number(form.earlyCheckinFee) : undefined,
lateCheckoutFee: form.lateCheckoutFee !== '' ? Number(form.lateCheckoutFee) : undefined,
@@ -290,36 +301,41 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Тип кровати</label>
<select className="input" value={form.bedType} onChange={e => set('bedType', e.target.value as BedType)}>
{BED_TYPES.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Основных гостей</label>
<input type="number" min={1} max={20} className="input" value={form.maxGuests} onChange={e => set('maxGuests', parseInt(e.target.value) || 1)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Макс. гостей</label>
<input type="number" min={1} max={10} className="input" value={form.maxGuests} onChange={e => set('maxGuests', parseInt(e.target.value) || 1)} />
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Доп. мест</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setExtraPlace(p => ({ ...p, enabled: p.count > 0, count: Math.max(0, p.count - 1) }))}
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
><Minus size={12} /></button>
<span className="w-8 text-center text-sm font-semibold text-slate-800 dark:text-slate-200">
{extraPlace.enabled ? extraPlace.count : 0}
</span>
<button
type="button"
onClick={() => setExtraPlace(p => ({ ...p, enabled: true, count: Math.min(10, p.count + 1) }))}
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
><Plus size={12} /></button>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена /ночь</label>
<input type="number" min={0} className="input" value={form.baseRate} onChange={e => set('baseRate', parseInt(e.target.value) || 0)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус номера</label>
<select className="input" value={form.status} onChange={e => set('status', e.target.value as RoomStatus)}>
{ROOM_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус уборки</label>
<select className="input" value={form.housekeepingStatus} onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}>
{HK_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<input
type="number" min={0} className="input"
placeholder="из категории"
value={form.baseRate}
onChange={e => set('baseRate', e.target.value)}
/>
{!form.baseRate && (
<p className="text-xs text-slate-400 mt-1">Будет взята из категории</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Порядок сортировки</label>
@@ -327,6 +343,24 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
</div>
</div>
{/* Статус и уборка — только при редактировании */}
{isEdit && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус номера</label>
<select className="input" value={form.status} onChange={e => set('status', e.target.value as RoomStatus)}>
{ROOM_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус уборки</label>
<select className="input" value={form.housekeepingStatus} onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}>
{HK_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
)}
{/* Hourly section */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
@@ -413,8 +447,8 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
{/* Beds constructor */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Спальные места</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Кровати, диваны и другая мебель для сна</p>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Основные спальные места</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Постоянные кровати, включённые в стоимость</p>
</div>
<div className="p-4 space-y-3">
{beds.map((bed, i) => (
@@ -470,12 +504,12 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
</div>
</div>
{/* Extra places */}
{/* Extra beds */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Дополнительные места</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Раскладные кровати, кушетки за доп. плату</p>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Дополнительные спальные места</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Раскладные кровати, кушетки за доп. плату</p>
</div>
<button
type="button"
@@ -486,25 +520,45 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
</button>
</div>
{extraPlace.enabled && (
<div className="p-4 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Кол-во мест</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.max(1, p.count - 1) }))}
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
<Minus size={13} />
</button>
<span className="w-8 text-center font-semibold text-slate-800 dark:text-slate-200">{extraPlace.count}</span>
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.min(5, p.count + 1) }))}
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
<Plus size={13} />
<div className="p-4 space-y-3">
{(extraPlace.beds ?? []).map((bed, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-lg w-7 text-center shrink-0">
{bedTypes.find(b => b.value === bed.type)?.icon ?? '🛏'}
</span>
<select
className="input flex-1"
value={bed.type}
onChange={e => updateExtraBed(i, { type: e.target.value })}
>
{bedTypes.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
<div className="flex items-center gap-1 shrink-0">
<button type="button"
onClick={() => updateExtraBed(i, { count: Math.max(1, bed.count - 1) })}
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
<Minus size={12} />
</button>
<span className="w-6 text-center text-sm font-medium text-slate-700 dark:text-slate-300">{bed.count}</span>
<button type="button"
onClick={() => updateExtraBed(i, { count: Math.min(6, bed.count + 1) })}
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
<Plus size={12} />
</button>
</div>
<button type="button" onClick={() => removeExtraBed(i)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-red-500">
<XIcon size={14} />
</button>
</div>
</div>
))}
<button type="button" onClick={addExtraBed}
className="btn-secondary w-full justify-center text-xs py-1.5">
<Plus size={13} /> Добавить доп. кровать
</button>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Цена /ночь</label>
<input
type="number" min={0} className="input"
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Цена /место/ночь</label>
<input type="number" min={0} className="input w-40"
value={extraPlace.price}
onChange={e => setExtraPlace(p => ({ ...p, price: parseInt(e.target.value) || 0 }))}
/>