diff --git a/backend/migrations/057_checklists.sql b/backend/migrations/057_checklists.sql new file mode 100644 index 0000000..39ceb10 --- /dev/null +++ b/backend/migrations/057_checklists.sql @@ -0,0 +1,27 @@ +-- Шаблоны чек-листов +CREATE TABLE checklist_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + task_type TEXT, -- 'checkout', 'daily', 'deep', NULL = all types + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE checklist_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES checklist_templates(id) ON DELETE CASCADE, + text TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +-- Отметки выполнения пункта (привязаны к задаче уборки) +CREATE TABLE checklist_completions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES housekeeping_tasks(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES checklist_items(id) ON DELETE CASCADE, + completed_by UUID REFERENCES users(id), + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(task_id, item_id) +); diff --git a/backend/migrations/058_minibar.sql b/backend/migrations/058_minibar.sql new file mode 100644 index 0000000..8e196a6 --- /dev/null +++ b/backend/migrations/058_minibar.sql @@ -0,0 +1,22 @@ +CREATE TABLE minibar_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + price NUMERIC(10,2) NOT NULL DEFAULT 0, + category TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE minibar_consumptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id), + room_id UUID NOT NULL REFERENCES rooms(id), + booking_id UUID REFERENCES bookings(id), + task_id UUID REFERENCES housekeeping_tasks(id), + item_id UUID NOT NULL REFERENCES minibar_items(id), + quantity INTEGER NOT NULL DEFAULT 1, + price_per_unit NUMERIC(10,2) NOT NULL, + recorded_by UUID REFERENCES users(id), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/migrations/059_deposits.sql b/backend/migrations/059_deposits.sql new file mode 100644 index 0000000..1a58a74 --- /dev/null +++ b/backend/migrations/059_deposits.sql @@ -0,0 +1,34 @@ +CREATE TABLE hotel_deposit_settings ( + hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE, + is_enabled BOOLEAN NOT NULL DEFAULT false, + amount NUMERIC(10,2) NOT NULL DEFAULT 5000, + yookassa_shop_id TEXT, + yookassa_secret_key TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE booking_deposits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id), + booking_id UUID NOT NULL REFERENCES bookings(id), + amount NUMERIC(10,2) NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + -- pending = ожидает оплаты + -- paid_cash = оплачен наличными + -- hold_created = холд создан в ЮКасса + -- hold_confirmed = холд подтверждён (webhook) + -- captured = частично/полностью списано + -- refunded = возвращён полностью + -- cancelled = отменён + payment_method TEXT, -- 'cash', 'yookassa_hold' + yookassa_payment_id TEXT, + yookassa_confirmation_url TEXT, + captured_amount NUMERIC(10,2), + retention_reason TEXT, + guest_email_sent BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + paid_at TIMESTAMPTZ, + released_at TIMESTAMPTZ +); + +CREATE INDEX ON booking_deposits(booking_id); diff --git a/backend/src/app.ts b/backend/src/app.ts index e44b36b..8300896 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -38,6 +38,9 @@ import workstationRoutes from './routes/workstations' import agentReleaseRoutes from './routes/agent-release' import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' import ttlockRoutes from './routes/ttlock' +import checklistsRoutes from './routes/checklists' +import minibarRoutes from './routes/minibar' +import depositRoutes from './routes/deposit' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -128,6 +131,9 @@ export async function buildApp() { await fastify.register(wifiSettingsRoutes) await fastify.register(wifiAuthVerify) await fastify.register(ttlockRoutes) + await fastify.register(checklistsRoutes) + await fastify.register(minibarRoutes) + await fastify.register(depositRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/email.ts b/backend/src/email.ts index 23c213a..17a1839 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -1,6 +1,6 @@ import nodemailer from 'nodemailer' -const transporter = nodemailer.createTransport({ +export const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST ?? 'smtp.timeweb.ru', port: parseInt(process.env.SMTP_PORT ?? '465', 10), secure: true, diff --git a/backend/src/routes/checklists.ts b/backend/src/routes/checklists.ts new file mode 100644 index 0000000..18cc5b7 --- /dev/null +++ b/backend/src/routes/checklists.ts @@ -0,0 +1,372 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugTplParam = { Params: { slug: string; templateId: string } } +type SlugTplItemParam = { Params: { slug: string; templateId: string; itemId: string } } +type SlugTaskParam = { Params: { slug: string; taskId: string } } +type SlugTaskItemParam = { Params: { slug: string; taskId: string; itemId: string } } + +const checklists: 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 + + const isManager = (role: string) => + ['super_admin', 'hotel_admin', 'manager'].includes(role) + + // ── GET /api/hotels/:slug/checklist-templates ───────────────────────────── + fastify.get( + '/api/hotels/:slug/checklist-templates', + { 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: templates } = await db.query( + `SELECT * FROM checklist_templates WHERE hotel_id = $1 ORDER BY sort_order, created_at`, + [hotelId], + ) + const { rows: items } = await db.query( + `SELECT ci.* FROM checklist_items ci + JOIN checklist_templates ct ON ct.id = ci.template_id + WHERE ct.hotel_id = $1 + ORDER BY ci.sort_order`, + [hotelId], + ) + + return templates.map((t: Record) => ({ + ...t, + items: items.filter((i: Record) => i.template_id === t.id), + })) + }, + ) + + // ── POST /api/hotels/:slug/checklist-templates ──────────────────────────── + fastify.post( + '/api/hotels/:slug/checklist-templates', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + 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, task_type } = request.body + const { rows } = await db.query( + `INSERT INTO checklist_templates (hotel_id, name, task_type) VALUES ($1, $2, $3) RETURNING *`, + [hotelId, name, task_type ?? null], + ) + return reply.code(201).send({ ...rows[0], items: [] }) + }, + ) + + // ── PATCH /api/hotels/:slug/checklist-templates/:templateId ────────────── + fastify.patch( + '/api/hotels/:slug/checklist-templates/:templateId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, templateId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['name', 'task_type', 'is_active', 'sort_order'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + const body = request.body as Record + for (const key of allowed) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(templateId, hotelId) + + const { rows } = await db.query( + `UPDATE checklist_templates SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Template not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/checklist-templates/:templateId ───────────── + fastify.delete( + '/api/hotels/:slug/checklist-templates/:templateId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, templateId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'DELETE FROM checklist_templates WHERE id = $1 AND hotel_id = $2', + [templateId, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Template not found' }) + return reply.code(204).send() + }, + ) + + // ── POST /api/hotels/:slug/checklist-templates/:templateId/items ────────── + fastify.post( + '/api/hotels/:slug/checklist-templates/:templateId/items', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, templateId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + // Verify template belongs to hotel + const { rows: tRows } = await db.query( + 'SELECT id FROM checklist_templates WHERE id = $1 AND hotel_id = $2', + [templateId, hotelId], + ) + if (!tRows[0]) return reply.code(404).send({ error: 'Template not found' }) + + const { text, sort_order = 0 } = request.body + const { rows } = await db.query( + `INSERT INTO checklist_items (template_id, text, sort_order) VALUES ($1, $2, $3) RETURNING *`, + [templateId, text, sort_order], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/checklist-templates/:templateId/items/:itemId ─ + fastify.patch( + '/api/hotels/:slug/checklist-templates/:templateId/items/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, templateId, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + const body = request.body as Record + for (const key of ['text', 'sort_order']) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(itemId, templateId) + + // Ensure item belongs to this template (which belongs to hotel) + const { rows } = await db.query( + `UPDATE checklist_items SET ${updates.join(', ')} + WHERE id = $${idx} AND template_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Item not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/checklist-templates/:templateId/items/:itemId + fastify.delete( + '/api/hotels/:slug/checklist-templates/:templateId/items/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, templateId, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + `DELETE FROM checklist_items WHERE id = $1 AND template_id IN ( + SELECT id FROM checklist_templates WHERE id = $2 AND hotel_id = $3 + )`, + [itemId, templateId, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Item not found' }) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/housekeeping/:taskId/checklist ────────────────── + fastify.get( + '/api/hotels/:slug/housekeeping/:taskId/checklist', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, taskId } = 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' }) + + // Get task to know its type + const { rows: taskRows } = await db.query( + 'SELECT id, type FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', + [taskId, hotelId], + ) + if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' }) + + const taskType = taskRows[0].type as string + + // Find matching template: exact task_type match first, then NULL (all types) fallback + const { rows: templates } = await db.query( + `SELECT * FROM checklist_templates + WHERE hotel_id = $1 AND is_active = true + AND (task_type = $2 OR task_type IS NULL) + ORDER BY CASE WHEN task_type = $2 THEN 0 ELSE 1 END, sort_order + LIMIT 1`, + [hotelId, taskType], + ) + + if (!templates[0]) { + return { templateId: null, templateName: null, items: [] } + } + + const template = templates[0] + + // Get items with completion status for this task + const { rows: items } = await db.query( + `SELECT ci.id, ci.text, ci.sort_order, + cc.completed_at, u.name AS completed_by + FROM checklist_items ci + LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1 + LEFT JOIN users u ON u.id = cc.completed_by + WHERE ci.template_id = $2 + ORDER BY ci.sort_order`, + [taskId, template.id], + ) + + return { + templateId: template.id, + templateName: template.name, + items, + } + }, + ) + + // ── POST /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete ─ + fastify.post( + '/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, taskId, itemId } = 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' }) + + // Verify task belongs to hotel + const { rows: taskRows } = await db.query( + 'SELECT id FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', + [taskId, hotelId], + ) + if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' }) + + const { rows } = await db.query( + `INSERT INTO checklist_completions (task_id, item_id, completed_by) + VALUES ($1, $2, $3) + ON CONFLICT (task_id, item_id) DO UPDATE SET completed_at = NOW(), completed_by = $3 + RETURNING *`, + [taskId, itemId, request.user.sub], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── DELETE /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete + fastify.delete( + '/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, taskId, itemId } = 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 checklist_completions WHERE task_id = $1 AND item_id = $2`, + [taskId, itemId], + ) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/reports/checklists ────────────────────────────── + fastify.get( + '/api/hotels/:slug/reports/checklists', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { from, to } = request.query + const conditions: string[] = ['t.hotel_id = $1', "t.status = 'done'"] + const values: unknown[] = [hotelId] + let idx = 2 + + if (from) { conditions.push(`t.completed_at >= $${idx}`); values.push(from); idx++ } + if (to) { conditions.push(`t.completed_at < $${idx}`); values.push(to); idx++ } + + const { rows } = await db.query( + `SELECT + t.id, t.type, t.completed_at, r.number AS room_number, + u.name AS assignee_name, + COUNT(ci.id) AS total_items, + COUNT(cc.id) AS completed_items + FROM housekeeping_tasks t + LEFT JOIN rooms r ON r.id = t.room_id + LEFT JOIN users u ON u.id = t.assignee_id + LEFT JOIN checklist_templates ct ON ct.hotel_id = t.hotel_id AND is_active = true + AND (ct.task_type = t.type OR ct.task_type IS NULL) + LEFT JOIN checklist_items ci ON ci.template_id = ct.id + LEFT JOIN checklist_completions cc ON cc.task_id = t.id AND cc.item_id = ci.id + WHERE ${conditions.join(' AND ')} + GROUP BY t.id, r.number, u.name + ORDER BY t.completed_at DESC`, + values, + ) + return rows + }, + ) +} + +export default checklists diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts new file mode 100644 index 0000000..313c96a --- /dev/null +++ b/backend/src/routes/deposit.ts @@ -0,0 +1,315 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import { createHold, capturePayment, cancelPayment } from '../services/yookassa' +import { transporter } from '../email' + +type SlugParam = { Params: { slug: string } } +type SlugBookingParam = { Params: { slug: string; bookingId: string } } + +const deposit: 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 + + const isManager = (role: string) => + ['super_admin', 'hotel_admin', 'manager'].includes(role) + + const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru' + + // ── GET /api/hotels/:slug/deposit/settings ──────────────────────────────── + fastify.get( + '/api/hotels/:slug/deposit/settings', + { 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 hotel_deposit_settings WHERE hotel_id = $1', + [hotelId], + ) + if (!rows[0]) { + return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null } + } + // Mask secret key + const row = rows[0] + return { + ...row, + yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null, + } + }, + ) + + // ── PATCH /api/hotels/:slug/deposit/settings ────────────────────────────── + fastify.patch( + '/api/hotels/:slug/deposit/settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key } = request.body + + // Upsert settings + const { rows } = await db.query( + `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (hotel_id) DO UPDATE SET + is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled), + amount = COALESCE($3, hotel_deposit_settings.amount), + yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id), + yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END, + updated_at = NOW() + RETURNING *`, + [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null], + ) + return rows[0] + }, + ) + + // ── GET /api/hotels/:slug/bookings/:bookingId/deposit ───────────────────── + fastify.get( + '/api/hotels/:slug/bookings/:bookingId/deposit', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = 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 booking_deposits WHERE booking_id = $1 AND hotel_id = $2 ORDER BY created_at DESC LIMIT 1`, + [bookingId, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'No deposit found' }) + // Mask confirmation URL isn't needed — expose it for QR + return rows[0] + }, + ) + + // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/cash ─────────────── + fastify.post( + '/api/hotels/:slug/bookings/:bookingId/deposit/cash', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = 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' }) + + // Verify booking + const { rows: bRows } = await db.query( + 'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2', + [bookingId, hotelId], + ) + if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' }) + + // Get deposit settings amount + const { rows: settingsRows } = await db.query( + 'SELECT amount FROM hotel_deposit_settings WHERE hotel_id = $1', + [hotelId], + ) + const amount = settingsRows[0]?.amount ?? '5000.00' + + const { rows } = await db.query( + `INSERT INTO booking_deposits (hotel_id, booking_id, amount, status, payment_method, paid_at) + VALUES ($1, $2, $3, 'paid_cash', 'cash', NOW()) + RETURNING *`, + [hotelId, bookingId, amount], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/yookassa ─────────── + fastify.post( + '/api/hotels/:slug/bookings/:bookingId/deposit/yookassa', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = 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' }) + + // Get settings including YooKassa credentials + const { rows: settingsRows } = await db.query( + 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', + [hotelId], + ) + const settings = settingsRows[0] + if (!settings) return reply.code(400).send({ error: 'Deposit settings not configured' }) + if (!settings.yookassa_shop_id || !settings.yookassa_secret_key) { + return reply.code(400).send({ error: 'YooKassa credentials not configured' }) + } + + // Get booking for description + const { rows: bRows } = await db.query( + 'SELECT id, guest_name FROM bookings WHERE id = $1 AND hotel_id = $2', + [bookingId, hotelId], + ) + if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' }) + + const payment = await createHold({ + shopId: settings.yookassa_shop_id, + secretKey: settings.yookassa_secret_key, + amount: Number(settings.amount), + description: `Депозит за бронирование — ${bRows[0].guest_name}`, + returnUrl: `${appUrl()}/${slug}/bookings`, + }) + + const { rows } = await db.query( + `INSERT INTO booking_deposits + (hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url) + VALUES ($1, $2, $3, 'hold_created', 'yookassa_hold', $4, $5) + RETURNING *`, + [hotelId, bookingId, settings.amount, payment.id, + payment.confirmation?.confirmation_url ?? null], + ) + return reply.code(201).send({ + ...rows[0], + confirmationUrl: payment.confirmation?.confirmation_url ?? null, + }) + }, + ) + + // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ──────────── + fastify.post( + '/api/hotels/:slug/bookings/:bookingId/deposit/release', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = 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 { capturedAmount, reason } = request.body + + // Get active deposit + const { rows: depRows } = await db.query( + `SELECT * FROM booking_deposits + WHERE booking_id = $1 AND hotel_id = $2 + AND status IN ('paid_cash', 'hold_created', 'hold_confirmed') + ORDER BY created_at DESC LIMIT 1`, + [bookingId, hotelId], + ) + if (!depRows[0]) return reply.code(404).send({ error: 'Active deposit not found' }) + const dep = depRows[0] + + let newStatus: string + if (dep.payment_method === 'yookassa_hold' && dep.yookassa_payment_id) { + // Get settings for credentials + const { rows: settingsRows } = await db.query( + 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', + [hotelId], + ) + const settings = settingsRows[0] + if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) { + return reply.code(400).send({ error: 'YooKassa credentials not configured' }) + } + + if (capturedAmount === 0) { + await cancelPayment({ + shopId: settings.yookassa_shop_id, + secretKey: settings.yookassa_secret_key, + paymentId: dep.yookassa_payment_id, + }) + newStatus = 'refunded' + } else { + await capturePayment({ + shopId: settings.yookassa_shop_id, + secretKey: settings.yookassa_secret_key, + paymentId: dep.yookassa_payment_id, + amount: capturedAmount, + }) + newStatus = 'captured' + } + } else { + // Cash deposit + newStatus = capturedAmount === 0 ? 'refunded' : 'captured' + } + + const { rows } = await db.query( + `UPDATE booking_deposits SET + status = $1, + captured_amount = $2, + retention_reason = $3, + released_at = NOW() + WHERE id = $4 RETURNING *`, + [newStatus, capturedAmount.toFixed(2), reason ?? null, dep.id], + ) + + // Send email to guest if email is available + const { rows: bRows } = await db.query( + 'SELECT guest_name, guest_email FROM bookings WHERE id = $1', + [bookingId], + ) + if (bRows[0]?.guest_email) { + const guest = bRows[0] + const actionText = capturedAmount === 0 + ? 'Депозит был полностью возвращён.' + : `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}` + transporter.sendMail({ + from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`, + to: guest.guest_email, + subject: 'Информация о депозите — HotelSync', + text: `Уважаемый(ая) ${guest.guest_name},\n\n${actionText}\n\nСпасибо за проживание.\n\n© 2026 HotelSync`, + }).catch(() => {}) + + await db.query( + 'UPDATE booking_deposits SET guest_email_sent = true WHERE id = $1', + [dep.id], + ) + } + + return rows[0] + }, + ) + + // ── POST /api/webhooks/yookassa ─────────────────────────────────────────── + fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>( + '/api/webhooks/yookassa', + async (request, reply) => { + const { object } = request.body + if (!object?.id) return reply.code(400).send({ error: 'Invalid webhook' }) + + const statusMap: Record = { + waiting_for_capture: 'hold_confirmed', + succeeded: 'captured', + canceled: 'cancelled', + } + const newStatus = statusMap[object.status] + if (!newStatus) return { ok: true } + + await db.query( + `UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`, + [newStatus, object.id], + ) + return { ok: true } + }, + ) +} + +export default deposit diff --git a/backend/src/routes/minibar.ts b/backend/src/routes/minibar.ts new file mode 100644 index 0000000..07aac83 --- /dev/null +++ b/backend/src/routes/minibar.ts @@ -0,0 +1,253 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugItemParam = { Params: { slug: string; itemId: string } } +type SlugTaskParam = { Params: { slug: string; taskId: string } } +type SlugConsumpParam = { Params: { slug: string; consumptionId: string } } +type SlugBookingParam = { Params: { slug: string; bookingId: string } } + +const minibar: 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 + + const isManager = (role: string) => + ['super_admin', 'hotel_admin', 'manager'].includes(role) + + // ── GET /api/hotels/:slug/minibar-items ─────────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-items', + { 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 minibar_items WHERE hotel_id = $1 AND is_active = true ORDER BY sort_order, name`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/minibar-items ────────────────────────────────── + fastify.post( + '/api/hotels/:slug/minibar-items', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + 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, price, category, sort_order = 0 } = request.body + const { rows } = await db.query( + `INSERT INTO minibar_items (hotel_id, name, price, category, sort_order) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [hotelId, name, price.toFixed(2), category ?? null, sort_order], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/minibar-items/:itemId ───────────────────────── + fastify.patch( + '/api/hotels/:slug/minibar-items/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['name', 'price', 'category', 'sort_order', 'is_active'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + const body = request.body as Record + for (const key of allowed) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(key === 'price' ? Number(body[key]).toFixed(2) : body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(itemId, hotelId) + + const { rows } = await db.query( + `UPDATE minibar_items SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Item not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/minibar-items/:itemId (soft delete) ────────── + fastify.delete( + '/api/hotels/:slug/minibar-items/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'UPDATE minibar_items SET is_active = false WHERE id = $1 AND hotel_id = $2', + [itemId, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Item not found' }) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/housekeeping/:taskId/minibar ──────────────────── + fastify.get( + '/api/hotels/:slug/housekeeping/:taskId/minibar', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, taskId } = 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 mc.*, mi.name AS item_name, u.name AS recorded_by_name + FROM minibar_consumptions mc + JOIN minibar_items mi ON mi.id = mc.item_id + LEFT JOIN users u ON u.id = mc.recorded_by + WHERE mc.task_id = $1 AND mc.hotel_id = $2 + ORDER BY mc.recorded_at`, + [taskId, hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/housekeeping/:taskId/minibar ─────────────────── + fastify.post( + '/api/hotels/:slug/housekeeping/:taskId/minibar', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, taskId } = 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' }) + + // Get task to know room_id + const { rows: taskRows } = await db.query( + 'SELECT id, room_id FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', + [taskId, hotelId], + ) + if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' }) + const roomId = taskRows[0].room_id as string | null + if (!roomId) return reply.code(400).send({ error: 'Task has no room' }) + + // Get minibar item price + const { rows: itemRows } = await db.query( + 'SELECT id, price FROM minibar_items WHERE id = $1 AND hotel_id = $2 AND is_active = true', + [request.body.item_id, hotelId], + ) + if (!itemRows[0]) return reply.code(404).send({ error: 'Minibar item not found' }) + + // Auto-find current booking for this room + const today = new Date().toISOString().slice(0, 10) + const { rows: bookingRows } = await db.query( + `SELECT id FROM bookings + WHERE room_id = $1 AND hotel_id = $2 + AND status IN ('confirmed', 'checked_in') + AND check_in <= $3 AND check_out > $3 + ORDER BY check_in DESC LIMIT 1`, + [roomId, hotelId, today], + ) + const bookingId = bookingRows[0]?.id ?? null + + const { rows } = await db.query( + `INSERT INTO minibar_consumptions + (hotel_id, room_id, booking_id, task_id, item_id, quantity, price_per_unit, recorded_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [hotelId, roomId, bookingId, taskId, request.body.item_id, + request.body.quantity, itemRows[0].price, request.user.sub], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── DELETE /api/hotels/:slug/minibar-consumptions/:consumptionId ────────── + fastify.delete( + '/api/hotels/:slug/minibar-consumptions/:consumptionId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, consumptionId } = 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 { rowCount } = await db.query( + 'DELETE FROM minibar_consumptions WHERE id = $1 AND hotel_id = $2', + [consumptionId, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Consumption not found' }) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/bookings/:bookingId/minibar ───────────────────── + fastify.get( + '/api/hotels/:slug/bookings/:bookingId/minibar', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = 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 mc.id, + mi.name AS item_name, + mc.quantity, + mc.price_per_unit, + (mc.quantity * mc.price_per_unit) AS total, + mc.recorded_at, + u.name AS recorded_by_name + FROM minibar_consumptions mc + JOIN minibar_items mi ON mi.id = mc.item_id + LEFT JOIN users u ON u.id = mc.recorded_by + WHERE mc.booking_id = $1 AND mc.hotel_id = $2 + ORDER BY mc.recorded_at`, + [bookingId, hotelId], + ) + return rows + }, + ) +} + +export default minibar diff --git a/backend/src/services/yookassa.ts b/backend/src/services/yookassa.ts new file mode 100644 index 0000000..e863612 --- /dev/null +++ b/backend/src/services/yookassa.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'crypto' + +interface YooKassaPayment { + id: string + status: string + confirmation?: { confirmation_url: string } +} + +export async function createHold(params: { + shopId: string + secretKey: string + amount: number + description: string + returnUrl: string + idempotenceKey?: string +}): Promise { + const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64') + const res = await fetch('https://api.yookassa.ru/v3/payments', { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + 'Idempotence-Key': params.idempotenceKey ?? randomUUID(), + }, + body: JSON.stringify({ + amount: { value: params.amount.toFixed(2), currency: 'RUB' }, + capture: false, + confirmation: { type: 'redirect', return_url: params.returnUrl }, + description: params.description, + }), + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`YooKassa error ${res.status}: ${err}`) + } + return res.json() as Promise +} + +export async function capturePayment(params: { + shopId: string + secretKey: string + paymentId: string + amount: number +}): Promise { + const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64') + const res = await fetch(`https://api.yookassa.ru/v3/payments/${params.paymentId}/capture`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + 'Idempotence-Key': randomUUID(), + }, + body: JSON.stringify({ + amount: { value: params.amount.toFixed(2), currency: 'RUB' }, + }), + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`YooKassa capture error ${res.status}: ${err}`) + } +} + +export async function cancelPayment(params: { + shopId: string + secretKey: string + paymentId: string +}): Promise { + const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64') + const res = await fetch(`https://api.yookassa.ru/v3/payments/${params.paymentId}/cancel`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + 'Idempotence-Key': randomUUID(), + }, + body: '{}', + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`YooKassa cancel error ${res.status}: ${err}`) + } +} diff --git a/src/App.tsx b/src/App.tsx index ad5712e..f338039 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,9 @@ import { BillingPage } from './pages/BillingPage' import { EquipmentPage } from './pages/EquipmentPage' import { WiFiPage } from './pages/WiFiPage' import { TTLockPage } from './pages/TTLockPage' +import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage' +import { MinibarSettingsPage } from './pages/MinibarSettingsPage' +import { DepositSettingsPage } from './pages/DepositSettingsPage' import { ModuleGuard } from './components/ModuleGuard' export default function App() { @@ -97,7 +100,10 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index fff9f73..17603da 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -1,6 +1,7 @@ import { useState, useLayoutEffect, useRef, useEffect } from 'react' import { format, addDays, parseISO } from 'date-fns' -import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone } from 'lucide-react' +import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone, ShoppingCart, ShieldCheck, ExternalLink } from 'lucide-react' +import type { MinibarBookingCharge, BookingDeposit } from '../../lib/api' import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage' import type { Discount } from '../../pages/DiscountsPage' import { Modal } from '../ui/Modal' @@ -247,6 +248,83 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav const [tvSent, setTvSent] = useState(false) const [tvError, setTvError] = useState('') + // Minibar charges state (for existing bookings) + const [minibarCharges, setMinibarCharges] = useState([]) + const [minibarLoaded, setMinibarLoaded] = useState(false) + + // Deposit state (for existing bookings) + const [deposit, setDeposit] = useState(null) + const [depositLoaded, setDepositLoaded] = useState(false) + const [depositSaving, setDepositSaving] = useState(false) + const [depositError, setDepositError] = useState(null) + const [captureAmount, setCaptureAmount] = useState('') + const [captureReason, setCaptureReason] = useState('') + const [showReleaseForm, setShowReleaseForm] = useState(false) + + // Load minibar + deposit when modal opens for existing booking + useEffect(() => { + if (!open || !existing?.id || !slug) return + if (!minibarLoaded) { + api.minibar.getBookingMinibar(slug, existing.id) + .then(charges => { setMinibarCharges(charges); setMinibarLoaded(true) }) + .catch(() => setMinibarLoaded(true)) + } + if (!depositLoaded) { + api.deposits.getBookingDeposit(slug, existing.id) + .then(d => { setDeposit(d); setDepositLoaded(true) }) + .catch(() => setDepositLoaded(true)) + } + }, [open, existing?.id, slug, minibarLoaded, depositLoaded]) + + const handlePayByCash = async () => { + if (!existing?.id || !slug) return + setDepositSaving(true) + setDepositError(null) + try { + const d = await api.deposits.payByCash(slug, existing.id) + setDeposit(d) + } catch (e) { + setDepositError(e instanceof Error ? e.message : 'Ошибка') + } finally { + setDepositSaving(false) + } + } + + const handleCreateYookassaHold = async () => { + if (!existing?.id || !slug) return + setDepositSaving(true) + setDepositError(null) + try { + const d = await api.deposits.createYookassaHold(slug, existing.id) + setDeposit(d) + if (d.confirmationUrl) { + window.open(d.confirmationUrl, '_blank') + } + } catch (e) { + setDepositError(e instanceof Error ? e.message : 'Ошибка') + } finally { + setDepositSaving(false) + } + } + + const handleReleaseDeposit = async () => { + if (!existing?.id || !slug) return + setDepositSaving(true) + setDepositError(null) + try { + const amount = parseFloat(captureAmount) || 0 + const d = await api.deposits.release(slug, existing.id, amount, captureReason || undefined) + setDeposit(d) + setShowReleaseForm(false) + setCaptureAmount('') + setCaptureReason('') + } catch (e) { + setDepositError(e instanceof Error ? e.message : 'Ошибка') + } finally { + setDepositSaving(false) + } + } + const handleSendTv = async () => { if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return setTvSending(true) @@ -1377,6 +1455,145 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav {/* end room form */} )} + {/* ── Minibar charges (existing bookings only) ── */} + {existing && minibarLoaded && minibarCharges.length > 0 && ( +
+
+ + Минибар +
+
+ {minibarCharges.map(c => ( +
+ {c.itemName} + × {c.quantity} + {Number(c.total).toLocaleString('ru-RU')} ₽ +
+ ))} +
+ Итого + + {minibarCharges.reduce((s, c) => s + Number(c.total), 0).toLocaleString('ru-RU')} ₽ + +
+
+
+ )} + + {/* ── Deposit section (existing bookings only) ── */} + {existing && depositLoaded && ( +
+
+ + Депозит + {deposit && ( + + {deposit.status === 'pending' ? 'Ожидает' + : deposit.status === 'paid_cash' ? 'Оплачен наличными' + : deposit.status === 'hold_created' ? 'Холд создан' + : deposit.status === 'hold_confirmed' ? 'Холд подтверждён' + : deposit.status === 'captured' ? 'Списан' + : deposit.status === 'refunded' ? 'Возвращён' + : deposit.status === 'cancelled' ? 'Отменён' + : deposit.status} + + )} +
+ + {depositError && ( +

{depositError}

+ )} + + {!deposit && ( +
+ + +
+ )} + + {deposit && ['paid_cash', 'hold_created', 'hold_confirmed'].includes(deposit.status) && !showReleaseForm && ( +
+

+ Сумма: {Number(deposit.amount).toLocaleString('ru-RU')} ₽ +

+ {deposit.yookassaConfirmationUrl && deposit.status === 'hold_created' && ( + + Ссылка для оплаты + + )} + +
+ )} + + {deposit && showReleaseForm && ( +
+

+ Сумма депозита: {Number(deposit.amount).toLocaleString('ru-RU')} ₽ +

+
+ + setCaptureAmount(e.target.value)} + placeholder="0" + className="input w-32 text-sm py-1" + /> +
+
+ + setCaptureReason(e.target.value)} + placeholder="Порча имущества..." + className="input w-full text-sm py-1" + /> +
+
+ + +
+
+ )} +
+ )} + diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3446227..5034d7b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { CalendarDays, BookOpen, BedDouble, Globe, Settings, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, KeyRound, + ListChecks, ShoppingCart, ShieldCheck, } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -185,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock'], + settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/deposit'], devGroup: ['/api-docs'], } @@ -417,8 +418,11 @@ export function Sidebar({ open, onClose }: SidebarProps) { - - + + + + + )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3bb6358..a27631e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -640,6 +640,118 @@ export const api = { getCards: (slug: string, bookingId: string) => req('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`), }, + + // ── Checklists ──────────────────────────────────────────────────────────── + checklists: { + listTemplates: (slug: string) => + req('GET', `/api/hotels/${slug}/checklist-templates`), + + createTemplate: (slug: string, data: { name: string; taskType?: string }) => + req('POST', `/api/hotels/${slug}/checklist-templates`, { + name: data.name, + task_type: data.taskType ?? null, + }), + + updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number }) => + req('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}`, { + name: data.name, + task_type: data.taskType, + is_active: data.isActive, + sort_order: data.sortOrder, + }), + + deleteTemplate: (slug: string, templateId: string) => + req('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}`), + + addItem: (slug: string, templateId: string, data: { text: string; sortOrder?: number }) => + req('POST', `/api/hotels/${slug}/checklist-templates/${templateId}/items`, { + text: data.text, + sort_order: data.sortOrder ?? 0, + }), + + updateItem: (slug: string, templateId: string, itemId: string, data: { text?: string; sortOrder?: number }) => + req('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`, { + text: data.text, + sort_order: data.sortOrder, + }), + + deleteItem: (slug: string, templateId: string, itemId: string) => + req('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`), + + getTaskChecklist: (slug: string, taskId: string) => + req('GET', `/api/hotels/${slug}/housekeeping/${taskId}/checklist`), + + completeItem: (slug: string, taskId: string, itemId: string) => + req<{ id: string }>('POST', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`), + + uncompleteItem: (slug: string, taskId: string, itemId: string) => + req('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`), + }, + + // ── Minibar ─────────────────────────────────────────────────────────────── + minibar: { + listItems: (slug: string) => + req('GET', `/api/hotels/${slug}/minibar-items`), + + createItem: (slug: string, data: { name: string; price: number; category?: string; sortOrder?: number }) => + req('POST', `/api/hotels/${slug}/minibar-items`, { + name: data.name, + price: data.price, + category: data.category, + sort_order: data.sortOrder ?? 0, + }), + + updateItem: (slug: string, itemId: string, data: { name?: string; price?: number; category?: string; sortOrder?: number; isActive?: boolean }) => + req('PATCH', `/api/hotels/${slug}/minibar-items/${itemId}`, { + name: data.name, + price: data.price, + category: data.category, + sort_order: data.sortOrder, + is_active: data.isActive, + }), + + deleteItem: (slug: string, itemId: string) => + req('DELETE', `/api/hotels/${slug}/minibar-items/${itemId}`), + + getTaskConsumptions: (slug: string, taskId: string) => + req('GET', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`), + + addConsumption: (slug: string, taskId: string, data: { itemId: string; quantity: number }) => + req('POST', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`, { + item_id: data.itemId, + quantity: data.quantity, + }), + + deleteConsumption: (slug: string, consumptionId: string) => + req('DELETE', `/api/hotels/${slug}/minibar-consumptions/${consumptionId}`), + + getBookingMinibar: (slug: string, bookingId: string) => + req('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`), + }, + + // ── Deposits ────────────────────────────────────────────────────────────── + deposits: { + getSettings: (slug: string) => + req('GET', `/api/hotels/${slug}/deposit/settings`), + + updateSettings: (slug: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/deposit/settings`, data), + + getBookingDeposit: (slug: string, bookingId: string) => + req('GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit`), + + payByCash: (slug: string, bookingId: string) => + req('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/cash`), + + createYookassaHold: (slug: string, bookingId: string) => + req('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/yookassa`), + + release: (slug: string, bookingId: string, capturedAmount: number, reason?: string) => + req('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/release`, { + captured_amount: capturedAmount, + reason, + }), + }, } // ── Schedule ───────────────────────────────────────────────────────────────── @@ -1153,6 +1265,111 @@ export interface WifiSettings { sessionHours: number } +// ── Checklist types ────────────────────────────────────────────────────────── + +export interface ChecklistItem { + id: string + templateId: string + text: string + sortOrder: number +} + +export interface ChecklistTemplate { + id: string + hotelId: string + name: string + taskType: string | null + isActive: boolean + sortOrder: number + createdAt: string + items: ChecklistItem[] +} + +export interface TaskChecklistItem { + id: string + text: string + sortOrder: number + completedAt: string | null + completedBy: string | null +} + +export interface TaskChecklist { + templateId: string | null + templateName: string | null + items: TaskChecklistItem[] +} + +// ── Minibar types ───────────────────────────────────────────────────────────── + +export interface MinibarItem { + id: string + hotelId: string + name: string + price: number + category: string | null + isActive: boolean + sortOrder: number +} + +export interface MinibarConsumption { + id: string + hotelId: string + roomId: string + bookingId: string | null + taskId: string | null + itemId: string + itemName: string + quantity: number + pricePerUnit: number + recordedByName: string | null + recordedAt: string +} + +export interface MinibarBookingCharge { + id: string + itemName: string + quantity: number + pricePerUnit: number + total: number + recordedAt: string + recordedByName: string | null +} + +// ── Deposit types ───────────────────────────────────────────────────────────── + +export interface DepositSettings { + hotelId: string + isEnabled: boolean + amount: number + yookassaShopId: string | null + yookassaSecretKey: string | null + updatedAt?: string +} + +export interface DepositSettingsPayload { + is_enabled?: boolean + amount?: number + yookassa_shop_id?: string + yookassa_secret_key?: string +} + +export interface BookingDeposit { + id: string + hotelId: string + bookingId: string + amount: number + status: string + paymentMethod: string | null + yookassaPaymentId: string | null + yookassaConfirmationUrl: string | null + capturedAmount: number | null + retentionReason: string | null + guestEmailSent: boolean + createdAt: string + paidAt: string | null + releasedAt: string | null +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/ChecklistSettingsPage.tsx b/src/pages/ChecklistSettingsPage.tsx new file mode 100644 index 0000000..cc9511b --- /dev/null +++ b/src/pages/ChecklistSettingsPage.tsx @@ -0,0 +1,332 @@ +import { useState, useEffect } from 'react' +import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight } from 'lucide-react' +import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import { cn } from '../lib/utils' + +const TASK_TYPE_LABELS: Record = { + checkout: 'После выезда', + daily: 'Ежедневная', + deep: 'Генеральная', +} + +function InlineEdit({ value, onSave, onCancel }: { + value: string + onSave: (v: string) => void + onCancel: () => void +}) { + const [v, setV] = useState(value) + return ( +
+ setV(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') onSave(v.trim()) + if (e.key === 'Escape') onCancel() + }} + className="input flex-1 py-1 text-sm" + /> + + +
+ ) +} + +export function ChecklistSettingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // New template form + const [newName, setNewName] = useState('') + const [newType, setNewType] = useState('') + const [adding, setAdding] = useState(false) + + // Editing states + const [editingTemplateId, setEditingTemplateId] = useState(null) + const [editingItemId, setEditingItemId] = useState(null) + const [newItemText, setNewItemText] = useState>({}) + + useEffect(() => { + if (!slug) return + api.checklists.listTemplates(slug) + .then(t => setTemplates(t)) + .catch(() => setError('Не удалось загрузить шаблоны')) + .finally(() => setLoading(false)) + }, [slug]) + + const addTemplate = async () => { + if (!newName.trim()) return + setAdding(true) + try { + const tpl = await api.checklists.createTemplate(slug, { + name: newName.trim(), + taskType: newType || undefined, + }) + setTemplates(prev => [...prev, tpl]) + setNewName('') + setNewType('') + } catch { + setError('Не удалось создать шаблон') + } finally { + setAdding(false) + } + } + + const updateTemplateName = async (id: string, name: string) => { + try { + const updated = await api.checklists.updateTemplate(slug, id, { name }) + setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: updated.name } : t)) + } catch { /* ignore */ } + setEditingTemplateId(null) + } + + const toggleActive = async (t: ChecklistTemplate) => { + try { + const updated = await api.checklists.updateTemplate(slug, t.id, { isActive: !t.isActive }) + setTemplates(prev => prev.map(x => x.id === t.id ? { ...x, isActive: updated.isActive } : x)) + } catch { /* ignore */ } + } + + const deleteTemplate = async (id: string) => { + if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return + try { + await api.checklists.deleteTemplate(slug, id) + setTemplates(prev => prev.filter(t => t.id !== id)) + } catch { /* ignore */ } + } + + const addItem = async (templateId: string) => { + const text = newItemText[templateId]?.trim() + if (!text) return + try { + const item = await api.checklists.addItem(slug, templateId, { text }) + setTemplates(prev => prev.map(t => t.id === templateId + ? { ...t, items: [...t.items, item] } + : t, + )) + setNewItemText(prev => ({ ...prev, [templateId]: '' })) + } catch { /* ignore */ } + } + + const updateItemText = async (templateId: string, item: ChecklistItem, text: string) => { + try { + const updated = await api.checklists.updateItem(slug, templateId, item.id, { text }) + setTemplates(prev => prev.map(t => t.id === templateId + ? { ...t, items: t.items.map(i => i.id === item.id ? { ...i, text: updated.text } : i) } + : t, + )) + } catch { /* ignore */ } + setEditingItemId(null) + } + + const deleteItem = async (templateId: string, itemId: string) => { + try { + await api.checklists.deleteItem(slug, templateId, itemId) + setTemplates(prev => prev.map(t => t.id === templateId + ? { ...t, items: t.items.filter(i => i.id !== itemId) } + : t, + )) + } catch { /* ignore */ } + } + + const moveItem = async (templateId: string, itemId: string, dir: 'up' | 'down') => { + const tpl = templates.find(t => t.id === templateId) + if (!tpl) return + const idx = tpl.items.findIndex(i => i.id === itemId) + if (dir === 'up' && idx === 0) return + if (dir === 'down' && idx === tpl.items.length - 1) return + + const newItems = [...tpl.items] + const swapIdx = dir === 'up' ? idx - 1 : idx + 1 + ;[newItems[idx], newItems[swapIdx]] = [newItems[swapIdx], newItems[idx]] + + // Update sort_order for both + const a = newItems[idx] + const b = newItems[swapIdx] + const sortA = a.sortOrder + const sortB = b.sortOrder + + setTemplates(prev => prev.map(t => t.id === templateId ? { ...t, items: newItems } : t)) + + try { + await Promise.all([ + api.checklists.updateItem(slug, templateId, a.id, { sortOrder: sortA }), + api.checklists.updateItem(slug, templateId, b.id, { sortOrder: sortB }), + ]) + } catch { /* ignore */ } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ + Шаблоны чек-листов +

