krona-front/src/features/operations/PosView.tsx

664 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { useDemoStore, PosCartItem } from '../../mock/store';
import { INITIAL_CATALOG } from '../../mock/mockData';
import { Button, Badge, MaturityBadge, Modal, useToast } from '../../shared/ui';
import { formatCurrency } from '../../shared/utils/formatters';
import {
Search,
Plus,
Minus,
Trash2,
CreditCard,
Banknote,
QrCode,
Receipt,
CheckCircle2,
User,
Printer,
ChevronDown,
AlertCircle,
} from 'lucide-react';
const SUGGESTED_CUSTOMERS = [
{ name: 'Александр Романов', phone: '+7 (913) 900-11-22', tag: 'VIP • Скидка 10%' },
{ name: 'Елена Морозова', phone: '+7 (923) 456-78-90', tag: 'Постоянный гость' },
{ name: 'Михаил Васильев', phone: '+7 (905) 111-22-33', tag: 'Бонусная карта 5%' },
{ name: 'ООО Альфа Консалт', phone: '+7 (383) 200-40-50', tag: 'B2B безнал' },
];
export const PosView: React.FC = () => {
const { showToast } = useToast();
const {
posCart,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
posDiscountPercent,
setPosDiscount,
posCustomerName,
setPosCustomer,
posOrderType,
setPosOrderType,
posTableNumber,
setPosTableNumber,
posPaymentModalOpen,
setPosPaymentModalOpen,
posLastReceipt,
completeCheckout,
getActiveProfile,
activeLocation,
} = useDemoStore();
const profile = getActiveProfile();
const [selectedCategory, setSelectedCategory] = useState<string>('Все');
const [searchQuery, setSearchQuery] = useState<string>('');
const [showReceiptModal, setShowReceiptModal] = useState(false);
const [showCustomerPicker, setShowCustomerPicker] = useState(false);
// Split payment state
const [isSplitMode, setIsSplitMode] = useState(false);
const [splitCashAmount, setSplitCashAmount] = useState<number>(0);
const [splitCardAmount, setSplitCardAmount] = useState<number>(0);
const categories = ['Все', 'Роллы', 'Горячее', 'Супы', 'Автоуслуги', 'Автосервис'];
const filteredItems = INITIAL_CATALOG.filter((item) => {
const matchesCat = selectedCategory === 'Все' || item.category === selectedCategory;
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCat && matchesSearch;
});
const subtotal = posCart.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discountAmount = (subtotal * posDiscountPercent) / 100;
const totalAmount = Math.max(0, subtotal - discountAmount);
const handlePayClick = () => {
if (posCart.length === 0) return;
setIsSplitMode(false);
setPosPaymentModalOpen(true);
};
const handleInitiateSplit = () => {
const half = Math.round(totalAmount / 2);
setSplitCashAmount(half);
setSplitCardAmount(totalAmount - half);
setIsSplitMode(true);
};
const handleConfirmSplitPayment = () => {
if (splitCashAmount + splitCardAmount !== totalAmount) return;
setPosPaymentModalOpen(false);
setIsSplitMode(false);
completeCheckout('split');
setShowReceiptModal(true);
};
const handlePaymentSelect = (method: 'cash' | 'card' | 'qr' | 'split') => {
if (method === 'split') {
handleInitiateSplit();
return;
}
setPosPaymentModalOpen(false);
completeCheckout(method);
setShowReceiptModal(true);
};
const handlePrintReceipt = () => {
showToast({
title: 'Печать фискального чека',
message: `Чек ${posLastReceipt?.orderNumber || ''} отправлен на кассовый регистратор (Атол 54-Ф)`,
type: 'success',
});
};
const remainingToSplit = totalAmount - (splitCashAmount + splitCardAmount);
return (
<div className="h-[calc(100vh-8.5rem)] flex flex-col md:flex-row gap-4 animate-fade-in overflow-hidden">
{/* Left: Product Catalog Grid */}
<div className="flex-1 flex flex-col min-w-0 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Filter bar */}
<div className="p-3 border-b border-krona-border flex flex-wrap items-center justify-between gap-3 bg-krona-surface/70">
<div className="flex items-center gap-1.5 overflow-x-auto">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCategory === cat
? 'bg-[#2F684C] text-[#F8F6F0] shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{cat}
</button>
))}
</div>
<div className="relative w-56">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-workspace border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
</div>
</div>
{/* Products Grid */}
<div className="flex-1 overflow-y-auto p-4 grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredItems.map((item) => {
const inCart = posCart.find((c) => c.id === item.id);
const isStop = item.isStopListed;
return (
<div
key={item.id}
onClick={() => !isStop && addToCart(item)}
className={`group relative p-3.5 rounded-xl border flex flex-col justify-between transition-all select-none ${
isStop
? 'opacity-50 border-krona-danger/40 bg-krona-danger/5 cursor-not-allowed'
: 'cursor-pointer border-krona-border bg-krona-surface hover:border-krona-green/60 hover:shadow-krona-soft active:scale-[0.99]'
} ${inCart ? 'ring-2 ring-krona-green/40' : ''}`}
>
<div>
<div className="flex items-center justify-between gap-1 mb-1.5">
<span className="text-[10px] text-krona-secondary uppercase font-semibold">
{item.category}
</span>
{isStop ? (
<Badge variant="danger" size="sm">Стоп-лист</Badge>
) : inCart ? (
<Badge variant="green" size="sm">{inCart.quantity} в чеке</Badge>
) : null}
</div>
<h4 className="text-xs font-bold text-krona-primary leading-snug line-clamp-2">
{item.name}
</h4>
{item.description && (
<p className="text-[11px] text-krona-secondary line-clamp-2 mt-1">
{item.description}
</p>
)}
</div>
<div className="mt-3 pt-2.5 border-t border-krona-border/40 flex items-baseline justify-between">
<span className="text-sm font-bold text-krona-primary">
{formatCurrency(item.price)}
</span>
<span className="text-[10px] text-krona-secondary">
Себест: {item.cost} ₽ ({item.marginPercent}%)
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Right: Cart & Order Register Panel */}
<div className="w-full md:w-80 lg:w-96 flex flex-col bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Order settings header */}
<div className="p-3 border-b border-krona-border bg-krona-surface/80 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Текущий чек
</span>
<MaturityBadge level="target" size="sm" />
</div>
<span className="text-xs text-krona-secondary">{activeLocation}</span>
</div>
<div className="grid grid-cols-3 gap-1 bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setPosOrderType('hall')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'hall' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
В зале
</button>
<button
onClick={() => setPosOrderType('takeaway')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'takeaway' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Навынос
</button>
<button
onClick={() => setPosOrderType('delivery')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'delivery' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Доставка
</button>
</div>
{/* Customer picker */}
<div className="relative">
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-1.5 px-2.5 py-1 bg-krona-workspace border border-krona-border rounded-md text-xs">
<User className="w-3.5 h-3.5 text-krona-secondary" />
<input
type="text"
value={posCustomerName}
onChange={(e) => setPosCustomer(e.target.value)}
placeholder="Клиент / Выбрать..."
className="w-full bg-transparent focus:outline-none text-xs text-krona-primary"
/>
<button
type="button"
onClick={() => setShowCustomerPicker(!showCustomerPicker)}
className="text-krona-secondary hover:text-krona-primary"
title="Выбрать из справочника"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
{posOrderType === 'hall' && (
<div className="w-24">
<input
type="text"
value={posTableNumber}
onChange={(e) => setPosTableNumber(e.target.value)}
placeholder="Стол..."
className="w-full px-2 py-1 bg-krona-workspace border border-krona-border rounded-md text-xs text-krona-primary text-center font-medium focus:outline-none"
/>
</div>
)}
</div>
{/* Quick Customer Picker Dropdown */}
{showCustomerPicker && (
<div className="absolute left-0 right-0 top-full mt-1 bg-krona-surface border border-krona-border rounded-xl shadow-lg z-20 p-2 space-y-1 text-xs">
<div className="text-[10px] font-bold text-krona-secondary px-2 py-1 uppercase">
Быстрый выбор клиента CRM
</div>
{SUGGESTED_CUSTOMERS.map((cust, idx) => (
<button
key={idx}
type="button"
onClick={() => {
setPosCustomer(cust.name);
setShowCustomerPicker(false);
if (cust.tag.includes('10%')) setPosDiscount(10);
else if (cust.tag.includes('5%')) setPosDiscount(5);
}}
className="w-full text-left p-2 rounded-lg hover:bg-krona-workspace flex items-center justify-between text-xs transition-colors"
>
<div>
<div className="font-semibold text-krona-primary">{cust.name}</div>
<div className="text-[10px] text-krona-secondary">{cust.phone}</div>
</div>
<Badge variant="gold" size="sm">{cust.tag}</Badge>
</button>
))}
<div className="pt-1 border-t border-krona-border/50 text-center">
<button
type="button"
onClick={() => setShowCustomerPicker(false)}
className="text-[11px] text-krona-secondary hover:text-krona-primary"
>
Закрыть список
</button>
</div>
</div>
)}
</div>
</div>
{/* Cart items list */}
<div className="flex-1 overflow-y-auto p-3 divide-y divide-krona-border/40">
{posCart.length === 0 ? (
<div className="py-16 text-center text-xs text-krona-secondary">
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-30" />
Чек пуст. Добавьте позиции из каталога слева.
</div>
) : (
posCart.map((item) => (
<div key={item.id} className="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between gap-2 text-xs">
<div className="flex-1 min-w-0">
<div className="font-semibold text-krona-primary truncate">{item.name}</div>
<div className="text-[11px] text-krona-secondary">
{formatCurrency(item.price)} × {item.quantity} = {formatCurrency(item.price * item.quantity)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => updateCartQuantity(item.id, -1)}
className="w-6 h-6 rounded bg-krona-workspace border border-krona-border flex items-center justify-center hover:bg-krona-border/50 text-krona-primary"
aria-label={`Уменьшить количество ${item.name}`}
>
<Minus className="w-3 h-3" />
</button>
<span className="w-6 text-center font-semibold text-xs text-krona-primary">
{item.quantity}
</span>
<button
onClick={() => updateCartQuantity(item.id, 1)}
className="w-6 h-6 rounded bg-krona-workspace border border-krona-border flex items-center justify-center hover:bg-krona-border/50 text-krona-primary"
aria-label={`Увеличить количество ${item.name}`}
>
<Plus className="w-3 h-3" />
</button>
<button
onClick={() => removeFromCart(item.id)}
className="p-1 text-krona-secondary hover:text-krona-danger ml-1"
aria-label={`Удалить ${item.name} из чека`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
{/* Discount & Totals */}
<div className="p-3.5 border-t border-krona-border bg-krona-surface/80 space-y-2 text-xs">
{/* Quick Discounts */}
<div className="flex items-center justify-between gap-1 text-[11px]">
<span className="text-krona-secondary">Скидка:</span>
<div className="flex items-center gap-1">
{[0, 5, 10, 15, 20].map((pct) => (
<button
key={pct}
onClick={() => setPosDiscount(pct)}
className={`px-2 py-0.5 rounded border transition-colors ${
posDiscountPercent === pct
? 'bg-krona-gold text-white font-bold border-krona-gold'
: 'bg-krona-workspace border-krona-border text-krona-secondary'
}`}
>
{pct}%
</button>
))}
</div>
</div>
<div className="space-y-1 pt-1">
<div className="flex justify-between text-krona-secondary">
<span>Сумма позиций:</span>
<span>{formatCurrency(subtotal)}</span>
</div>
{posDiscountPercent > 0 && (
<div className="flex justify-between text-krona-danger">
<span>Скидка ({posDiscountPercent}%):</span>
<span>-{formatCurrency(discountAmount)}</span>
</div>
)}
<div className="flex justify-between text-base font-bold text-krona-primary pt-1 border-t border-krona-border">
<span>К оплате:</span>
<span className="text-krona-green">{formatCurrency(totalAmount)}</span>
</div>
</div>
<div className="pt-2 flex gap-2">
<Button
variant="secondary"
size="md"
onClick={clearCart}
disabled={posCart.length === 0}
className="px-3"
title="Очистить чек"
>
Сброс
</Button>
<Button
variant="primary"
size="md"
onClick={handlePayClick}
disabled={posCart.length === 0}
className="flex-1 text-sm font-semibold"
>
Оплатить {formatCurrency(totalAmount)}
</Button>
</div>
</div>
</div>
{/* Modal: Payment Method Selection & Split Payment */}
<Modal
isOpen={posPaymentModalOpen}
onClose={() => {
setPosPaymentModalOpen(false);
setIsSplitMode(false);
}}
title={isSplitMode ? "Смешанная оплата (Наличные + Карта)" : "Оплата заказа"}
subtitle={`Сумма к оплате: ${formatCurrency(totalAmount)}`}
maxWidth="md"
>
{!isSplitMode ? (
<div className="space-y-3">
<p className="text-xs text-krona-secondary">
Выберите способ расчета клиента на кассе точки «{activeLocation}»:
</p>
<div className="grid grid-cols-2 gap-3 pt-1">
<button
onClick={() => handlePaymentSelect('card')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<CreditCard className="w-8 h-8 text-krona-green group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Банковская карта</div>
<div className="text-[10px] text-krona-secondary">Терминал эквайринга</div>
</button>
<button
onClick={() => handlePaymentSelect('cash')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<Banknote className="w-8 h-8 text-krona-green group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Наличные</div>
<div className="text-[10px] text-krona-secondary">С выдачей сдачи</div>
</button>
<button
onClick={() => handlePaymentSelect('qr')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<QrCode className="w-8 h-8 text-krona-gold group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">СБП / QR-код</div>
<div className="text-[10px] text-krona-secondary">Мгновенное зачисление</div>
</button>
<button
onClick={() => handlePaymentSelect('split')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<Receipt className="w-8 h-8 text-krona-secondary group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Смешанная оплата</div>
<div className="text-[10px] text-krona-secondary">Часть нал + часть карта</div>
</button>
</div>
</div>
) : (
<div className="space-y-4 text-xs">
<div className="bg-krona-workspace p-3 rounded-lg border border-krona-border space-y-2">
<div className="flex justify-between font-bold text-krona-primary">
<span>Общая сумма к оплате:</span>
<span>{formatCurrency(totalAmount)}</span>
</div>
<p className="text-[11px] text-krona-secondary">
Распределите сумму между наличным расчетом и банковской картой:
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[11px] font-semibold text-krona-secondary flex items-center gap-1">
<Banknote className="w-3.5 h-3.5 text-krona-green" /> Сумма наличными (₽):
</label>
<input
type="number"
min={0}
max={totalAmount}
value={splitCashAmount}
onChange={(e) => {
const val = Math.max(0, Number(e.target.value));
setSplitCashAmount(val);
setSplitCardAmount(Math.max(0, totalAmount - val));
}}
className="w-full p-2 bg-krona-surface border border-krona-border rounded-lg font-mono font-bold text-sm text-krona-primary focus:outline-none focus:border-krona-green"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-semibold text-krona-secondary flex items-center gap-1">
<CreditCard className="w-3.5 h-3.5 text-sky-600" /> Сумма картой (₽):
</label>
<input
type="number"
min={0}
max={totalAmount}
value={splitCardAmount}
onChange={(e) => {
const val = Math.max(0, Number(e.target.value));
setSplitCardAmount(val);
setSplitCashAmount(Math.max(0, totalAmount - val));
}}
className="w-full p-2 bg-krona-surface border border-krona-border rounded-lg font-mono font-bold text-sm text-krona-primary focus:outline-none focus:border-krona-green"
/>
</div>
</div>
{/* Balance check indicator */}
<div className={`p-2.5 rounded-lg border flex items-center justify-between ${
remainingToSplit === 0
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-700 dark:text-emerald-300'
: 'bg-rose-500/10 border-rose-500/30 text-rose-700 dark:text-rose-300'
}`}>
<div className="flex items-center gap-1.5 font-medium">
{remainingToSplit === 0 ? (
<>
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span>Сумма распределена верно (100%)</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-rose-600" />
<span>{remainingToSplit > 0 ? `Не распределено: ${formatCurrency(remainingToSplit)}` : `Переплата: ${formatCurrency(Math.abs(remainingToSplit))}`}</span>
</>
)}
</div>
<span className="font-mono font-bold">{formatCurrency(splitCashAmount + splitCardAmount)} / {formatCurrency(totalAmount)}</span>
</div>
<div className="flex gap-2 pt-2 border-t border-krona-border">
<Button
variant="secondary"
size="md"
onClick={() => setIsSplitMode(false)}
className="w-1/3"
>
Назад
</Button>
<Button
variant="primary"
size="md"
onClick={handleConfirmSplitPayment}
disabled={remainingToSplit !== 0}
className="flex-1"
>
Подтвердить раздельную оплату
</Button>
</div>
</div>
)}
</Modal>
{/* Modal: Fiscal Receipt Details */}
{showReceiptModal && posLastReceipt && (
<Modal
isOpen={showReceiptModal}
onClose={() => setShowReceiptModal(false)}
title="Чек успешно пробит!"
subtitle={`Заказ ${posLastReceipt.orderNumber} • ${posLastReceipt.timestamp}`}
maxWidth="sm"
footer={
<div className="flex gap-2 w-full">
<Button
variant="secondary"
onClick={handlePrintReceipt}
className="flex-1 flex items-center justify-center gap-1.5"
>
<Printer className="w-4 h-4" />
Распечатать чек
</Button>
<Button
variant="primary"
onClick={() => setShowReceiptModal(false)}
className="flex-1"
>
Новый заказ
</Button>
</div>
}
>
<div className="font-mono text-xs bg-krona-workspace p-3.5 rounded-lg border border-krona-border space-y-2">
<div className="text-center font-bold pb-1 border-b border-dashed border-krona-border">
{profile.companyName}
<div className="text-[10px] font-normal text-krona-secondary">{posLastReceipt.location}</div>
<div className="text-[10px] text-krona-secondary font-normal">Касса №1 • Смена открыта</div>
</div>
<div className="space-y-1 py-1">
{posLastReceipt.items.map((it: PosCartItem, i: number) => (
<div key={i} className="flex justify-between">
<span>{it.name} × {it.quantity}</span>
<span>{formatCurrency(it.price * it.quantity)}</span>
</div>
))}
</div>
<div className="pt-2 border-t border-dashed border-krona-border space-y-1">
<div className="flex justify-between">
<span>Сумма:</span>
<span>{formatCurrency(posLastReceipt.subtotal)}</span>
</div>
{posLastReceipt.discount > 0 && (
<div className="flex justify-between text-krona-danger">
<span>Скидка:</span>
<span>-{formatCurrency(posLastReceipt.discount)}</span>
</div>
)}
<div className="flex justify-between font-bold text-sm pt-1 border-t border-dotted border-krona-border">
<span>ИТОГ К ОПЛАТЕ:</span>
<span>{formatCurrency(posLastReceipt.total)}</span>
</div>
<div className="flex justify-between text-[11px] text-krona-secondary">
<span>Способ оплаты:</span>
<span className="uppercase font-semibold">{posLastReceipt.paymentType}</span>
</div>
{posLastReceipt.customer && (
<div className="flex justify-between text-[11px] text-krona-secondary">
<span>Клиент:</span>
<span>{posLastReceipt.customer}</span>
</div>
)}
<div className="flex justify-between text-[10px] text-krona-secondary pt-1">
<span>ФП (фискальный признак):</span>
<span>ФП: 49204910283</span>
</div>
<div className="flex justify-between text-[10px] text-krona-secondary">
<span>ФН: 996044030198 • РН ККТ: 00049281001</span>
</div>
</div>
<div className="pt-2 text-center text-[10px] text-krona-secondary border-t border-dashed border-krona-border">
Спасибо за заказ в «{profile.companyName}»!
</div>
</div>
</Modal>
)}
</div>
);
};