diff --git a/backend/migrations/047_rental_is_active.sql b/backend/migrations/047_rental_is_active.sql new file mode 100644 index 0000000..f0cc89b --- /dev/null +++ b/backend/migrations/047_rental_is_active.sql @@ -0,0 +1,5 @@ +-- Add is_active flag to rental_objects (false = template/draft, true = published) +ALTER TABLE rental_objects ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; + +-- Mark all existing objects as active (they were already in use) +UPDATE rental_objects SET is_active = true; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 53c78e3..6aa6ad1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -171,6 +171,22 @@ const auth: FastifyPluginAsync = async (fastify) => { VALUES ($1, $2, $3, 'hotel_admin', $4, $5, false, $6, NOW())`, [contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken], ) + // Template rental objects (inactive drafts — user activates as needed) + const templates = [ + { name: 'Теннисный корт', icon: '🎾', color: 'bg-green-500', text_color: 'text-green-700 dark:text-green-400', price_h: 1500, price_d: 8000, open: 8, close: 22, sort: 1 }, + { name: 'Баня / сауна', icon: '🛁', color: 'bg-orange-500', text_color: 'text-orange-700 dark:text-orange-400', price_h: 2500, price_d: 12000, open: 10, close: 23, sort: 2 }, + { name: 'Конференц-зал', icon: '🏛️', color: 'bg-blue-500', text_color: 'text-blue-700 dark:text-blue-400', price_h: 3000, price_d: 15000, open: 9, close: 20, sort: 3 }, + ] + for (const t of templates) { + await client.query( + `INSERT INTO rental_objects + (hotel_id, name, icon, color, text_color, price_per_hour, price_per_day, + open_hour, close_hour, sort_order, is_active) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, false)`, + [hotel.id, t.name, t.icon, t.color, t.text_color, t.price_h, t.price_d, t.open, t.close, t.sort], + ) + } + await client.query('COMMIT') } catch (err) { await client.query('ROLLBACK') diff --git a/backend/src/routes/rental.ts b/backend/src/routes/rental.ts index 49cc181..4cd360a 100644 --- a/backend/src/routes/rental.ts +++ b/backend/src/routes/rental.ts @@ -74,7 +74,7 @@ const rental: FastifyPluginAsync = async (fastify) => { if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const b = request.body as Record const allowed = ['name','icon','color','text_color','price_per_hour','price_per_day', - 'open_hour','close_hour','max_hours_per_slot','buffer_minutes','sort_order'] + 'open_hour','close_hour','max_hours_per_slot','buffer_minutes','sort_order','is_active'] const sets: string[] = [] const vals: unknown[] = [] for (const key of allowed) { diff --git a/src/components/rooms/RoomModal.tsx b/src/components/rooms/RoomModal.tsx index 4ecc1d8..3959a4a 100644 --- a/src/components/rooms/RoomModal.tsx +++ b/src/components/rooms/RoomModal.tsx @@ -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( - 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) => + setExtraPlace(p => ({ ...p, beds: (p.beds ?? []).map((b, idx) => idx === i ? { ...b, ...patch } : b) })) const [childPolicy, setChildPolicy] = useState( 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
- - + + set('maxGuests', parseInt(e.target.value) || 1)} />
- - set('maxGuests', parseInt(e.target.value) || 1)} /> + +
+ + + {extraPlace.enabled ? extraPlace.count : 0} + + +
- set('baseRate', parseInt(e.target.value) || 0)} /> -
-
- - -
-
- -
-
- - + set('baseRate', e.target.value)} + /> + {!form.baseRate && ( +

Будет взята из категории

+ )}
@@ -327,6 +343,24 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
+ {/* Статус и уборка — только при редактировании */} + {isEdit && ( +
+
+ + +
+
+ + +
+
+ )} + {/* Hourly section */}
@@ -413,8 +447,8 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s {/* Beds constructor */}
-

Спальные места

-

Кровати, диваны и другая мебель для сна

+

Основные спальные места

+

Постоянные кровати, включённые в стоимость

{beds.map((bed, i) => ( @@ -470,12 +504,12 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], s
- {/* Extra places */} + {/* Extra beds */}
-

Дополнительные места

-

Раскладные кровати, кушетки за доп. плату

+

Дополнительные спальные места

+

Раскладные кровати, кушетки — за доп. плату

{extraPlace.enabled && ( -
-
- -
- - {extraPlace.count} - + {bed.count} + +
+
-
+ ))} +
- - Цена ₽/место/ночь + setExtraPlace(p => ({ ...p, price: parseInt(e.target.value) || 0 }))} /> diff --git a/src/data/rentalData.ts b/src/data/rentalData.ts index 908389d..251133e 100644 --- a/src/data/rentalData.ts +++ b/src/data/rentalData.ts @@ -12,6 +12,7 @@ export interface RentalObject { closeHour: number // 22 maxHoursPerSlot?: number bufferMinutes?: number // tech break between bookings + isActive?: boolean } export interface RentalBooking { diff --git a/src/lib/api.ts b/src/lib/api.ts index 273e06a..53c4cf1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -817,6 +817,7 @@ export interface RentalObjectApi { maxHoursPerSlot: number | null bufferMinutes: number sortOrder: number + isActive: boolean createdAt: string } diff --git a/src/pages/RentalPage.tsx b/src/pages/RentalPage.tsx index c7269c1..cabc380 100644 --- a/src/pages/RentalPage.tsx +++ b/src/pages/RentalPage.tsx @@ -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 (
{/* Header */}

Аренда объектов

-

{objects.length} объектов · управление и расписание

+

{activeObjects.length} объектов · управление и расписание

)} + + {/* Draft templates */} + {draftObjects.length > 0 && ( +
+
+ +

Шаблоны

+
+ {draftObjects.map(obj => ( +
+
+
+ {obj.icon} +
+
+

{obj.name}

+

Черновик · не активен

+
+
+ + +
+
+
+ ))} +
+ )}
{/* Right: bookings for selected object */} diff --git a/src/pages/TariffsPage.tsx b/src/pages/TariffsPage.tsx index 3a805b6..a7639ec 100644 --- a/src/pages/TariffsPage.tsx +++ b/src/pages/TariffsPage.tsx @@ -166,6 +166,18 @@ function TariffModal({ const set = (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({ <> @@ -221,13 +237,13 @@ function TariffModal({ Название тарифа * set('name', e.target.value)} /> + value={form.name} onChange={e => handleNameChange(e.target.value)} />
- set('code', e.target.value.toUpperCase())} />
diff --git a/src/types/index.ts b/src/types/index.ts index adbe473..3668529 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,8 +60,9 @@ export interface BedItem { export interface ExtraPlace { enabled: boolean - count: number // max extra places available - price: number // price per extra place per night + count: number // max extra places available (derived from beds sum) + price: number // price per extra place per night + beds?: BedItem[] // bed types for extra places } export interface ChildPolicy {