From 5c40b154d04e494c70ba933c5f447d70962ff810 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 11:42:29 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20categories=20+=20tariffs=20=E2=80=94=20?= =?UTF-8?q?migration,=20API=20routes,=20frontend=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migrations/018_categories_tariffs.sql | 33 +++++ backend/src/app.ts | 4 + backend/src/routes/categories.ts | 105 ++++++++++++++++ backend/src/routes/tariffs.ts | 119 ++++++++++++++++++ src/lib/api.ts | 80 ++++++++++++ src/pages/RoomCategoriesPage.tsx | 104 +++++++-------- src/pages/TariffsPage.tsx | 78 ++++++++++-- 7 files changed, 459 insertions(+), 64 deletions(-) create mode 100644 backend/migrations/018_categories_tariffs.sql create mode 100644 backend/src/routes/categories.ts create mode 100644 backend/src/routes/tariffs.ts diff --git a/backend/migrations/018_categories_tariffs.sql b/backend/migrations/018_categories_tariffs.sql new file mode 100644 index 0000000..aa94c3e --- /dev/null +++ b/backend/migrations/018_categories_tariffs.sql @@ -0,0 +1,33 @@ +-- Migration 018 — room_categories + tariffs + +CREATE TABLE IF NOT EXISTS room_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + color VARCHAR(20) NOT NULL DEFAULT '#4F46E5', + amenities TEXT[] NOT NULL DEFAULT '{}', + photos TEXT[] NOT NULL DEFAULT '{}', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tariffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + code VARCHAR(20) NOT NULL, + meal_plan VARCHAR(20) NOT NULL DEFAULT 'no_meals', + inclusions TEXT[] NOT NULL DEFAULT '{}', + modifier_type VARCHAR(10) NOT NULL DEFAULT 'fixed' + CHECK (modifier_type IN ('fixed', 'percent')), + modifier_value INTEGER NOT NULL DEFAULT 0, + min_nights INTEGER NOT NULL DEFAULT 1, + cancellation_policy VARCHAR(20) NOT NULL DEFAULT 'flexible' + CHECK (cancellation_policy IN ('flexible', 'moderate', 'strict', 'nonrefundable')), + description TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/app.ts b/backend/src/app.ts index 8c1424b..3c69828 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -20,6 +20,8 @@ import guestsRoutes from './routes/guests' import bookingGuestsRoutes from './routes/booking-guests' import hotelSettingsRoutes from './routes/hotel-settings' import rentalRoutes from './routes/rental' +import categoriesRoutes from './routes/categories' +import tariffsRoutes from './routes/tariffs' export async function buildApp() { const fastify = Fastify({ @@ -81,6 +83,8 @@ export async function buildApp() { await fastify.register(bookingGuestsRoutes) await fastify.register(hotelSettingsRoutes) await fastify.register(rentalRoutes) + await fastify.register(categoriesRoutes) + await fastify.register(tariffsRoutes) return fastify } diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts new file mode 100644 index 0000000..9c400a8 --- /dev/null +++ b/backend/src/routes/categories.ts @@ -0,0 +1,105 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const categories: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // GET /api/hotels/:slug/categories + fastify.get( + '/api/hotels/:slug/categories', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const { rows } = await db.query( + `SELECT * FROM room_categories WHERE hotel_id = $1 ORDER BY sort_order, created_at`, + [hotelId], + ) + return rows + }, + ) + + // POST /api/hotels/:slug/categories + fastify.post( + '/api/hotels/:slug/categories', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const { name, description = '', color = '#4F46E5', amenities = [], photos = [], sort_order = 0 } = request.body + const { rows } = await db.query( + `INSERT INTO room_categories (hotel_id, name, description, color, amenities, photos, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [hotelId, name, description, color, amenities, photos, sort_order], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // PATCH /api/hotels/:slug/categories/:id + fastify.patch( + '/api/hotels/:slug/categories/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const b = request.body + const sets: string[] = ['updated_at = NOW()'] + const vals: unknown[] = [hotelId, id] + let i = 3 + if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) } + if (b.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) } + if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) } + if (b.amenities !== undefined) { sets.push(`amenities = $${i++}`); vals.push(b.amenities) } + if (b.photos !== undefined) { sets.push(`photos = $${i++}`); vals.push(b.photos) } + if (b.sort_order !== undefined) { sets.push(`sort_order = $${i++}`); vals.push(b.sort_order) } + const { rows } = await db.query( + `UPDATE room_categories SET ${sets.join(', ')} WHERE hotel_id=$1 AND id=$2 RETURNING *`, + vals, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // DELETE /api/hotels/:slug/categories/:id + fastify.delete( + '/api/hotels/:slug/categories/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + await db.query('DELETE FROM room_categories WHERE hotel_id=$1 AND id=$2', [hotelId, id]) + return reply.code(204).send() + }, + ) +} + +export default categories diff --git a/backend/src/routes/tariffs.ts b/backend/src/routes/tariffs.ts new file mode 100644 index 0000000..84b40ce --- /dev/null +++ b/backend/src/routes/tariffs.ts @@ -0,0 +1,119 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const tariffs: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // GET /api/hotels/:slug/tariffs + fastify.get( + '/api/hotels/:slug/tariffs', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const { rows } = await db.query( + `SELECT * FROM tariffs WHERE hotel_id = $1 ORDER BY is_active DESC, created_at`, + [hotelId], + ) + return rows + }, + ) + + // POST /api/hotels/:slug/tariffs + fastify.post( + '/api/hotels/:slug/tariffs', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const { + name, code, + meal_plan = 'no_meals', inclusions = [], + modifier_type = 'fixed', modifier_value = 0, min_nights = 1, + cancellation_policy = 'flexible', description = '', is_active = true, + } = request.body + const { rows } = await db.query( + `INSERT INTO tariffs + (hotel_id, name, code, meal_plan, inclusions, modifier_type, modifier_value, + min_nights, cancellation_policy, description, is_active) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, + [hotelId, name, code, meal_plan, inclusions, modifier_type, modifier_value, + min_nights, cancellation_policy, description, is_active], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // PATCH /api/hotels/:slug/tariffs/:id + fastify.patch( + '/api/hotels/:slug/tariffs/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + const b = request.body + const sets: string[] = ['updated_at = NOW()'] + const vals: unknown[] = [hotelId, id] + let i = 3 + if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) } + if (b.code !== undefined) { sets.push(`code = $${i++}`); vals.push(b.code) } + if (b.meal_plan !== undefined) { sets.push(`meal_plan = $${i++}`); vals.push(b.meal_plan) } + if (b.inclusions !== undefined) { sets.push(`inclusions = $${i++}`); vals.push(b.inclusions) } + if (b.modifier_type !== undefined) { sets.push(`modifier_type = $${i++}`); vals.push(b.modifier_type) } + if (b.modifier_value !== undefined) { sets.push(`modifier_value = $${i++}`); vals.push(b.modifier_value) } + if (b.min_nights !== undefined) { sets.push(`min_nights = $${i++}`); vals.push(b.min_nights) } + if (b.cancellation_policy !== undefined) { sets.push(`cancellation_policy = $${i++}`); vals.push(b.cancellation_policy) } + if (b.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) } + if (b.is_active !== undefined) { sets.push(`is_active = $${i++}`); vals.push(b.is_active) } + const { rows } = await db.query( + `UPDATE tariffs SET ${sets.join(', ')} WHERE hotel_id=$1 AND id=$2 RETURNING *`, + vals, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // DELETE /api/hotels/:slug/tariffs/:id + fastify.delete( + '/api/hotels/:slug/tariffs/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + await db.query('DELETE FROM tariffs WHERE hotel_id=$1 AND id=$2', [hotelId, id]) + return reply.code(204).send() + }, + ) +} + +export default tariffs diff --git a/src/lib/api.ts b/src/lib/api.ts index baddb59..644d6d0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -344,6 +344,36 @@ export const api = { req('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`), }, + // ── Categories ──────────────────────────────────────────────────────────── + categories: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/categories`), + + create: (slug: string, data: CategoryPayload) => + req('POST', `/api/hotels/${slug}/categories`, data), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/categories/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/categories/${id}`), + }, + + // ── Tariffs ─────────────────────────────────────────────────────────────── + tariffs: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/tariffs`), + + create: (slug: string, data: TariffPayload) => + req('POST', `/api/hotels/${slug}/tariffs`, data), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/tariffs/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/tariffs/${id}`), + }, + // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => @@ -614,6 +644,56 @@ export interface RentalObjectPayload { sort_order?: number } +export interface CategoryApi { + id: string + hotel_id: string + name: string + description: string + color: string + amenities: string[] + photos: string[] + sort_order: number + created_at: string +} + +export interface CategoryPayload { + name: string + description?: string + color?: string + amenities?: string[] + photos?: string[] + sort_order?: number +} + +export interface TariffApi { + id: string + hotel_id: string + name: string + code: string + meal_plan: string + inclusions: string[] + modifier_type: 'fixed' | 'percent' + modifier_value: number + min_nights: number + cancellation_policy: 'flexible' | 'moderate' | 'strict' | 'nonrefundable' + description: string + is_active: boolean + created_at: string +} + +export interface TariffPayload { + name: string + code: string + meal_plan?: string + inclusions?: string[] + modifier_type?: string + modifier_value?: number + min_nights?: number + cancellation_policy?: string + description?: string + is_active?: boolean +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx index c7c94c4..92fb6c7 100644 --- a/src/pages/RoomCategoriesPage.tsx +++ b/src/pages/RoomCategoriesPage.tsx @@ -1,8 +1,11 @@ -import { useState, useRef } from 'react' -import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight } from 'lucide-react' +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 ───────────────────────────────────────────────────────────────── @@ -11,41 +14,16 @@ const CATEGORY_COLORS = [ '#DC2626', '#D97706', '#DB2777', '#475569', ] -export const MOCK_CATEGORIES: RoomCategory[] = [ - { - id: 'cat1', - hotelId: 'hotel-1', - name: 'Стандарт', - description: 'Уютные номера с базовым набором удобств. Идеально для деловых поездок и короткого отдыха.', - photos: [], - color: '#4F46E5', - amenities: ['Wi-Fi', 'TV', 'AC'], - }, - { - id: 'cat2', - hotelId: 'hotel-1', - name: 'Делюкс', - description: 'Просторные номера с улучшенным интерьером, панорамными окнами и расширенным набором услуг.', - photos: [], - color: '#059669', - amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe'], - }, - { - id: 'cat3', - hotelId: 'hotel-1', - name: 'Люкс & Пентхаус', - description: 'Роскошные апартаменты на верхних этажах с потрясающими видами, джакузи и персональным дворецким.', - photos: [], - color: '#7C3AED', - amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Panoramic view', 'Butler', 'Terrace'], - }, -] - -// Mock room counts per category -const MOCK_ROOM_COUNTS: Record = { - cat1: 8, - cat2: 5, - cat3: 2, +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 ──────────────────────────────────────────────────────── @@ -261,24 +239,53 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { // ── Main page ───────────────────────────────────────────────────────────────── export function RoomCategoriesPage() { - const [categories, setCategories] = useState(MOCK_CATEGORIES) + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) const [editingCat, setEditingCat] = useState(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 = (cat: RoomCategory) => { - setCategories(prev => { - const idx = prev.findIndex(c => c.id === cat.id) - if (idx >= 0) { const next = [...prev]; next[idx] = cat; return next } - return [...prev, cat] - }) + 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 = (id: string) => + 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 ( +
+ +
+ ) + } return (
@@ -313,7 +320,6 @@ export function RoomCategoriesPage() { {/* Categories list */}
{categories.map(cat => { - const roomCount = MOCK_ROOM_COUNTS[cat.id] ?? 0 return (
@@ -331,12 +337,6 @@ export function RoomCategoriesPage() {

{cat.name}

- - {roomCount} {roomCount === 1 ? 'номер' : roomCount < 5 ? 'номера' : 'номеров'} -
{cat.description && ( diff --git a/src/pages/TariffsPage.tsx b/src/pages/TariffsPage.tsx index c98e98b..3a805b6 100644 --- a/src/pages/TariffsPage.tsx +++ b/src/pages/TariffsPage.tsx @@ -1,11 +1,14 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Coffee, Utensils, Wifi, Car, Waves, Sparkles, Plane, Package, Check, X, Info, Tag, - ChevronDown, ChevronUp, Pencil, + ChevronDown, ChevronUp, Pencil, Loader2, } from 'lucide-react' import { Modal } from '../components/ui/Modal' import { cn } from '../lib/utils' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../lib/api' +import type { TariffApi } from '../lib/api' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -50,7 +53,19 @@ interface Tariff { isActive: boolean } -// ─── Mock ───────────────────────────────────────────────────────────────────── +// ─── Converter ──────────────────────────────────────────────────────────────── + +function fromApi(t: TariffApi): Tariff { + return { + id: t.id, name: t.name, code: t.code, + mealPlan: t.meal_plan, inclusions: t.inclusions, + modifierType: t.modifier_type, modifierValue: t.modifier_value, + minNights: t.min_nights, cancellationPolicy: t.cancellation_policy, + description: t.description, isActive: t.is_active, + } +} + +// ─── Mock (fallback, will be replaced by API) ───────────────────────────────── const MOCK_TARIFFS: Tariff[] = [ { @@ -374,10 +389,22 @@ function TariffModal({ // ─── Main Page ──────────────────────────────────────────────────────────────── export function TariffsPage() { - const [tariffs, setTariffs] = useState(MOCK_TARIFFS) + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [tariffs, setTariffs] = useState([]) + const [loading, setLoading] = useState(true) const [modal, setModal] = useState<'create' | Tariff | null>(null) const [deleteTarget, setDeleteTarget] = useState(null) + useEffect(() => { + if (!slug) return + api.tariffs.list(slug) + .then(rows => setTariffs(rows.map(fromApi))) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug]) + // Editable meal plans & inclusions const [mealPlans, setMealPlans] = useState>(MEAL_PLANS) const [allInclusions, setAllInclusions] = useState(ALL_INCLUSIONS) @@ -397,21 +424,46 @@ export function TariffsPage() { const DEFAULT_MEAL_IDS = MEAL_PLANS.map(m => m.id) const DEFAULT_INC_IDS = ALL_INCLUSIONS.map(i => i.id) - const save = (data: Omit) => { - if (typeof modal === 'object' && modal !== null) { - setTariffs(prev => prev.map(t => t.id === modal.id ? { ...data, id: modal.id } : t)) - } else { - setTariffs(prev => [...prev, { ...data, id: `t-${Date.now()}` }]) + const save = async (data: Omit) => { + if (!slug) return + const payload = { + name: data.name, code: data.code, meal_plan: data.mealPlan, + inclusions: data.inclusions, modifier_type: data.modifierType, + modifier_value: data.modifierValue, min_nights: data.minNights, + cancellation_policy: data.cancellationPolicy, + description: data.description, is_active: data.isActive, } + const isEdit = typeof modal === 'object' && modal !== null + const saved = isEdit + ? await api.tariffs.update(slug, modal.id, payload) + : await api.tariffs.create(slug, payload) + const converted = fromApi(saved) + setTariffs(prev => isEdit + ? prev.map(t => t.id === modal.id ? converted : t) + : [...prev, converted], + ) setModal(null) } - const toggleActive = (id: string) => - setTariffs(prev => prev.map(t => t.id === id ? { ...t, isActive: !t.isActive } : t)) + const toggleActive = async (id: string) => { + if (!slug) return + const t = tariffs.find(x => x.id === id) + if (!t) return + const saved = await api.tariffs.update(slug, id, { is_active: !t.isActive }) + setTariffs(prev => prev.map(x => x.id === id ? fromApi(saved) : x)) + } const activeTariffs = tariffs.filter(t => t.isActive) const inactiveTariffs = tariffs.filter(t => !t.isActive) + if (loading) { + return ( +
+ +
+ ) + } + return (
{/* Header */} @@ -689,7 +741,9 @@ export function TariffsPage() {