From 1f4def0a342611a7d54561a26f0ef8c904e5acab Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 6 Apr 2026 15:36:40 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20minibar=20=E2=80=94=20category=20dropdo?= =?UTF-8?q?wn,=20=D1=83=D1=87=D1=91=D1=82=20=D0=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20(=D0=BF=D1=80=D0=B8=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B/=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F/=D0=B8?= =?UTF-8?q?=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F/=D0=BE=D1=82=D1=87=D1=91=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MinibarSettingsPage: поле категории → выпадающий список с существующими + «Новая...» - Кнопка «Учёт и остатки» ведёт на /settings/minibar/stock - MinibarStockPage: 5 вкладок — Остатки, Приходы, Списания, Инвентаризация, Отчёт - api.ts: добавлены методы и типы для stock/receipts/writeoffs/inventory/report Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/063_minibar_stock.sql | 64 +++ backend/src/routes/minibar.ts | 443 +++++++++++++++ src/App.tsx | 4 +- src/components/layout/Sidebar.tsx | 2 +- src/lib/api.ts | 93 ++++ src/pages/MinibarSettingsPage.tsx | 92 +++- src/pages/MinibarStockPage.tsx | 668 +++++++++++++++++++++++ 7 files changed, 1340 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/063_minibar_stock.sql create mode 100644 src/pages/MinibarStockPage.tsx diff --git a/backend/migrations/063_minibar_stock.sql b/backend/migrations/063_minibar_stock.sql new file mode 100644 index 0000000..27b9739 --- /dev/null +++ b/backend/migrations/063_minibar_stock.sql @@ -0,0 +1,64 @@ +CREATE TABLE minibar_stock ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES minibar_items(id) ON DELETE CASCADE, + quantity NUMERIC(10,3) NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(hotel_id, item_id) +); + +CREATE TABLE minibar_receipts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + supplier TEXT, + notes TEXT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE minibar_receipt_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + receipt_id UUID NOT NULL REFERENCES minibar_receipts(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES minibar_items(id), + quantity NUMERIC(10,3) NOT NULL, + cost_price NUMERIC(10,2) +); + +CREATE TABLE minibar_writeoffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + notes TEXT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE minibar_writeoff_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + writeoff_id UUID NOT NULL REFERENCES minibar_writeoffs(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES minibar_items(id), + quantity NUMERIC(10,3) NOT NULL +); + +CREATE TABLE minibar_inventory_checks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + checked_at DATE NOT NULL DEFAULT CURRENT_DATE, + notes TEXT, + is_complete BOOLEAN NOT NULL DEFAULT false, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE minibar_inventory_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + check_id UUID NOT NULL REFERENCES minibar_inventory_checks(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES minibar_items(id), + expected_qty NUMERIC(10,3) NOT NULL DEFAULT 0, + actual_qty NUMERIC(10,3) NOT NULL DEFAULT 0 +); + +CREATE INDEX ON minibar_stock(hotel_id); +CREATE INDEX ON minibar_receipts(hotel_id); +CREATE INDEX ON minibar_writeoffs(hotel_id); +CREATE INDEX ON minibar_inventory_checks(hotel_id); diff --git a/backend/src/routes/minibar.ts b/backend/src/routes/minibar.ts index 07aac83..1c08b72 100644 --- a/backend/src/routes/minibar.ts +++ b/backend/src/routes/minibar.ts @@ -6,6 +6,8 @@ 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 } } +type SlugCheckParam = { Params: { slug: string; checkId: string } } +type SlugCheckItemParam = { Params: { slug: string; checkId: string; itemId: string } } const minibar: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { @@ -193,6 +195,16 @@ const minibar: FastifyPluginAsync = async (fastify) => { [hotelId, roomId, bookingId, taskId, request.body.item_id, request.body.quantity, itemRows[0].price, request.user.sub], ) + + // Auto-deduct from stock + await db.query( + `INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (hotel_id, item_id) DO UPDATE + SET quantity = minibar_stock.quantity - EXCLUDED.quantity, updated_at = NOW()`, + [hotelId, request.body.item_id, request.body.quantity], + ) + return reply.code(201).send(rows[0]) }, ) @@ -248,6 +260,437 @@ const minibar: FastifyPluginAsync = async (fastify) => { return rows }, ) + + // ── GET /api/hotels/:slug/minibar-stock ─────────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-stock', + { 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 mi.*, COALESCE(ms.quantity, 0) AS stock_qty + FROM minibar_items mi + LEFT JOIN minibar_stock ms ON ms.item_id = mi.id AND ms.hotel_id = mi.hotel_id + WHERE mi.hotel_id = $1 AND mi.is_active = true + ORDER BY mi.sort_order, mi.name`, + [hotelId], + ) + return rows + }, + ) + + // ── GET /api/hotels/:slug/minibar-receipts ──────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-receipts', + { 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 r.*, COALESCE( + json_agg(json_build_object( + 'id', ri.id, + 'item_id', ri.item_id, + 'item_name', mi.name, + 'quantity', ri.quantity, + 'cost_price', ri.cost_price + )) FILTER (WHERE ri.id IS NOT NULL), '[]' + ) AS items + FROM minibar_receipts r + LEFT JOIN minibar_receipt_items ri ON ri.receipt_id = r.id + LEFT JOIN minibar_items mi ON mi.id = ri.item_id + WHERE r.hotel_id = $1 + GROUP BY r.id ORDER BY r.created_at DESC LIMIT 50`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/minibar-receipts ─────────────────────────────── + fastify.post } }>( + '/api/hotels/:slug/minibar-receipts', + { 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 { supplier, notes, items } = request.body + if (!items || items.length === 0) return reply.code(400).send({ error: 'Items required' }) + + const { rows: receiptRows } = await db.query( + `INSERT INTO minibar_receipts (hotel_id, supplier, notes, created_by) + VALUES ($1, $2, $3, $4) RETURNING *`, + [hotelId, supplier ?? null, notes ?? null, request.user.sub], + ) + const receipt = receiptRows[0] + + for (const item of items) { + await db.query( + `INSERT INTO minibar_receipt_items (receipt_id, item_id, quantity, cost_price) + VALUES ($1, $2, $3, $4)`, + [receipt.id, item.item_id, item.quantity, item.cost_price ?? null], + ) + // Upsert stock: add quantity + await db.query( + `INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (hotel_id, item_id) DO UPDATE + SET quantity = minibar_stock.quantity + EXCLUDED.quantity, updated_at = NOW()`, + [hotelId, item.item_id, item.quantity], + ) + } + + // Return receipt with items + const { rows } = await db.query( + `SELECT r.*, COALESCE( + json_agg(json_build_object( + 'id', ri.id, + 'item_id', ri.item_id, + 'item_name', mi.name, + 'quantity', ri.quantity, + 'cost_price', ri.cost_price + )) FILTER (WHERE ri.id IS NOT NULL), '[]' + ) AS items + FROM minibar_receipts r + LEFT JOIN minibar_receipt_items ri ON ri.receipt_id = r.id + LEFT JOIN minibar_items mi ON mi.id = ri.item_id + WHERE r.id = $1 + GROUP BY r.id`, + [receipt.id], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug/minibar-writeoffs ─────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-writeoffs', + { 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 w.*, COALESCE( + json_agg(json_build_object( + 'id', wi.id, + 'item_id', wi.item_id, + 'item_name', mi.name, + 'quantity', wi.quantity + )) FILTER (WHERE wi.id IS NOT NULL), '[]' + ) AS items + FROM minibar_writeoffs w + LEFT JOIN minibar_writeoff_items wi ON wi.writeoff_id = w.id + LEFT JOIN minibar_items mi ON mi.id = wi.item_id + WHERE w.hotel_id = $1 + GROUP BY w.id ORDER BY w.created_at DESC LIMIT 50`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/minibar-writeoffs ────────────────────────────── + fastify.post } }>( + '/api/hotels/:slug/minibar-writeoffs', + { 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 { reason, notes, items } = request.body + if (!reason) return reply.code(400).send({ error: 'Reason required' }) + if (!items || items.length === 0) return reply.code(400).send({ error: 'Items required' }) + + const { rows: writeoffRows } = await db.query( + `INSERT INTO minibar_writeoffs (hotel_id, reason, notes, created_by) + VALUES ($1, $2, $3, $4) RETURNING *`, + [hotelId, reason, notes ?? null, request.user.sub], + ) + const writeoff = writeoffRows[0] + + for (const item of items) { + await db.query( + `INSERT INTO minibar_writeoff_items (writeoff_id, item_id, quantity) + VALUES ($1, $2, $3)`, + [writeoff.id, item.item_id, item.quantity], + ) + // Deduct from stock + await db.query( + `INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (hotel_id, item_id) DO UPDATE + SET quantity = minibar_stock.quantity - EXCLUDED.quantity, updated_at = NOW()`, + [hotelId, item.item_id, item.quantity], + ) + } + + // Return writeoff with items + const { rows } = await db.query( + `SELECT w.*, COALESCE( + json_agg(json_build_object( + 'id', wi.id, + 'item_id', wi.item_id, + 'item_name', mi.name, + 'quantity', wi.quantity + )) FILTER (WHERE wi.id IS NOT NULL), '[]' + ) AS items + FROM minibar_writeoffs w + LEFT JOIN minibar_writeoff_items wi ON wi.writeoff_id = w.id + LEFT JOIN minibar_items mi ON mi.id = wi.item_id + WHERE w.id = $1 + GROUP BY w.id`, + [writeoff.id], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug/minibar-inventory ─────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-inventory', + { 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_inventory_checks + WHERE hotel_id = $1 + ORDER BY created_at DESC LIMIT 50`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/minibar-inventory ────────────────────────────── + fastify.post( + '/api/hotels/:slug/minibar-inventory', + { 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 { checked_at, notes } = request.body + const { rows: checkRows } = await db.query( + `INSERT INTO minibar_inventory_checks (hotel_id, checked_at, notes, created_by) + VALUES ($1, $2, $3, $4) RETURNING *`, + [hotelId, checked_at ?? new Date().toISOString().slice(0, 10), notes ?? null, request.user.sub], + ) + const check = checkRows[0] + + // Pre-fill expected_qty from current stock for all active items + const { rows: stockRows } = await db.query( + `SELECT mi.id AS item_id, COALESCE(ms.quantity, 0) AS stock_qty + FROM minibar_items mi + LEFT JOIN minibar_stock ms ON ms.item_id = mi.id AND ms.hotel_id = mi.hotel_id + WHERE mi.hotel_id = $1 AND mi.is_active = true`, + [hotelId], + ) + + for (const row of stockRows) { + await db.query( + `INSERT INTO minibar_inventory_items (check_id, item_id, expected_qty, actual_qty) + VALUES ($1, $2, $3, 0)`, + [check.id, row.item_id, row.stock_qty], + ) + } + + // Return check with items + const { rows } = await db.query( + `SELECT ic.*, COALESCE( + json_agg(json_build_object( + 'id', ii.id, + 'item_id', ii.item_id, + 'item_name', mi.name, + 'category', mi.category, + 'expected_qty', ii.expected_qty, + 'actual_qty', ii.actual_qty + ) ORDER BY mi.sort_order, mi.name) FILTER (WHERE ii.id IS NOT NULL), '[]' + ) AS items + FROM minibar_inventory_checks ic + LEFT JOIN minibar_inventory_items ii ON ii.check_id = ic.id + LEFT JOIN minibar_items mi ON mi.id = ii.item_id + WHERE ic.id = $1 + GROUP BY ic.id`, + [check.id], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug/minibar-inventory/:checkId ───────────────────── + fastify.get( + '/api/hotels/:slug/minibar-inventory/:checkId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, checkId } = 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 ic.*, COALESCE( + json_agg(json_build_object( + 'id', ii.id, + 'item_id', ii.item_id, + 'item_name', mi.name, + 'category', mi.category, + 'expected_qty', ii.expected_qty, + 'actual_qty', ii.actual_qty + ) ORDER BY mi.sort_order, mi.name) FILTER (WHERE ii.id IS NOT NULL), '[]' + ) AS items + FROM minibar_inventory_checks ic + LEFT JOIN minibar_inventory_items ii ON ii.check_id = ic.id + LEFT JOIN minibar_items mi ON mi.id = ii.item_id + WHERE ic.id = $1 AND ic.hotel_id = $2 + GROUP BY ic.id`, + [checkId, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Check not found' }) + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug/minibar-inventory/:checkId/items/:itemId ────── + fastify.patch( + '/api/hotels/:slug/minibar-inventory/:checkId/items/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, checkId, 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 check belongs to hotel + const { rows: checkRows } = await db.query( + 'SELECT id FROM minibar_inventory_checks WHERE id = $1 AND hotel_id = $2 AND is_complete = false', + [checkId, hotelId], + ) + if (!checkRows[0]) return reply.code(404).send({ error: 'Check not found or already complete' }) + + const { rowCount } = await db.query( + `UPDATE minibar_inventory_items SET actual_qty = $1 + WHERE check_id = $2 AND item_id = $3`, + [request.body.actual_qty, checkId, itemId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Item not found in check' }) + return { ok: true } + }, + ) + + // ── POST /api/hotels/:slug/minibar-inventory/:checkId/complete ──────────── + fastify.post( + '/api/hotels/:slug/minibar-inventory/:checkId/complete', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, checkId } = 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 { rows: checkRows } = await db.query( + 'SELECT id FROM minibar_inventory_checks WHERE id = $1 AND hotel_id = $2 AND is_complete = false', + [checkId, hotelId], + ) + if (!checkRows[0]) return reply.code(404).send({ error: 'Check not found or already complete' }) + + // Get inventory items + const { rows: invItems } = await db.query( + 'SELECT item_id, actual_qty FROM minibar_inventory_items WHERE check_id = $1', + [checkId], + ) + + // Adjust stock to actual counts + for (const item of invItems) { + await db.query( + `INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (hotel_id, item_id) DO UPDATE + SET quantity = $3, updated_at = NOW()`, + [hotelId, item.item_id, item.actual_qty], + ) + } + + // Mark check as complete + await db.query( + 'UPDATE minibar_inventory_checks SET is_complete = true WHERE id = $1', + [checkId], + ) + + return { ok: true } + }, + ) + + // ── GET /api/hotels/:slug/minibar-report ────────────────────────────────── + fastify.get( + '/api/hotels/:slug/minibar-report', + { 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 { start, end } = request.query as { start?: string; end?: string } + const startDate = start ?? new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10) + const endDate = end ?? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toISOString().slice(0, 10) + + const { rows } = await db.query( + `SELECT mi.name, mi.category, + SUM(mc.quantity) AS total_qty, + SUM(mc.quantity * mc.price_per_unit) AS total_revenue + FROM minibar_consumptions mc + JOIN minibar_items mi ON mi.id = mc.item_id + WHERE mc.hotel_id = $1 AND mc.recorded_at >= $2 AND mc.recorded_at < $3 + GROUP BY mi.id, mi.name, mi.category + ORDER BY total_revenue DESC`, + [hotelId, startDate, endDate], + ) + return rows + }, + ) } export default minibar diff --git a/src/App.tsx b/src/App.tsx index cd57e77..0a4f18b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ import { WiFiPage } from './pages/WiFiPage' import { TTLockPage } from './pages/TTLockPage' import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage' import { MinibarSettingsPage } from './pages/MinibarSettingsPage' +import { MinibarStockPage } from './pages/MinibarStockPage' import { DepositSettingsPage } from './pages/DepositSettingsPage' import { DepositHistoryPage } from './pages/DepositHistoryPage' import { PayDepositPage } from './pages/PayDepositPage' @@ -105,7 +106,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5034d7b..516f5b5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -186,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/deposit'], + settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'], devGroup: ['/api-docs'], } diff --git a/src/lib/api.ts b/src/lib/api.ts index 97bd188..e5bafc2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -740,6 +740,39 @@ export const api = { getBookingMinibar: (slug: string, bookingId: string) => req('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`), + + getStock: (slug: string) => + req('GET', `/api/hotels/${slug}/minibar-stock`), + + getReceipts: (slug: string) => + req('GET', `/api/hotels/${slug}/minibar-receipts`), + + createReceipt: (slug: string, data: { supplier?: string; notes?: string; items: Array<{ itemId: string; quantity: number; costPrice?: number }> }) => + req('POST', `/api/hotels/${slug}/minibar-receipts`, data), + + getWriteoffs: (slug: string) => + req('GET', `/api/hotels/${slug}/minibar-writeoffs`), + + createWriteoff: (slug: string, data: { reason: string; notes?: string; items: Array<{ itemId: string; quantity: number }> }) => + req('POST', `/api/hotels/${slug}/minibar-writeoffs`, data), + + getInventories: (slug: string) => + req('GET', `/api/hotels/${slug}/minibar-inventory`), + + createInventory: (slug: string, data: { checkedAt?: string; notes?: string }) => + req('POST', `/api/hotels/${slug}/minibar-inventory`, data), + + getInventory: (slug: string, checkId: string) => + req('GET', `/api/hotels/${slug}/minibar-inventory/${checkId}`), + + updateInventoryItem: (slug: string, checkId: string, itemId: string, actualQty: number) => + req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/minibar-inventory/${checkId}/items/${itemId}`, { actual_qty: actualQty }), + + completeInventory: (slug: string, checkId: string) => + req<{ ok: boolean }>('POST', `/api/hotels/${slug}/minibar-inventory/${checkId}/complete`, {}), + + getReport: (slug: string, start?: string, end?: string) => + req('GET', `/api/hotels/${slug}/minibar-report${start ? `?start=${start}&end=${end ?? ''}` : ''}`), }, // ── Deposits ────────────────────────────────────────────────────────────── @@ -1380,6 +1413,66 @@ export interface MinibarBookingCharge { recordedByName: string | null } +export interface MinibarStockItem extends MinibarItem { + stockQty: number +} + +export interface MinibarReceiptItem { + id: string + itemId: string + itemName: string + quantity: number + costPrice: number | null +} + +export interface MinibarReceipt { + id: string + supplier: string | null + notes: string | null + createdAt: string + items: MinibarReceiptItem[] +} + +export interface MinibarWriteoffItem { + id: string + itemId: string + itemName: string + quantity: number +} + +export interface MinibarWriteoff { + id: string + reason: string + notes: string | null + createdAt: string + items: MinibarWriteoffItem[] +} + +export interface MinibarInventoryItem { + id: string + itemId: string + itemName: string + category: string | null + expectedQty: number + actualQty: number +} + +export interface MinibarInventoryCheck { + id: string + checkedAt: string + notes: string | null + isComplete: boolean + createdAt: string + items?: MinibarInventoryItem[] +} + +export interface MinibarReportRow { + name: string + category: string | null + totalQty: number + totalRevenue: number +} + // ── Deposit types ───────────────────────────────────────────────────────────── export interface DepositPreset { diff --git a/src/pages/MinibarSettingsPage.tsx b/src/pages/MinibarSettingsPage.tsx index a4e47c2..614c139 100644 --- a/src/pages/MinibarSettingsPage.tsx +++ b/src/pages/MinibarSettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' -import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight, Tag } from 'lucide-react' +import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight, Tag, Package } from 'lucide-react' +import { Link } from 'react-router-dom' import { api, type MinibarItem } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { cn } from '../lib/utils' @@ -8,10 +9,49 @@ interface EditRow { name: string price: string category: string + newCat: string // custom category being typed } -function EditForm({ initial, onSave, onCancel }: { +function CategorySelect({ value, onChange, categories }: { + value: string + onChange: (v: string) => void + categories: string[] +}) { + const isNew = value !== '' && !categories.includes(value) + const [showNew, setShowNew] = useState(isNew) + + return ( +
+ {showNew ? ( + onChange(e.target.value)} + placeholder="Новая категория" + className="input py-1 text-sm flex-1" + onBlur={() => { if (!value.trim()) { setShowNew(false); onChange('') } }} + /> + ) : ( + + )} +
+ ) +} + +function EditForm({ initial, categories, onSave, onCancel }: { initial: EditRow + categories: string[] onSave: (v: EditRow) => void onCancel: () => void }) { @@ -34,12 +74,7 @@ function EditForm({ initial, onSave, onCancel }: { placeholder="Цена" className="input w-24 py-1 text-sm" /> - setV(p => ({ ...p, category: e.target.value }))} - placeholder="Категория" - className="input w-32 py-1 text-sm" - /> + setV(p => ({ ...p, category: cat }))} categories={categories} /> @@ -59,7 +94,7 @@ export function MinibarSettingsPage() { const [error, setError] = useState(null) const [editingId, setEditingId] = useState(null) const [adding, setAdding] = useState(false) - const [addForm, setAddForm] = useState({ name: '', price: '', category: '' }) + const [addForm, setAddForm] = useState({ name: '', price: '', category: '', newCat: '' }) const [addingRow, setAddingRow] = useState(false) const [requireMinibarCheck, setRequireMinibarCheck] = useState(false) const [editingCat, setEditingCat] = useState(null) @@ -113,7 +148,7 @@ export function MinibarSettingsPage() { }) setItems(prev => [...prev, item]) setAdding(false) - setAddForm({ name: '', price: '', category: '' }) + setAddForm({ name: '', price: '', category: '', newCat: '' }) } catch { setError('Не удалось добавить позицию') } finally { @@ -141,6 +176,9 @@ export function MinibarSettingsPage() { } catch { /* ignore */ } } + // Existing categories for dropdown + const categories = [...new Set(items.map(i => i.category).filter(Boolean) as string[])] + // Group by category const grouped = items.reduce>((acc, item) => { const cat = item.category ?? 'Без категории' @@ -159,14 +197,20 @@ export function MinibarSettingsPage() { return (
-
-

