feat(pos): implement integrated full-screen POS point-of-sale system with inventory deduction and thermal receipt
This commit is contained in:
1 parent
b585277214
commit
5796e4d868
10 files changed
+1546
-20
No files matched your search
@@ -68,6 +68,7 @@ import RepeatCustomerDetailPage from './pages/analytics/RepeatCustomerDetailPage
|
||||
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
||||
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||
import PosPage from './pages/PosPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -78,8 +79,12 @@ function App() {
|
||||
|
||||
{/* Guarded Admin Routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* Fullscreen POS Route (no admin sidebar) */}
|
||||
<Route path="/pos" element={<PosPage />} />
|
||||
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||
<Route path="/products/new" element={<ProductEditPage />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Outlet, Link } from 'react-router-dom'
|
||||
import { Store } from 'lucide-react'
|
||||
import Sidebar from './Sidebar'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
|
||||
@@ -19,11 +20,22 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/pos"
|
||||
className="flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500/20 via-teal-500/20 to-cyan-500/20 hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 text-emerald-400 hover:text-emerald-300 border border-emerald-500/30 hover:border-emerald-500/50 shadow-lg shadow-emerald-500/5 transition-all duration-200 text-xs font-semibold group"
|
||||
title="เปิดระบบจุดขาย POS (Point of Sale)"
|
||||
>
|
||||
<Store className="w-4 h-4 transition-transform group-hover:scale-110 text-emerald-400" />
|
||||
<span className="tracking-wide">ระบบ POS</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 rounded bg-emerald-500/20 border border-emerald-500/30 text-emerald-300">Live</span>
|
||||
</Link>
|
||||
<div className="h-4 w-px bg-white/10" />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 p-8 lg:p-10">
|
||||
<div className="w-full max-w-[1600px] mx-auto space-y-10">
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function OrderDetailPage() {
|
||||
switch (status) {
|
||||
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
||||
case 'PAID': return t('orders.statusPaid', 'Paid')
|
||||
case 'COMPLETED': return t('orders.statusCompleted', 'Completed')
|
||||
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
||||
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
||||
default: return status.replace('_', ' ')
|
||||
@@ -70,10 +71,22 @@ export default function OrderDetailPage() {
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold font-mono">Order #{order.id.toString().padStart(4, '0')}</h1>
|
||||
{order.channel === 'POS' ? (
|
||||
<span className="px-2 py-0.5 rounded-md text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
POS In-Store
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded-md text-xs font-bold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
ONLINE Store
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">{new Date(order.createdAt).toLocaleString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')}</p>
|
||||
</div>
|
||||
<div className={`ml-auto px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest border ${
|
||||
order.status === 'COMPLETED' ? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40' :
|
||||
order.status === 'PAID' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' :
|
||||
order.status === 'SHIPPED' ? 'bg-cyan-glow/10 text-cyan-glow border-cyan-glow/20' :
|
||||
order.status === 'CANCELLED' ? 'bg-rose-500/10 text-rose-400 border-rose-500/20' :
|
||||
@@ -83,6 +96,7 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Left Col: Order Info & Items */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
@@ -166,7 +180,36 @@ export default function OrderDetailPage() {
|
||||
{t('orders.paymentSlip', 'Payment Verification')}
|
||||
</h2>
|
||||
|
||||
{order.paymentSlipUrl ? (
|
||||
{order.channel === 'POS' ? (
|
||||
<div className="space-y-3 bg-emerald-500/10 border border-emerald-500/20 p-4 rounded-xl text-xs">
|
||||
<div className="flex justify-between items-center pb-2 border-b border-white/10">
|
||||
<span className="text-slate-400">ช่องทางการขาย</span>
|
||||
<span className="font-bold text-emerald-400">จุดขายหน้าร้าน (POS)</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400">วิธีการชำระ</span>
|
||||
<span className="font-bold text-white">{order.paymentMethod || 'เงินสด (CASH)'}</span>
|
||||
</div>
|
||||
{order.receivedAmount != null && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400">ยอดเงินที่รับ</span>
|
||||
<span className="font-medium text-slate-200">{formatPrice(order.receivedAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.changeAmount != null && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-400">เงินทอน</span>
|
||||
<span className="font-medium text-slate-200">{formatPrice(order.changeAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.pointsEarned ? (
|
||||
<div className="flex justify-between items-center text-emerald-400 font-semibold pt-1 border-t border-white/5">
|
||||
<span>แต้มสะสมที่มอบให้</span>
|
||||
<span>+{order.pointsEarned} แต้ม</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : order.paymentSlipUrl ? (
|
||||
<div className="space-y-4">
|
||||
<a
|
||||
href={order.paymentSlipUrl}
|
||||
@@ -196,6 +239,11 @@ export default function OrderDetailPage() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{order.status === 'COMPLETED' && (
|
||||
<div className="text-center p-4 bg-emerald-500/10 rounded-lg border border-emerald-500/20 text-emerald-300 text-xs font-semibold">
|
||||
✓ การขายเสร็จสมบูรณ์แล้ว (จุดขายหน้าร้าน POS)
|
||||
</div>
|
||||
)}
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<button
|
||||
onClick={() => updateStatus('PAID')}
|
||||
@@ -214,7 +262,7 @@ export default function OrderDetailPage() {
|
||||
<Truck className="w-4 h-4" /> {t('orders.markShipped', 'Mark as Shipped')}
|
||||
</button>
|
||||
)}
|
||||
{order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && (
|
||||
{order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && order.status !== 'COMPLETED' && (
|
||||
<button
|
||||
onClick={() => updateStatus('CANCELLED')}
|
||||
disabled={updating}
|
||||
@@ -229,6 +277,7 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function OrdersPage() {
|
||||
switch (status) {
|
||||
case 'PENDING_PAYMENT': return <Clock className="w-4 h-4 text-amber-500" />
|
||||
case 'PAID': return <CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
case 'COMPLETED': return <CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
case 'SHIPPED': return <Truck className="w-4 h-4 text-cyan-glow" />
|
||||
case 'CANCELLED': return <XCircle className="w-4 h-4 text-rose-500" />
|
||||
default: return null
|
||||
@@ -54,6 +55,7 @@ export default function OrdersPage() {
|
||||
switch (status) {
|
||||
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
||||
case 'PAID': return t('orders.statusPaid', 'Paid')
|
||||
case 'COMPLETED': return t('orders.statusCompleted', 'Completed')
|
||||
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
||||
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
||||
default: return status.replace('_', ' ')
|
||||
@@ -64,10 +66,12 @@ export default function OrdersPage() {
|
||||
{ key: 'ALL', label: t('orders.statusAll', 'All') },
|
||||
{ key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') },
|
||||
{ key: 'PAID', label: t('orders.statusPaid', 'Paid') },
|
||||
{ key: 'COMPLETED', label: t('orders.statusCompleted', 'Completed') },
|
||||
{ key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') },
|
||||
{ key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') },
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
@@ -141,8 +145,21 @@ export default function OrdersPage() {
|
||||
) : (
|
||||
filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="p-4 font-mono text-cyan-glow text-sm font-bold">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-cyan-glow text-sm font-bold">
|
||||
#{order.id.toString().padStart(4, '0')}
|
||||
</span>
|
||||
{order.channel === 'POS' ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
POS
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
ONLINE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.customer ? (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -314,10 +314,11 @@ model NewsletterSubscriber {
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
customerName String
|
||||
customerEmail String
|
||||
customerPhone String
|
||||
shippingAddress String
|
||||
channel String @default("ONLINE") // "ONLINE" | "POS"
|
||||
customerName String?
|
||||
customerEmail String?
|
||||
customerPhone String?
|
||||
shippingAddress String?
|
||||
|
||||
subtotal Float @default(0)
|
||||
promoLevel1Name String?
|
||||
@@ -332,9 +333,13 @@ model Order {
|
||||
pointsDiscount Float @default(0)
|
||||
pointsEarned Int @default(0)
|
||||
|
||||
shippingFee Float @default(35)
|
||||
shippingFee Float @default(0)
|
||||
totalAmount Float
|
||||
status String @default("PENDING_PAYMENT")
|
||||
paymentMethod String? @default("CASH")
|
||||
receivedAmount Float?
|
||||
changeAmount Float?
|
||||
cashierId Int?
|
||||
paymentSlipUrl String?
|
||||
trackingNo String?
|
||||
|
||||
@@ -344,6 +349,9 @@ model Order {
|
||||
items OrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([channel])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
|
||||
@@ -66,4 +66,34 @@ export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||
pointsDelta: t.Number(),
|
||||
reason: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const { displayName, phone, email } = body as { displayName?: string, phone?: string, email?: string };
|
||||
|
||||
// Auto-generate a unique lineUserId placeholder for in-store/POS customers
|
||||
const lineUserId = `POS_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
const newCustomer = await prisma.customer.create({
|
||||
data: {
|
||||
lineUserId,
|
||||
displayName: displayName || "ลูกค้าหน้าร้าน",
|
||||
phone: phone || null,
|
||||
email: email || null,
|
||||
points: 0
|
||||
}
|
||||
});
|
||||
return newCustomer;
|
||||
} catch (err: any) {
|
||||
console.error("[Customers] Create error:", err);
|
||||
set.status = 400;
|
||||
return { error: err.message || "Failed to create customer" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
displayName: t.Optional(t.String()),
|
||||
phone: t.Optional(t.String()),
|
||||
email: t.Optional(t.String())
|
||||
})
|
||||
});
|
||||
|
||||
@@ -48,13 +48,17 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
})
|
||||
}
|
||||
|
||||
const isPos = data.channel === "POS";
|
||||
const initialStatus = isPos ? "COMPLETED" : "PENDING_PAYMENT";
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
channel: data.channel || "ONLINE",
|
||||
customerId: data.customerId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
shippingAddress: data.shippingAddress,
|
||||
customerName: data.customerName || (isPos ? "ลูกค้าหน้าร้าน (Walk-in)" : null),
|
||||
customerEmail: data.customerEmail || null,
|
||||
customerPhone: data.customerPhone || null,
|
||||
shippingAddress: data.shippingAddress || (isPos ? "หน้าร้าน (Takeaway)" : null),
|
||||
subtotal: data.subtotal || 0,
|
||||
promoLevel1Name: data.promoLevel1Name,
|
||||
promoLevel1Disc: data.promoLevel1Disc || 0,
|
||||
@@ -65,8 +69,13 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
pointsUsed: data.pointsUsed || 0,
|
||||
pointsDiscount: data.pointsDiscount || 0,
|
||||
pointsEarned: pointsEarned,
|
||||
shippingFee: data.shippingFee,
|
||||
shippingFee: data.shippingFee || 0,
|
||||
totalAmount: data.totalAmount,
|
||||
status: initialStatus,
|
||||
paymentMethod: data.paymentMethod || (isPos ? "CASH" : null),
|
||||
receivedAmount: data.receivedAmount,
|
||||
changeAmount: data.changeAmount,
|
||||
cashierId: data.cashierId,
|
||||
items: {
|
||||
create: data.items.map((item: any) => ({
|
||||
productId: item.productId,
|
||||
@@ -82,6 +91,33 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
// For POS orders: Deduct stock immediately and credit points
|
||||
if (isPos) {
|
||||
for (const item of data.items) {
|
||||
if (item.variantId) {
|
||||
try {
|
||||
await prisma.productVariant.update({
|
||||
where: { id: item.variantId },
|
||||
data: { stock: { decrement: item.quantity } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[POS] Failed to decrement stock for variant ${item.variantId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.customerId && pointsEarned > 0) {
|
||||
try {
|
||||
await prisma.customer.update({
|
||||
where: { id: data.customerId },
|
||||
data: { points: { increment: pointsEarned } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[POS] Failed to credit points for customer ${data.customerId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a coupon was used, increment its usedCount
|
||||
if (data.couponCode) {
|
||||
try {
|
||||
@@ -107,13 +143,18 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
channel: t.Optional(t.String()),
|
||||
customerId: t.Optional(t.Nullable(t.Number())),
|
||||
customerName: t.String(),
|
||||
customerEmail: t.String(),
|
||||
customerPhone: t.String(),
|
||||
shippingAddress: t.String(),
|
||||
customerName: t.Optional(t.Nullable(t.String())),
|
||||
customerEmail: t.Optional(t.Nullable(t.String())),
|
||||
customerPhone: t.Optional(t.Nullable(t.String())),
|
||||
shippingAddress: t.Optional(t.Nullable(t.String())),
|
||||
paymentMethod: t.Optional(t.Nullable(t.String())),
|
||||
receivedAmount: t.Optional(t.Nullable(t.Number())),
|
||||
changeAmount: t.Optional(t.Nullable(t.Number())),
|
||||
cashierId: t.Optional(t.Nullable(t.Number())),
|
||||
totalAmount: t.Number(),
|
||||
shippingFee: t.Number(),
|
||||
shippingFee: t.Optional(t.Number()),
|
||||
subtotal: t.Optional(t.Number()),
|
||||
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
||||
promoLevel1Disc: t.Optional(t.Number()),
|
||||
|
||||
@@ -133,8 +133,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ variants: { some: { OR: [
|
||||
{ barcode: { contains: search, mode: "insensitive" as const } },
|
||||
{ sku: { contains: search, mode: "insensitive" as const } }
|
||||
] } } },
|
||||
],
|
||||
}),
|
||||
|
||||
} as const;
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
|
||||
@@ -267,6 +267,11 @@ export interface Order {
|
||||
totalAmount: number
|
||||
shippingFee: number
|
||||
status: string
|
||||
channel?: string
|
||||
paymentMethod?: string | null
|
||||
receivedAmount?: number | null
|
||||
changeAmount?: number | null
|
||||
cashierId?: number | null
|
||||
paymentSlipUrl?: string | null
|
||||
trackingNo?: string | null
|
||||
customerId?: number | null
|
||||
@@ -276,6 +281,7 @@ export interface Order {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
|
||||
export interface Article {
|
||||
id: number
|
||||
slug: string
|
||||
|
||||
Reference in new issue
Block a user