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
+1549
-23
No files matched your search
@@ -68,6 +68,7 @@ import RepeatCustomerDetailPage from './pages/analytics/RepeatCustomerDetailPage
|
|||||||
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
||||||
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
||||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||||
|
import PosPage from './pages/PosPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -78,8 +79,12 @@ function App() {
|
|||||||
|
|
||||||
{/* Guarded Admin Routes */}
|
{/* Guarded Admin Routes */}
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
|
{/* Fullscreen POS Route (no admin sidebar) */}
|
||||||
|
<Route path="/pos" element={<PosPage />} />
|
||||||
|
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<AdminLayout />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||||
<Route path="/products/new" element={<ProductEditPage />} />
|
<Route path="/products/new" element={<ProductEditPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
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 Sidebar from './Sidebar'
|
||||||
import LanguageSelector from './LanguageSelector'
|
import LanguageSelector from './LanguageSelector'
|
||||||
|
|
||||||
@@ -19,11 +20,22 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 p-8 lg:p-10">
|
<main className="flex-1 p-8 lg:p-10">
|
||||||
<div className="w-full max-w-[1600px] mx-auto space-y-10">
|
<div className="w-full max-w-[1600px] mx-auto space-y-10">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function OrderDetailPage() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
||||||
case 'PAID': return t('orders.statusPaid', 'Paid')
|
case 'PAID': return t('orders.statusPaid', 'Paid')
|
||||||
|
case 'COMPLETED': return t('orders.statusCompleted', 'Completed')
|
||||||
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
||||||
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
||||||
default: return status.replace('_', ' ')
|
default: return status.replace('_', ' ')
|
||||||
@@ -70,10 +71,22 @@ export default function OrderDetailPage() {
|
|||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<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>
|
<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>
|
||||||
<div className={`ml-auto px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest border ${
|
<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 === '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 === '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' :
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-8">
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
{/* Left Col: Order Info & Items */}
|
{/* Left Col: Order Info & Items */}
|
||||||
<div className="lg:col-span-2 space-y-8">
|
<div className="lg:col-span-2 space-y-8">
|
||||||
@@ -166,7 +180,36 @@ export default function OrderDetailPage() {
|
|||||||
{t('orders.paymentSlip', 'Payment Verification')}
|
{t('orders.paymentSlip', 'Payment Verification')}
|
||||||
</h2>
|
</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">
|
<div className="space-y-4">
|
||||||
<a
|
<a
|
||||||
href={order.paymentSlipUrl}
|
href={order.paymentSlipUrl}
|
||||||
@@ -196,6 +239,11 @@ export default function OrderDetailPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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' && (
|
{order.status === 'PENDING_PAYMENT' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => updateStatus('PAID')}
|
onClick={() => updateStatus('PAID')}
|
||||||
@@ -214,7 +262,7 @@ export default function OrderDetailPage() {
|
|||||||
<Truck className="w-4 h-4" /> {t('orders.markShipped', 'Mark as Shipped')}
|
<Truck className="w-4 h-4" /> {t('orders.markShipped', 'Mark as Shipped')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && (
|
{order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && order.status !== 'COMPLETED' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => updateStatus('CANCELLED')}
|
onClick={() => updateStatus('CANCELLED')}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
@@ -229,6 +277,7 @@ export default function OrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function OrdersPage() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'PENDING_PAYMENT': return <Clock className="w-4 h-4 text-amber-500" />
|
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 '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 'SHIPPED': return <Truck className="w-4 h-4 text-cyan-glow" />
|
||||||
case 'CANCELLED': return <XCircle className="w-4 h-4 text-rose-500" />
|
case 'CANCELLED': return <XCircle className="w-4 h-4 text-rose-500" />
|
||||||
default: return null
|
default: return null
|
||||||
@@ -54,6 +55,7 @@ export default function OrdersPage() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
|
||||||
case 'PAID': return t('orders.statusPaid', 'Paid')
|
case 'PAID': return t('orders.statusPaid', 'Paid')
|
||||||
|
case 'COMPLETED': return t('orders.statusCompleted', 'Completed')
|
||||||
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
|
||||||
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
|
||||||
default: return status.replace('_', ' ')
|
default: return status.replace('_', ' ')
|
||||||
@@ -64,10 +66,12 @@ export default function OrdersPage() {
|
|||||||
{ key: 'ALL', label: t('orders.statusAll', 'All') },
|
{ key: 'ALL', label: t('orders.statusAll', 'All') },
|
||||||
{ key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') },
|
{ key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') },
|
||||||
{ key: 'PAID', label: t('orders.statusPaid', 'Paid') },
|
{ key: 'PAID', label: t('orders.statusPaid', 'Paid') },
|
||||||
|
{ key: 'COMPLETED', label: t('orders.statusCompleted', 'Completed') },
|
||||||
{ key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') },
|
{ key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') },
|
||||||
{ key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') },
|
{ key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -141,8 +145,21 @@ export default function OrdersPage() {
|
|||||||
) : (
|
) : (
|
||||||
filteredOrders.map((order) => (
|
filteredOrders.map((order) => (
|
||||||
<tr key={order.id} className="hover:bg-white/5 transition-colors group">
|
<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">
|
||||||
#{order.id.toString().padStart(4, '0')}
|
<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>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{order.customer ? (
|
{order.customer ? (
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -313,11 +313,12 @@ model NewsletterSubscriber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
customerName String
|
channel String @default("ONLINE") // "ONLINE" | "POS"
|
||||||
customerEmail String
|
customerName String?
|
||||||
customerPhone String
|
customerEmail String?
|
||||||
shippingAddress String
|
customerPhone String?
|
||||||
|
shippingAddress String?
|
||||||
|
|
||||||
subtotal Float @default(0)
|
subtotal Float @default(0)
|
||||||
promoLevel1Name String?
|
promoLevel1Name String?
|
||||||
@@ -332,9 +333,13 @@ model Order {
|
|||||||
pointsDiscount Float @default(0)
|
pointsDiscount Float @default(0)
|
||||||
pointsEarned Int @default(0)
|
pointsEarned Int @default(0)
|
||||||
|
|
||||||
shippingFee Float @default(35)
|
shippingFee Float @default(0)
|
||||||
totalAmount Float
|
totalAmount Float
|
||||||
status String @default("PENDING_PAYMENT")
|
status String @default("PENDING_PAYMENT")
|
||||||
|
paymentMethod String? @default("CASH")
|
||||||
|
receivedAmount Float?
|
||||||
|
changeAmount Float?
|
||||||
|
cashierId Int?
|
||||||
paymentSlipUrl String?
|
paymentSlipUrl String?
|
||||||
trackingNo String?
|
trackingNo String?
|
||||||
|
|
||||||
@@ -344,6 +349,9 @@ model Order {
|
|||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([channel])
|
||||||
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrderItem {
|
model OrderItem {
|
||||||
|
|||||||
@@ -66,4 +66,34 @@ export const customersRoute = new Elysia({ prefix: "/customers" })
|
|||||||
pointsDelta: t.Number(),
|
pointsDelta: t.Number(),
|
||||||
reason: t.Optional(t.String())
|
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({
|
const order = await prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
|
channel: data.channel || "ONLINE",
|
||||||
customerId: data.customerId,
|
customerId: data.customerId,
|
||||||
customerName: data.customerName,
|
customerName: data.customerName || (isPos ? "ลูกค้าหน้าร้าน (Walk-in)" : null),
|
||||||
customerEmail: data.customerEmail,
|
customerEmail: data.customerEmail || null,
|
||||||
customerPhone: data.customerPhone,
|
customerPhone: data.customerPhone || null,
|
||||||
shippingAddress: data.shippingAddress,
|
shippingAddress: data.shippingAddress || (isPos ? "หน้าร้าน (Takeaway)" : null),
|
||||||
subtotal: data.subtotal || 0,
|
subtotal: data.subtotal || 0,
|
||||||
promoLevel1Name: data.promoLevel1Name,
|
promoLevel1Name: data.promoLevel1Name,
|
||||||
promoLevel1Disc: data.promoLevel1Disc || 0,
|
promoLevel1Disc: data.promoLevel1Disc || 0,
|
||||||
@@ -65,8 +69,13 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
|||||||
pointsUsed: data.pointsUsed || 0,
|
pointsUsed: data.pointsUsed || 0,
|
||||||
pointsDiscount: data.pointsDiscount || 0,
|
pointsDiscount: data.pointsDiscount || 0,
|
||||||
pointsEarned: pointsEarned,
|
pointsEarned: pointsEarned,
|
||||||
shippingFee: data.shippingFee,
|
shippingFee: data.shippingFee || 0,
|
||||||
totalAmount: data.totalAmount,
|
totalAmount: data.totalAmount,
|
||||||
|
status: initialStatus,
|
||||||
|
paymentMethod: data.paymentMethod || (isPos ? "CASH" : null),
|
||||||
|
receivedAmount: data.receivedAmount,
|
||||||
|
changeAmount: data.changeAmount,
|
||||||
|
cashierId: data.cashierId,
|
||||||
items: {
|
items: {
|
||||||
create: data.items.map((item: any) => ({
|
create: data.items.map((item: any) => ({
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
@@ -82,6 +91,33 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
|||||||
include: { items: true }
|
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 a coupon was used, increment its usedCount
|
||||||
if (data.couponCode) {
|
if (data.couponCode) {
|
||||||
try {
|
try {
|
||||||
@@ -107,13 +143,18 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
channel: t.Optional(t.String()),
|
||||||
customerId: t.Optional(t.Nullable(t.Number())),
|
customerId: t.Optional(t.Nullable(t.Number())),
|
||||||
customerName: t.String(),
|
customerName: t.Optional(t.Nullable(t.String())),
|
||||||
customerEmail: t.String(),
|
customerEmail: t.Optional(t.Nullable(t.String())),
|
||||||
customerPhone: t.String(),
|
customerPhone: t.Optional(t.Nullable(t.String())),
|
||||||
shippingAddress: 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(),
|
totalAmount: t.Number(),
|
||||||
shippingFee: t.Number(),
|
shippingFee: t.Optional(t.Number()),
|
||||||
subtotal: t.Optional(t.Number()),
|
subtotal: t.Optional(t.Number()),
|
||||||
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
||||||
promoLevel1Disc: t.Optional(t.Number()),
|
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 } } } },
|
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
|
||||||
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
|
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
|
||||||
{ category: { 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;
|
} as const;
|
||||||
|
|
||||||
const [products, total] = await Promise.all([
|
const [products, total] = await Promise.all([
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ export interface Order {
|
|||||||
totalAmount: number
|
totalAmount: number
|
||||||
shippingFee: number
|
shippingFee: number
|
||||||
status: string
|
status: string
|
||||||
|
channel?: string
|
||||||
|
paymentMethod?: string | null
|
||||||
|
receivedAmount?: number | null
|
||||||
|
changeAmount?: number | null
|
||||||
|
cashierId?: number | null
|
||||||
paymentSlipUrl?: string | null
|
paymentSlipUrl?: string | null
|
||||||
trackingNo?: string | null
|
trackingNo?: string | null
|
||||||
customerId?: number | null
|
customerId?: number | null
|
||||||
@@ -276,6 +281,7 @@ export interface Order {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number
|
id: number
|
||||||
slug: string
|
slug: string
|
||||||
|
|||||||
Reference in new issue
Block a user