- - Минибар -

-

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

+
+
+

+ + Минибар +

+

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

+
+ + + Учёт и остатки +
{error && ( @@ -213,17 +257,16 @@ export function MinibarSettingsPage() { placeholder="0.00" className="input py-1 text-sm" /> - setAddForm(p => ({ ...p, category: e.target.value }))} - placeholder="Напитки..." - className="input py-1 text-sm" + onChange={cat => setAddForm(p => ({ ...p, category: cat }))} + categories={categories} />
-
@@ -242,7 +285,8 @@ export function MinibarSettingsPage() { <>
handleUpdate(item.id, v)} onCancel={() => setEditingId(null)} /> diff --git a/src/pages/MinibarStockPage.tsx b/src/pages/MinibarStockPage.tsx new file mode 100644 index 0000000..e43b4be --- /dev/null +++ b/src/pages/MinibarStockPage.tsx @@ -0,0 +1,668 @@ +import { useState, useEffect, useCallback } from 'react' +import { ArrowLeft, Loader2, Package, Plus, Trash2, ChevronDown, ChevronUp, Check, RefreshCw } from 'lucide-react' +import { Link } from 'react-router-dom' +import { api, type MinibarItem, type MinibarStockItem, type MinibarReceipt, type MinibarWriteoff, type MinibarInventoryCheck, type MinibarReportRow } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import { cn, formatCurrency } from '../lib/utils' +import { format, startOfMonth, endOfMonth, subMonths } from 'date-fns' + +type Tab = 'stock' | 'receipts' | 'writeoffs' | 'inventory' | 'report' + +const WRITEOFF_REASONS = ['Порча', 'Истёк срок годности', 'Брак', 'Недостача', 'Личное потребление персонала', 'Другое'] + +// ── Stock tab ───────────────────────────────────────────────────────────────── +function StockTab({ slug }: { slug: string }) { + const [stock, setStock] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.minibar.getStock(slug).then(setStock).catch(() => {}).finally(() => setLoading(false)) + }, [slug]) + + if (loading) return
+ + const grouped = stock.reduce>((acc, item) => { + const cat = item.category ?? 'Без категории' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(item) + return acc + }, {}) + + return ( +
+
+ + + + + + + + + + + {Object.entries(grouped).map(([cat, items]) => ( + <> + + + + {items.map(item => ( + + + + + + + ))} + + ))} + + + + + + + +
ПозицияЦенаОстатокСумма
+ {cat} +
{item.name}{formatCurrency(item.price)} + {item.stockQty} + + {formatCurrency(item.stockQty * Number(item.price))} +
Итого + {formatCurrency(stock.reduce((s, i) => s + i.stockQty * Number(i.price), 0))} +
+
+ {stock.length === 0 && ( +

Нет позиций. Сначала добавьте товары в настройках минибара.

+ )} +
+ ) +} + +// ── Receipts tab ────────────────────────────────────────────────────────────── +function ReceiptsTab({ slug, items }: { slug: string; items: MinibarItem[] }) { + const [receipts, setReceipts] = useState([]) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState(null) + const [showForm, setShowForm] = useState(false) + const [supplier, setSupplier] = useState('') + const [notes, setNotes] = useState('') + const [lines, setLines] = useState>([{ itemId: '', quantity: '', costPrice: '' }]) + const [saving, setSaving] = useState(false) + + const load = useCallback(() => { + setLoading(true) + api.minibar.getReceipts(slug).then(setReceipts).catch(() => {}).finally(() => setLoading(false)) + }, [slug]) + + useEffect(() => { load() }, [load]) + + const addLine = () => setLines(p => [...p, { itemId: '', quantity: '', costPrice: '' }]) + const removeLine = (i: number) => setLines(p => p.filter((_, idx) => idx !== i)) + + const handleSubmit = async () => { + const valid = lines.filter(l => l.itemId && parseFloat(l.quantity) > 0) + if (valid.length === 0) return + setSaving(true) + try { + const r = await api.minibar.createReceipt(slug, { + supplier: supplier.trim() || undefined, + notes: notes.trim() || undefined, + items: valid.map(l => ({ itemId: l.itemId, quantity: parseFloat(l.quantity), costPrice: l.costPrice ? parseFloat(l.costPrice) : undefined })), + }) + setReceipts(p => [r, ...p]) + setShowForm(false) + setSupplier(''); setNotes('') + setLines([{ itemId: '', quantity: '', costPrice: '' }]) + } catch { /* ignore */ } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+ + {showForm && ( +
+

Новый приход

+
+
+ + setSupplier(e.target.value)} placeholder="ООО Напитки" className="input w-full" /> +
+
+ + setNotes(e.target.value)} placeholder="Накладная №..." className="input w-full" /> +
+
+
+
+ ПозицияКол-воСебест., руб. +
+ {lines.map((line, i) => ( +
+ + setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" /> + setLines(p => p.map((l, idx) => idx === i ? { ...l, costPrice: e.target.value } : l))} placeholder="0.00" className="input text-sm py-1 text-right" /> + +
+ ))} + +
+
+ + +
+
+ )} + + {loading ?
: ( +
+ {receipts.length === 0 &&

Нет приходов

} + {receipts.map(r => ( +
+ + {expanded === r.id && ( +
+ {r.notes &&

{r.notes}

} + + + {r.items.map(it => ( + + + + {it.costPrice != null && } + + ))} + +
{it.itemName}{it.quantity} шт.{formatCurrency(it.costPrice)} / шт.
+
+ )} +
+ ))} +
+ )} +
+ ) +} + +// ── Writeoffs tab ───────────────────────────────────────────────────────────── +function WriteoffsTab({ slug, items }: { slug: string; items: MinibarItem[] }) { + const [writeoffs, setWriteoffs] = useState([]) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState(null) + const [showForm, setShowForm] = useState(false) + const [reason, setReason] = useState(WRITEOFF_REASONS[0]) + const [customReason, setCustomReason] = useState('') + const [notes, setNotes] = useState('') + const [lines, setLines] = useState>([{ itemId: '', quantity: '' }]) + const [saving, setSaving] = useState(false) + + useEffect(() => { + api.minibar.getWriteoffs(slug).then(setWriteoffs).catch(() => {}).finally(() => setLoading(false)) + }, [slug]) + + const handleSubmit = async () => { + const finalReason = reason === 'Другое' ? customReason.trim() : reason + if (!finalReason) return + const valid = lines.filter(l => l.itemId && parseFloat(l.quantity) > 0) + if (valid.length === 0) return + setSaving(true) + try { + const w = await api.minibar.createWriteoff(slug, { + reason: finalReason, + notes: notes.trim() || undefined, + items: valid.map(l => ({ itemId: l.itemId, quantity: parseFloat(l.quantity) })), + }) + setWriteoffs(p => [w, ...p]) + setShowForm(false) + setReason(WRITEOFF_REASONS[0]); setCustomReason(''); setNotes('') + setLines([{ itemId: '', quantity: '' }]) + } catch { /* ignore */ } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+ + {showForm && ( +
+

