feat: checklists, minibar and deposit modules
- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits - Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture) - YooKassa service for hold/capture/cancel payments - Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage - HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons - BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings - Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
backend/migrations/057_checklists.sql
Normal file
27
backend/migrations/057_checklists.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
22
backend/migrations/058_minibar.sql
Normal file
22
backend/migrations/058_minibar.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
34
backend/migrations/059_deposits.sql
Normal file
34
backend/migrations/059_deposits.sql
Normal file
@@ -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);
|
||||||
@@ -38,6 +38,9 @@ import workstationRoutes from './routes/workstations'
|
|||||||
import agentReleaseRoutes from './routes/agent-release'
|
import agentReleaseRoutes from './routes/agent-release'
|
||||||
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
||||||
import ttlockRoutes from './routes/ttlock'
|
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 { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -128,6 +131,9 @@ export async function buildApp() {
|
|||||||
await fastify.register(wifiSettingsRoutes)
|
await fastify.register(wifiSettingsRoutes)
|
||||||
await fastify.register(wifiAuthVerify)
|
await fastify.register(wifiAuthVerify)
|
||||||
await fastify.register(ttlockRoutes)
|
await fastify.register(ttlockRoutes)
|
||||||
|
await fastify.register(checklistsRoutes)
|
||||||
|
await fastify.register(minibarRoutes)
|
||||||
|
await fastify.register(depositRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
export const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST ?? 'smtp.timeweb.ru',
|
host: process.env.SMTP_HOST ?? 'smtp.timeweb.ru',
|
||||||
port: parseInt(process.env.SMTP_PORT ?? '465', 10),
|
port: parseInt(process.env.SMTP_PORT ?? '465', 10),
|
||||||
secure: true,
|
secure: true,
|
||||||
|
|||||||
372
backend/src/routes/checklists.ts
Normal file
372
backend/src/routes/checklists.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<SlugParam>(
|
||||||
|
'/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<string, unknown>) => ({
|
||||||
|
...t,
|
||||||
|
items: items.filter((i: Record<string, unknown>) => i.template_id === t.id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── POST /api/hotels/:slug/checklist-templates ────────────────────────────
|
||||||
|
fastify.post<SlugParam & { Body: { name: string; task_type?: string } }>(
|
||||||
|
'/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<SlugTplParam & { Body: { name?: string; task_type?: string | null; is_active?: boolean; sort_order?: number } }>(
|
||||||
|
'/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<string, unknown>
|
||||||
|
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<SlugTplParam>(
|
||||||
|
'/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<SlugTplParam & { Body: { text: string; sort_order?: number } }>(
|
||||||
|
'/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<SlugTplItemParam & { Body: { text?: string; sort_order?: number } }>(
|
||||||
|
'/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<string, unknown>
|
||||||
|
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<SlugTplItemParam>(
|
||||||
|
'/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<SlugTaskParam>(
|
||||||
|
'/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<SlugTaskItemParam>(
|
||||||
|
'/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<SlugTaskItemParam>(
|
||||||
|
'/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<SlugParam & { Querystring: { from?: string; to?: string } }>(
|
||||||
|
'/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
|
||||||
315
backend/src/routes/deposit.ts
Normal file
315
backend/src/routes/deposit.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<SlugParam>(
|
||||||
|
'/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<SlugParam & { Body: {
|
||||||
|
is_enabled?: boolean; amount?: number
|
||||||
|
yookassa_shop_id?: string; yookassa_secret_key?: string
|
||||||
|
} }>(
|
||||||
|
'/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<SlugBookingParam>(
|
||||||
|
'/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<SlugBookingParam>(
|
||||||
|
'/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<SlugBookingParam>(
|
||||||
|
'/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<SlugBookingParam & { Body: { capturedAmount: number; reason?: string } }>(
|
||||||
|
'/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<string, string> = {
|
||||||
|
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
|
||||||
253
backend/src/routes/minibar.ts
Normal file
253
backend/src/routes/minibar.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<SlugParam>(
|
||||||
|
'/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<SlugParam & { Body: { name: string; price: number; category?: string; sort_order?: number } }>(
|
||||||
|
'/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<SlugItemParam & { Body: { name?: string; price?: number; category?: string; sort_order?: number; is_active?: boolean } }>(
|
||||||
|
'/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<string, unknown>
|
||||||
|
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<SlugItemParam>(
|
||||||
|
'/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<SlugTaskParam>(
|
||||||
|
'/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<SlugTaskParam & { Body: { item_id: string; quantity: number } }>(
|
||||||
|
'/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<SlugConsumpParam>(
|
||||||
|
'/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<SlugBookingParam>(
|
||||||
|
'/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
|
||||||
82
backend/src/services/yookassa.ts
Normal file
82
backend/src/services/yookassa.ts
Normal file
@@ -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<YooKassaPayment> {
|
||||||
|
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<YooKassaPayment>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function capturePayment(params: {
|
||||||
|
shopId: string
|
||||||
|
secretKey: string
|
||||||
|
paymentId: string
|
||||||
|
amount: number
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ import { BillingPage } from './pages/BillingPage'
|
|||||||
import { EquipmentPage } from './pages/EquipmentPage'
|
import { EquipmentPage } from './pages/EquipmentPage'
|
||||||
import { WiFiPage } from './pages/WiFiPage'
|
import { WiFiPage } from './pages/WiFiPage'
|
||||||
import { TTLockPage } from './pages/TTLockPage'
|
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'
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -98,6 +101,9 @@ export default function App() {
|
|||||||
<Route path="/equipment" element={<EquipmentPage />} />
|
<Route path="/equipment" element={<EquipmentPage />} />
|
||||||
<Route path="/wifi" element={<WiFiPage />} />
|
<Route path="/wifi" element={<WiFiPage />} />
|
||||||
<Route path="/ttlock" element={<TTLockPage />} />
|
<Route path="/ttlock" element={<TTLockPage />} />
|
||||||
|
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
||||||
|
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
|
||||||
|
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useLayoutEffect, useRef, useEffect } from 'react'
|
import { useState, useLayoutEffect, useRef, useEffect } from 'react'
|
||||||
import { format, addDays, parseISO } from 'date-fns'
|
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 { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
|
||||||
import type { Discount } from '../../pages/DiscountsPage'
|
import type { Discount } from '../../pages/DiscountsPage'
|
||||||
import { Modal } from '../ui/Modal'
|
import { Modal } from '../ui/Modal'
|
||||||
@@ -247,6 +248,83 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
const [tvSent, setTvSent] = useState(false)
|
const [tvSent, setTvSent] = useState(false)
|
||||||
const [tvError, setTvError] = useState('')
|
const [tvError, setTvError] = useState('')
|
||||||
|
|
||||||
|
// Minibar charges state (for existing bookings)
|
||||||
|
const [minibarCharges, setMinibarCharges] = useState<MinibarBookingCharge[]>([])
|
||||||
|
const [minibarLoaded, setMinibarLoaded] = useState(false)
|
||||||
|
|
||||||
|
// Deposit state (for existing bookings)
|
||||||
|
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
|
||||||
|
const [depositLoaded, setDepositLoaded] = useState(false)
|
||||||
|
const [depositSaving, setDepositSaving] = useState(false)
|
||||||
|
const [depositError, setDepositError] = useState<string | null>(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 () => {
|
const handleSendTv = async () => {
|
||||||
if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return
|
if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return
|
||||||
setTvSending(true)
|
setTvSending(true)
|
||||||
@@ -1377,6 +1455,145 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
|||||||
{/* end room form */}
|
{/* end room form */}
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* ── Minibar charges (existing bookings only) ── */}
|
||||||
|
{existing && minibarLoaded && minibarCharges.length > 0 && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ShoppingCart size={14} className="text-brand-600" />
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">Минибар</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{minibarCharges.map(c => (
|
||||||
|
<div key={c.id} className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
<span className="flex-1 truncate">{c.itemName}</span>
|
||||||
|
<span className="shrink-0">× {c.quantity}</span>
|
||||||
|
<span className="shrink-0 font-medium">{Number(c.total).toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-slate-100 dark:border-slate-700">
|
||||||
|
<span className="text-xs text-slate-500">Итого</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{minibarCharges.reduce((s, c) => s + Number(c.total), 0).toLocaleString('ru-RU')} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Deposit section (existing bookings only) ── */}
|
||||||
|
{existing && depositLoaded && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ShieldCheck size={14} className="text-brand-600" />
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">Депозит</span>
|
||||||
|
{deposit && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full font-medium',
|
||||||
|
deposit.status === 'paid_cash' || deposit.status === 'hold_confirmed' || deposit.status === 'captured'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||||
|
: deposit.status === 'refunded' || deposit.status === 'cancelled'
|
||||||
|
? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
)}>
|
||||||
|
{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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{depositError && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mb-2">{depositError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!deposit && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePayByCash}
|
||||||
|
disabled={depositSaving}
|
||||||
|
className="btn-secondary py-1.5 px-3 text-xs flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <Banknote size={12} />}
|
||||||
|
Наличные
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateYookassaHold}
|
||||||
|
disabled={depositSaving}
|
||||||
|
className="btn-secondary py-1.5 px-3 text-xs flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <CreditCard size={12} />}
|
||||||
|
ЮКасса (холд)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deposit && ['paid_cash', 'hold_created', 'hold_confirmed'].includes(deposit.status) && !showReleaseForm && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
Сумма: <strong>{Number(deposit.amount).toLocaleString('ru-RU')} ₽</strong>
|
||||||
|
</p>
|
||||||
|
{deposit.yookassaConfirmationUrl && deposit.status === 'hold_created' && (
|
||||||
|
<a
|
||||||
|
href={deposit.yookassaConfirmationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Ссылка для оплаты
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReleaseForm(true)}
|
||||||
|
className="btn-secondary py-1.5 px-3 text-xs"
|
||||||
|
>
|
||||||
|
Завершить / вернуть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deposit && showReleaseForm && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
Сумма депозита: {Number(deposit.amount).toLocaleString('ru-RU')} ₽
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 block mb-1">Сумма к удержанию (0 = полный возврат)</label>
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="100"
|
||||||
|
value={captureAmount}
|
||||||
|
onChange={e => setCaptureAmount(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="input w-32 text-sm py-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 block mb-1">Причина удержания</label>
|
||||||
|
<input
|
||||||
|
value={captureReason}
|
||||||
|
onChange={e => setCaptureReason(e.target.value)}
|
||||||
|
placeholder="Порча имущества..."
|
||||||
|
className="input w-full text-sm py-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleReleaseDeposit} disabled={depositSaving} className="btn-primary py-1.5 px-3 text-xs flex items-center gap-1.5">
|
||||||
|
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle2 size={12} />}
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowReleaseForm(false)} className="btn-secondary py-1.5 px-3 text-xs">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
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,
|
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, KeyRound,
|
||||||
|
ListChecks, ShoppingCart, ShieldCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
@@ -185,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
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'],
|
devGroup: ['/api-docs'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +420,9 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
||||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
||||||
|
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
|
||||||
|
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
|
||||||
|
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
217
src/lib/api.ts
217
src/lib/api.ts
@@ -640,6 +640,118 @@ export const api = {
|
|||||||
getCards: (slug: string, bookingId: string) =>
|
getCards: (slug: string, bookingId: string) =>
|
||||||
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
|
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Checklists ────────────────────────────────────────────────────────────
|
||||||
|
checklists: {
|
||||||
|
listTemplates: (slug: string) =>
|
||||||
|
req<ChecklistTemplate[]>('GET', `/api/hotels/${slug}/checklist-templates`),
|
||||||
|
|
||||||
|
createTemplate: (slug: string, data: { name: string; taskType?: string }) =>
|
||||||
|
req<ChecklistTemplate>('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<ChecklistTemplate>('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<void>('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}`),
|
||||||
|
|
||||||
|
addItem: (slug: string, templateId: string, data: { text: string; sortOrder?: number }) =>
|
||||||
|
req<ChecklistItem>('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<ChecklistItem>('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`, {
|
||||||
|
text: data.text,
|
||||||
|
sort_order: data.sortOrder,
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteItem: (slug: string, templateId: string, itemId: string) =>
|
||||||
|
req<void>('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`),
|
||||||
|
|
||||||
|
getTaskChecklist: (slug: string, taskId: string) =>
|
||||||
|
req<TaskChecklist>('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<void>('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Minibar ───────────────────────────────────────────────────────────────
|
||||||
|
minibar: {
|
||||||
|
listItems: (slug: string) =>
|
||||||
|
req<MinibarItem[]>('GET', `/api/hotels/${slug}/minibar-items`),
|
||||||
|
|
||||||
|
createItem: (slug: string, data: { name: string; price: number; category?: string; sortOrder?: number }) =>
|
||||||
|
req<MinibarItem>('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<MinibarItem>('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<void>('DELETE', `/api/hotels/${slug}/minibar-items/${itemId}`),
|
||||||
|
|
||||||
|
getTaskConsumptions: (slug: string, taskId: string) =>
|
||||||
|
req<MinibarConsumption[]>('GET', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`),
|
||||||
|
|
||||||
|
addConsumption: (slug: string, taskId: string, data: { itemId: string; quantity: number }) =>
|
||||||
|
req<MinibarConsumption>('POST', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`, {
|
||||||
|
item_id: data.itemId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteConsumption: (slug: string, consumptionId: string) =>
|
||||||
|
req<void>('DELETE', `/api/hotels/${slug}/minibar-consumptions/${consumptionId}`),
|
||||||
|
|
||||||
|
getBookingMinibar: (slug: string, bookingId: string) =>
|
||||||
|
req<MinibarBookingCharge[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Deposits ──────────────────────────────────────────────────────────────
|
||||||
|
deposits: {
|
||||||
|
getSettings: (slug: string) =>
|
||||||
|
req<DepositSettings>('GET', `/api/hotels/${slug}/deposit/settings`),
|
||||||
|
|
||||||
|
updateSettings: (slug: string, data: Partial<DepositSettingsPayload>) =>
|
||||||
|
req<DepositSettings>('PATCH', `/api/hotels/${slug}/deposit/settings`, data),
|
||||||
|
|
||||||
|
getBookingDeposit: (slug: string, bookingId: string) =>
|
||||||
|
req<BookingDeposit>('GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
||||||
|
|
||||||
|
payByCash: (slug: string, bookingId: string) =>
|
||||||
|
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/cash`),
|
||||||
|
|
||||||
|
createYookassaHold: (slug: string, bookingId: string) =>
|
||||||
|
req<BookingDeposit & { confirmationUrl: string | null }>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/yookassa`),
|
||||||
|
|
||||||
|
release: (slug: string, bookingId: string, capturedAmount: number, reason?: string) =>
|
||||||
|
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/release`, {
|
||||||
|
captured_amount: capturedAmount,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schedule ─────────────────────────────────────────────────────────────────
|
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1153,6 +1265,111 @@ export interface WifiSettings {
|
|||||||
sessionHours: number
|
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<string, unknown> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
if (h.name !== undefined) out.name = h.name
|
if (h.name !== undefined) out.name = h.name
|
||||||
|
|||||||
332
src/pages/ChecklistSettingsPage.tsx
Normal file
332
src/pages/ChecklistSettingsPage.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
checkout: 'После выезда',
|
||||||
|
daily: 'Ежедневная',
|
||||||
|
deep: 'Генеральная',
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineEdit({ value, onSave, onCancel }: {
|
||||||
|
value: string
|
||||||
|
onSave: (v: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState(value)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={v}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button onClick={() => onSave(v.trim())} className="p-1 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="p-1 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChecklistSettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<ChecklistTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// New template form
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newType, setNewType] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
// Editing states
|
||||||
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
||||||
|
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||||||
|
const [newItemText, setNewItemText] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<ListChecks size={22} className="text-brand-600" />
|
||||||
|
Шаблоны чек-листов
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Настройте пункты проверки для задач уборки. Шаблон применяется автоматически по типу задачи.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add template form */}
|
||||||
|
<div className="card p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Новый шаблон</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
placeholder="Название шаблона"
|
||||||
|
className="input flex-1"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addTemplate()}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newType}
|
||||||
|
onChange={e => setNewType(e.target.value)}
|
||||||
|
className="input w-44"
|
||||||
|
>
|
||||||
|
<option value="">Любой тип</option>
|
||||||
|
<option value="checkout">После выезда</option>
|
||||||
|
<option value="daily">Ежедневная</option>
|
||||||
|
<option value="deep">Генеральная</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={addTemplate}
|
||||||
|
disabled={!newName.trim() || adding}
|
||||||
|
className="btn-primary flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates list */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-400 dark:text-slate-500">
|
||||||
|
Нет шаблонов. Создайте первый шаблон чек-листа.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{templates.map(tpl => (
|
||||||
|
<div key={tpl.id} className={cn(
|
||||||
|
'border rounded-xl overflow-hidden',
|
||||||
|
tpl.isActive
|
||||||
|
? 'border-slate-200 dark:border-slate-700'
|
||||||
|
: 'border-dashed border-slate-200 dark:border-slate-700 opacity-60',
|
||||||
|
)}>
|
||||||
|
{/* Template header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
{editingTemplateId === tpl.id ? (
|
||||||
|
<InlineEdit
|
||||||
|
value={tpl.name}
|
||||||
|
onSave={name => updateTemplateName(tpl.id, name)}
|
||||||
|
onCancel={() => setEditingTemplateId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-slate-800 dark:text-slate-200 flex-1">{tpl.name}</span>
|
||||||
|
{tpl.taskType && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||||
|
{TASK_TYPE_LABELS[tpl.taskType] ?? tpl.taskType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!tpl.taskType && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||||
|
Любой тип
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setEditingTemplateId(tpl.id)} className="p-1.5 rounded hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-400">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => toggleActive(tpl)} className={cn('p-1.5 rounded text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600', tpl.isActive ? 'text-brand-600' : 'text-slate-400')}>
|
||||||
|
{tpl.isActive ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteTemplate(tpl.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="p-4 bg-white dark:bg-slate-900 space-y-1.5">
|
||||||
|
{tpl.items.map((item, idx) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-2 group">
|
||||||
|
<span className="text-slate-400 text-xs w-5 text-right shrink-0">{idx + 1}.</span>
|
||||||
|
{editingItemId === item.id ? (
|
||||||
|
<InlineEdit
|
||||||
|
value={item.text}
|
||||||
|
onSave={text => updateItemText(tpl.id, item, text)}
|
||||||
|
onCancel={() => setEditingItemId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{item.text}</span>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onClick={() => moveItem(tpl.id, item.id, 'up')} disabled={idx === 0} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||||||
|
<ChevronUp size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => moveItem(tpl.id, item.id, 'down')} disabled={idx === tpl.items.length - 1} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingItemId(item.id)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteItem(tpl.id, item.id)} className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add item */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||||
|
<input
|
||||||
|
value={newItemText[tpl.id] ?? ''}
|
||||||
|
onChange={e => 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)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => addItem(tpl.id)}
|
||||||
|
disabled={!newItemText[tpl.id]?.trim()}
|
||||||
|
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/pages/DepositSettingsPage.tsx
Normal file
194
src/pages/DepositSettingsPage.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={onChange}
|
||||||
|
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DepositSettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<DepositSettings | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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<string, unknown> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<ShieldCheck size={22} className="text-brand-600" />
|
||||||
|
Депозит
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Настройте предоплату или залог при заселении гостей.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card p-5 space-y-5">
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Включить депозит</p>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
При включении на странице бронирования появятся кнопки для управления депозитом
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle on={isEnabled} onChange={() => setIsEnabled(v => !v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
|
||||||
|
{/* Amount */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Сумма депозита, руб.</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
className="input w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* YooKassa section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты. Без этих данных доступен только наличный депозит.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">ID магазина (shopId)</label>
|
||||||
|
<input
|
||||||
|
value={shopId}
|
||||||
|
onChange={e => setShopId(e.target.value)}
|
||||||
|
placeholder="123456"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Секретный ключ</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showSecret ? 'text' : 'password'}
|
||||||
|
value={secretKey}
|
||||||
|
onChange={e => setSecretKey(e.target.value)}
|
||||||
|
placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'}
|
||||||
|
className="input w-full pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSecret(s => !s)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showSecret ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{settings?.yookassaSecretKey && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
{saved ? 'Сохранено!' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
{saved && (
|
||||||
|
<span className="text-sm text-emerald-600 dark:text-emerald-400">Настройки сохранены</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 { useAuth } from '../contexts/AuthContext'
|
||||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||||
@@ -313,7 +314,7 @@ export function HousekeepingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{colTasks.map(task => (
|
{colTasks.map(task => (
|
||||||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
|
<TaskCard key={task.id} task={task} slug={slug} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
|
||||||
))}
|
))}
|
||||||
{colTasks.length === 0 && (
|
{colTasks.length === 0 && (
|
||||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||||
@@ -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' },
|
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
|
task: HousekeepingTask
|
||||||
|
slug: string
|
||||||
onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void
|
onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void
|
||||||
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: 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 [completingOpen, setCompletingOpen] = useState(false)
|
||||||
const [completionComment, setCompletionComment] = useState('')
|
const [completionComment, setCompletionComment] = useState('')
|
||||||
|
|
||||||
|
// Checklist + Minibar detail panel
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailTab, setDetailTab] = useState<'checklist' | 'minibar'>('checklist')
|
||||||
|
const [checklist, setChecklist] = useState<TaskChecklist | null>(null)
|
||||||
|
const [checklistLoading, setChecklistLoading] = useState(false)
|
||||||
|
const [minibarItems, setMinibarItems] = useState<MinibarItem[]>([])
|
||||||
|
const [consumptions, setConsumptions] = useState<MinibarConsumption[]>([])
|
||||||
|
const [minibarLoading, setMinibarLoading] = useState(false)
|
||||||
|
const [minibarQty, setMinibarQty] = useState<Record<string, number>>({})
|
||||||
|
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) => {
|
const uploadReportPhoto = async (file: File) => {
|
||||||
setPhotoUploading(true)
|
setPhotoUploading(true)
|
||||||
try {
|
try {
|
||||||
@@ -835,6 +918,146 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Detail panel: Checklist + Minibar */}
|
||||||
|
{detailOpen && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setDetailTab('checklist'); if (!checklist) openDetail('checklist') }}
|
||||||
|
className={cn(
|
||||||
|
'text-xs py-1 px-2.5 rounded-md font-medium transition-colors flex items-center gap-1',
|
||||||
|
detailTab === 'checklist'
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListChecks size={11} /> Чек-лист
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDetailTab('minibar'); if (minibarItems.length === 0) openDetail('minibar') }}
|
||||||
|
className={cn(
|
||||||
|
'text-xs py-1 px-2.5 rounded-md font-medium transition-colors flex items-center gap-1',
|
||||||
|
detailTab === 'minibar'
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShoppingCart size={11} /> Минибар
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setDetailOpen(false)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
||||||
|
<XIcon size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checklist tab */}
|
||||||
|
{detailTab === 'checklist' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{checklistLoading && <div className="flex justify-center py-4"><Loader2 size={16} className="animate-spin text-brand-600" /></div>}
|
||||||
|
{!checklistLoading && checklist && checklist.items.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 text-center py-3">Шаблон чек-листа не настроен</p>
|
||||||
|
)}
|
||||||
|
{!checklistLoading && checklist && checklist.items.map(item => {
|
||||||
|
const done = !!item.completedAt
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => toggleChecklistItem(item.id, done)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left flex items-start gap-2 p-1.5 rounded-lg transition-colors',
|
||||||
|
done ? 'bg-emerald-50 dark:bg-emerald-900/10' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-4 h-4 rounded border-2 shrink-0 mt-0.5 flex items-center justify-center transition-colors',
|
||||||
|
done ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300 dark:border-slate-500',
|
||||||
|
)}>
|
||||||
|
{done && <CheckCircle2 size={10} className="text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={cn('text-xs', done ? 'line-through text-slate-400' : 'text-slate-700 dark:text-slate-300')}>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
{done && item.completedBy && (
|
||||||
|
<span className="block text-[10px] text-slate-400 dark:text-slate-500">{item.completedBy}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Minibar tab */}
|
||||||
|
{detailTab === 'minibar' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{minibarLoading && <div className="flex justify-center py-4"><Loader2 size={16} className="animate-spin text-brand-600" /></div>}
|
||||||
|
|
||||||
|
{!minibarLoading && minibarItems.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 text-center py-3">Позиции минибара не настроены</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!minibarLoading && minibarItems.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{minibarItems.map(item => {
|
||||||
|
const qty = minibarQty[item.id] ?? 0
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="flex items-center gap-2 py-1">
|
||||||
|
<span className="flex-1 text-xs text-slate-700 dark:text-slate-300 truncate">{item.name}</span>
|
||||||
|
<span className="text-xs text-slate-400 w-14 text-right shrink-0">{Number(item.price).toFixed(0)} руб.</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setMinibarQty(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.id] ?? 0) - 1) }))}
|
||||||
|
className="w-6 h-6 rounded bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-30"
|
||||||
|
disabled={qty === 0}
|
||||||
|
>
|
||||||
|
<Minus size={10} />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-xs font-medium text-slate-700 dark:text-slate-300">{qty}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMinibarQty(prev => ({ ...prev, [item.id]: (prev[item.id] ?? 0) + 1 }))}
|
||||||
|
className="w-6 h-6 rounded bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
<Plus size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{Object.values(minibarQty).some(q => q > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={submitMinibar}
|
||||||
|
disabled={minibarSaving}
|
||||||
|
className="w-full text-xs py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium mt-2 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{minibarSaving ? <Loader2 size={11} className="animate-spin" /> : <Send size={11} />}
|
||||||
|
Записать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recorded consumptions */}
|
||||||
|
{consumptions.length > 0 && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1">
|
||||||
|
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide">Записано</p>
|
||||||
|
{consumptions.map(c => (
|
||||||
|
<div key={c.id} className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
<span className="flex-1 truncate">{c.itemName} × {c.quantity}</span>
|
||||||
|
<span className="shrink-0">{(Number(c.pricePerUnit) * c.quantity).toFixed(0)} руб.</span>
|
||||||
|
<button onClick={() => deleteConsumption(c.id)} className="text-slate-300 hover:text-red-500">
|
||||||
|
<XIcon size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-1.5 pt-1">
|
<div className="flex gap-1.5 pt-1">
|
||||||
{task.status === 'pending' && (
|
{task.status === 'pending' && (
|
||||||
<button
|
<button
|
||||||
@@ -852,6 +1075,15 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
Завершить
|
Завершить
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!reportOpen && !completingOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => openDetail(detailOpen && detailTab === 'checklist' ? 'minibar' : 'checklist')}
|
||||||
|
title="Чек-лист / Минибар"
|
||||||
|
className="text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0 bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ListChecks size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!reportOpen && !completingOpen && (
|
{!reportOpen && !completingOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setReportOpen(true)}
|
onClick={() => setReportOpen(true)}
|
||||||
|
|||||||
247
src/pages/MinibarSettingsPage.tsx
Normal file
247
src/pages/MinibarSettingsPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart } from 'lucide-react'
|
||||||
|
import { api, type MinibarItem } from '../lib/api'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
interface EditRow {
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditForm({ initial, onSave, onCancel }: {
|
||||||
|
initial: EditRow
|
||||||
|
onSave: (v: EditRow) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState(initial)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={v.name}
|
||||||
|
onChange={e => setV(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="Название"
|
||||||
|
className="input flex-1 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={v.price}
|
||||||
|
onChange={e => setV(p => ({ ...p, price: e.target.value }))}
|
||||||
|
placeholder="Цена"
|
||||||
|
className="input w-24 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={v.category}
|
||||||
|
onChange={e => setV(p => ({ ...p, category: e.target.value }))}
|
||||||
|
placeholder="Категория"
|
||||||
|
className="input w-32 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={() => onSave(v)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MinibarSettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [items, setItems] = useState<MinibarItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [addForm, setAddForm] = useState<EditRow>({ 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<Record<string, MinibarItem[]>>((acc, item) => {
|
||||||
|
const cat = item.category ?? 'Без категории'
|
||||||
|
if (!acc[cat]) acc[cat] = []
|
||||||
|
acc[cat].push(item)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<ShoppingCart size={22} className="text-brand-600" />
|
||||||
|
Минибар
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Настройте позиции минибара. Горничные смогут отмечать потреблённые гостем товары во время уборки.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Позиции минибара</span>
|
||||||
|
{!adding && (
|
||||||
|
<button onClick={() => setAdding(true)} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
|
||||||
|
<Plus size={14} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="grid grid-cols-[1fr_100px_120px_64px] gap-2 text-xs font-medium text-slate-500 dark:text-slate-400 px-1">
|
||||||
|
<span>Название</span>
|
||||||
|
<span>Цена, руб.</span>
|
||||||
|
<span>Категория</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add row */}
|
||||||
|
{adding && (
|
||||||
|
<div className="grid grid-cols-[1fr_100px_120px_64px] gap-2 items-center bg-brand-50 dark:bg-brand-900/10 rounded-lg px-1 py-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={e => setAddForm(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="Название"
|
||||||
|
className="input py-1 text-sm"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdd(addForm)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="0.01"
|
||||||
|
value={addForm.price}
|
||||||
|
onChange={e => setAddForm(p => ({ ...p, price: e.target.value }))}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="input py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={addForm.category}
|
||||||
|
onChange={e => setAddForm(p => ({ ...p, category: e.target.value }))}
|
||||||
|
placeholder="Напитки..."
|
||||||
|
className="input py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleAdd(addForm)} disabled={!addForm.name.trim() || addingRow} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||||
|
{addingRow ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setAdding(false); setAddForm({ name: '', price: '', category: '' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group items */}
|
||||||
|
{Object.entries(grouped).map(([cat, catItems]) => (
|
||||||
|
<div key={cat} className="space-y-1">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide px-1 pt-1">
|
||||||
|
{cat}
|
||||||
|
</div>
|
||||||
|
{catItems.map(item => (
|
||||||
|
<div key={item.id} className="grid grid-cols-[1fr_100px_120px_64px] gap-2 items-center group px-1 py-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<>
|
||||||
|
<div className="col-span-4">
|
||||||
|
<EditForm
|
||||||
|
initial={{ name: item.name, price: String(item.price), category: item.category ?? '' }}
|
||||||
|
onSave={v => handleUpdate(item.id, v)}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-slate-700 dark:text-slate-300">{item.name}</span>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">{Number(item.price).toFixed(2)}</span>
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">{item.category ?? '—'}</span>
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onClick={() => setEditingId(item.id)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(item.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{items.length === 0 && !adding && (
|
||||||
|
<div className="text-center py-10 text-slate-400 dark:text-slate-500 text-sm">
|
||||||
|
Позиции не добавлены. Нажмите «Добавить» для создания первой позиции.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user