feat: minibar — category dropdown, учёт остатков (приходы/списания/инвентаризация/отчёт)
- MinibarSettingsPage: поле категории → выпадающий список с существующими + «Новая...» - Кнопка «Учёт и остатки» ведёт на /settings/minibar/stock - MinibarStockPage: 5 вкладок — Остатки, Приходы, Списания, Инвентаризация, Отчёт - api.ts: добавлены методы и типы для stock/receipts/writeoffs/inventory/report Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
64
backend/migrations/063_minibar_stock.sql
Normal file
64
backend/migrations/063_minibar_stock.sql
Normal file
@@ -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);
|
||||
@@ -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<string | null> => {
|
||||
@@ -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<SlugParam>(
|
||||
'/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<SlugParam>(
|
||||
'/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<SlugParam & { Body: { supplier?: string; notes?: string; items: Array<{ item_id: string; quantity: number; cost_price?: number }> } }>(
|
||||
'/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<SlugParam>(
|
||||
'/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<SlugParam & { Body: { reason: string; notes?: string; items: Array<{ item_id: string; quantity: number }> } }>(
|
||||
'/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<SlugParam>(
|
||||
'/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<SlugParam & { Body: { checked_at?: string; notes?: string } }>(
|
||||
'/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<SlugCheckParam>(
|
||||
'/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<SlugCheckItemParam & { Body: { actual_qty: number } }>(
|
||||
'/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<SlugCheckParam>(
|
||||
'/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<SlugParam & { Querystring: { start?: string; end?: string } }>(
|
||||
'/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
|
||||
|
||||
@@ -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'
|
||||
@@ -106,6 +107,7 @@ export default function App() {
|
||||
<Route path="/ttlock" element={<TTLockPage />} />
|
||||
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
||||
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
|
||||
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} />
|
||||
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
|
||||
<Route path="/settings/deposit/history" element={<DepositHistoryPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,39 @@ export const api = {
|
||||
|
||||
getBookingMinibar: (slug: string, bookingId: string) =>
|
||||
req<MinibarBookingCharge[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`),
|
||||
|
||||
getStock: (slug: string) =>
|
||||
req<MinibarStockItem[]>('GET', `/api/hotels/${slug}/minibar-stock`),
|
||||
|
||||
getReceipts: (slug: string) =>
|
||||
req<MinibarReceipt[]>('GET', `/api/hotels/${slug}/minibar-receipts`),
|
||||
|
||||
createReceipt: (slug: string, data: { supplier?: string; notes?: string; items: Array<{ itemId: string; quantity: number; costPrice?: number }> }) =>
|
||||
req<MinibarReceipt>('POST', `/api/hotels/${slug}/minibar-receipts`, data),
|
||||
|
||||
getWriteoffs: (slug: string) =>
|
||||
req<MinibarWriteoff[]>('GET', `/api/hotels/${slug}/minibar-writeoffs`),
|
||||
|
||||
createWriteoff: (slug: string, data: { reason: string; notes?: string; items: Array<{ itemId: string; quantity: number }> }) =>
|
||||
req<MinibarWriteoff>('POST', `/api/hotels/${slug}/minibar-writeoffs`, data),
|
||||
|
||||
getInventories: (slug: string) =>
|
||||
req<MinibarInventoryCheck[]>('GET', `/api/hotels/${slug}/minibar-inventory`),
|
||||
|
||||
createInventory: (slug: string, data: { checkedAt?: string; notes?: string }) =>
|
||||
req<MinibarInventoryCheck>('POST', `/api/hotels/${slug}/minibar-inventory`, data),
|
||||
|
||||
getInventory: (slug: string, checkId: string) =>
|
||||
req<MinibarInventoryCheck>('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<MinibarReportRow[]>('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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex gap-1 w-36">
|
||||
{showNew ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Новая категория"
|
||||
className="input py-1 text-sm flex-1"
|
||||
onBlur={() => { if (!value.trim()) { setShowNew(false); onChange('') } }}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__new__') { setShowNew(true); onChange('') }
|
||||
else onChange(e.target.value)
|
||||
}}
|
||||
className="input py-1 text-sm flex-1"
|
||||
>
|
||||
<option value="">Без категории</option>
|
||||
{categories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="__new__">+ Новая...</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
value={v.category}
|
||||
onChange={e => setV(p => ({ ...p, category: e.target.value }))}
|
||||
placeholder="Категория"
|
||||
className="input w-32 py-1 text-sm"
|
||||
/>
|
||||
<CategorySelect value={v.category} onChange={cat => setV(p => ({ ...p, category: cat }))} categories={categories} />
|
||||
<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>
|
||||
@@ -59,7 +94,7 @@ export function MinibarSettingsPage() {
|
||||
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 [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '', newCat: '' })
|
||||
const [addingRow, setAddingRow] = useState(false)
|
||||
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
|
||||
const [editingCat, setEditingCat] = useState<string | null>(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<Record<string, MinibarItem[]>>((acc, item) => {
|
||||
const cat = item.category ?? 'Без категории'
|
||||
@@ -159,6 +197,7 @@ export function MinibarSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<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" />
|
||||
@@ -168,6 +207,11 @@ export function MinibarSettingsPage() {
|
||||
Настройте позиции минибара. Горничные смогут отмечать потреблённые гостем товары во время уборки.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/settings/minibar/stock" className="btn-secondary flex items-center gap-1.5 text-sm shrink-0">
|
||||
<Package size={15} />
|
||||
Учёт и остатки
|
||||
</Link>
|
||||
</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">
|
||||
@@ -213,17 +257,16 @@ export function MinibarSettingsPage() {
|
||||
placeholder="0.00"
|
||||
className="input py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
<CategorySelect
|
||||
value={addForm.category}
|
||||
onChange={e => setAddForm(p => ({ ...p, category: e.target.value }))}
|
||||
placeholder="Напитки..."
|
||||
className="input py-1 text-sm"
|
||||
onChange={cat => setAddForm(p => ({ ...p, category: cat }))}
|
||||
categories={categories}
|
||||
/>
|
||||
<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">
|
||||
<button onClick={() => { setAdding(false); setAddForm({ name: '', price: '', category: '', newCat: '' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -242,7 +285,8 @@ export function MinibarSettingsPage() {
|
||||
<>
|
||||
<div className="col-span-4">
|
||||
<EditForm
|
||||
initial={{ name: item.name, price: String(item.price), category: item.category ?? '' }}
|
||||
initial={{ name: item.name, price: String(item.price), category: item.category ?? '', newCat: '' }}
|
||||
categories={categories}
|
||||
onSave={v => handleUpdate(item.id, v)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
|
||||
668
src/pages/MinibarStockPage.tsx
Normal file
668
src/pages/MinibarStockPage.tsx
Normal file
@@ -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<MinibarStockItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.minibar.getStock(slug).then(setStock).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div>
|
||||
|
||||
const grouped = stock.reduce<Record<string, MinibarStockItem[]>>((acc, item) => {
|
||||
const cat = item.category ?? 'Без категории'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
<th className="text-left px-4 py-3 font-medium">Позиция</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Цена</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Остаток</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
|
||||
{Object.entries(grouped).map(([cat, items]) => (
|
||||
<>
|
||||
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
|
||||
<td colSpan={4} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
{cat}
|
||||
</td>
|
||||
</tr>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className={cn('hover:bg-slate-50 dark:hover:bg-slate-800/40', item.stockQty <= 0 && 'opacity-50')}>
|
||||
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-200">{item.name}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500 dark:text-slate-400">{formatCurrency(item.price)}</td>
|
||||
<td className={cn('px-4 py-2.5 text-right font-semibold', item.stockQty <= 0 ? 'text-red-500' : 'text-slate-800 dark:text-slate-100')}>
|
||||
{item.stockQty}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500 dark:text-slate-400">
|
||||
{formatCurrency(item.stockQty * Number(item.price))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-slate-200 dark:border-slate-600">
|
||||
<td colSpan={3} className="px-4 py-3 text-sm font-semibold text-slate-700 dark:text-slate-200">Итого</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(stock.reduce((s, i) => s + i.stockQty * Number(i.price), 0))}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{stock.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-400 py-8">Нет позиций. Сначала добавьте товары в настройках минибара.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Receipts tab ──────────────────────────────────────────────────────────────
|
||||
function ReceiptsTab({ slug, items }: { slug: string; items: MinibarItem[] }) {
|
||||
const [receipts, setReceipts] = useState<MinibarReceipt[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [supplier, setSupplier] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [lines, setLines] = useState<Array<{ itemId: string; quantity: string; costPrice: string }>>([{ 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => setShowForm(p => !p)} className="btn-primary flex items-center gap-1.5 text-sm">
|
||||
<Plus size={14} />
|
||||
Приход
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card p-4 space-y-4">
|
||||
<p className="font-semibold text-slate-800 dark:text-slate-200">Новый приход</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="form-label">Поставщик</label>
|
||||
<input value={supplier} onChange={e => setSupplier(e.target.value)} placeholder="ООО Напитки" className="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Примечание</label>
|
||||
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Накладная №..." className="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_80px_100px_32px] gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Позиция</span><span>Кол-во</span><span>Себест., руб.</span><span></span>
|
||||
</div>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_80px_100px_32px] gap-2 items-center">
|
||||
<select value={line.itemId} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, itemId: e.target.value } : l))} className="input text-sm py-1">
|
||||
<option value="">Выберите...</option>
|
||||
{items.map(it => <option key={it.id} value={it.id}>{it.name}</option>)}
|
||||
</select>
|
||||
<input type="number" min="1" value={line.quantity} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" />
|
||||
<input type="number" min="0" step="0.01" value={line.costPrice} onChange={e => 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" />
|
||||
<button onClick={() => removeLine(i)} disabled={lines.length === 1} className="p-1 rounded text-slate-300 hover:text-red-500 disabled:invisible">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addLine} className="text-xs text-brand-600 hover:underline flex items-center gap-1">
|
||||
<Plus size={12} /> Добавить строку
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn-primary flex items-center gap-1.5 text-sm">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Сохранить приход
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="btn-secondary text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
|
||||
<div className="space-y-2">
|
||||
{receipts.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет приходов</p>}
|
||||
{receipts.map(r => (
|
||||
<div key={r.id} className="card overflow-hidden">
|
||||
<button onClick={() => setExpanded(p => p === r.id ? null : r.id)} className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<div className="flex items-center gap-3 text-left">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(r.createdAt), 'dd.MM.yyyy HH:mm')}</span>
|
||||
{r.supplier && <span className="text-sm text-slate-500">{r.supplier}</span>}
|
||||
<span className="text-xs text-slate-400">{r.items.length} поз.</span>
|
||||
</div>
|
||||
{expanded === r.id ? <ChevronUp size={15} className="text-slate-400" /> : <ChevronDown size={15} className="text-slate-400" />}
|
||||
</button>
|
||||
{expanded === r.id && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 px-4 pb-3">
|
||||
{r.notes && <p className="text-xs text-slate-400 mt-2 mb-2">{r.notes}</p>}
|
||||
<table className="w-full text-sm mt-2">
|
||||
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
|
||||
{r.items.map(it => (
|
||||
<tr key={it.id}>
|
||||
<td className="py-1.5 text-slate-700 dark:text-slate-300">{it.itemName}</td>
|
||||
<td className="py-1.5 text-right text-slate-500">{it.quantity} шт.</td>
|
||||
{it.costPrice != null && <td className="py-1.5 text-right text-slate-400">{formatCurrency(it.costPrice)} / шт.</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Writeoffs tab ─────────────────────────────────────────────────────────────
|
||||
function WriteoffsTab({ slug, items }: { slug: string; items: MinibarItem[] }) {
|
||||
const [writeoffs, setWriteoffs] = useState<MinibarWriteoff[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<string | null>(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<Array<{ itemId: string; quantity: string }>>([{ 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => setShowForm(p => !p)} className="btn-primary flex items-center gap-1.5 text-sm">
|
||||
<Plus size={14} />
|
||||
Списание
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card p-4 space-y-4">
|
||||
<p className="font-semibold text-slate-800 dark:text-slate-200">Новое списание</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="form-label">Причина</label>
|
||||
<select value={reason} onChange={e => setReason(e.target.value)} className="input w-full">
|
||||
{WRITEOFF_REASONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
{reason === 'Другое' && (
|
||||
<input value={customReason} onChange={e => setCustomReason(e.target.value)} placeholder="Укажите причину" className="input w-full mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Примечание</label>
|
||||
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Необязательно" className="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_80px_32px] gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Позиция</span><span>Кол-во</span><span></span>
|
||||
</div>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_80px_32px] gap-2 items-center">
|
||||
<select value={line.itemId} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, itemId: e.target.value } : l))} className="input text-sm py-1">
|
||||
<option value="">Выберите...</option>
|
||||
{items.map(it => <option key={it.id} value={it.id}>{it.name}</option>)}
|
||||
</select>
|
||||
<input type="number" min="1" value={line.quantity} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" />
|
||||
<button onClick={() => setLines(p => p.filter((_, idx) => idx !== i))} disabled={lines.length === 1} className="p-1 rounded text-slate-300 hover:text-red-500 disabled:invisible">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setLines(p => [...p, { itemId: '', quantity: '' }])} className="text-xs text-brand-600 hover:underline flex items-center gap-1">
|
||||
<Plus size={12} /> Добавить строку
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn-primary flex items-center gap-1.5 text-sm">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Сохранить списание
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="btn-secondary text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
|
||||
<div className="space-y-2">
|
||||
{writeoffs.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет списаний</p>}
|
||||
{writeoffs.map(w => (
|
||||
<div key={w.id} className="card overflow-hidden">
|
||||
<button onClick={() => setExpanded(p => p === w.id ? null : w.id)} className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<div className="flex items-center gap-3 text-left">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(w.createdAt), 'dd.MM.yyyy HH:mm')}</span>
|
||||
<span className="text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 px-2 py-0.5 rounded-full">{w.reason}</span>
|
||||
<span className="text-xs text-slate-400">{w.items.length} поз.</span>
|
||||
</div>
|
||||
{expanded === w.id ? <ChevronUp size={15} className="text-slate-400" /> : <ChevronDown size={15} className="text-slate-400" />}
|
||||
</button>
|
||||
{expanded === w.id && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 px-4 pb-3">
|
||||
{w.notes && <p className="text-xs text-slate-400 mt-2">{w.notes}</p>}
|
||||
<table className="w-full text-sm mt-2">
|
||||
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
|
||||
{w.items.map(it => (
|
||||
<tr key={it.id}>
|
||||
<td className="py-1.5 text-slate-700 dark:text-slate-300">{it.itemName}</td>
|
||||
<td className="py-1.5 text-right text-slate-500">{it.quantity} шт.</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Inventory tab ─────────────────────────────────────────────────────────────
|
||||
function InventoryTab({ slug }: { slug: string }) {
|
||||
const [checks, setChecks] = useState<MinibarInventoryCheck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCheck, setActiveCheck] = useState<MinibarInventoryCheck | null>(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<Record<string, typeof activeCheck.items>>((acc, it) => {
|
||||
const cat = it!.category ?? 'Без категории'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat]!.push(it)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setActiveCheck(null)} className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<ArrowLeft size={15} /> К списку
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', activeCheck.isComplete ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
|
||||
{activeCheck.isComplete ? 'Завершена' : 'Открыта'}
|
||||
</span>
|
||||
{!activeCheck.isComplete && (
|
||||
<button onClick={completeCheck} disabled={completing} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{completing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Завершить инвентаризацию
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 text-sm text-slate-600 dark:text-slate-400">
|
||||
Дата: <strong>{format(new Date(activeCheck.checkedAt), 'dd.MM.yyyy')}</strong>
|
||||
{activeCheck.notes && <span className="ml-3">{activeCheck.notes}</span>}
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
<th className="text-left px-4 py-3 font-medium">Позиция</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Ожидается</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Факт</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Расхождение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
|
||||
{Object.entries(grouped).map(([cat, catItems]) => (
|
||||
<>
|
||||
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
|
||||
<td colSpan={4} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{cat}</td>
|
||||
</tr>
|
||||
{catItems!.map(it => {
|
||||
const diff = it!.actualQty - it!.expectedQty
|
||||
return (
|
||||
<tr key={it!.itemId}>
|
||||
<td className="px-4 py-2 text-slate-700 dark:text-slate-200">{it!.itemName}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-500">{it!.expectedQty}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{activeCheck.isComplete ? (
|
||||
<span className="font-semibold">{it!.actualQty}</span>
|
||||
) : (
|
||||
<input
|
||||
type="number" min="0"
|
||||
defaultValue={it!.actualQty}
|
||||
onBlur={e => updateQty(activeCheck.id, it!.itemId, parseInt(e.target.value) || 0)}
|
||||
className="input text-sm py-0.5 w-20 text-right ml-auto"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn('px-4 py-2 text-right font-medium', diff < 0 ? 'text-red-500' : diff > 0 ? 'text-amber-500' : 'text-slate-400')}>
|
||||
{diff === 0 ? '—' : (diff > 0 ? '+' : '') + diff}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4 flex items-end gap-3">
|
||||
<div>
|
||||
<label className="form-label">Дата инвентаризации</label>
|
||||
<input type="date" value={checkedAt} onChange={e => setCheckedAt(e.target.value)} className="input" />
|
||||
</div>
|
||||
<button onClick={createCheck} disabled={creating} className="btn-primary flex items-center gap-1.5 text-sm">
|
||||
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
Начать инвентаризацию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
|
||||
<div className="space-y-2">
|
||||
{checks.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет инвентаризаций</p>}
|
||||
{checks.map(c => (
|
||||
<button key={c.id} onClick={() => openCheck(c.id)} className="card w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(c.checkedAt), 'dd.MM.yyyy')}</span>
|
||||
{c.notes && <span className="text-sm text-slate-500">{c.notes}</span>}
|
||||
</div>
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', c.isComplete ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
|
||||
{c.isComplete ? 'Завершена' : 'Открыта'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<MinibarReportRow[]>([])
|
||||
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<Record<string, MinibarReportRow[]>>((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 (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4 flex items-end gap-3 flex-wrap">
|
||||
<div className="flex gap-1.5">
|
||||
{[['Тек. месяц', 0], ['Пред. месяц', 1], ['2 мес. назад', 2]].map(([label, n]) => (
|
||||
<button key={n} onClick={() => setPreset(Number(n))} className="btn-secondary text-xs py-1 px-2">{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<label className="form-label">С</label>
|
||||
<input type="date" value={start} onChange={e => setStart(e.target.value)} className="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">По</label>
|
||||
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className="input" />
|
||||
</div>
|
||||
<button onClick={load} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
|
||||
<>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-center text-sm text-slate-400 py-8">Нет данных за выбранный период</p>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
<th className="text-left px-4 py-3 font-medium">Позиция</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Кол-во</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Выручка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
|
||||
{Object.entries(grouped).map(([cat, catRows]) => (
|
||||
<>
|
||||
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
|
||||
<td colSpan={3} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{cat}</td>
|
||||
</tr>
|
||||
{catRows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
||||
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-200">{r.name}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-600 dark:text-slate-300">{Number(r.totalQty)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-medium text-slate-900 dark:text-slate-100">{formatCurrency(Number(r.totalRevenue))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-slate-200 dark:border-slate-600">
|
||||
<td className="px-4 py-3 text-sm font-semibold text-slate-700 dark:text-slate-200">Итого</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-slate-800 dark:text-slate-100">{totalQty}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(totalRevenue)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
export function MinibarStockPage() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
const [tab, setTab] = useState<Tab>('stock')
|
||||
const [items, setItems] = useState<MinibarItem[]>([])
|
||||
|
||||
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 (
|
||||
<div className="p-4 md:p-6 space-y-5 max-w-4xl">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Link to="/settings/minibar" className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
|
||||
<ArrowLeft size={16} />
|
||||
Назад
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Package size={22} className="text-brand-600" />
|
||||
Учёт минибара
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 flex-wrap border-b border-slate-200 dark:border-slate-700">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.id
|
||||
? 'border-brand-600 text-brand-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'stock' && <StockTab slug={slug} />}
|
||||
{tab === 'receipts' && <ReceiptsTab slug={slug} items={items} />}
|
||||
{tab === 'writeoffs' && <WriteoffsTab slug={slug} items={items} />}
|
||||
{tab === 'inventory' && <InventoryTab slug={slug} />}
|
||||
{tab === 'report' && <ReportTab slug={slug} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user