+

+ Настройте пункты проверки для задач уборки. Шаблон применяется автоматически по типу задачи. +

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Add template form */} +
+

Новый шаблон

+
+ setNewName(e.target.value)} + placeholder="Название шаблона" + className="input flex-1" + onKeyDown={e => e.key === 'Enter' && addTemplate()} + /> + + +
+
+ + {/* Templates list */} +
+ {templates.length === 0 && ( +
+ Нет шаблонов. Создайте первый шаблон чек-листа. +
+ )} + {templates.map(tpl => ( +
+ {/* Template header */} +
+ {editingTemplateId === tpl.id ? ( + updateTemplateName(tpl.id, name)} + onCancel={() => setEditingTemplateId(null)} + /> + ) : ( + <> + {tpl.name} + {tpl.taskType && ( + + {TASK_TYPE_LABELS[tpl.taskType] ?? tpl.taskType} + + )} + {!tpl.taskType && ( + + Любой тип + + )} + + + + + )} +
+ + {/* Items */} +
+ {tpl.items.map((item, idx) => ( +
+ {idx + 1}. + {editingItemId === item.id ? ( + updateItemText(tpl.id, item, text)} + onCancel={() => setEditingItemId(null)} + /> + ) : ( + <> + {item.text} +
+ + + + +
+ + )} +
+ ))} + + {/* Add item */} +
+ setNewItemText(prev => ({ ...prev, [tpl.id]: e.target.value }))} + placeholder="Новый пункт..." + className="input flex-1 py-1.5 text-sm" + onKeyDown={e => e.key === 'Enter' && addItem(tpl.id)} + /> + +
+
+
+ ))} +
+
+ ) +} diff --git a/src/pages/DepositSettingsPage.tsx b/src/pages/DepositSettingsPage.tsx new file mode 100644 index 0000000..db46725 --- /dev/null +++ b/src/pages/DepositSettingsPage.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect } from 'react' +import { Save, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react' +import { api, type DepositSettings } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import { cn } from '../lib/utils' + +function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { + return ( + + ) +} + +export function DepositSettingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + const [isEnabled, setIsEnabled] = useState(false) + const [amount, setAmount] = useState('5000') + const [shopId, setShopId] = useState('') + const [secretKey, setSecretKey] = useState('') + const [showSecret, setShowSecret] = useState(false) + + useEffect(() => { + if (!slug) return + api.deposits.getSettings(slug) + .then(s => { + setSettings(s) + setIsEnabled(s.isEnabled) + setAmount(String(s.amount)) + setShopId(s.yookassaShopId ?? '') + setSecretKey(s.yookassaSecretKey ?? '') + }) + .catch(() => { + // No settings yet — defaults + setIsEnabled(false) + setAmount('5000') + setShopId('') + setSecretKey('') + }) + .finally(() => setLoading(false)) + }, [slug]) + + const handleSave = async () => { + setSaving(true) + setError(null) + try { + const payload: Record = { + is_enabled: isEnabled, + amount: parseFloat(amount) || 5000, + } + if (shopId) payload.yookassa_shop_id = shopId + if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey + + const updated = await api.deposits.updateSettings(slug, payload) + setSettings(updated) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch { + setError('Не удалось сохранить настройки') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ + Депозит +

+

+ Настройте предоплату или залог при заселении гостей. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Enable toggle */} +
+
+

