feat(pos): implement integrated full-screen POS point-of-sale system with inventory deduction and thermal receipt

This commit is contained in:
Your Name committed 2026-09-24 19:35:56 +07:00
1 parent b585277214
commit 5796e4d868
10 files changed
+1549 -23

No files matched your search

+5
View File
@@ -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 />} />
+14 -2
View File
@@ -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">
+52 -3
View File
@@ -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>
<h1 className="text-3xl font-bold font-mono">Order #{order.id.toString().padStart(4, '0')}</h1>
<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>
+19 -2
View File
@@ -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">
#{order.id.toString().padStart(4, '0')}
<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
+14 -6
View File
@@ -313,11 +313,12 @@ model NewsletterSubscriber {
}
model Order {
id Int @id @default(autoincrement())
customerName String
customerEmail String
customerPhone String
shippingAddress String
id Int @id @default(autoincrement())
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 {
+30
View File
@@ -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())
})
});
+51 -10
View File
@@ -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,
@@ -81,6 +90,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) {
@@ -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()),
+5
View File
@@ -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([
+6
View File
@@ -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