diff --git a/backend/migrations/009_guests.sql b/backend/migrations/009_guests.sql new file mode 100644 index 0000000..6853139 --- /dev/null +++ b/backend/migrations/009_guests.sql @@ -0,0 +1,12 @@ +-- Migration 009 — Add passport_series, passport_number and rating to guests + +ALTER TABLE guests + ADD COLUMN IF NOT EXISTS passport_series VARCHAR(20), + ADD COLUMN IF NOT EXISTS passport_number VARCHAR(20), + ADD COLUMN IF NOT EXISTS rating INTEGER NOT NULL DEFAULT 3 + CHECK (rating BETWEEN 1 AND 5); + +-- Unique index: one profile per passport (series+number) per hotel +CREATE UNIQUE INDEX IF NOT EXISTS idx_guests_passport_unique + ON guests(hotel_id, passport_series, passport_number) + WHERE passport_series IS NOT NULL AND passport_number IS NOT NULL; diff --git a/backend/src/app.ts b/backend/src/app.ts index c00a750..fad638b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -16,6 +16,7 @@ import housekeepingRoutes from './routes/housekeeping' import channelsRoutes from './routes/channels' import usersRoutes from './routes/users' import netupRoutes from './routes/netup' +import guestsRoutes from './routes/guests' export async function buildApp() { const fastify = Fastify({ @@ -73,6 +74,7 @@ export async function buildApp() { await fastify.register(channelsRoutes) await fastify.register(usersRoutes) await fastify.register(netupRoutes) + await fastify.register(guestsRoutes) return fastify } diff --git a/backend/src/routes/guests.ts b/backend/src/routes/guests.ts new file mode 100644 index 0000000..c92c964 --- /dev/null +++ b/backend/src/routes/guests.ts @@ -0,0 +1,244 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const GUEST_FIELDS = ` + g.id, g.hotel_id, g.first_name, g.last_name, g.email, g.phone, + g.passport, g.passport_series, g.passport_number, + g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags, + g.loyalty_tier, g.loyalty_points, g.rating, + g.created_at, g.updated_at` + +const STATS_JOIN = ` + LEFT JOIN ( + SELECT + guest_id, + COUNT(*) FILTER (WHERE status NOT IN ('cancelled', 'no_show')) AS total_stays, + COALESCE(SUM(paid_amount) FILTER (WHERE status = 'checked_out'), 0) AS total_spent, + MAX(check_out) FILTER (WHERE status = 'checked_out') AS last_visit + FROM bookings + WHERE hotel_id = $1 + GROUP BY guest_id + ) stats ON stats.guest_id = g.id` + +const guests: 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/guests ──────────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/guests', + { 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 { q } = request.query as { q?: string } + const params: unknown[] = [hotelId] + + let where = 'WHERE g.hotel_id = $1' + if (q) { + params.push(`%${q.toLowerCase()}%`) + where += ` AND ( + LOWER(g.first_name) LIKE $2 OR LOWER(g.last_name) LIKE $2 OR + LOWER(COALESCE(g.email,'')) LIKE $2 OR COALESCE(g.phone,'') LIKE $2 OR + COALESCE(g.passport_series,'') LIKE $2 OR COALESCE(g.passport_number,'') LIKE $2 + )` + } + + const { rows } = await db.query( + `SELECT ${GUEST_FIELDS}, + COALESCE(stats.total_stays, 0) AS total_stays, + COALESCE(stats.total_spent, 0) AS total_spent, + stats.last_visit + FROM guests g ${STATS_JOIN} + ${where} + ORDER BY g.last_name, g.first_name`, + params, + ) + return rows + }, + ) + + // ── GET /api/hotels/:slug/guests/:id ──────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/guests/: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 { rows: [guest] } = await db.query( + `SELECT ${GUEST_FIELDS}, + COALESCE(stats.total_stays, 0) AS total_stays, + COALESCE(stats.total_spent, 0) AS total_spent, + stats.last_visit + FROM guests g ${STATS_JOIN} + WHERE g.id = $2 AND g.hotel_id = $1`, + [hotelId, id], + ) + if (!guest) return reply.code(404).send({ error: 'Guest not found' }) + + const { rows: history } = await db.query( + `SELECT b.id, b.check_in, b.check_out, b.status, + b.total_amount, b.paid_amount, + r.number AS room_number, r.type AS room_type + FROM bookings b + LEFT JOIN rooms r ON r.id = b.room_id + WHERE b.guest_id = $1 AND b.hotel_id = $2 + ORDER BY b.check_in DESC`, + [id, hotelId], + ) + return { ...guest, history } + }, + ) + + // ── POST /api/hotels/:slug/guests ──────────────────────────────────────────── + // Creates a new guest or returns existing one if passport matches + fastify.post( + '/api/hotels/:slug/guests', + { 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 b = request.body + + // If passport provided — try to find existing guest + if (b.passport_series && b.passport_number) { + const { rows: existing } = await db.query( + `SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`, + [hotelId, b.passport_series.trim(), b.passport_number.trim()], + ) + if (existing.length > 0) { + const { rows: [updated] } = await db.query( + `UPDATE guests SET + first_name = COALESCE($3, first_name), + last_name = COALESCE($4, last_name), + email = COALESCE($5, email), + phone = COALESCE($6, phone), + updated_at = NOW() + WHERE id = $1 AND hotel_id = $2 + RETURNING *`, + [existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.email || null, b.phone || null], + ) + return reply.code(200).send(updated) + } + } + + const { rows: [guest] } = await db.query( + `INSERT INTO guests + (hotel_id, first_name, last_name, email, phone, + passport, passport_series, passport_number, + birth_date, nationality, gender, city, notes, tags, rating) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING *`, + [ + hotelId, b.first_name, b.last_name, + b.email ?? null, b.phone ?? null, + b.passport ?? null, b.passport_series ?? null, b.passport_number ?? null, + b.birth_date ?? null, b.nationality ?? null, b.gender ?? null, b.city ?? null, + b.notes ?? '', b.tags ?? [], b.rating ?? 3, + ], + ) + return reply.code(201).send(guest) + }, + ) + + // ── PATCH /api/hotels/:slug/guests/:id ────────────────────────────────────── + fastify.patch }>( + '/api/hotels/:slug/guests/: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[] = [] + const vals: unknown[] = [id, hotelId] + let idx = 3 + + const add = (col: string, val: unknown) => { + if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } + } + add('first_name', b.first_name) + add('last_name', b.last_name) + add('email', b.email) + add('phone', b.phone) + add('passport', b.passport) + add('passport_series', b.passport_series) + add('passport_number', b.passport_number) + add('birth_date', b.birth_date || null) + add('nationality', b.nationality) + add('gender', b.gender) + add('city', b.city) + add('notes', b.notes) + add('tags', b.tags) + add('rating', b.rating) + + if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' }) + sets.push('updated_at = NOW()') + + const { rows: [guest] } = await db.query( + `UPDATE guests SET ${sets.join(', ')} WHERE id = $1 AND hotel_id = $2 RETURNING *`, + vals, + ) + if (!guest) return reply.code(404).send({ error: 'Guest not found' }) + return guest + }, + ) + + // ── DELETE /api/hotels/:slug/guests/:id ───────────────────────────────────── + fastify.delete( + '/api/hotels/:slug/guests/: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 guests WHERE id = $1 AND hotel_id = $2', [id, hotelId]) + return reply.code(204).send() + }, + ) +} + +export default guests diff --git a/src/lib/api.ts b/src/lib/api.ts index 26dce3a..eee8d20 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -266,6 +266,24 @@ export const api = { req('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)), }, + // ── Guests ──────────────────────────────────────────────────────────────── + guests: { + list: (slug: string, q?: string) => + req('GET', `/api/hotels/${slug}/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`), + + get: (slug: string, id: string) => + req('GET', `/api/hotels/${slug}/guests/${id}`), + + create: (slug: string, data: GuestPayload) => + req('POST', `/api/hotels/${slug}/guests`, data), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/guests/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/guests/${id}`), + }, + // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => @@ -373,6 +391,59 @@ export interface HotelPayload { checkInTime?: string; checkOutTime?: string } +export interface GuestApiType { + id: string + hotelId: string + firstName: string + lastName: string + email: string | null + phone: string | null + passport: string | null + passportSeries: string | null + passportNumber: string | null + birthDate: string | null + nationality: string | null + gender: string | null + city: string | null + notes: string + tags: string[] + loyaltyTier: string + loyaltyPoints: number + rating: number + totalStays: number + totalSpent: number + lastVisit: string | null + createdAt: string + updatedAt: string + history?: { + id: string + checkIn: string + checkOut: string + status: string + totalAmount: number | null + paidAmount: number + roomNumber: string | null + roomType: string | null + }[] +} + +export interface GuestPayload { + first_name?: string + last_name?: string + email?: string + phone?: string + passport?: string + passport_series?: string + passport_number?: string + birth_date?: string + nationality?: string + gender?: string + city?: string + notes?: string + tags?: string[] + rating?: number +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/GuestsPage.tsx b/src/pages/GuestsPage.tsx index 6e98009..e36f4d4 100644 --- a/src/pages/GuestsPage.tsx +++ b/src/pages/GuestsPage.tsx @@ -1,172 +1,50 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Search, Star, Phone, Mail, User, TrendingUp, Award, Users, Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag, - Repeat2, ShieldCheck, Plus, + Repeat2, ShieldCheck, Plus, ChevronsUpDown, AlertCircle, Loader2, } from 'lucide-react' import { cn } from '../lib/utils' +import { api, type GuestApiType } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' // ─── Types ─────────────────────────────────────────────────────────────────── -type GuestTag = 'VIP' | 'Постоянный' | 'Корпоративный' | 'Медовый месяц' | 'Проблемный' - -interface StayRecord { - id: string - checkIn: string - checkOut: string - roomNumber: string - roomType: string - amount: number - paid: number - status: 'completed' | 'cancelled' | 'no_show' -} - -interface Guest { - id: string - name: string - email: string - phone: string - rating: number // 1–5 - tags: GuestTag[] - totalStays: number - totalSpent: number - lastVisit: string - notes: string - history: StayRecord[] -} - -// ─── Mock Data ──────────────────────────────────────────────────────────────── - -const MOCK_GUESTS: Guest[] = [ - { - id: 'g-1', - name: 'Александр Петров', - email: 'a.petrov@gmail.com', - phone: '+7 916 123-45-67', - rating: 5, - tags: ['VIP', 'Постоянный'], - totalStays: 14, - totalSpent: 287400, - lastVisit: '2026-03-01', - notes: 'Предпочитает номера с видом на море. Всегда заказывает завтрак. Любит тихие номера подальше от лифта.', - history: [ - { id: 'b-101', checkIn: '2026-03-01', checkOut: '2026-03-05', roomNumber: '205', roomType: 'Делюкс', amount: 24000, paid: 24000, status: 'completed' }, - { id: 'b-102', checkIn: '2025-12-20', checkOut: '2025-12-26', roomNumber: '205', roomType: 'Делюкс', amount: 36000, paid: 36000, status: 'completed' }, - { id: 'b-103', checkIn: '2025-09-10', checkOut: '2025-09-14', roomNumber: '301', roomType: 'Сьют', amount: 48000, paid: 48000, status: 'completed' }, - { id: 'b-104', checkIn: '2025-06-01', checkOut: '2025-06-07', roomNumber: '205', roomType: 'Делюкс', amount: 42000, paid: 42000, status: 'completed' }, - ], - }, - { - id: 'g-2', - name: 'Мария Сидорова', - email: 'm.sidorova@mail.ru', - phone: '+7 903 456-78-90', - rating: 4, - tags: ['Корпоративный'], - totalStays: 6, - totalSpent: 98200, - lastVisit: '2026-02-15', - notes: 'Корпоративный гость компании ООО "ТехСтрой". Нужен ранний заезд.', - history: [ - { id: 'b-201', checkIn: '2026-02-13', checkOut: '2026-02-15', roomNumber: '102', roomType: 'Стандарт', amount: 11200, paid: 11200, status: 'completed' }, - { id: 'b-202', checkIn: '2025-11-20', checkOut: '2025-11-23', roomNumber: '102', roomType: 'Стандарт', amount: 16800, paid: 16800, status: 'completed' }, - ], - }, - { - id: 'g-3', - name: 'Дмитрий Козлов', - email: 'dkozlov@yandex.ru', - phone: '+7 926 789-01-23', - rating: 3, - tags: [], - totalStays: 2, - totalSpent: 18600, - lastVisit: '2026-01-20', - notes: '', - history: [ - { id: 'b-301', checkIn: '2026-01-18', checkOut: '2026-01-20', roomNumber: '110', roomType: 'Стандарт', amount: 9300, paid: 9300, status: 'completed' }, - { id: 'b-302', checkIn: '2025-08-05', checkOut: '2025-08-07', roomNumber: '108', roomType: 'Стандарт', amount: 9300, paid: 0, status: 'no_show' }, - ], - }, - { - id: 'g-4', - name: 'Елена Новикова', - email: 'e.novikova@gmail.com', - phone: '+7 985 234-56-78', - rating: 5, - tags: ['VIP', 'Медовый месяц'], - totalStays: 3, - totalSpent: 76500, - lastVisit: '2026-02-28', - notes: 'Отмечала медовый месяц. Очень довольна обслуживанием, оставила 5* отзыв на Booking.', - history: [ - { id: 'b-401', checkIn: '2026-02-14', checkOut: '2026-02-21', roomNumber: '401', roomType: 'Люкс', amount: 63000, paid: 63000, status: 'completed' }, - { id: 'b-402', checkIn: '2024-12-30', checkOut: '2025-01-03', roomNumber: '301', roomType: 'Сьют', amount: 13500, paid: 13500, status: 'completed' }, - ], - }, - { - id: 'g-5', - name: 'Игорь Волков', - email: 'i.volkov@corp.ru', - phone: '+7 909 345-67-89', - rating: 2, - tags: ['Проблемный'], - totalStays: 4, - totalSpent: 44000, - lastVisit: '2025-11-10', - notes: 'Были жалобы от соседних номеров на шум. Конфликт с персоналом 11.11.2025. Требует внимания при заселении.', - history: [ - { id: 'b-501', checkIn: '2025-11-08', checkOut: '2025-11-10', roomNumber: '215', roomType: 'Делюкс', amount: 14000, paid: 14000, status: 'completed' }, - { id: 'b-502', checkIn: '2025-07-14', checkOut: '2025-07-17', roomNumber: '108', roomType: 'Стандарт', amount: 13500, paid: 13500, status: 'completed' }, - ], - }, - { - id: 'g-6', - name: 'Анна Лебедева', - email: 'anna.l@mail.ru', - phone: '+7 965 567-89-01', - rating: 4, - tags: ['Постоянный'], - totalStays: 8, - totalSpent: 132000, - lastVisit: '2026-03-10', - notes: 'Регулярно приезжает в командировку. Всегда берёт одноместный стандарт.', - history: [ - { id: 'b-601', checkIn: '2026-03-08', checkOut: '2026-03-10', roomNumber: '104', roomType: 'Стандарт', amount: 11200, paid: 11200, status: 'completed' }, - { id: 'b-602', checkIn: '2026-02-01', checkOut: '2026-02-04', roomNumber: '104', roomType: 'Стандарт', amount: 16800, paid: 16800, status: 'completed' }, - ], - }, - { - id: 'g-7', - name: 'Сергей Морозов', - email: '', - phone: '+7 977 678-90-12', - rating: 4, - tags: [], - totalStays: 1, - totalSpent: 8400, - lastVisit: '2026-03-12', - notes: '', - history: [ - { id: 'b-701', checkIn: '2026-03-11', checkOut: '2026-03-12', roomNumber: '109', roomType: 'Стандарт', amount: 8400, paid: 8400, status: 'completed' }, - ], - }, -] +type Guest = GuestApiType // ─── Helpers ────────────────────────────────────────────────────────────────── -const STAY_STATUS: Record = { - completed: { label: 'Выполнено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, - cancelled: { label: 'Отменено', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, - no_show: { label: 'Неявка', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' }, +const STAY_STATUS: Record = { + completed: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, + checked_out: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, + checked_in: { label: 'Проживает', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, + confirmed: { label: 'Подтверждено', cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' }, + cancelled: { label: 'Отменено', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, + no_show: { label: 'Неявка', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' }, } -const TAG_META: Record = { - VIP: { cls: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, - Постоянный: { cls: 'bg-brand-100 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-400' }, - Корпоративный: { cls: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, - 'Медовый месяц':{ cls: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, - Проблемный: { cls: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400' }, +const TAG_CLS: Record = { + VIP: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400', + Постоянный: 'bg-brand-100 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-400', + Корпоративный: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400', + 'Медовый месяц': 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400', + Проблемный: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400', +} + +const ALL_TAGS = Object.keys(TAG_CLS) + +const GENDER_OPTIONS = [ + { value: 'male', label: 'Мужской' }, + { value: 'female', label: 'Женский' }, + { value: 'other', label: 'Другой' }, +] + +function guestName(g: Guest) { + return [g.firstName, g.lastName].filter(Boolean).join(' ') || '—' +} + +function guestInitials(g: Guest) { + return `${g.firstName?.[0] ?? ''}${g.lastName?.[0] ?? ''}`.toUpperCase() } function StarRating({ value, onChange }: { value: number; onChange?: (v: number) => void }) { @@ -187,7 +65,7 @@ function StarRating({ value, onChange }: { value: number; onChange?: (v: number) ) } -function formatDate(iso: string) { +function formatDate(iso: string | null | undefined) { if (!iso) return '—' const d = new Date(iso) return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) @@ -200,82 +78,184 @@ function nights(checkIn: string, checkOut: string) { // ─── Guest Detail Modal ─────────────────────────────────────────────────────── -function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) { - const [tab, setTab] = useState<'overview' | 'history' | 'payments'>('overview') - const [editNotes, setEditNotes] = useState(guest.notes) - const [rating, setRating] = useState(guest.rating) +interface GuestModalProps { + guestId: string + slug: string + onClose: () => void + onUpdated: (g: Guest) => void +} - const completedStays = guest.history.filter(s => s.status === 'completed') - const totalPaid = guest.history.reduce((s, h) => s + h.paid, 0) - const avgBill = completedStays.length ? Math.round(totalPaid / completedStays.length) : 0 +function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) { + const [tab, setTab] = useState<'overview' | 'history' | 'payments' | 'passport'>('overview') + const [guest, setGuest] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // Edit state + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [rating, setRating] = useState(3) + const [notes, setNotes] = useState('') + const [tags, setTags] = useState([]) + // Passport + const [passportSeries, setPassportSeries] = useState('') + const [passportNumber, setPassportNumber] = useState('') + const [birthDate, setBirthDate] = useState('') + const [nationality, setNationality] = useState('') + const [gender, setGender] = useState('') + const [city, setCity] = useState('') + + useEffect(() => { + setLoading(true) + api.guests.get(slug, guestId) + .then(g => { + setGuest(g) + setFirstName(g.firstName ?? '') + setLastName(g.lastName ?? '') + setEmail(g.email ?? '') + setPhone(g.phone ?? '') + setRating(g.rating) + setNotes(g.notes ?? '') + setTags(g.tags ?? []) + setPassportSeries(g.passportSeries ?? '') + setPassportNumber(g.passportNumber ?? '') + setBirthDate(g.birthDate?.slice(0, 10) ?? '') + setNationality(g.nationality ?? '') + setGender(g.gender ?? '') + setCity(g.city ?? '') + }) + .catch(() => setError('Не удалось загрузить данные гостя')) + .finally(() => setLoading(false)) + }, [slug, guestId]) + + const handleSave = async () => { + if (!guest) return + setSaving(true) + try { + const updated = await api.guests.update(slug, guest.id, { + first_name: firstName, + last_name: lastName, + email: email || undefined, + phone: phone || undefined, + rating, + notes, + tags, + passport_series: passportSeries || undefined, + passport_number: passportNumber || undefined, + birth_date: birthDate || undefined, + nationality: nationality || undefined, + gender: gender || undefined, + city: city || undefined, + }) + onUpdated(updated) + onClose() + } catch { + setError('Ошибка при сохранении') + } finally { + setSaving(false) + } + } + + const toggleTag = (tag: string) => + setTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]) + + const history = guest?.history ?? [] + const completedStays = history.filter(s => s.status === 'checked_out' || s.status === 'completed') + const totalPaid = history.reduce((s, h) => s + (h.paidAmount ?? 0), 0) + const avgBill = completedStays.length ? Math.round(totalPaid / completedStays.length) : 0 return (
+ {/* Header */}
-
-
- - {guest.name.split(' ').map(p => p[0]).join('').slice(0, 2)} - -
-
-
-