Новое списание

+
+
+ + + {reason === 'Другое' && ( + setCustomReason(e.target.value)} placeholder="Укажите причину" className="input w-full mt-2" /> + )} +
+
+ + setNotes(e.target.value)} placeholder="Необязательно" className="input w-full" /> +
+
+
+
+ ПозицияКол-во +
+ {lines.map((line, i) => ( +
+ + setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" /> + +
+ ))} + +
+
+ + +
+
+ )} + + {loading ?
: ( +
+ {writeoffs.length === 0 &&

Нет списаний

} + {writeoffs.map(w => ( +
+ + {expanded === w.id && ( +
+ {w.notes &&

{w.notes}

} + + + {w.items.map(it => ( + + + + + ))} + +
{it.itemName}{it.quantity} шт.
+
+ )} +
+ ))} +
+ )} +
+ ) +} + +// ── Inventory tab ───────────────────────────────────────────────────────────── +function InventoryTab({ slug }: { slug: string }) { + const [checks, setChecks] = useState([]) + const [loading, setLoading] = useState(true) + const [activeCheck, setActiveCheck] = useState(null) + const [creating, setCreating] = useState(false) + const [completing, setCompleting] = useState(false) + const [checkedAt, setCheckedAt] = useState(() => new Date().toISOString().slice(0, 10)) + + const load = useCallback(() => { + setLoading(true) + api.minibar.getInventories(slug).then(setChecks).catch(() => {}).finally(() => setLoading(false)) + }, [slug]) + + useEffect(() => { load() }, [load]) + + const openCheck = async (id: string) => { + const data = await api.minibar.getInventory(slug, id) + setActiveCheck(data) + } + + const createCheck = async () => { + setCreating(true) + try { + const c = await api.minibar.createInventory(slug, { checkedAt }) + const full = await api.minibar.getInventory(slug, c.id) + setActiveCheck(full) + setChecks(p => [c, ...p]) + } catch { /* ignore */ } finally { + setCreating(false) + } + } + + const updateQty = async (checkId: string, itemId: string, qty: number) => { + await api.minibar.updateInventoryItem(slug, checkId, itemId, qty).catch(() => {}) + setActiveCheck(prev => prev ? { + ...prev, + items: prev.items?.map(it => it.itemId === itemId ? { ...it, actualQty: qty } : it), + } : prev) + } + + const completeCheck = async () => { + if (!activeCheck) return + if (!confirm('Завершить инвентаризацию? Остатки будут обновлены по фактическим данным.')) return + setCompleting(true) + try { + await api.minibar.completeInventory(slug, activeCheck.id) + setActiveCheck(prev => prev ? { ...prev, isComplete: true } : prev) + setChecks(prev => prev.map(c => c.id === activeCheck.id ? { ...c, isComplete: true } : c)) + } catch { /* ignore */ } finally { + setCompleting(false) + } + } + + if (activeCheck) { + const grouped = (activeCheck.items ?? []).reduce>((acc, it) => { + const cat = it!.category ?? 'Без категории' + if (!acc[cat]) acc[cat] = [] + acc[cat]!.push(it) + return acc + }, {}) + + return ( +
+
+ +
+ + {activeCheck.isComplete ? 'Завершена' : 'Открыта'} + + {!activeCheck.isComplete && ( + + )} +
+
+ +
+
+ Дата: {format(new Date(activeCheck.checkedAt), 'dd.MM.yyyy')} + {activeCheck.notes && {activeCheck.notes}} +
+ + + + + + + + + + + {Object.entries(grouped).map(([cat, catItems]) => ( + <> + + + + {catItems!.map(it => { + const diff = it!.actualQty - it!.expectedQty + return ( + + + + + + + ) + })} + + ))} + +
ПозицияОжидаетсяФактРасхождение
{cat}
{it!.itemName}{it!.expectedQty} + {activeCheck.isComplete ? ( + {it!.actualQty} + ) : ( + updateQty(activeCheck.id, it!.itemId, parseInt(e.target.value) || 0)} + className="input text-sm py-0.5 w-20 text-right ml-auto" + /> + )} + 0 ? 'text-amber-500' : 'text-slate-400')}> + {diff === 0 ? '—' : (diff > 0 ? '+' : '') + diff} +
+
+
+ ) + } + + return ( +
+
+
+ + setCheckedAt(e.target.value)} className="input" /> +
+ +
+ + {loading ?
: ( +
+ {checks.length === 0 &&

Нет инвентаризаций

} + {checks.map(c => ( + + ))} +
+ )} +
+ ) +} + +// ── Report tab ──────────────────────────────────────────────────────────────── +function ReportTab({ slug }: { slug: string }) { + const now = new Date() + const [start, setStart] = useState(() => format(startOfMonth(now), 'yyyy-MM-dd')) + const [end, setEnd] = useState(() => format(endOfMonth(now), 'yyyy-MM-dd')) + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(false) + + const load = useCallback(() => { + setLoading(true) + api.minibar.getReport(slug, start, format(new Date(end + 'T23:59:59'), 'yyyy-MM-dd') + 'T23:59:59').then(setRows).catch(() => {}).finally(() => setLoading(false)) + }, [slug, start, end]) + + useEffect(() => { load() }, [load]) + + const setPreset = (months: number) => { + const d = months === 0 ? now : subMonths(now, months - 1) + setStart(format(startOfMonth(d), 'yyyy-MM-dd')) + setEnd(format(endOfMonth(d), 'yyyy-MM-dd')) + } + + const grouped = rows.reduce>((acc, r) => { + const cat = r.category ?? 'Без категории' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(r) + return acc + }, {}) + + const totalRevenue = rows.reduce((s, r) => s + Number(r.totalRevenue), 0) + const totalQty = rows.reduce((s, r) => s + Number(r.totalQty), 0) + + return ( +
+
+
+ {[['Тек. месяц', 0], ['Пред. месяц', 1], ['2 мес. назад', 2]].map(([label, n]) => ( + + ))} +
+
+
+ + setStart(e.target.value)} className="input" /> +
+
+ + setEnd(e.target.value)} className="input" /> +
+ +
+
+ + {loading ?
: ( + <> + {rows.length === 0 ? ( +

Нет данных за выбранный период

+ ) : ( +
+ + + + + + + + + + {Object.entries(grouped).map(([cat, catRows]) => ( + <> + + + + {catRows.map((r, i) => ( + + + + + + ))} + + ))} + + + + + + + + +
ПозицияКол-воВыручка
{cat}
{r.name}{Number(r.totalQty)}{formatCurrency(Number(r.totalRevenue))}
Итого{totalQty}{formatCurrency(totalRevenue)}
+
+ )} + + )} +
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export function MinibarStockPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + const [tab, setTab] = useState('stock') + const [items, setItems] = useState([]) + + useEffect(() => { + if (!slug) return + api.minibar.listItems(slug).then(setItems).catch(() => {}) + }, [slug]) + + const tabs: Array<{ id: Tab; label: string }> = [ + { id: 'stock', label: 'Остатки' }, + { id: 'receipts', label: 'Приходы' }, + { id: 'writeoffs', label: 'Списания' }, + { id: 'inventory', label: 'Инвентаризация' }, + { id: 'report', label: 'Отчёт' }, + ] + + return ( +
+
+ + + Назад + +

+ + Учёт минибара +

+
+ + {/* Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {tab === 'stock' && } + {tab === 'receipts' && } + {tab === 'writeoffs' && } + {tab === 'inventory' && } + {tab === 'report' && } +
+ ) +}