Включить депозит

+

+ При включении на странице бронирования появятся кнопки для управления депозитом +

+
+ setIsEnabled(v => !v)} /> +
+ + {isEnabled && ( + <> +
+ {/* Amount */} +
+ + setAmount(e.target.value)} + className="input w-48" + /> +
+ + {/* YooKassa section */} +
+
+

ЮКасса (опционально)

+

+ Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты. Без этих данных доступен только наличный депозит. +

+
+ +
+ + setShopId(e.target.value)} + placeholder="123456" + className="input w-full" + /> +
+ +
+ +
+ setSecretKey(e.target.value)} + placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'} + className="input w-full pr-10" + /> + +
+ {settings?.yookassaSecretKey && ( +

+ Ключ уже сохранён. Оставьте поле пустым, чтобы не менять. +

+ )} +
+
+
+ + )} +
+ +
+ + {saved && ( + Настройки сохранены + )} +
+
+ ) +} diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index 69aee3a..d6285d4 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react' +import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera, ShoppingCart, Plus, Minus } from 'lucide-react' +import type { TaskChecklist, MinibarItem, MinibarConsumption } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { useHotelSocket } from '../hooks/useHotelSocket' import type { WsMessage } from '../hooks/useHotelSocket' @@ -313,7 +314,7 @@ export function HousekeepingPage() {
{colTasks.map(task => ( - addMaintenanceReport(id, note, sev, photos)} /> + addMaintenanceReport(id, note, sev, photos)} /> ))} {colTasks.length === 0 && (
@@ -622,8 +623,9 @@ const SEVERITY_CONFIG = { high: { label: 'Экстренно!', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-200 dark:border-red-800', dot: 'bg-red-500' }, } -function TaskCard({ task, onStatusChange, onReport }: { +function TaskCard({ task, onStatusChange, onReport, slug }: { task: HousekeepingTask + slug: string onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void }) { @@ -636,6 +638,87 @@ function TaskCard({ task, onStatusChange, onReport }: { const [completingOpen, setCompletingOpen] = useState(false) const [completionComment, setCompletionComment] = useState('') + // Checklist + Minibar detail panel + const [detailOpen, setDetailOpen] = useState(false) + const [detailTab, setDetailTab] = useState<'checklist' | 'minibar'>('checklist') + const [checklist, setChecklist] = useState(null) + const [checklistLoading, setChecklistLoading] = useState(false) + const [minibarItems, setMinibarItems] = useState([]) + const [consumptions, setConsumptions] = useState([]) + const [minibarLoading, setMinibarLoading] = useState(false) + const [minibarQty, setMinibarQty] = useState>({}) + const [minibarSaving, setMinibarSaving] = useState(false) + + const openDetail = async (tab: 'checklist' | 'minibar') => { + setDetailTab(tab) + setDetailOpen(true) + if (tab === 'checklist' && !checklist) { + setChecklistLoading(true) + try { + const cl = await api.checklists.getTaskChecklist(slug, task.id) + setChecklist(cl) + } catch { /* ignore */ } finally { + setChecklistLoading(false) + } + } + if (tab === 'minibar' && minibarItems.length === 0) { + setMinibarLoading(true) + try { + const [items, cons] = await Promise.all([ + api.minibar.listItems(slug), + api.minibar.getTaskConsumptions(slug, task.id), + ]) + setMinibarItems(items) + setConsumptions(cons) + } catch { /* ignore */ } finally { + setMinibarLoading(false) + } + } + } + + const toggleChecklistItem = async (itemId: string, completed: boolean) => { + if (!checklist) return + try { + if (completed) { + await api.checklists.uncompleteItem(slug, task.id, itemId) + setChecklist(prev => prev ? { + ...prev, + items: prev.items.map(i => i.id === itemId ? { ...i, completedAt: null, completedBy: null } : i), + } : null) + } else { + await api.checklists.completeItem(slug, task.id, itemId) + setChecklist(prev => prev ? { + ...prev, + items: prev.items.map(i => i.id === itemId ? { ...i, completedAt: new Date().toISOString(), completedBy: 'Вы' } : i), + } : null) + } + } catch { /* ignore */ } + } + + const submitMinibar = async () => { + const entries = Object.entries(minibarQty).filter(([, q]) => q > 0) + if (entries.length === 0) return + setMinibarSaving(true) + try { + const newCons: MinibarConsumption[] = [] + for (const [itemId, qty] of entries) { + const c = await api.minibar.addConsumption(slug, task.id, { itemId, quantity: qty }) + newCons.push(c) + } + setConsumptions(prev => [...prev, ...newCons]) + setMinibarQty({}) + } catch { /* ignore */ } finally { + setMinibarSaving(false) + } + } + + const deleteConsumption = async (id: string) => { + try { + await api.minibar.deleteConsumption(slug, id) + setConsumptions(prev => prev.filter(c => c.id !== id)) + } catch { /* ignore */ } + } + const uploadReportPhoto = async (file: File) => { setPhotoUploading(true) try { @@ -835,6 +918,146 @@ function TaskCard({ task, onStatusChange, onReport }: {
)} + {/* Detail panel: Checklist + Minibar */} + {detailOpen && ( +
+
+
+ + +
+ +
+ + {/* Checklist tab */} + {detailTab === 'checklist' && ( +
+ {checklistLoading &&
} + {!checklistLoading && checklist && checklist.items.length === 0 && ( +

Шаблон чек-листа не настроен

+ )} + {!checklistLoading && checklist && checklist.items.map(item => { + const done = !!item.completedAt + return ( + + ) + })} +
+ )} + + {/* Minibar tab */} + {detailTab === 'minibar' && ( +
+ {minibarLoading &&
} + + {!minibarLoading && minibarItems.length === 0 && ( +

Позиции минибара не настроены

+ )} + + {!minibarLoading && minibarItems.length > 0 && ( +
+ {minibarItems.map(item => { + const qty = minibarQty[item.id] ?? 0 + return ( +
+ {item.name} + {Number(item.price).toFixed(0)} руб. +
+ + {qty} + +
+
+ ) + })} + {Object.values(minibarQty).some(q => q > 0) && ( + + )} +
+ )} + + {/* Recorded consumptions */} + {consumptions.length > 0 && ( +
+

Записано

+ {consumptions.map(c => ( +
+ {c.itemName} × {c.quantity} + {(Number(c.pricePerUnit) * c.quantity).toFixed(0)} руб. + +
+ ))} +
+ )} +
+ )} +
+ )} +
{task.status === 'pending' && ( + )} {!reportOpen && !completingOpen && ( + +
+ ) +} + +export function MinibarSettingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editingId, setEditingId] = useState(null) + const [adding, setAdding] = useState(false) + const [addForm, setAddForm] = useState({ name: '', price: '', category: '' }) + const [addingRow, setAddingRow] = useState(false) + + useEffect(() => { + if (!slug) return + api.minibar.listItems(slug) + .then(setItems) + .catch(() => setError('Не удалось загрузить позиции минибара')) + .finally(() => setLoading(false)) + }, [slug]) + + const handleAdd = async (v: EditRow) => { + if (!v.name.trim()) return + setAddingRow(true) + try { + const item = await api.minibar.createItem(slug, { + name: v.name.trim(), + price: parseFloat(v.price) || 0, + category: v.category.trim() || undefined, + }) + setItems(prev => [...prev, item]) + setAdding(false) + setAddForm({ name: '', price: '', category: '' }) + } catch { + setError('Не удалось добавить позицию') + } finally { + setAddingRow(false) + } + } + + const handleUpdate = async (id: string, v: EditRow) => { + try { + const updated = await api.minibar.updateItem(slug, id, { + name: v.name.trim(), + price: parseFloat(v.price) || 0, + category: v.category.trim() || undefined, + }) + setItems(prev => prev.map(i => i.id === id ? { ...i, ...updated } : i)) + } catch { /* ignore */ } + setEditingId(null) + } + + const handleDelete = async (id: string) => { + if (!confirm('Скрыть позицию из минибара?')) return + try { + await api.minibar.deleteItem(slug, id) + setItems(prev => prev.filter(i => i.id !== id)) + } catch { /* ignore */ } + } + + // Group by category + const grouped = items.reduce>((acc, item) => { + const cat = item.category ?? 'Без категории' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(item) + return acc + }, {}) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ + Минибар +

+

+ Настройте позиции минибара. Горничные смогут отмечать потреблённые гостем товары во время уборки. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ Позиции минибара + {!adding && ( + + )} +
+ +
+ {/* Table header */} +
+ Название + Цена, руб. + Категория + +
+ + {/* Add row */} + {adding && ( +
+ setAddForm(p => ({ ...p, name: e.target.value }))} + placeholder="Название" + className="input py-1 text-sm" + onKeyDown={e => e.key === 'Enter' && handleAdd(addForm)} + /> + setAddForm(p => ({ ...p, price: e.target.value }))} + placeholder="0.00" + className="input py-1 text-sm" + /> + setAddForm(p => ({ ...p, category: e.target.value }))} + placeholder="Напитки..." + className="input py-1 text-sm" + /> +
+ + +
+
+ )} + + {/* Group items */} + {Object.entries(grouped).map(([cat, catItems]) => ( +
+
+ {cat} +
+ {catItems.map(item => ( +
+ {editingId === item.id ? ( + <> +
+ handleUpdate(item.id, v)} + onCancel={() => setEditingId(null)} + /> +
+ + ) : ( + <> + {item.name} + {Number(item.price).toFixed(2)} + {item.category ?? '—'} +
+ + +
+ + )} +
+ ))} +
+ ))} + + {items.length === 0 && !adding && ( +
+ Позиции не добавлены. Нажмите «Добавить» для создания первой позиции. +
+ )} +
+
+
+ ) +}