{guest.name}

- {guest.tags.includes('VIP') && ( - - VIP - - )} -
- -
- {guest.phone && ( - - {guest.phone} - - )} - {guest.email && ( - - {guest.email} - - )} + {loading ? ( +
+
+
+
+
-
+ ) : guest ? ( +
+
+ + {guestInitials(guest)} + +
+
+
+

+ {[firstName, lastName].filter(Boolean).join(' ')} +

+ {tags.includes('VIP') && ( + + VIP + + )} +
+ +
+ {phone && ( + + {phone} + + )} + {email && ( + + {email} + + )} +
+
+
+ ) : null}
+ {error && ( +
+ {error} +
+ )} + {/* Stats strip */} -
- {[ - { label: 'Визитов', value: guest.totalStays }, - { label: 'Потрачено', value: `${guest.totalSpent.toLocaleString('ru-RU')} ₽` }, - { label: 'Средний чек', value: `${avgBill.toLocaleString('ru-RU')} ₽` }, - { label: 'Последний визит', value: formatDate(guest.lastVisit) }, - ].map(s => ( -
-

{s.label}

-

{s.value}

-
- ))} -
+ {!loading && guest && ( +
+ {[ + { label: 'Визитов', value: guest.totalStays }, + { label: 'Потрачено', value: `${Number(guest.totalSpent).toLocaleString('ru-RU')} ₽` }, + { label: 'Средний чек', value: `${avgBill.toLocaleString('ru-RU')} ₽` }, + { label: 'Последний визит', value: formatDate(guest.lastVisit) }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ )} {/* Tabs */} -
+
{([ - { id: 'overview' as const, label: 'Обзор' }, - { id: 'history' as const, label: 'История проживания' }, - { id: 'payments' as const, label: 'Платежи' }, + { id: 'overview' as const, label: 'Обзор' }, + { id: 'history' as const, label: 'История' }, + { id: 'payments' as const, label: 'Платежи' }, + { id: 'passport' as const, label: 'Паспорт' }, ]).map(t => ( + ))}
@@ -316,52 +326,54 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) { className="input resize-none text-sm" rows={4} placeholder="Особые предпочтения, пожелания, замечания..." - value={editNotes} - onChange={e => setEditNotes(e.target.value)} + value={notes} + onChange={e => setNotes(e.target.value)} />
- {/* Quick stats */} -
-
-

Статус лояльности

-
- {guest.totalStays >= 10 ? ( - <>Золотой - ) : guest.totalStays >= 5 ? ( - <>Серебряный - ) : ( - <>Базовый - )} + {/* Loyalty status */} + {guest && ( +
+
+

Статус лояльности

+
+ {guest.totalStays >= 10 ? ( + <>Золотой + ) : guest.totalStays >= 5 ? ( + <>Серебряный + ) : ( + <>Базовый + )} +
+

+ {guest.totalStays >= 10 ? '10+ визитов' : guest.totalStays >= 5 ? '5+ визитов' : 'До 5 визитов'} +

+
+
+

Задолженность

+ {(() => { + const debt = history.reduce((s, h) => s + ((h.totalAmount ?? 0) - (h.paidAmount ?? 0)), 0) + return debt > 0 ? ( +

{debt.toLocaleString('ru-RU')} ₽

+ ) : ( +

Нет задолженности

+ ) + })()}
-

- {guest.totalStays >= 10 ? '10+ визитов' : guest.totalStays >= 5 ? '5+ визитов' : 'До 5 визитов'} -

-
-

Оплата долгов

- {(() => { - const debt = guest.history.reduce((s, h) => s + (h.amount - h.paid), 0) - return debt > 0 ? ( -

{debt.toLocaleString('ru-RU')} ₽

- ) : ( -

Нет задолженности

- ) - })()} -
-
+ )}
)} - {tab === 'history' && ( + {!loading && tab === 'history' && (
- {guest.history.length === 0 && ( + {history.length === 0 && (

История проживания пуста

)} - {guest.history.map(stay => ( + {history.map(stay => (
@@ -369,7 +381,8 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {

- Номер {stay.roomNumber} — {stay.roomType} + {stay.roomNumber ? `Номер ${stay.roomNumber}` : 'Номер —'} + {stay.roomType ? ` — ${stay.roomType}` : ''}

{formatDate(stay.checkIn)} → {formatDate(stay.checkOut)} · {nights(stay.checkIn, stay.checkOut)} @@ -378,10 +391,13 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {

- {stay.amount.toLocaleString('ru-RU')} ₽ + {(stay.totalAmount ?? 0).toLocaleString('ru-RU')} ₽

- - {STAY_STATUS[stay.status].label} + + {(STAY_STATUS[stay.status] ?? STAY_STATUS.confirmed).label}
@@ -389,14 +405,13 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {
)} - {tab === 'payments' && ( + {!loading && tab === 'payments' && (
- {/* Summary */}
{[ - { label: 'Всего начислено', value: guest.history.reduce((s, h) => s + h.amount, 0), color: 'text-slate-900 dark:text-slate-100' }, - { label: 'Оплачено', value: guest.history.reduce((s, h) => s + h.paid, 0), color: 'text-emerald-600 dark:text-emerald-400' }, - { label: 'Долг', value: guest.history.reduce((s, h) => s + (h.amount - h.paid), 0), color: 'text-red-600 dark:text-red-400' }, + { label: 'Всего начислено', value: history.reduce((s, h) => s + (h.totalAmount ?? 0), 0), color: 'text-slate-900 dark:text-slate-100' }, + { label: 'Оплачено', value: history.reduce((s, h) => s + (h.paidAmount ?? 0), 0), color: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'Долг', value: history.reduce((s, h) => s + ((h.totalAmount ?? 0) - (h.paidAmount ?? 0)), 0), color: 'text-red-600 dark:text-red-400' }, ].map(item => (

{item.label}

@@ -406,22 +421,21 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {
))}
- {/* Per-stay payments */} - {guest.history.map(stay => ( + {history.map(stay => (
-
-

- {formatDate(stay.checkIn)} — {stay.roomType} №{stay.roomNumber} -

-
+

+ {formatDate(stay.checkIn)} — {stay.roomType ?? '—'} {stay.roomNumber ? `№${stay.roomNumber}` : ''} +

- {stay.paid.toLocaleString('ru-RU')} ₽ - {stay.paid < stay.amount && ( + + {(stay.paidAmount ?? 0).toLocaleString('ru-RU')} ₽ + + {(stay.paidAmount ?? 0) < (stay.totalAmount ?? 0) && ( - долг {(stay.amount - stay.paid).toLocaleString('ru-RU')} ₽ + долг {((stay.totalAmount ?? 0) - (stay.paidAmount ?? 0)).toLocaleString('ru-RU')} ₽ )}
@@ -429,19 +443,201 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) { ))}
)} + + {!loading && tab === 'passport' && ( +
+

+ Паспортные данные используются для идентификации гостя в системе. Серия и номер паспорта — уникальный ключ. +

+ +
+
+ + setPassportSeries(e.target.value.toUpperCase())} + placeholder="1234" + maxLength={10} + /> +
+
+ + setPassportNumber(e.target.value)} + placeholder="567890" + maxLength={20} + /> +
+
+ +
+
+ + setBirthDate(e.target.value)} + /> +
+
+ + +
+
+ +
+
+ + setNationality(e.target.value)} + placeholder="Россия" + /> +
+
+ + setCity(e.target.value)} + placeholder="Москва" + /> +
+
+ + {passportSeries && passportNumber && ( +
+

+ Уникальный ключ: {passportSeries} {passportNumber} +

+

+ Гость будет идентифицирован по этим данным при следующем бронировании +

+
+ )} +
+ )}
{/* Footer */}
- +
) } -// ─── Main Page ──────────────────────────────────────────────────────────────── +// ─── Add Guest Modal ────────────────────────────────────────────────────────── + +interface AddGuestModalProps { + slug: string + onClose: () => void + onCreated: (g: Guest) => void +} + +function AddGuestModal({ slug, onClose, onCreated }: AddGuestModalProps) { + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [phone, setPhone] = useState('') + const [email, setEmail] = useState('') + const [passportSeries, setPassportSeries] = useState('') + const [passportNumber, setPassportNumber] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleCreate = async () => { + if (!firstName.trim() || !lastName.trim()) { + setError('Имя и фамилия обязательны') + return + } + setSaving(true) + setError(null) + try { + const g = await api.guests.create(slug, { + first_name: firstName.trim(), + last_name: lastName.trim(), + phone: phone || undefined, + email: email || undefined, + passport_series: passportSeries || undefined, + passport_number: passportNumber || undefined, + }) + onCreated(g) + onClose() + } catch (err: unknown) { + setError((err as Error).message || 'Ошибка при создании гостя') + } finally { + setSaving(false) + } + } + + return ( +
+
+
+

Добавить гостя

+ +
+
+ {error && ( +
+ {error} +
+ )} +
+
+ + setFirstName(e.target.value)} placeholder="Иван" /> +
+
+ + setLastName(e.target.value)} placeholder="Петров" /> +
+
+
+ + setPhone(e.target.value)} placeholder="+7 900 000-00-00" /> +
+
+ + setEmail(e.target.value)} placeholder="email@example.com" /> +
+
+
+ + setPassportSeries(e.target.value.toUpperCase())} placeholder="1234" /> +
+
+ + setPassportNumber(e.target.value)} placeholder="567890" /> +
+
+
+
+ + +
+
+
+ ) +} // ─── Guest Statuses Tab ─────────────────────────────────────────────────────── @@ -469,59 +665,20 @@ function GuestStatusesTab() {

Статусы отображаются в карточках гостей и при создании бронирования. Они помогают быстро идентифицировать тип гостя.

- - {/* Tag list */}
{tags.map(tag => (
- setTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))} - className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0" - /> - setTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))} - /> - - {tag.label} - - + setTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))} className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0" /> + setTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))} /> + {tag.label} +
))}
- - {/* Add new tag */}
- setNewColor(e.target.value)} - className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0" - /> - setNewLabel(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') addTag() }} - /> - + setNewColor(e.target.value)} className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0" /> + setNewLabel(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addTag() }} /> +
) @@ -535,34 +692,67 @@ const PAGE_TABS = [ ] as const type PageTab = typeof PAGE_TABS[number]['id'] -export function GuestsPage() { - const [pageTab, setPageTab] = useState('list') - const [search, setSearch] = useState('') - const [filterTag, setFilterTag] = useState('all') - const [selectedGuest, setSelectedGuest] = useState(null) +type FilterTag = typeof ALL_TAGS[number] | 'all' - const filtered = MOCK_GUESTS.filter(g => { +export function GuestsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [pageTab, setPageTab] = useState('list') + const [guests, setGuests] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [filterTag, setFilterTag] = useState('all') + const [selectedId, setSelectedId] = useState(null) + const [showAdd, setShowAdd] = useState(false) + + const loadGuests = useCallback(async () => { + if (!slug) return + setLoading(true) + try { + const data = await api.guests.list(slug) + setGuests(data) + } catch { + // silently fail on list + } finally { + setLoading(false) + } + }, [slug]) + + useEffect(() => { loadGuests() }, [loadGuests]) + + const filtered = guests.filter(g => { const q = search.toLowerCase() + const name = guestName(g).toLowerCase() const matchSearch = !q - || g.name.toLowerCase().includes(q) - || g.email.toLowerCase().includes(q) - || g.phone.includes(q) + || name.includes(q) + || (g.email ?? '').toLowerCase().includes(q) + || (g.phone ?? '').includes(q) + || (g.passportSeries ?? '').toLowerCase().includes(q) + || (g.passportNumber ?? '').toLowerCase().includes(q) const matchTag = filterTag === 'all' || g.tags.includes(filterTag) return matchSearch && matchTag }) - const vipCount = MOCK_GUESTS.filter(g => g.tags.includes('VIP')).length - const repeatCount = MOCK_GUESTS.filter(g => g.totalStays >= 3).length - const avgRating = (MOCK_GUESTS.reduce((s, g) => s + g.rating, 0) / MOCK_GUESTS.length).toFixed(1) + const vipCount = guests.filter(g => g.tags.includes('VIP')).length + const repeatCount = guests.filter(g => g.totalStays >= 3).length + const avgRating = guests.length + ? (guests.reduce((s, g) => s + g.rating, 0) / guests.length).toFixed(1) + : '—' return (
{/* Header */} -
-

Гости

-

- База гостей отеля · история проживания, оплаты, рейтинги -

+
+
+

Гости

+

+ База гостей отеля · история проживания, оплаты, рейтинги +

+
+
{/* Tabs */} @@ -586,177 +776,207 @@ export function GuestsPage() { {pageTab === 'statuses' && } {pageTab === 'list' && <> - - {/* Stats */} -
- {[ - { icon: Users, label: 'Всего гостей', value: MOCK_GUESTS.length, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' }, - { icon: Award, label: 'VIP гостей', value: vipCount, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' }, - { icon: Repeat2, label: 'Постоянных', value: repeatCount, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' }, - { icon: Star, label: 'Средний рейтинг',value: avgRating, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' }, - ].map(s => ( -
-
- + {/* Stats */} +
+ {[ + { icon: Users, label: 'Всего гостей', value: guests.length, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' }, + { icon: Award, label: 'VIP гостей', value: vipCount, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' }, + { icon: Repeat2, label: 'Постоянных', value: repeatCount, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' }, + { icon: Star, label: 'Средний рейтинг', value: avgRating, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' }, + ].map(s => ( +
+
+ +
+
+

{s.label}

+

{s.value}

+
-
-

{s.label}

-

{s.value}

-
-
- ))} -
- - {/* Search + Filters */} -
-
-
- - setSearch(e.target.value)} - /> -
-
- {(['all', 'VIP', 'Постоянный', 'Корпоративный', 'Проблемный'] as const).map(tag => ( - - ))} -
+ ))}
-
- {/* Table */} -
-
- - - - - - - - - - - - - - {filtered.length === 0 && ( - - - - )} - {filtered.map(guest => ( - setSelectedGuest(guest)} + {/* Search + Filters */} +
+
+
+ + setSearch(e.target.value)} + /> +
+
+ {(['all', ...ALL_TAGS.slice(0, 4)] as const).map(tag => ( +
- - - - - - - - + {tag === 'all' ? 'Все' : tag} + ))} - -
ГостьКонтактыВизитыПотраченоРейтингПоследний визитМетки -
- Гости не найдены -
-
-
- - {guest.name.split(' ').map(p => p[0]).join('').slice(0, 2)} - -
- {guest.name} -
-
-
- {guest.phone && ( -
- {guest.phone} -
- )} - {guest.email && ( -
- {guest.email} -
- )} -
-
-
- - {guest.totalStays} -
-
- - {guest.totalSpent.toLocaleString('ru-RU')} ₽ - - - - - {formatDate(guest.lastVisit)} - -
- {guest.tags.map(tag => ( - - {tag === 'VIP' && }{tag} - - ))} -
-
- -
+
+
-
-

- Показано {filtered.length} из {MOCK_GUESTS.length} гостей -

-
-
+ {/* Table */} +
+
+ + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && filtered.length === 0 && ( + + + + )} + {!loading && filtered.map(guest => ( + setSelectedId(guest.id)} + > + + + + + + + + + + ))} + +
ГостьКонтактыВизитыПотраченоРейтингПоследний визитМетки +
+ +
+ {guests.length === 0 ? 'Гостей пока нет' : 'Гости не найдены'} +
+
+
+ + {guestInitials(guest)} + +
+
+ {guestName(guest)} + {(guest.passportSeries || guest.passportNumber) && ( +

+ {[guest.passportSeries, guest.passportNumber].filter(Boolean).join(' ')} +

+ )} +
+
+
+
+ {guest.phone && ( +
+ {guest.phone} +
+ )} + {guest.email && ( +
+ {guest.email} +
+ )} +
+
+
+ + {guest.totalStays} +
+
+ + {Number(guest.totalSpent).toLocaleString('ru-RU')} ₽ + + + + + {formatDate(guest.lastVisit)} + +
+ {guest.tags.map(tag => ( + + {tag === 'VIP' && }{tag} + + ))} +
+
+ +
+
- {/* Future note */} -
-
- -
-

Единая база гостей HotelSync

-

- В будущих версиях рейтинг гостей будет доступен всем отелям сети — гость, уже имеющий историю в другом отеле, - автоматически подгрузит свой рейтинг и теги при бронировании. +

+

+ Показано {filtered.length} из {guests.length} гостей

-
+ + {/* Future note */} +
+
+ +
+

Единая база гостей HotelSync

+

+ В будущих версиях рейтинг гостей будет доступен всем отелям сети — гость, уже имеющий историю в другом отеле, + автоматически подгрузит свой рейтинг и теги при бронировании. +

+
+
+
+ } {/* Guest Detail Modal */} - {selectedGuest && ( - setSelectedGuest(null)} /> + {selectedId && ( + setSelectedId(null)} + onUpdated={updated => { + setGuests(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g)) + }} + /> + )} + + {/* Add Guest Modal */} + {showAdd && ( + setShowAdd(false)} + onCreated={g => setGuests(prev => [g, ...prev])} + /> )} - }
) }