Harden TPOS MVP payment, stock, and portal parity

This commit is contained in:
Ho Ngoc Hai
2026-06-03 13:17:46 +07:00
parent f0dad8881a
commit 9a875643b4
79 changed files with 7186 additions and 695 deletions

View File

@@ -1,11 +1,14 @@
import { notFound, redirect } from "next/navigation";
import {
AdminSectionView,
AdminNotFoundView,
ShopFinanceView,
ShopHistoryView,
ShopOverviewView,
StoreCreateWizard,
StoreListView
} from "@/components/admin/AdminReferenceViews";
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { filterPortalShops, requirePortalRole } from "@/server/auth/portal";
import { getDashboardStats } from "@/server/db/queries";
import { getShopService, getShopStatsService, listShopsService } from "@/server/services/shop";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
@@ -32,6 +35,8 @@ import {
listStaff,
listTherapists,
listUsers,
listWallets,
listWalletTransactions,
reportRevenue,
reportTopProducts
} from "@/server/services/parity";
@@ -42,6 +47,7 @@ export const dynamic = "force-dynamic";
export default async function AdminCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const user = await requirePortalRole(["admin"], `/admin/${path.join("/")}`, path[0] === "shop" || path[0] === "store" ? path[1] : null);
if (path[0] === "store" && path[1]) {
if (path.length !== 3 || path[2] !== "stock") notFound();
redirect(`/admin/shop/${path[1]}/${path[2] === "stock" ? "inventory" : path.slice(2).join("/") || "overview"}`);
@@ -54,7 +60,9 @@ export default async function AdminCatchAllPage({ params }: { params: Promise<{
if (path[0] === "stores" && !path[1]) {
const [shops, shopStats] = await Promise.all([listShopsService(), getShopStatsService()]);
return <StoreListView shops={shops} shopStats={shopStats} />;
const visibleShops = filterPortalShops(user, shops);
const visibleIds = new Set(visibleShops.map((item) => item.id));
return <StoreListView shops={visibleShops} shopStats={shopStats.filter((item) => visibleIds.has(item.shopId))} />;
}
const isShopRoute = path[0] === "shop";
@@ -64,7 +72,8 @@ export default async function AdminCatchAllPage({ params }: { params: Promise<{
return <AdminNotFoundView />;
}
const scopedShop = isShopRoute || path.length === 0 ? shop : null;
const stats = await getDashboardStats(scopedShop?.id);
const allowedShopIds = allowedShopIdsForUser(user);
const stats = await getAdminScopedStats(scopedShop?.id, allowedShopIds);
const section = isShopRoute ? path.slice(2).join("/") || "overview" : path.join("/") || "dashboard";
if (!isKnownAdminSection(section, isShopRoute ? shop : null)) notFound();
@@ -75,36 +84,59 @@ export default async function AdminCatchAllPage({ params }: { params: Promise<{
if (isShopRoute && section === "overview" && shop) {
const [overviewStats, orders, products, tables, staff, appointments] = await Promise.all([
getDashboardStats(shop.id),
listOrdersService({ shopId: shop.id, page: 1, pageSize: 24, filter: "all" }),
listOrdersService({ shopId: shop.id, page: 1, pageSize: 500, filter: "all" }),
listCatalogProductsByShop(shop.id),
listTablesByShop(shop.id),
listStaff(shop.id),
listAppointments(shop.id)
]);
return <ShopOverviewView payload={{ shop, stats: overviewStats, orders: orders.items, products, tables, staffCount: staff.length, appointmentCount: appointments.length }} />;
return <ShopOverviewView payload={{ shop, stats: overviewStats, orders: orders.items, products, tables, staffCount: staff.length, appointments: appointments as Parameters<typeof ShopOverviewView>[0]["payload"]["appointments"] }} />;
}
const items = await loadItems(section, isShopRoute ? shop : null);
if (isShopRoute && (section === "history" || section === "orders") && shop) {
const orders = await listOrdersService({ shopId: shop.id, page: 1, pageSize: 500, filter: "all" });
return <ShopHistoryView payload={{ shop, orders: orders.items }} />;
}
return (
<TposPortal
kind="admin"
path={path}
payload={buildPortalPayload("admin", {
shop: scopedShop ? { id: scopedShop.id, name: scopedShop.name, vertical: scopedShop.vertical, status: scopedShop.status } : null,
stats,
title: adminTitle(section),
items
})}
/>
);
if (isShopRoute && section === "finance" && shop) {
const [orders, wallets, walletTransactions, revenueRows, topProducts] = await Promise.all([
listOrdersService({ shopId: shop.id, page: 1, pageSize: 500, filter: "all" }),
listWallets(user.id),
listWalletTransactions(user.id, 25),
scopedRevenueRows(shop.id, allowedShopIds),
scopedTopProductRows(shop.id, allowedShopIds)
]);
return <ShopFinanceView payload={{
shop,
orders: orders.items,
wallets: wallets as unknown as Parameters<typeof ShopFinanceView>[0]["payload"]["wallets"],
walletTransactions: walletTransactions as unknown as Parameters<typeof ShopFinanceView>[0]["payload"]["walletTransactions"],
revenueRows: revenueRows as unknown as Parameters<typeof ShopFinanceView>[0]["payload"]["revenueRows"],
topProducts: topProducts as unknown as Parameters<typeof ShopFinanceView>[0]["payload"]["topProducts"]
}} />;
}
const items = await loadItems(section, isShopRoute ? shop : null, allowedShopIds);
return <AdminSectionView payload={{ title: adminTitle(section), section, shop: scopedShop, stats, items }} />;
}
async function loadItems(section: string, shop?: Shop | null) {
async function loadItems(section: string, shop?: Shop | null, allowedShopIds?: string[] | null) {
const shopId = shop?.id;
if (section === "dashboard") {
const [shops, stats] = await Promise.all([listShopsService(), getShopStatsService()]);
const visible = shops.filter((item) => allowedShopIds == null || allowedShopIds.includes(item.id));
const visibleIds = new Set(visible.map((item) => item.id));
return [
...visible.map((item) => ({ title: item.name, meta: item.category, value: item.status, href: `/admin/shop/${item.id}/overview` })),
...stats.filter((item) => visibleIds.has(item.shopId)).map((item) => ({ title: `Doanh thu ${item.shopId.slice(0, 8)}`, meta: `${item.todayOrderCount} đơn hôm nay`, value: formatMoney(item.monthRevenue) }))
];
}
if (section === "stores") {
const shops = await listShopsService();
return shops.map((item) => ({ title: item.name, meta: item.category, value: item.status, href: `/admin/shop/${item.id}/overview` }));
return shops
.filter((item) => allowedShopIds == null || allowedShopIds.includes(item.id))
.map((item) => ({ title: item.name, meta: item.category, value: item.status, href: `/admin/shop/${item.id}/overview` }));
}
if (section === "system/audit") {
const logs = await auditLogs(24);
@@ -115,6 +147,14 @@ async function loadItems(section: string, shop?: Shop | null) {
}));
}
if (section === "users") {
if (shopId || allowedShopIds !== null) {
const staff = await scopedStaffRows(shopId, allowedShopIds);
return staff.map((item) => ({
title: `${item.first_name ?? "Nhân viên"} ${item.last_name ?? ""}`.trim(),
meta: String(item.email ?? item.phone ?? item.employee_code ?? "Staff"),
value: String(item.role ?? item.status ?? "active")
}));
}
const users = await listUsers();
return users.map((user) => ({
title: String(user.displayName),
@@ -126,6 +166,21 @@ async function loadItems(section: string, shop?: Shop | null) {
const roles = await listRoles();
return roles.map((role) => ({ title: role.name, meta: role.portal, value: role.code }));
}
if (section === "settings" && shop) {
const hours = shop.openTime && shop.closeTime ? `${shop.openTime} - ${shop.closeTime}` : "Chưa cấu hình";
const activeDays = shop.activeDays?.length ? shop.activeDays.join(", ") : "Chưa cấu hình";
const address = [shop.address, shop.district, shop.city].filter(Boolean).join(", ") || "Chưa cấu hình";
return [
{ title: shop.name, meta: shop.email ?? shop.phone ?? "Chưa có liên hệ", value: shop.status },
{ title: "Ngành vận hành", meta: shop.category, value: normalizeVertical(shop.vertical), href: `/pos/${shop.id}/${normalizeVertical(shop.vertical)}` },
{ title: "Địa chỉ", meta: address, value: "Location" },
{ title: "Giờ mở cửa", meta: `${hours} · ${activeDays}`, value: "Store hours" },
{ title: "Tính năng ngành", meta: "QR ordering, POS terminal, kitchen/barista, inventory", value: "Enabled" },
{ title: "Mẫu hóa đơn", meta: "Logo, footer, thuế/phí, phiếu bếp", value: "Template", href: `/admin/shop/${shop.id}/receipt-templates` },
{ title: "AI assistant", meta: "Provider, system prompt, local tool calls", value: "Config", href: `/admin/shop/${shop.id}/ai-chat` },
{ title: "Menu khách hàng", meta: "QR ordering", value: "Public", href: `/menu/${shop.id}` }
];
}
if (section === "settings") {
return [
{ title: "Cài đặt hệ thống", meta: "Thông tin tài khoản, gói dịch vụ và thông báo", value: "Admin" },
@@ -146,17 +201,6 @@ async function loadItems(section: string, shop?: Shop | null) {
{ title: "Social", meta: "Facebook, Zalo, WhatsApp, X", value: "Env required" }
];
}
if (section === "settings" && shop) {
return [
{ title: shop.name, meta: shop.email ?? shop.phone ?? "Chưa có liên hệ", value: shop.status },
{ title: "Ngành vận hành", meta: shop.category, value: normalizeVertical(shop.vertical), href: `/pos/${shop.id}/${normalizeVertical(shop.vertical)}` },
{ title: "Giờ mở cửa", meta: "08:00 - 22:00 · tất cả ngày bán", value: "Store hours" },
{ title: "Tính năng ngành", meta: "QR ordering, POS terminal, kitchen/barista, inventory", value: "Enabled" },
{ title: "Mẫu hóa đơn", meta: "Logo, footer, thuế/phí, phiếu bếp", value: "Template", href: `/admin/shop/${shop.id}/receipt-templates` },
{ title: "AI assistant", meta: "Provider, system prompt, local tool calls", value: "Config", href: `/admin/shop/${shop.id}/ai-chat` },
{ title: "Menu khách hàng", meta: "QR ordering", value: "Public", href: `/menu/${shop.id}` }
];
}
if (section === "qr-codes" && shopId) {
const tables = await listTablesByShop(shopId);
return tables.map((table) => ({
@@ -185,6 +229,15 @@ async function loadItems(section: string, shop?: Shop | null) {
const tables = await listTablesByShop(shopId);
return tables.map((table) => ({ title: `${section === "rooms" ? "Phòng" : "Bàn"} ${table.tableNumber}`, meta: `${table.zone ?? "Khu chính"} · ${table.capacity} chỗ`, value: table.status, href: `/admin/shop/${shopId}/${section}` }));
}
if ((section === "history" || section === "orders") && shopId) {
const orders = await listOrdersService({ shopId, page: 1, pageSize: 100, filter: "all" });
return orders.items.map((order) => ({
title: `#${order.id.slice(0, 8).toUpperCase()}`,
meta: `${order.itemCount} món · ${new Date(order.createdAt).toLocaleString("vi-VN")}`,
value: formatMoney(order.totalAmount),
href: `/pos/${shopId}/${normalizeVertical(shop?.vertical)}?tab=history`
}));
}
if (section === "zones" && shopId) {
const tables = await listTablesByShop(shopId);
const zones = new Map<string, { total: number; occupied: number }>();
@@ -230,9 +283,8 @@ async function loadItems(section: string, shop?: Shop | null) {
return members.map((member) => ({ title: String(member.display_name ?? "Khách hàng"), meta: String(member.phone ?? member.level_name ?? ""), value: `Level ${member.current_level}` }));
}
if (section === "promotions" || section === "happy-hour") {
const campaigns = await listCampaigns();
const campaigns = await listCampaigns(shopId);
return campaigns
.filter((item) => !shopId || String(item.shop_id ?? "") === shopId)
.map((item) => ({ title: String(item.name), meta: String(item.description ?? "Campaign"), value: String(item.status) }));
}
if (section === "appointments" || section === "spa/appointments") {
@@ -248,19 +300,35 @@ async function loadItems(section: string, shop?: Shop | null) {
return resources.map((item) => ({ title: String(item.name), meta: String(item.resource_type ?? "resource"), value: `${item.capacity ?? 1}` }));
}
if (section === "finance" && shopId) {
const [revenue, products] = await Promise.all([reportRevenue(shopId), reportTopProducts(shopId)]);
const [revenue, products] = await Promise.all([scopedRevenueRows(shopId, allowedShopIds), scopedTopProductRows(shopId, allowedShopIds)]);
return [
...revenue.slice(0, 6).map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...products.slice(0, 6).map((row) => ({ title: String(row.product_name), meta: `${row.quantity_sold ?? 0} bán`, value: formatMoney(Number(row.revenue ?? 0)) }))
];
}
if (section === "reports" || section === "reports/eod") {
const [revenue, products] = await Promise.all([reportRevenue(shopId), reportTopProducts(shopId)]);
if (section === "reports" || section === "reports/eod" || section === "reports/revenue") {
const [revenue, products] = await Promise.all([scopedRevenueRows(shopId, allowedShopIds), scopedTopProductRows(shopId, allowedShopIds)]);
return [
...revenue.slice(0, 8).map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...products.slice(0, 6).map((row) => ({ title: String(row.product_name), meta: `${row.quantity_sold ?? 0} bán`, value: formatMoney(Number(row.revenue ?? 0)) }))
];
}
if (section === "reports/staff") {
const staff = await scopedStaffRows(shopId, allowedShopIds);
return staff.map((item) => ({ title: `${item.first_name ?? "Nhân viên"} ${item.last_name ?? ""}`, meta: String(item.employee_code ?? item.role ?? "Staff"), value: String(item.status ?? "active") }));
}
if (section.startsWith("onboarding/")) {
return onboardingItems(section);
}
if (section === "spa/therapists") {
const shops = await listShopsService();
const scopedShops = shops.filter((item) => (allowedShopIds == null || allowedShopIds.includes(item.id)) && normalizeVertical(item.vertical) === "spa");
const rows = (await Promise.all(scopedShops.map(async (item) => {
const therapists = await listTherapists(item.id);
return therapists.map((therapist) => ({ title: String(therapist.name), meta: `${item.name} · ${String(therapist.specialty ?? "Therapist")}`, value: String(therapist.status ?? "active") }));
}))).flat();
return rows.length ? rows : [{ title: "Chưa có therapist", meta: "Thêm nhân sự spa trong từng cửa hàng", value: "Empty" }];
}
if (section === "returns") {
return [
{ title: "Đổi hàng", meta: "Kiểm bill gốc, tồn kho và bù trừ", value: "Ready" },
@@ -280,7 +348,7 @@ async function loadItems(section: string, shop?: Shop | null) {
];
}
if (section === "drive" && shopId) {
const [files, folders] = await Promise.all([listFiles(shopId), listFolders()]);
const [files, folders] = await Promise.all([listFiles(shopId), listFolders(shopId)]);
return [
...folders.map((folder) => ({ title: String(folder.name), meta: "Thư mục", value: "Folder" })),
...files.map((file) => ({ title: String(file.file_name), meta: String(file.content_type ?? "file"), value: `${Math.round(Number(file.byte_size ?? 0) / 1024)} KB` }))
@@ -322,6 +390,8 @@ function adminTitle(section: string) {
kitchen: "Bếp",
recipes: "Công thức",
shifts: "Ca bán",
history: "Lịch sử đơn",
orders: "Lịch sử đơn",
finance: "Tài chính",
staff: "Nhân sự",
attendance: "Điểm danh",
@@ -355,6 +425,45 @@ function normalizeVertical(value?: string | null) {
return value === "restaurant" || value === "karaoke" || value === "spa" || value === "beauty" || value === "retail" ? value : "cafe";
}
function allowedShopIdsForUser(user: Awaited<ReturnType<typeof requirePortalRole>>) {
return user.roles.some((role) => role.code === "superadmin") ? null : user.roles.map((role) => role.shop_id).filter(Boolean) as string[];
}
async function getAdminScopedStats(shopId?: string | null, allowedShopIds?: string[] | null) {
if (shopId || allowedShopIds == null) return getDashboardStats(shopId);
if (allowedShopIds.length === 0) {
return {
shopCount: 0,
activeShopCount: 0,
productCount: 0,
orderCount: 0,
todayRevenue: 0,
monthRevenue: 0,
lowStockCount: 0,
tableCount: 0,
recentOrders: [],
lowStock: []
};
}
const [shops, shopStats] = await Promise.all([listShopsService(), getShopStatsService()]);
const allowed = new Set(allowedShopIds);
const visibleShops = shops.filter((item) => allowed.has(item.id));
const visibleStats = shopStats.filter((item) => allowed.has(item.shopId));
return {
shopCount: visibleShops.length,
activeShopCount: visibleShops.filter((item) => item.statusId === 2 || item.status.toLowerCase() === "active").length,
productCount: visibleStats.reduce((sum, item) => sum + item.productCount, 0),
orderCount: visibleStats.reduce((sum, item) => sum + item.orderCount, 0),
todayRevenue: visibleStats.reduce((sum, item) => sum + item.revenue, 0),
monthRevenue: visibleStats.reduce((sum, item) => sum + item.monthRevenue, 0),
lowStockCount: 0,
tableCount: 0,
recentOrders: [],
lowStock: []
};
}
function isKnownAdminSection(section: string, shop?: Shop | null) {
if (shop) {
const vertical = normalizeVertical(shop.vertical) as VerticalKind;
@@ -363,6 +472,8 @@ function isKnownAdminSection(section: string, shop?: Shop | null) {
"products",
"combos",
"doctors",
"history",
"orders",
"qr-codes"
]);
return sidebarSections.has(section as never) || dataSections.has(section);
@@ -386,6 +497,45 @@ function isKnownAdminSection(section: string, shop?: Shop | null) {
return globalSections.has(section);
}
async function scopedRevenueRows(shopId?: string | null, allowedShopIds?: string[] | null) {
if (shopId) return reportRevenue(shopId);
if (allowedShopIds == null) return reportRevenue(null);
if (allowedShopIds.length === 0) return [];
return (await Promise.all(allowedShopIds.map((id) => reportRevenue(id)))).flat();
}
async function scopedTopProductRows(shopId?: string | null, allowedShopIds?: string[] | null) {
if (shopId) return reportTopProducts(shopId);
if (allowedShopIds == null) return reportTopProducts(null);
if (allowedShopIds.length === 0) return [];
return (await Promise.all(allowedShopIds.map((id) => reportTopProducts(id)))).flat();
}
async function scopedStaffRows(shopId?: string | null, allowedShopIds?: string[] | null) {
if (shopId) return listStaff(shopId);
if (allowedShopIds == null) return listStaff(null);
if (allowedShopIds.length === 0) return [];
return (await Promise.all(allowedShopIds.map((id) => listStaff(id)))).flat();
}
function onboardingItems(section: string) {
const steps = [
["onboarding/business", "Thông tin doanh nghiệp", "Tên pháp lý, MST, ngành kinh doanh"],
["onboarding/store", "Cửa hàng đầu tiên", "Địa chỉ, giờ mở cửa và cấu hình ngành"],
["onboarding/products", "Menu & sản phẩm", "Danh mục, giá bán và tồn đầu kỳ"],
["onboarding/staff", "Nhân sự", "Tài khoản staff, ca làm và phân quyền"],
["onboarding/device", "Thiết bị", "POS terminal, máy in, KDS và QR ordering"],
["onboarding/ready", "Sẵn sàng vận hành", "Mở POS, test đơn hàng và bàn giao"]
];
const currentIndex = Math.max(0, steps.findIndex(([slug]) => slug === section));
return steps.map(([slug, title, meta], index) => ({
title,
meta,
value: index < currentIndex ? "Done" : index === currentIndex ? "Current" : "Next",
href: `/admin/${slug}`
}));
}
function formatMoney(value: number) {
return new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 }).format(value);
}

View File

@@ -1,11 +1,15 @@
import { AdminDashboardView } from "@/components/admin/AdminReferenceViews";
import { filterPortalShops, requirePortalRole } from "@/server/auth/portal";
import { getShopStatsService, listShopsService } from "@/server/services/shop";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const user = await requirePortalRole(["admin"], "/admin");
const [shops, shopStats] = await Promise.all([listShopsService(), getShopStatsService()]);
return <AdminDashboardView shops={shops} shopStats={shopStats} serviceHealth={defaultServiceHealth()} />;
const visibleShops = filterPortalShops(user, shops);
const visibleIds = new Set(visibleShops.map((shop) => shop.id));
return <AdminDashboardView shops={visibleShops} shopStats={shopStats.filter((stat) => visibleIds.has(stat.shopId))} serviceHealth={defaultServiceHealth()} />;
}
function defaultServiceHealth() {

View File

@@ -2404,6 +2404,13 @@ button:disabled {
cursor: pointer;
}
.pos-bottom-nav__tab span {
max-width: 100%;
line-height: 1.15;
overflow-wrap: anywhere;
text-align: center;
}
.pos-bottom-nav__tab--active,
.pos-bottom-nav__tab:hover {
border-color: rgba(255, 92, 0, 0.42);
@@ -2620,6 +2627,11 @@ button:disabled {
color: #ffffff;
}
.pos-payment-method-btn:disabled {
cursor: not-allowed;
opacity: 0.46;
}
.pos-payment-quick-amounts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2679,13 +2691,21 @@ button:disabled {
display: none;
}
.pos-clone .pos-content-area {
grid-template-columns: minmax(0, 1fr);
}
.pos-clone .pos-content-area {
grid-template-columns: minmax(0, 1fr);
overflow: auto;
}
.pos-cart-panel {
display: none;
}
.pos-cart-panel {
display: flex;
width: 100%;
min-width: 0;
height: auto;
min-height: 560px;
border-top: 1px solid #202024;
border-left: 0;
overflow: visible;
}
}
@media (max-width: 760px) {
@@ -2714,15 +2734,16 @@ button:disabled {
flex-direction: column;
}
.pos-clone .pos-content-area {
flex: 1;
display: block;
overflow: hidden;
}
.pos-clone .pos-content-area {
flex: 1;
display: block;
overflow-y: auto;
}
.pos-product-panel {
height: calc(100vh - 50px - 64px);
}
.pos-product-panel {
height: auto;
min-height: calc(100vh - 50px - 64px);
}
.pos-clone .pos-bottom-nav {
order: 2;
@@ -3450,6 +3471,7 @@ textarea {
}
.pos-bottom-nav__tab {
position: relative;
min-height: auto;
gap: 3px;
margin: 0 6px;
@@ -3467,6 +3489,17 @@ textarea {
color: #ff5c00;
}
.pos-bottom-nav__tab--active::before {
content: "";
position: absolute;
top: 10px;
bottom: 10px;
left: 0;
width: 3px;
border-radius: 999px;
background: #ff5c00;
}
.pos-clone .pos-history {
grid-column: 1;
grid-row: 1;
@@ -4391,7 +4424,8 @@ textarea {
gap: 6px;
}
.workflow-action-panel input {
.workflow-action-panel input,
.workflow-action-panel select {
min-height: 44px;
border: 1px solid #2a2a2e;
border-radius: 10px;
@@ -4400,6 +4434,37 @@ textarea {
padding: 0 12px;
}
.workflow-action-panel--methods {
align-items: stretch;
grid-template-columns: minmax(0, 1fr) minmax(280px, 440px);
}
.workflow-payment-method-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.workflow-payment-method {
min-height: 74px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #0a0a0b;
color: #ffffff;
font-weight: 800;
text-decoration: none;
}
.workflow-payment-method:disabled {
cursor: not-allowed;
opacity: 0.44;
}
/* Customer QR menu follows the original compact white mobile menu */
.customer-menu {
min-height: 100vh;
@@ -4802,13 +4867,21 @@ textarea {
grid-template-columns: 1fr;
}
.pos-clone .pos-content-area {
grid-template-columns: minmax(0, 1fr);
}
.pos-clone .pos-content-area {
grid-template-columns: minmax(0, 1fr);
overflow: auto;
}
.pos-cart-panel {
display: none;
}
.pos-cart-panel {
display: flex;
width: 100%;
min-width: 0;
height: auto;
min-height: 560px;
border-top: 1px solid #202024;
border-left: 0;
overflow: visible;
}
.workflow-action-panel {
grid-template-columns: 1fr;
@@ -4839,15 +4912,16 @@ textarea {
display: flex;
}
.pos-clone .pos-bottom-nav {
width: 64px;
.pos-clone .pos-bottom-nav {
width: 64px;
height: auto;
order: 2;
}
.pos-product-panel {
height: calc(100vh - 48px);
}
.pos-product-panel {
height: auto;
min-height: calc(100vh - 48px);
}
.pos-product-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4925,12 +4999,12 @@ textarea {
.landing-hero {
position: relative;
min-height: 64vh;
min-height: 46vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px 48px;
padding: 52px 24px 30px;
text-align: center;
overflow: hidden;
}
@@ -5013,13 +5087,29 @@ textarea {
color: #ff5c00;
}
.home-hero__brand {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
color: #ffffff;
font-size: 28px;
font-weight: 800;
}
.home-hero__brand svg {
color: #ff5c00;
}
.home-hero__badge {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 32px;
margin-bottom: 22px;
padding: 8px 20px;
border: 1px solid rgba(255, 92, 0, 0.25);
border-radius: 999px;
@@ -5033,9 +5123,9 @@ textarea {
.home-hero__title {
position: relative;
z-index: 1;
max-width: 800px;
margin: 0 0 24px;
font-size: 48px;
max-width: 780px;
margin: 0 0 20px;
font-size: 44px;
font-weight: 800;
line-height: 1.08;
letter-spacing: 0;
@@ -5045,8 +5135,8 @@ textarea {
.home-hero__subtitle {
position: relative;
z-index: 1;
max-width: 640px;
margin: 0 auto 40px;
max-width: 700px;
margin: 0 auto 30px;
color: #adadb0;
font-size: 16px;
line-height: 1.7;
@@ -5059,7 +5149,7 @@ textarea {
justify-content: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 48px;
margin-bottom: 30px;
}
.home-hero__btn {
@@ -5111,9 +5201,33 @@ textarea {
}
.home-verticals {
max-width: 900px;
max-width: 1080px;
margin: 0 auto;
padding: 48px 24px 64px;
padding: 14px 24px 44px;
}
.home-verticals__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.home-verticals__head span {
color: #ffffff;
font-size: 18px;
font-weight: 800;
}
.home-verticals__head a {
display: inline-flex;
align-items: center;
gap: 6px;
color: #ff8a4c;
font-size: 14px;
font-weight: 700;
text-decoration: none;
}
.home-verticals__grid {
@@ -5123,13 +5237,13 @@ textarea {
}
.home-vertical-card {
min-height: 128px;
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px 16px;
padding: 22px 14px;
border: 1px solid #1f1f23;
border-radius: 14px;
background: #111113;
@@ -5144,12 +5258,48 @@ textarea {
color: #ff5c00;
}
.home-vertical-card span {
color: #ffffff;
font-size: 14px;
}
.home-vertical-card p {
margin: 0;
color: #8b8b90;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.home-vertical-card:hover {
border-color: #ff5c00;
background: rgba(255, 92, 0, 0.15);
color: #ffffff;
}
.home-portal-strip {
max-width: 1080px;
min-height: 86px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 0 auto;
padding: 0 24px 48px;
}
.home-portal-strip > div {
display: inline-flex;
align-items: center;
gap: 10px;
color: #ffffff;
font-weight: 700;
}
.home-portal-strip svg {
color: #ff5c00;
}
.tpos-section,
.project-intro,
.landing-cta {
@@ -5436,7 +5586,7 @@ textarea {
}
.home-verticals__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tpos-feature-grid,
@@ -5464,7 +5614,8 @@ textarea {
}
.home-hero__actions,
.landing-cta div {
.landing-cta div,
.home-portal-strip {
width: 100%;
flex-direction: column;
}
@@ -5475,7 +5626,17 @@ textarea {
.home-verticals__grid,
.login-portal__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
}
.home-verticals__head {
align-items: flex-start;
flex-direction: column;
}
.home-portal-strip {
align-items: stretch;
padding: 0 16px 36px;
}
}
@@ -5747,11 +5908,11 @@ textarea {
/* Final POS responsive guard: keep History/Dashboard out from under the right rail. */
@media (max-width: 760px) {
.pos-clone .pos-page-content {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
height: calc(100vh - 48px);
}
.pos-clone .pos-page-content {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
height: calc(100vh - 48px);
}
.pos-clone .pos-history,
.pos-clone .pos-dashboard,
@@ -5778,6 +5939,15 @@ textarea {
color: #f5f5f7;
}
.admin-mobile-bar {
display: none;
}
.admin-sidebar-overlay,
.admin-sidebar__close {
display: none;
}
.admin-sidebar {
width: 260px;
min-width: 260px;
@@ -5802,6 +5972,10 @@ textarea {
text-decoration: none;
}
.admin-sidebar__logo-text {
flex: 1;
}
.admin-sidebar__logo-icon {
width: 40px;
height: 40px;
@@ -5974,6 +6148,66 @@ textarea {
color: #22c55e;
}
.admin-reference-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-reference-row {
min-height: 64px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid #242429;
border-radius: 8px;
background: #151518;
color: inherit;
text-decoration: none;
}
.admin-reference-row:hover {
border-color: rgba(255, 92, 0, 0.55);
background: #1a1a1e;
}
.admin-reference-row__main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.admin-reference-row__main strong {
overflow: hidden;
color: #ffffff;
font-size: 14px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-reference-row__main span {
overflow: hidden;
color: #8b8b90;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-reference-row > b {
color: #f5f5f7;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.admin-reference-row > svg {
color: #77777d;
}
.admin-main {
flex: 1;
display: flex;
@@ -6790,6 +7024,170 @@ textarea {
font-size: 13px;
}
.admin-inline-note {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(59, 130, 246, 0.18);
border-radius: 10px;
background: rgba(59, 130, 246, 0.08);
color: #adadb0;
padding: 9px 12px;
font-size: 12px;
font-weight: 600;
}
.admin-inline-note svg {
flex-shrink: 0;
color: #60a5fa;
}
.admin-table-wrap {
overflow-x: auto;
}
.admin-data-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.admin-data-table th {
padding: 10px 12px;
border-bottom: 1px solid #2a2a2e;
color: #8b8b90;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
text-align: left;
text-transform: uppercase;
}
.admin-data-table td {
padding: 11px 12px;
border-bottom: 1px solid #242428;
color: #d7d7db;
font-size: 13px;
vertical-align: middle;
}
.admin-data-table td > b,
.admin-data-table td > strong {
display: block;
color: #ffffff;
font-size: 13px;
}
.admin-data-table td > span {
display: block;
margin-top: 3px;
color: #8b8b90;
font-size: 11px;
}
.admin-data-table .is-right {
text-align: right;
}
.admin-finance-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.55fr);
gap: 20px;
}
.admin-finance-grid--bottom {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.admin-finance-chart {
min-height: 340px;
}
.admin-finance-bars {
height: 250px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(44px, 1fr));
gap: 10px;
align-items: end;
padding-top: 8px;
}
.admin-finance-bar {
min-width: 0;
display: grid;
gap: 7px;
align-items: end;
text-align: center;
}
.admin-finance-bar b {
overflow: hidden;
color: #ff7a2f;
font-size: 10px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-finance-bar > div {
height: 190px;
display: flex;
align-items: end;
border-radius: 9px;
background: #111114;
overflow: hidden;
}
.admin-finance-bar span {
width: 100%;
display: block;
border-radius: 9px 9px 0 0;
background: linear-gradient(180deg, #ff7a2f 0%, #ff5c00 100%);
}
.admin-finance-bar small {
color: #8b8b90;
font-size: 11px;
}
.admin-compact-list {
display: grid;
gap: 8px;
}
.admin-compact-row {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: 10px;
background: #202024;
padding: 10px 12px;
}
.admin-compact-row > div {
min-width: 0;
display: grid;
gap: 2px;
}
.admin-compact-row b,
.admin-compact-row strong {
overflow: hidden;
color: #ffffff;
font-size: 13px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-compact-row span {
color: #8b8b90;
font-size: 12px;
}
.spinner-small,
.spin {
animation: spin 0.9s linear infinite;
@@ -6854,3 +7252,150 @@ textarea {
align-items: flex-start;
}
}
@media (max-width: 960px) {
.admin-layout {
min-height: 100dvh;
padding-top: 56px;
}
.admin-mobile-bar {
position: fixed;
inset: 0 0 auto;
z-index: 75;
min-height: 56px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-bottom: 1px solid #242429;
background: #161619;
}
.admin-mobile-bar__button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #2d2d33;
border-radius: 8px;
background: #1c1c20;
color: #f5f5f7;
}
.admin-mobile-bar div {
display: flex;
min-width: 0;
flex-direction: column;
}
.admin-mobile-bar b {
color: #ffffff;
font-size: 14px;
}
.admin-mobile-bar span {
overflow: hidden;
color: #8b8b90;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-sidebar {
position: fixed;
z-index: 90;
inset: 0 auto 0 0;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.admin-sidebar--open {
transform: translateX(0);
}
.admin-sidebar-overlay {
position: fixed;
z-index: 85;
inset: 0;
display: block;
border: 0;
background: rgba(0, 0, 0, 0.55);
}
.admin-sidebar__close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #2d2d33;
border-radius: 8px;
background: #1c1c20;
color: #f5f5f7;
}
.admin-main {
width: 100%;
}
.admin-topbar {
min-height: 0;
padding: 14px;
}
.admin-topbar__right {
flex-wrap: wrap;
}
.admin-search {
flex: 1 1 220px;
}
.admin-finance-grid,
.admin-finance-grid--bottom {
grid-template-columns: 1fr;
}
.admin-content {
padding: 14px;
}
}
@media (max-width: 640px) {
.admin-reference-row {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.admin-reference-row > b {
white-space: normal;
}
.admin-topbar__title {
font-size: 22px;
}
}
@media (min-width: 761px) {
.pos-clone .pos-main {
display: grid;
grid-template-columns: 156px minmax(0, 1fr);
}
.pos-clone .pos-sidebar {
position: static;
width: auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 10px;
border-right: 1px solid #202024;
background: rgba(17, 17, 20, 0.96);
}
.pos-clone .pos-page-content {
display: grid;
grid-template-columns: minmax(0, 1fr) 82px;
}
}

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { portalShopId, requirePortalRole } from "@/server/auth/portal";
import { providerCredentialStatus } from "@/server/integrations/external";
import { listCampaigns, listMembers, reportRevenue } from "@/server/services/parity";
import { portalNav } from "@/components/tpos-config";
@@ -8,9 +9,11 @@ export const dynamic = "force-dynamic";
export default async function MarketingCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const user = await requirePortalRole(["admin", "marketing"], `/marketing/${path.join("/")}`);
const shopId = portalShopId(user, "marketing") ?? portalShopId(user, "admin");
const section = path.join("/") || "marketing";
if (!isKnownMarketingSection(section)) notFound();
const items = await loadItems(section);
const items = await loadItems(section, shopId);
const status = providerCredentialStatus();
return (
<TposPortal
@@ -25,7 +28,7 @@ export default async function MarketingCatchAllPage({ params }: { params: Promis
);
}
async function loadItems(section: string) {
async function loadItems(section: string, shopId?: string | null) {
const status = providerCredentialStatus();
if (section === "livechat") {
return [
@@ -35,15 +38,15 @@ async function loadItems(section: string) {
];
}
if (section === "customers") {
const members = await listMembers();
const members = await listMembers(null, shopId);
return members.map((member) => ({ title: String(member.display_name ?? "Khách hàng"), meta: String(member.phone ?? ""), value: `Level ${member.current_level}` }));
}
if (section === "analytics") {
const rows = await reportRevenue();
const rows = await reportRevenue(shopId);
return rows.map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: `${row.revenue} VND` }));
}
if (section === "content") {
const campaigns = await listCampaigns();
const campaigns = await listCampaigns(shopId);
return [
{ title: "Lịch nội dung", meta: "Bài viết theo kênh", value: `${campaigns.length} chiến dịch` },
{ title: "AI caption", meta: "Sinh nội dung qua provider đã cấu hình", value: "AI" },
@@ -58,7 +61,7 @@ async function loadItems(section: string) {
];
}
if (section === "marketing") {
const campaigns = await listCampaigns();
const campaigns = await listCampaigns(shopId);
return campaigns.map((campaign) => ({ title: String(campaign.name), meta: String(campaign.description ?? "Campaign"), value: String(campaign.status) }));
}
notFound();

View File

@@ -1,11 +1,14 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { portalShopId, requirePortalRole } from "@/server/auth/portal";
import { listCampaigns } from "@/server/services/parity";
import { providerCredentialStatus } from "@/server/integrations/external";
export const dynamic = "force-dynamic";
export default async function MarketingPage() {
const campaigns = await listCampaigns();
const user = await requirePortalRole(["admin", "marketing"], "/marketing");
const shopId = portalShopId(user, "marketing") ?? portalShopId(user, "admin");
const campaigns = await listCampaigns(shopId);
const status = providerCredentialStatus();
return (
<TposPortal

View File

@@ -1,12 +1,19 @@
import { renderPosExperience } from "../../../pos-experience";
import { posTabFromPath, posTabFromQuery, renderPosExperience, verticalFamilyWorkflow } from "../../../pos-experience";
export const dynamic = "force-dynamic";
type SearchParams = Record<string, string | string[] | undefined>;
export default async function PosVerticalPage({
params
params,
searchParams
}: {
params: Promise<{ shopId: string; vertical: string; workflow?: string[] }>;
searchParams?: Promise<SearchParams>;
}) {
const { shopId, vertical, workflow } = await params;
return renderPosExperience(shopId, vertical, workflow);
const query = await searchParams;
const pathTab = posTabFromPath(workflow?.[0]);
const resolvedWorkflow = pathTab ? undefined : verticalFamilyWorkflow(workflow, query);
return renderPosExperience(shopId, vertical, resolvedWorkflow, pathTab ?? posTabFromQuery(query?.tab));
}

View File

@@ -1,4 +1,4 @@
import { firstQueryValue, renderPosExperience } from "../../../pos-experience";
import { appendWorkflowContext, dialogWorkflow, firstQueryValue, renderPosExperience } from "../../../pos-experience";
export const dynamic = "force-dynamic";
@@ -13,27 +13,5 @@ export default async function PosDialogAlias({
}) {
const { shopId, path } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), dialogWorkflow(path));
}
function dialogWorkflow(path: string[]) {
const [head, ...rest] = path.length ? path : ["order-edit"];
const map: Record<string, string> = {
order: "order-edit",
"order-edit": "order-edit",
note: "order-edit",
discount: "discount",
customer: "customer-select",
"customer-select": "customer-select",
table: "table-transfer",
"table-transfer": "table-transfer",
"split-bill": "split-bill",
cancel: "void-refund",
"order-cancel": "void-refund",
"price-check": "product-search",
"stock-in": "stock-check",
"stock-out": "stock-check",
"stock-transfer": "stock-check"
};
return [map[head] ?? head, ...rest];
return renderPosExperience(shopId, firstQueryValue(query?.vertical), appendWorkflowContext(dialogWorkflow(path), query));
}

View File

@@ -0,0 +1,17 @@
import { appendWorkflowContext, dialogWorkflow, firstQueryValue, renderPosExperience } from "../../pos-experience";
export const dynamic = "force-dynamic";
type SearchParams = Record<string, string | string[] | undefined>;
export default async function PosDialogIndex({
params,
searchParams
}: {
params: Promise<{ shopId: string }>;
searchParams?: Promise<SearchParams>;
}) {
const { shopId } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), appendWorkflowContext(dialogWorkflow([]), query));
}

View File

@@ -1,4 +1,4 @@
import { firstQueryValue, renderPosExperience } from "../../../pos-experience";
import { appendWorkflowContext, firstQueryValue, operationWorkflow, renderPosExperience } from "../../../pos-experience";
export const dynamic = "force-dynamic";
@@ -13,29 +13,5 @@ export default async function PosOperationsAlias({
}) {
const { shopId, path } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), operationWorkflow(path));
}
function operationWorkflow(path: string[]) {
const [head, ...rest] = path.length ? path : ["shift"];
const map: Record<string, string> = {
drawer: "cash-drawer",
"cash-drawer": "cash-drawer",
shift: "shift",
pending: "pending-orders",
"pending-orders": "pending-orders",
quick: "quick-sale",
"quick-sale": "quick-sale",
split: "split-bill",
"split-bill": "split-bill",
refund: "void-refund",
"void-refund": "void-refund",
"clock-in-out": "shift",
"stock-in": "stock-check",
"stock-out": "stock-check",
"stock-transfer": "stock-check",
"price-check": "product-search",
"order-cancel": "void-refund"
};
return [map[head] ?? head, ...rest];
return renderPosExperience(shopId, firstQueryValue(query?.vertical), appendWorkflowContext(operationWorkflow(path), query));
}

View File

@@ -0,0 +1,17 @@
import { appendWorkflowContext, firstQueryValue, operationWorkflow, renderPosExperience } from "../../pos-experience";
export const dynamic = "force-dynamic";
type SearchParams = Record<string, string | string[] | undefined>;
export default async function PosOperationsIndex({
params,
searchParams
}: {
params: Promise<{ shopId: string }>;
searchParams?: Promise<SearchParams>;
}) {
const { shopId } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), appendWorkflowContext(operationWorkflow([]), query));
}

View File

@@ -1,4 +1,4 @@
import { firstQueryValue, renderPosExperience } from "../../../pos-experience";
import { firstQueryValue, paymentWorkflow, renderPosExperience } from "../../../pos-experience";
export const dynamic = "force-dynamic";
@@ -13,31 +13,5 @@ export default async function PosPaymentAlias({
}) {
const { shopId, path } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), paymentWorkflow(path));
}
function paymentWorkflow(path: string[]) {
const [head, ...rest] = path.length ? path : ["method-select"];
const map: Record<string, string> = {
cash: "cash-payment",
"cash-payment": "cash-payment",
card: "card-payment",
"card-payment": "card-payment",
qr: "qr-payment",
"qr-payment": "qr-payment",
transfer: "transfer-payment",
"transfer-payment": "transfer-payment",
"gift-card": "gift-card-payment",
"gift-card-payment": "gift-card-payment",
"bank-transfer": "transfer-payment",
partial: "partial-payment",
"partial-payment": "partial-payment",
pending: "payment-pending",
"payment-pending": "payment-pending",
success: "payment-success",
"payment-success": "payment-success",
receipt: "payment-success",
tip: "partial-payment"
};
return [map[head] ?? head, ...rest];
return renderPosExperience(shopId, firstQueryValue(query?.vertical), paymentWorkflow(path, firstQueryValue(query?.orderId)));
}

View File

@@ -0,0 +1,17 @@
import { firstQueryValue, paymentWorkflow, renderPosExperience } from "../../pos-experience";
export const dynamic = "force-dynamic";
type SearchParams = Record<string, string | string[] | undefined>;
export default async function PosPaymentIndex({
params,
searchParams
}: {
params: Promise<{ shopId: string }>;
searchParams?: Promise<SearchParams>;
}) {
const { shopId } = await params;
const query = await searchParams;
return renderPosExperience(shopId, firstQueryValue(query?.vertical), paymentWorkflow([], firstQueryValue(query?.orderId)));
}

View File

@@ -1,38 +1,57 @@
import { notFound } from "next/navigation";
import { TposPosExperience } from "@/components/TposPosExperience";
import { requirePortalRole } from "@/server/auth/portal";
import { getShopService } from "@/server/services/shop";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { listTablesByShop } from "@/server/services/fnb";
import { getPosDashboardService, listOrdersService } from "@/server/services/order";
import { listInventoryItems } from "@/server/services/inventory";
import { getOrderService, getPosDashboardService, listOrdersService } from "@/server/services/order";
import { listBaristaQueue, listKitchenTickets } from "@/server/services/parity";
import type { VerticalKind } from "@/components/tpos-config";
export async function renderPosExperience(shopId: string, vertical: string | null | undefined, workflow?: string[]) {
type InitialPosTab = "sale" | "history" | "dashboard" | "settings";
type SearchParamRecord = Record<string, string | string[] | undefined>;
export async function renderPosExperience(
shopId: string,
vertical: string | null | undefined,
workflow?: string[],
initialTab: InitialPosTab = "sale"
) {
if (!isUuid(shopId)) notFound();
const shop = await getShopService(shopId);
if (!shop) notFound();
await requirePortalRole(["admin", "staff"], `/pos/${shopId}/${vertical ?? shop.vertical}${workflow?.length ? `/${workflow.join("/")}` : ""}`, shopId);
const normalizedVertical = normalizeVertical(vertical ?? shop.vertical);
if (!normalizedVertical) notFound();
const [products, categories, tables, orders, dashboard, kitchenTickets, baristaQueue] = await Promise.all([
const contextOrderId = workflow?.[1] ?? null;
const [products, categories, tables, inventory, orders, dashboard, kitchenTickets, baristaQueue, paymentContextOrder] = await Promise.all([
listCatalogProductsByShop(shopId),
listCatalogCategoriesByShop(shopId),
listTablesByShop(shopId),
listOrdersService({ shopId, page: 1, pageSize: 24, filter: "all" }),
listInventoryItems(shopId),
listOrdersService({ shopId, page: 1, pageSize: 80, filter: "all" }),
getPosDashboardService(shopId, "today"),
listKitchenTickets(shopId),
listBaristaQueue(shopId)
listBaristaQueue(shopId),
contextOrderId ? getOrderService(contextOrderId, shopId).catch(() => null) : Promise.resolve(null)
]);
const orderItems = paymentContextOrder && !orders.items.some((order) => order.id === paymentContextOrder.id)
? [paymentContextOrder, ...orders.items]
: orders.items;
return (
<TposPosExperience
shop={shop}
vertical={normalizedVertical}
workflow={workflow}
initialTab={initialTab}
products={products}
categories={categories}
tables={tables}
orders={orders.items}
inventory={inventory}
orders={orderItems}
dashboard={dashboard as Record<string, unknown>}
kitchenTickets={kitchenTickets}
baristaQueue={baristaQueue}
@@ -49,6 +68,108 @@ export function firstQueryValue(value?: string | string[]) {
return Array.isArray(value) ? value[0] : value;
}
export function posTabFromQuery(value?: string | string[] | null): InitialPosTab {
const tab = firstQueryValue(value ?? undefined);
return tab === "history" || tab === "dashboard" || tab === "settings" ? tab : "sale";
}
export function posTabFromPath(value?: string | null): InitialPosTab | null {
return value === "history" || value === "dashboard" || value === "settings" ? value : null;
}
export function contextIdFromQuery(query?: SearchParamRecord) {
return firstQueryValue(query?.orderId) ?? firstQueryValue(query?.tableId) ?? firstQueryValue(query?.roomId);
}
export function appendWorkflowContext(workflow: string[], query?: SearchParamRecord) {
const contextId = contextIdFromQuery(query);
return contextId && workflow.length === 1 ? [...workflow, contextId] : workflow;
}
export function verticalFamilyWorkflow(path: string[] | undefined, query?: SearchParamRecord) {
if (!path?.length) return undefined;
const [family, head = "", ...rest] = path;
const familyPath = head ? [head, ...rest] : [];
if (family === "payment") return appendWorkflowContext(paymentWorkflow(familyPath, firstQueryValue(query?.orderId)), query);
if (family === "dialog") return appendWorkflowContext(dialogWorkflow(familyPath), query);
if (family === "operations") return appendWorkflowContext(operationWorkflow(familyPath), query);
return path;
}
export function paymentWorkflow(path: string[], orderId?: string | null) {
const [head, ...rest] = path.length ? path : ["method-select"];
const map: Record<string, string> = {
cash: "cash-payment",
"cash-payment": "cash-payment",
card: "card-payment",
"card-payment": "card-payment",
qr: "qr-payment",
"qr-payment": "qr-payment",
transfer: "transfer-payment",
"transfer-payment": "transfer-payment",
"gift-card": "gift-card-payment",
"gift-card-payment": "gift-card-payment",
"bank-transfer": "transfer-payment",
partial: "partial-payment",
"partial-payment": "partial-payment",
pending: "payment-pending",
"payment-pending": "payment-pending",
success: "payment-success",
"payment-success": "payment-success",
receipt: "receipt",
tip: "tip"
};
const workflow = [map[head] ?? head, ...rest];
return orderId && workflow.length === 1 ? [...workflow, orderId] : workflow;
}
export function dialogWorkflow(path: string[]) {
const [head, ...rest] = path.length ? path : ["order-edit"];
const map: Record<string, string> = {
edit: "order-edit",
"order-edit": "order-edit",
discount: "discount",
customer: "customer-select",
"customer-select": "customer-select",
table: "table-transfer",
"table-transfer": "table-transfer",
split: "split-bill",
"split-bill": "split-bill",
refund: "void-refund",
void: "void-refund",
"void-refund": "void-refund",
"price-check": "product-search",
"stock-in": "stock-in",
"stock-out": "stock-out",
"stock-transfer": "stock-transfer"
};
return [map[head] ?? head, ...rest];
}
export function operationWorkflow(path: string[]) {
const [head, ...rest] = path.length ? path : ["shift"];
const map: Record<string, string> = {
drawer: "cash-drawer",
"cash-drawer": "cash-drawer",
shift: "shift",
pending: "pending-orders",
"pending-orders": "pending-orders",
quick: "quick-sale",
"quick-sale": "quick-sale",
split: "split-bill",
"split-bill": "split-bill",
refund: "void-refund",
"void-refund": "void-refund",
"clock-in-out": "shift",
"stock-in": "stock-check",
"stock-out": "stock-check",
"stock-transfer": "stock-check",
"price-check": "product-search",
"order-cancel": "void-refund"
};
return [map[head] ?? head, ...rest];
}
function isUuid(value: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

View File

@@ -1,5 +1,5 @@
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
export default function RegisterPage() {
return <TposAuthBoundary mode="register" role="admin" />;
return <TposAuthBoundary mode="register" role="customer" />;
}

View File

@@ -1,19 +1,23 @@
import { notFound, redirect } from "next/navigation";
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { portalShopId, requirePortalRole } from "@/server/auth/portal";
import { getDashboardStats } from "@/server/db/queries";
import { listTablesByShop } from "@/server/services/fnb";
import { listOrdersService } from "@/server/services/order";
import { getShopService } from "@/server/services/shop";
import { getAttendance, listKitchenTickets, listLeaveRequests, listNotifications, listSchedules, listStaff } from "@/server/services/parity";
import { getAttendance, getStaffProfile, listKitchenTickets, listLeaveRequests, listNotifications, listSchedules, listStaff } from "@/server/services/parity";
import { portalNav } from "@/components/tpos-config";
export const dynamic = "force-dynamic";
export default async function StaffCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const user = await requirePortalRole(["staff"], `/staff/${path.join("/")}`);
const section = path.join("/") || "dashboard";
if (!isKnownStaffSection(section)) notFound();
const shop = await getShopService();
const shop = await getShopService(portalShopId(user, "staff"));
const staffProfile = await getStaffProfile(user.id);
if (!staffAllowedSections(String(staffProfile?.role ?? "staff")).has(section)) notFound();
if (section === "pos" && shop) {
redirect(`/pos/${shop.id}/${normalizeVertical(shop.vertical)}`);
}
@@ -27,14 +31,15 @@ export default async function StaffCatchAllPage({ params }: { params: Promise<{
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
stats,
title: staffTitle(section),
metrics: [
{ label: "Ca hôm nay", value: "08:00-17:00", tone: "green" },
{ label: "Phiếu bếp/quầy", value: items.length, tone: "orange" },
{ label: "Trạng thái", value: "Sẵn sàng", tone: "blue" }
],
items
})}
/>
metrics: [
{ label: "Ca hôm nay", value: "08:00-17:00", tone: "green" },
{ label: "Phiếu bếp/quầy", value: items.length, tone: "orange" },
{ label: "Trạng thái", value: "Sẵn sàng", tone: "blue" }
],
items,
nav: staffNavForRole(String(staffProfile?.role ?? "staff"))
})}
/>
);
}
@@ -109,6 +114,41 @@ function isKnownStaffSection(section: string) {
return sections.has(section);
}
function sectionFromStaffHref(href: string) {
return href.replace(/^\/staff\/?/, "") || "dashboard";
}
function staffAllowedSections(role: string) {
const normalized = role.toLowerCase();
const sections = new Set(["dashboard", "overview", "attendance", "schedule", "leave", "notifications"]);
if (normalized.includes("manager") || normalized.includes("lead") || normalized.includes("admin")) {
for (const [, , href] of portalNav.staff) sections.add(sectionFromStaffHref(href));
return sections;
}
if (normalized.includes("kitchen") || normalized.includes("bếp") || normalized.includes("chef")) {
sections.add("kitchen");
}
if (normalized.includes("waiter") || normalized.includes("phục vụ") || normalized.includes("server")) {
sections.add("pos");
sections.add("tables");
sections.add("kitchen");
}
if (normalized.includes("cashier") || normalized.includes("thu ngân")) {
sections.add("pos");
sections.add("tables");
sections.add("payroll");
}
if (sections.size === 6) sections.add("pos");
return sections;
}
function staffNavForRole(role: string) {
const allowed = staffAllowedSections(role);
return portalNav.staff
.filter(([, , href]) => allowed.has(sectionFromStaffHref(href)))
.map(([label, , href, Icon]) => ({ label, href, Icon }));
}
function money(value: number) {
return new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 }).format(value);
}

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { requirePortalRole } from "@/server/auth/portal";
import { auditLogs, listFeatureFlags, listPlans, listRoles, listUsers, platformStats, systemHealth } from "@/server/services/parity";
import { listShopsService } from "@/server/services/shop";
import { portalNav } from "@/components/tpos-config";
@@ -8,6 +9,7 @@ export const dynamic = "force-dynamic";
export default async function SuperAdminCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
await requirePortalRole(["superadmin"], `/superadmin/${path.join("/")}`);
const section = path.join("/") || "dashboard";
if (!isKnownSuperAdminSection(section)) notFound();
const stats = await platformStats();

View File

@@ -139,13 +139,13 @@ export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; ro
setMessage("OTP đăng nhập khách hàng chưa được cấu hình trong MVP");
return;
}
startTransition(async () => {
const response = await fetch("/api/bff/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const payload = (await response.json()) as { success: boolean; error?: string; data?: { roles?: Array<{ portal: string }>; defaultShopId?: string } };
startTransition(async () => {
const response = await fetch("/api/bff/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, role: selected.role })
});
const payload = (await response.json()) as { success: boolean; error?: string; data?: { roles?: Array<{ code?: string; portal: string }>; defaultShopId?: string } };
if (!response.ok || !payload.success) {
setMessage(payload.error ?? "Không thể đăng nhập");
return;
@@ -155,7 +155,8 @@ export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; ro
if (returnUrl.startsWith("/") && !returnUrl.startsWith("//")) router.push(returnUrl);
return;
}
const portal = payload.data?.roles?.[0]?.portal ?? "admin";
const requestedPortal = selected.role === "branch" ? "admin" : selected.role;
const portal = payload.data?.roles?.find((item) => item.code === requestedPortal || item.portal === requestedPortal)?.portal ?? payload.data?.roles?.[0]?.portal ?? "admin";
router.push(portal === "staff" ? "/staff/dashboard" : portal === "superadmin" ? "/superadmin/dashboard" : portal === "customer" ? "/" : "/admin");
router.refresh();
});
@@ -321,14 +322,14 @@ export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; ro
function flowCopy(mode: string) {
const map: Record<string, { title: string; description: string; badge: string; formTitle: string; formText: string; submit: string; success: string }> = {
register: {
title: "Đăng ký dùng thử",
description: "Tạo tài khoản merchant/customer MVP, chọn vai trò và bắt đầu onboarding.",
badge: "REGISTER",
formTitle: "Tạo tài khoản",
formText: "Dữ liệu ghi vào MVP DB auth, role và session sẽ dùng chung BFF.",
submit: "Tạo tài khoản",
success: "Đã tạo tài khoản. Có thể quay lại đăng nhập."
register: {
title: "Đăng ký tài khoản khách hàng",
description: "Tạo tài khoản loyalty để tích điểm, nhận voucher và theo dõi lịch sử mua hàng.",
badge: "REGISTER",
formTitle: "Tạo tài khoản",
formText: "Dữ liệu ghi vào MVP DB auth và dùng chung cho QR menu, ví điểm và voucher.",
submit: "Tạo tài khoản",
success: "Đã tạo tài khoản. Có thể quay lại đăng nhập."
},
"forgot-password": {
title: "Khôi phục mật khẩu",
@@ -385,7 +386,7 @@ function AuthNav() {
<Link href="/" className="tpos-logo">aPOS</Link>
<div>
<Link href="/#features">Tính năng</Link>
<Link href="/#pricing">Bảng giá</Link>
<Link href="/project#pricing">Bảng giá</Link>
<Link href="/auth/login">Đăng nhập</Link>
<Link className="btn-accent" href="/register">Dùng thử miễn phí</Link>
</div>

View File

@@ -9,10 +9,12 @@ import {
ShieldCheck,
Store
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { portalNav, shopSections, type PortalKind, type VerticalKind } from "./tpos-config";
type Metric = { label: string; value: string | number; tone?: "orange" | "green" | "blue" | "red" };
type ListItem = { id?: string; title: string; meta?: string; value?: string; href?: string };
type PortalNavItem = { label: string; href: string; Icon: LucideIcon };
export type PortalPayload = {
title: string;
@@ -22,6 +24,7 @@ export type PortalPayload = {
primary?: ListItem[];
secondary?: ListItem[];
status?: Array<{ label: string; value: string; tone?: Metric["tone"] }>;
nav?: PortalNavItem[];
};
function labelFromPath(kind: PortalKind, segments: string[]) {
@@ -40,7 +43,9 @@ export function TposPortal({
}) {
const shopVertical = payload.shop && ((payload.shop.vertical ?? "cafe") as VerticalKind) in shopSections ? (payload.shop.vertical as VerticalKind) : "cafe";
const isShopAdmin = kind === "admin" && path[0] === "shop" && Boolean(payload.shop);
const nav = isShopAdmin && payload.shop
const nav = payload.nav
? payload.nav.map(({ label, href, Icon }) => [label, href, href, Icon] as const)
: isShopAdmin && payload.shop
? shopSections[shopVertical].map(([label, slug, Icon]) => [
label,
slug,
@@ -50,6 +55,7 @@ export function TposPortal({
: portalNav[kind];
const active = labelFromPath(kind, path);
const currentHref = `/${kind}${path.length ? `/${path.join("/")}` : ""}`;
const primaryAction = primaryActionFor(kind, path, payload.shop);
const isActiveHref = (href: string) => {
if (isShopAdmin && href.startsWith("/pos/")) return false;
if (href === `/${kind}`) return path.length === 0 || path[0] === "dashboard";
@@ -92,10 +98,12 @@ export function TposPortal({
<ArrowUpRight size={16} />
</Link>
) : null}
<button className="primary-action">
<Plus size={16} />
Tạo mới
</button>
{primaryAction ? (
<Link className="primary-action" href={primaryAction.href}>
<Plus size={16} />
{primaryAction.label}
</Link>
) : null}
</div>
</header>
@@ -151,8 +159,8 @@ export function TposPortal({
<article className="portal-panel">
<div className="panel-head">
<div>
<span className="eyebrow">LINKS</span>
<h2>Route parity</h2>
<span className="eyebrow">LIÊN KẾT</span>
<h2>Truy cập nhanh</h2>
</div>
<Globe2 size={18} />
</div>
@@ -261,14 +269,44 @@ function defaultSecondary(kind: PortalKind, shop?: PortalPayload["shop"]): ListI
{ title: "Feature flags", href: "/superadmin/system/flags" },
{ title: "Audit log", href: "/superadmin/system/audit" }
];
if (kind === "marketing") return [
{ title: "Social hub", href: "/marketing" },
{ title: "Content studio", href: "/marketing/content" },
{ title: "Analytics", href: "/marketing/analytics" }
];
if (kind === "staff") return [
{ title: "Ca làm", href: "/staff/dashboard" },
{ title: "Bếp", href: "/staff/kitchen" },
{ title: "Điểm danh", href: "/staff/attendance" }
];
const vertical = shop?.vertical ?? "cafe";
return [
return shop ? [
{ title: "Customer menu", href: shop ? `/menu/${shop.id}` : "/" },
{ title: "POS terminal", href: shop ? `/pos/${shop.id}/${vertical}` : "/pos" },
{ title: "Settings", href: shop ? `/admin/shop/${shop.id}/settings` : "/settings" }
] : [
{ title: "Cửa hàng", href: "/admin/stores" },
{ title: "Báo cáo", href: "/admin/reports" },
{ title: "Cài đặt", href: "/admin/settings" }
];
}
function primaryActionFor(kind: PortalKind, path: string[], shop?: PortalPayload["shop"]): { label: string; href: string } | null {
if (kind === "admin" && shop) {
const section = path[2] ?? "overview";
if (section === "staff") return { label: "Thêm nhân sự", href: `/admin/shop/${shop.id}/staff` };
if (section === "inventory") return { label: "Nhập kho", href: `/admin/shop/${shop.id}/inventory` };
if (section === "tables" || section === "rooms") return { label: section === "rooms" ? "Thêm phòng" : "Thêm bàn", href: `/admin/shop/${shop.id}/${section}` };
if (section === "promotions") return { label: "Tạo khuyến mãi", href: `/admin/shop/${shop.id}/promotions` };
return { label: "Mở POS", href: `/pos/${shop.id}/${shop.vertical ?? "cafe"}` };
}
if (kind === "admin") return { label: "Tạo cửa hàng", href: "/admin/stores/create" };
if (kind === "staff") return { label: "Check-in", href: "/staff/attendance" };
if (kind === "marketing") return { label: "Tạo nội dung", href: "/marketing/content" };
if (kind === "superadmin") return { label: "Feature flag", href: "/superadmin/system/flags" };
return null;
}
export function buildPortalPayload(kind: PortalKind, input: {
shop?: PortalPayload["shop"];
stats?: Record<string, unknown>;
@@ -276,18 +314,20 @@ export function buildPortalPayload(kind: PortalKind, input: {
title?: string;
path?: string[];
items?: ListItem[];
nav?: PortalNavItem[];
}): PortalPayload {
const money = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 });
const stats = input.stats ?? {};
return {
title: input.title ?? (kind === "admin" ? "Bảng điều khiển vận hành" : kind === "staff" ? "Ca làm nhân viên" : kind === "marketing" ? "Marketing hub" : "Platform control"),
subtitle: input.shop ? `Đang vận hành ${input.shop.name}` : "Route parity với web-client-tpos-net trong Next MVP.",
subtitle: input.shop ? `Đang vận hành ${input.shop.name}` : "Bảng vận hành TPOS cho cửa hàng, nhân sự, marketing và nền tảng.",
shop: input.shop,
metrics: input.metrics ?? [
{ label: "Doanh thu", value: money.format(Number(stats.todayRevenue ?? stats.revenue ?? 0)), tone: "orange" },
{ label: "Đơn hàng", value: Number(stats.orderCount ?? 0), tone: "green" },
{ label: "Cửa hàng", value: Number(stats.shopCount ?? 1), tone: "blue" }
],
primary: input.items
primary: input.items,
nav: input.nav
};
}

View File

@@ -17,11 +17,11 @@ import {
} from "lucide-react";
const verticals = [
{ label: "Cafe", icon: Coffee },
{ label: "Nhà hàng & F&B", icon: UtensilsCrossed },
{ label: "Karaoke", icon: Mic },
{ label: "TMV/Spa", icon: Sparkles },
{ label: "Bán lẻ", icon: ShoppingBag }
{ label: "Cafe", desc: "Order nhanh, pha chế, ca bán", icon: Coffee },
{ label: "Nhà hàng & F&B", desc: "Bàn, bếp, thanh toán tách/gộp", icon: UtensilsCrossed },
{ label: "Karaoke", desc: "Phòng, giờ hát, dịch vụ đi kèm", icon: Mic },
{ label: "TMV/Spa", desc: "Lịch hẹn, liệu trình, khách hàng", icon: Sparkles },
{ label: "Bán lẻ", desc: "Sản phẩm, tồn kho, khách thân thiết", icon: ShoppingBag }
];
const features = [
@@ -42,37 +42,46 @@ export function TposPublicLanding({ variant = "home" }: { variant?: "home" | "pr
return (
<main className="public-shell">
<PublicNav />
<PublicNav variant={variant} />
<section className={isProject ? "landing-hero landing-hero--project" : "landing-hero"}>
<div className="landing-terminal-preview" aria-hidden="true">
<div className="terminal-bar">
<span>aPOS POS</span>
<b>Online</b>
</div>
<div className="terminal-body">
<div className="terminal-sidebar" />
<div className="terminal-grid">
<i />
<i />
<i />
<i />
{isProject ? (
<div className="landing-terminal-preview" aria-hidden="true">
<div className="terminal-bar">
<span>aPOS POS</span>
<b>Online</b>
</div>
<div className="terminal-order">
<span>Đơn hàng</span>
<b>245.000 đ</b>
<div className="terminal-body">
<div className="terminal-sidebar" />
<div className="terminal-grid">
<i />
<i />
<i />
<i />
</div>
<div className="terminal-order">
<span>Đơn hàng</span>
<b>245.000 đ</b>
</div>
</div>
</div>
</div>
) : null}
<div className="home-hero__badge">{isProject ? "GoodGo TPOS MVP" : "AI-Powered POS & Loyalty"}</div>
{!isProject ? (
<div className="home-hero__brand">
<Store size={34} />
<span>TPOS</span>
</div>
) : null}
<div className="home-hero__badge">{isProject ? "GoodGo TPOS MVP" : "Nền tảng POS đa ngành"}</div>
<h1 className="home-hero__title">
{isProject ? "Giới thiệu dự án TPOS Next MVP" : "Quản lý bán hàng thông minh với AI"}
{isProject ? "Giới thiệu dự án TPOS Next MVP" : "Quản lý bán hàng cho từng mô hình vận hành"}
</h1>
<p className="home-hero__subtitle">
{isProject
? "Bản Next MVP tái hiện web-client-tpos-net bằng kiến trúc BFF, database nội bộ và giao diện public/auth/portal/POS đồng bộ."
: "Giải pháp POS toàn diện cho Nhà hàng, Cafe, Bar, Karaoke Spa - tự động hóa kế toán, tích điểm khách hàng và báo cáo realtime."}
: "TPOS hỗ trợ Cafe, Nhà hàng, Karaoke, Spa và Bán lẻ với bán hàng tại quầy, quản lý ca, khách hàng thân thiết và báo cáo theo thời gian thực."}
</p>
<div className="home-hero__actions">
<Link href="/register" className="home-hero__btn home-hero__btn--primary">
@@ -86,26 +95,39 @@ export function TposPublicLanding({ variant = "home" }: { variant?: "home" | "pr
</div>
<div className="home-trust">
<span className="home-trust__label">Hơn 2,000 doanh nghiệp đã tin tưởng sử dụng</span>
<span className="home-trust__label">Dành cho điểm bán cần vận hành nhanh ng</span>
<div className="home-trust__stats">
<span className="home-trust__stat">50,000+ giao dịch/ngày</span>
<span className="home-trust__stat">POS tại quầy</span>
<span className="home-trust__divider"></span>
<span className="home-trust__stat">99.9% uptime</span>
<span className="home-trust__stat">Portal quản trị</span>
</div>
</div>
</section>
<section className="home-verticals" id="features">
<div className="home-verticals__head">
<span>Chọn ngành hàng</span>
<Link href="/auth/login">Vào portal <ArrowRight size={15} /></Link>
</div>
<div className="home-verticals__grid">
{verticals.map(({ label, icon: Icon }) => (
{verticals.map(({ label, desc, icon: Icon }) => (
<Link href="/register" className="home-vertical-card" key={label}>
<Icon size={28} />
<span>{label}</span>
<p>{desc}</p>
</Link>
))}
</div>
</section>
{isProject ? <ProjectSections /> : <HomePortalCta />}
</main>
);
}
function ProjectSections() {
return (
<>
<section className="tpos-section">
<div className="tpos-section-header">
<span className="home-hero__badge">TPOS Modules</span>
@@ -169,20 +191,34 @@ export function TposPublicLanding({ variant = "home" }: { variant?: "home" | "pr
<Link href="/admin" className="home-hero__btn home-hero__btn--primary">Vào admin demo</Link>
</div>
</section>
</main>
</>
);
}
function PublicNav() {
function HomePortalCta() {
return (
<section className="home-portal-strip">
<div>
<ShieldCheck size={22} />
<span>Đã tài khoản TPOS?</span>
</div>
<Link href="/auth/login" className="home-hero__btn home-hero__btn--secondary">Mở portal đăng nhập</Link>
</section>
);
}
function PublicNav({ variant }: { variant: "home" | "project" }) {
const isProject = variant === "project";
return (
<nav className="tpos-navbar">
<div className="tpos-navbar-inner">
<Link href="/" className="tpos-logo">aPOS</Link>
<div className="tpos-nav-links">
<Link href="/#features" className="tpos-nav-link">Tính năng</Link>
<Link href="/#features" className="tpos-nav-link">{isProject ? "Tính năng" : "Ngành hàng"}</Link>
<Link href="/about" className="tpos-nav-link">Giới thiệu</Link>
<Link href="/#pricing" className="tpos-nav-link">Bảng giá</Link>
<Link href="/#contact" className="tpos-nav-link">Liên hệ</Link>
<Link href="/project" className="tpos-nav-link">Dự án</Link>
{isProject ? <Link href="/project#pricing" className="tpos-nav-link">Bảng giá</Link> : null}
<Link href="/login" className="tpos-nav-link">Đăng nhập</Link>
<Link href="/register" className="btn-accent">Dùng thử miễn phí</Link>
</div>

View File

@@ -8,6 +8,7 @@ import {
Activity,
ArrowLeft,
ArrowRight,
Banknote,
BarChart3,
Bell,
Building2,
@@ -18,21 +19,26 @@ import {
Coffee,
Database,
MapPin,
Menu,
Mic,
MonitorSmartphone,
Package,
Plus,
PlusCircle,
RefreshCw,
Receipt,
Scissors,
Search,
Settings,
Shield,
ShoppingBag,
Sparkles,
Store,
TrendingUp,
Users,
UtensilsCrossed,
Wallet,
X,
Zap
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -41,6 +47,7 @@ import type { DashboardStats, OrderSummary, Product, Shop, TableInfo } from "@/s
import type { ShopStatRow } from "@/server/services/shop";
type ServiceHealth = { name: string; icon: string; isOnline: boolean; latencyMs?: number };
type AppointmentRow = { status?: string | null; appointment_time?: string | null; start_time?: string | null };
type ShopOverviewPayload = {
shop: Shop;
stats: DashboardStats;
@@ -48,7 +55,34 @@ type ShopOverviewPayload = {
products: Product[];
tables: TableInfo[];
staffCount: number;
appointmentCount: number;
appointments: AppointmentRow[];
};
type RevenueRow = { day: string; order_count: number | string; revenue: number | string };
type TopProductRow = { product_name: string | null; quantity_sold: number | string | null; revenue: number | string | null };
type WalletRow = { balance: number | string | null; currency: string | null; total_income: number | string | null; total_expense: number | string | null };
type WalletTransactionRow = {
id: string;
amount: number | string | null;
description: string | null;
item_name: string | null;
created_at: string | null;
currency: string | null;
};
type ShopFinancePayload = {
shop: Shop;
orders: OrderSummary[];
wallets: WalletRow[];
walletTransactions: WalletTransactionRow[];
revenueRows: RevenueRow[];
topProducts: TopProductRow[];
};
type AdminSectionItem = { id?: string; title: string; meta?: string; value?: string; href?: string };
type AdminSectionPayload = {
title: string;
section: string;
shop?: Shop | null;
stats: DashboardStats;
items: AdminSectionItem[];
};
const money = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 });
@@ -56,9 +90,10 @@ const money = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND
const storeTypes = [
{ key: "cafe", label: "Café", icon: Coffee, color: "#FF5C00", desc: "Quán cà phê, trà sữa" },
{ key: "restaurant", label: "Nhà hàng / Bar", icon: UtensilsCrossed, color: "#3B82F6", desc: "Nhà hàng, quán ăn, bar" },
{ key: "karaoke", label: "Karaoke", icon: Mic, color: "#8B5CF6", desc: "Karaoke, entertainment" },
{ key: "spa", label: "Spa", icon: Sparkles, color: "#EC4899", desc: "Spa, massage, wellness" },
{ key: "beauty", label: "Thẩm mỹ viện", icon: Scissors, color: "#F472B6", desc: "Salon, nail, thẩm mỹ" }
{ key: "karaoke", label: "Karaoke", icon: Mic, color: "#8B5CF6", desc: "Phòng hát, dịch vụ giải trí" },
{ key: "spa", label: "Spa", icon: Sparkles, color: "#EC4899", desc: "Spa, massage, chăm sóc sức khỏe" },
{ key: "beauty", label: "Thẩm mỹ viện", icon: Scissors, color: "#F472B6", desc: "Salon, nail, thẩm mỹ" },
{ key: "retail", label: "Bán lẻ", icon: ShoppingBag, color: "#10B981", desc: "Cửa hàng bán lẻ, tiện lợi" }
] as const;
export function AdminDashboardView({
@@ -287,13 +322,14 @@ export function StoreCreateWizard() {
function nextStep() {
setError(null);
if (step === 1) {
if (!form.name.trim()) return setError("Vui lòng nhập tên cửa hàng");
if (!form.slug.trim()) return setError("Vui lòng nhập slug cho cửa hàng");
if (!form.vertical) return setError("Vui lòng chọn loại hình kinh doanh");
}
setStep((current) => Math.min(3, current + 1));
}
if (step === 1) {
if (!form.name.trim()) return setError("Vui lòng nhập tên cửa hàng");
if (!form.slug.trim()) return setError("Vui lòng nhập slug cho cửa hàng");
if (!form.vertical) return setError("Vui lòng chọn loại hình kinh doanh");
}
if (step === 2 && !form.address.trim()) return setError("Vui lòng nhập địa chỉ cửa hàng");
setStep((current) => Math.min(3, current + 1));
}
async function createStore() {
setError(null);
@@ -429,6 +465,8 @@ export function StoreCreateWizard() {
<SummaryRow label="Slug" value={form.slug || "—"} />
<SummaryRow label="Loại hình" value={storeTypes.find((type) => type.key === form.vertical)?.label ?? "—"} />
<SummaryRow label="Địa chỉ" value={form.address ? `${form.address}${form.district ? `, ${form.district}` : ""}` : "—"} />
<SummaryRow label="Giờ hoạt động" value={`${form.openTime} - ${form.closeTime}`} />
<SummaryRow label="Ngày hoạt động" value={form.activeDays.length ? form.activeDays.join(", ") : "—"} />
</div>
</section>
</>
@@ -461,7 +499,7 @@ export function StoreCreateWizard() {
}
export function ShopOverviewView({ payload }: { payload: ShopOverviewPayload }) {
const { shop, stats, orders, products, tables, staffCount, appointmentCount } = payload;
const { shop, stats, orders, products, tables, staffCount, appointments } = payload;
const [period, setPeriod] = useState<"today" | "7d" | "30d" | "all">("30d");
const [revenuePeriod, setRevenuePeriod] = useState<"day" | "week" | "month">("day");
const visibleOrders = useMemo(() => orders.filter((order) => period === "all" || isInsidePeriod(order.createdAt, period)), [orders, period]);
@@ -527,7 +565,7 @@ export function ShopOverviewView({ payload }: { payload: ShopOverviewPayload })
<KpiCard icon={Package} color="#EC4899" label="Sản phẩm" value={products.length} />
</div>
<VerticalTodayPanel vertical={vertical} tables={tables} appointmentCount={appointmentCount} />
<VerticalTodayPanel vertical={vertical} tables={tables} appointments={appointments} />
<div className="shop-overview-grid">
<section className="admin-panel shop-overview-chart">
@@ -575,8 +613,11 @@ export function ShopOverviewView({ payload }: { payload: ShopOverviewPayload })
<SummaryRow label="Trạng thái" value={isActiveShop(shop) ? "Đang mở" : "Thiết lập"} />
<SummaryRow label="Ngành hàng" value={verticalLabel} />
<SummaryRow label="Slug" value={shop.slug} />
<SummaryRow label={vertical === "karaoke" ? "Phòng" : "Bàn"} value={tables.length || stats.tableCount} />
<SummaryRow label={vertical === "karaoke" ? "Phòng" : vertical === "spa" || vertical === "beauty" ? "Lịch hẹn" : "Bàn"} value={vertical === "spa" || vertical === "beauty" ? appointments.length : tables.length || stats.tableCount} />
<SummaryRow label="Nhân viên" value={staffCount} />
<SummaryRow label="Giờ mở cửa" value={shop.openTime && shop.closeTime ? `${shop.openTime} - ${shop.closeTime}` : "Chưa cấu hình"} />
<SummaryRow label="Ngày bán" value={shop.activeDays?.length ? shop.activeDays.join(", ") : "Chưa cấu hình"} />
<SummaryRow label="Địa chỉ" value={[shop.address, shop.district, shop.city].filter(Boolean).join(", ") || "Chưa cấu hình"} />
<SummaryRow label="Điện thoại" value={shop.phone ?? "Chưa cấu hình"} />
<SummaryRow label="Email" value={shop.email ?? "Chưa cấu hình"} />
</div>
@@ -588,6 +629,369 @@ export function ShopOverviewView({ payload }: { payload: ShopOverviewPayload })
);
}
export function ShopHistoryView({ payload }: { payload: { shop: Shop; orders: OrderSummary[] } }) {
const { shop, orders } = payload;
const vertical = normalizeVertical(shop.vertical);
const [period, setPeriod] = useState<"today" | "7d" | "30d" | "all">("30d");
const [query, setQuery] = useState("");
const visibleOrders = useMemo(() => {
const normalized = query.trim().toLowerCase();
return orders.filter((order) => {
const matchesPeriod = period === "all" || isInsidePeriod(order.createdAt, period);
const matchesQuery = !normalized || [
order.id,
order.tableNumber,
order.paymentMethod,
order.transactionId,
order.status
].some((value) => String(value ?? "").toLowerCase().includes(normalized));
return matchesPeriod && matchesQuery;
});
}, [orders, period, query]);
const revenue = visibleOrders.reduce((sum, order) => sum + order.totalAmount, 0);
const paidCount = visibleOrders.filter((order) => isPaidOrder(order)).length;
return (
<AdminShell activeHref={`/admin/shop/${shop.id}/history`} shop={shop}>
<div className="admin-topbar">
<div className="admin-topbar__left">
<div>
<h1 className="admin-topbar__title">Lịch sử đơn</h1>
<p className="admin-topbar__subtitle">{shop.name} đi soát đơn POS</p>
</div>
</div>
<div className="admin-topbar__right">
<label className="admin-search">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Tìm mã đơn, bàn, giao dịch..." />
</label>
<Link className="admin-btn-primary" href={`/pos/${shop.id}/${vertical}?tab=history`}>
<Receipt size={16} />
<span>Mở trên POS</span>
</Link>
</div>
</div>
<div className="admin-content">
<div className="shop-period-row">
<div className="admin-period-tabs">
{[
["today", "Hôm nay"],
["7d", "7 ngày"],
["30d", "30 ngày"],
["all", "Tất cả"]
].map(([value, label]) => (
<button key={value} className={period === value ? "active" : ""} onClick={() => setPeriod(value as typeof period)}>{label}</button>
))}
</div>
<span>{shopOverviewPeriodLabel(period)} {visibleOrders.length} đơn</span>
</div>
<div className="admin-kpi-row">
<KpiCard icon={TrendingUp} color="#22C55E" label="Doanh thu" value={money.format(revenue)} />
<KpiCard icon={Receipt} color="#3B82F6" label="Tổng đơn" value={visibleOrders.length} />
<KpiCard icon={CheckCircle} color="#FF5C00" label="Đã thanh toán" value={paidCount} />
<KpiCard icon={Banknote} color="#8B5CF6" label="TB / đơn" value={money.format(visibleOrders.length ? revenue / visibleOrders.length : 0)} />
</div>
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Receipt size={20} />Đơn hàng</h3>
<span className="admin-panel__action">{visibleOrders.length}/{orders.length}</span>
</div>
<div className="admin-panel__body admin-table-wrap">
{visibleOrders.length ? (
<table className="admin-data-table">
<thead>
<tr>
<th> đơn</th>
<th>Bàn/phòng</th>
<th>Món</th>
<th>Thanh toán</th>
<th>Trạng thái</th>
<th className="is-right">Tổng tiền</th>
<th>Thời gian</th>
</tr>
</thead>
<tbody>
{visibleOrders.map((order) => (
<tr key={order.id}>
<td><b>#{order.id.slice(0, 8).toUpperCase()}</b><span>{order.transactionId ?? "Chưa có giao dịch"}</span></td>
<td>{order.tableNumber ?? "Mang đi"}</td>
<td>{order.itemCount} món</td>
<td>{paymentMethodLabel(order.paymentMethod)}</td>
<td><OrderStatusBadge order={order} /></td>
<td className="is-right"><strong>{money.format(order.totalAmount)}</strong></td>
<td>{formatDateTime(order.createdAt)}</td>
</tr>
))}
</tbody>
</table>
) : (
<AdminEmptyState icon={Receipt} title="Không có đơn phù hợp" description="Thử đổi khoảng thời gian hoặc từ khóa tìm kiếm." />
)}
</div>
</section>
</div>
</AdminShell>
);
}
export function ShopFinanceView({ payload }: { payload: ShopFinancePayload }) {
const { shop, orders, wallets, walletTransactions, revenueRows, topProducts } = payload;
const vertical = normalizeVertical(shop.vertical);
const [period, setPeriod] = useState<"7d" | "30d" | "all">("30d");
const visibleOrders = useMemo(() => orders.filter((order) => period === "all" || isInsidePeriod(order.createdAt, period)), [orders, period]);
const revenue = visibleOrders.reduce((sum, order) => sum + order.totalAmount, 0);
const walletBalance = wallets.reduce((sum, wallet) => sum + toNumber(wallet.balance), 0);
const walletIncome = wallets.reduce((sum, wallet) => sum + toNumber(wallet.total_income), 0);
const walletExpense = wallets.reduce((sum, wallet) => sum + toNumber(wallet.total_expense), 0);
return (
<AdminShell activeHref={`/admin/shop/${shop.id}/finance`} shop={shop}>
<div className="admin-topbar">
<div className="admin-topbar__left">
<div>
<h1 className="admin-topbar__title">Tài chính</h1>
<p className="admin-topbar__subtitle">Đơn hàng theo cửa hàng tiền theo tài khoản</p>
</div>
</div>
<div className="admin-topbar__right">
<Link className="admin-btn-secondary" href={`/admin/shop/${shop.id}/history`}>
<Receipt size={16} />
<span>Lịch sử đơn</span>
</Link>
<Link className="admin-btn-primary" href={`/pos/${shop.id}/${vertical}`}>
<MonitorSmartphone size={16} />
<span>Mở POS</span>
</Link>
</div>
</div>
<div className="admin-content">
<div className="admin-inline-note">
<Database size={15} />
<span>Dữ liệu đưc đc từ MVP DB. Các phương thức thanh toán chưa cấu hình không ghi nhận doanh thu giả.</span>
</div>
<div className="shop-period-row">
<div className="admin-period-tabs">
{[
["7d", "7 ngày"],
["30d", "30 ngày"],
["all", "Tất cả"]
].map(([value, label]) => (
<button key={value} className={period === value ? "active" : ""} onClick={() => setPeriod(value as typeof period)}>{label}</button>
))}
</div>
<span>{shopOverviewPeriodLabel(period)} {visibleOrders.length} đơn</span>
</div>
<div className="admin-kpi-row">
<KpiCard icon={TrendingUp} color="#22C55E" label="Tổng doanh thu" value={money.format(revenue)} />
<KpiCard icon={Receipt} color="#3B82F6" label="Đơn hàng" value={visibleOrders.length} />
<KpiCard icon={Banknote} color="#FF5C00" label="TB / đơn" value={money.format(visibleOrders.length ? revenue / visibleOrders.length : 0)} />
<KpiCard icon={Wallet} color="#8B5CF6" label="Số dư ví" value={money.format(walletBalance)} />
</div>
<div className="admin-finance-grid">
<section className="admin-panel admin-finance-chart">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><BarChart3 size={20} />Doanh thu gần đây</h3>
<span className="admin-panel__action">{revenueRows.length} ngày</span>
</div>
<div className="admin-panel__body">
{revenueRows.length ? <FinanceRevenueBars rows={revenueRows} /> : <AdminEmptyState icon={BarChart3} title="Chưa có doanh thu" description="Dữ liệu sẽ cập nhật khi POS có đơn đã thanh toán." />}
</div>
</section>
<aside className="admin-side-stack">
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Wallet size={20} /> tài khoản</h3>
</div>
<div className="admin-panel__body admin-info-list">
<SummaryRow label="Số dư" value={money.format(walletBalance)} />
<SummaryRow label="Tổng thu" value={money.format(walletIncome)} />
<SummaryRow label="Tổng chi" value={money.format(walletExpense)} />
</div>
</section>
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Package size={20} />Top sản phẩm</h3>
</div>
<div className="admin-panel__body admin-compact-list">
{topProducts.slice(0, 6).map((item, index) => (
<div className="admin-compact-row" key={`${item.product_name ?? "product"}-${index}`}>
<div>
<b>{item.product_name ?? "Sản phẩm"}</b>
<span>{toNumber(item.quantity_sold)} bán</span>
</div>
<strong>{money.format(toNumber(item.revenue))}</strong>
</div>
))}
{topProducts.length === 0 ? <AdminEmptyState icon={Package} title="Chưa có sản phẩm bán chạy" description="Chưa có dữ liệu trong kỳ." /> : null}
</div>
</section>
</aside>
</div>
<div className="admin-finance-grid admin-finance-grid--bottom">
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Wallet size={20} />Giao dịch gần đây</h3>
</div>
<div className="admin-panel__body admin-table-wrap">
{walletTransactions.length ? (
<table className="admin-data-table">
<thead><tr><th> tả</th><th className="is-right">Số tiền</th><th>Ngày</th></tr></thead>
<tbody>
{walletTransactions.map((txn) => {
const amount = toNumber(txn.amount);
return (
<tr key={txn.id}>
<td><b>{txn.description ?? txn.item_name ?? "Giao dịch ví"}</b><span>{txn.currency ?? "VND"}</span></td>
<td className={amount >= 0 ? "is-right tone-green" : "is-right tone-red"}><strong>{amount >= 0 ? "+" : ""}{money.format(amount)}</strong></td>
<td>{formatDateTime(txn.created_at)}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<AdminEmptyState icon={Wallet} title="Chưa có giao dịch ví" description="Giao dịch ví của tài khoản sẽ hiển thị tại đây." />
)}
</div>
</section>
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Receipt size={20} />Đơn hàng gần đây</h3>
</div>
<div className="admin-panel__body admin-table-wrap">
{orders.length ? (
<table className="admin-data-table">
<thead><tr><th> đơn</th><th>Trạng thái</th><th className="is-right">Số tiền</th><th>Ngày</th></tr></thead>
<tbody>
{orders.slice(0, 20).map((order) => (
<tr key={order.id}>
<td><b>#{order.id.slice(0, 8).toUpperCase()}</b><span>{paymentMethodLabel(order.paymentMethod)}</span></td>
<td><OrderStatusBadge order={order} /></td>
<td className="is-right"><strong>{money.format(order.totalAmount)}</strong></td>
<td>{formatDateTime(order.createdAt)}</td>
</tr>
))}
</tbody>
</table>
) : (
<AdminEmptyState icon={Receipt} title="Chưa có đơn hàng" description="Mở POS để tạo đơn đầu tiên." actionHref={`/pos/${shop.id}/${vertical}`} actionLabel="Mở POS" />
)}
</div>
</section>
</div>
</div>
</AdminShell>
);
}
export function AdminSectionView({ payload }: { payload: AdminSectionPayload }) {
const { title, section, shop, stats, items } = payload;
const [query, setQuery] = useState("");
const filteredItems = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) => `${item.title} ${item.meta ?? ""} ${item.value ?? ""}`.toLowerCase().includes(needle));
}, [items, query]);
const activeHref = shop ? `/admin/shop/${shop.id}/${section || "overview"}` : section === "dashboard" ? "/admin" : `/admin/${section}`;
const vertical = normalizeVertical(shop?.vertical);
return (
<AdminShell activeHref={activeHref} shop={shop ?? undefined}>
<div className="admin-topbar">
<div className="admin-topbar__left">
<div>
<h1 className="admin-topbar__title">{title}</h1>
<p className="admin-topbar__subtitle">
{shop ? `${shop.name}${verticalText(vertical)}` : "Console quản trị TPOS"} MVP DB
</p>
</div>
</div>
<div className="admin-topbar__right">
<label className="admin-search">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Tìm kiếm..." />
</label>
{shop ? (
<Link className="admin-btn-primary" href={`/pos/${shop.id}/${vertical}`}>
<MonitorSmartphone size={16} />
<span>Mở POS</span>
</Link>
) : (
<Link className="admin-btn-primary" href="/admin/stores/create">
<Plus size={16} />
<span>Tạo cửa hàng</span>
</Link>
)}
</div>
</div>
<div className="admin-content">
<div className="admin-kpi-row">
<KpiCard icon={Store} color="#8B5CF6" value={stats.shopCount} label="Cửa hàng" />
<KpiCard icon={TrendingUp} color="#22C55E" value={money.format(stats.todayRevenue)} label="Doanh thu hôm nay" />
<KpiCard icon={Package} color="#3B82F6" value={stats.orderCount} label="Đơn hàng" />
<KpiCard icon={Users} color="#EC4899" value={stats.productCount} label="Sản phẩm" />
</div>
<section className="admin-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title"><Database size={20} />{title}</h3>
<span className="admin-panel__action">{filteredItems.length}/{items.length}</span>
</div>
<div className="admin-panel__body">
{filteredItems.length ? (
<div className="admin-reference-list">
{filteredItems.map((item, index) => (
<AdminReferenceRow key={`${item.id ?? item.title}-${index}`} item={item} />
))}
</div>
) : (
<AdminEmptyState
icon={Database}
title="Chưa có dữ liệu"
description="Dữ liệu sẽ xuất hiện khi workflow tương ứng ghi vào MVP DB."
actionHref={shop ? `/pos/${shop.id}/${vertical}` : "/admin/stores/create"}
actionLabel={shop ? "Mở POS" : "Tạo cửa hàng"}
/>
)}
</div>
</section>
</div>
</AdminShell>
);
}
function AdminReferenceRow({ item }: { item: AdminSectionItem }) {
const content = (
<>
<div className="admin-reference-row__main">
<strong>{item.title}</strong>
{item.meta ? <span>{item.meta}</span> : null}
</div>
{item.value ? <b>{item.value}</b> : null}
{item.href ? <ChevronRight size={16} /> : null}
</>
);
return item.href ? (
<Link className="admin-reference-row" href={item.href}>
{content}
</Link>
) : (
<div className="admin-reference-row">{content}</div>
);
}
export function AdminNotFoundView() {
return (
<AdminShell activeHref="/admin/stores">
@@ -605,6 +1009,7 @@ export function AdminNotFoundView() {
}
function AdminShell({ activeHref, shop, children }: { activeHref: string; shop?: Shop; children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const vertical = normalizeVertical(shop?.vertical);
const nav = shop
? shopSections[vertical].map(([label, slug, Icon]) => ({
@@ -617,18 +1022,35 @@ function AdminShell({ activeHref, shop, children }: { activeHref: string; shop?:
return (
<main className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-mobile-bar">
<button className="admin-mobile-bar__button" onClick={() => setSidebarOpen(true)} aria-label="Mở menu quản trị">
<Menu size={20} />
</button>
<div>
<b>aPOS Admin</b>
<span>{shop ? shop.name : "Management Console"}</span>
</div>
</div>
{sidebarOpen ? <button className="admin-sidebar-overlay" aria-label="Đóng menu quản trị" onClick={() => setSidebarOpen(false)} /> : null}
<aside className={sidebarOpen ? "admin-sidebar admin-sidebar--open" : "admin-sidebar"}>
<Link href={brandHref} className="admin-sidebar__logo">
<span className="admin-sidebar__logo-icon">G</span>
<span className="admin-sidebar__logo-text">
<span className="admin-sidebar__logo-name">aPOS Admin</span>
<span className="admin-sidebar__logo-sub">Management Console</span>
</span>
<button className="admin-sidebar__close" onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setSidebarOpen(false);
}} aria-label="Đóng menu quản trị">
<X size={18} />
</button>
</Link>
<nav className="admin-sidebar__nav">
{shop ? (
<>
<Link href="/admin" className="admin-nav-item admin-nav-item--back">
<Link href="/admin" className="admin-nav-item admin-nav-item--back" onClick={() => setSidebarOpen(false)}>
<ArrowLeft size={16} />
<span>Quay lại Admin</span>
</Link>
@@ -647,7 +1069,7 @@ function AdminShell({ activeHref, shop, children }: { activeHref: string; shop?:
{nav.map(({ label, href, Icon }) => {
const active = href === "/admin" ? activeHref === href : activeHref === href || (!href.startsWith("/pos/") && activeHref.startsWith(`${href}/`));
return (
<Link key={href} href={href} className={active ? "admin-nav-item admin-nav-item--active" : "admin-nav-item"}>
<Link key={href} href={href} onClick={() => setSidebarOpen(false)} className={active ? "admin-nav-item admin-nav-item--active" : "admin-nav-item"}>
<Icon size={18} />
<span>{label}</span>
</Link>
@@ -728,7 +1150,7 @@ function KpiCard({ icon: Icon, color, value, label }: { icon: LucideIcon; color:
);
}
function VerticalTodayPanel({ vertical, tables, appointmentCount }: { vertical: VerticalKind; tables: TableInfo[]; appointmentCount: number }) {
function VerticalTodayPanel({ vertical, tables, appointments }: { vertical: VerticalKind; tables: TableInfo[]; appointments: AppointmentRow[] }) {
if (vertical === "restaurant" || vertical === "karaoke") {
const available = tables.filter((table) => /available|free|trống/i.test(table.status)).length;
const occupied = tables.filter((table) => /occupied|busy|serving|đang/i.test(table.status)).length;
@@ -748,14 +1170,18 @@ function VerticalTodayPanel({ vertical, tables, appointmentCount }: { vertical:
}
if (vertical === "spa" || vertical === "beauty") {
const todayAppointments = appointments.filter((appointment) => isToday(appointment.appointment_time ?? appointment.start_time));
const confirmed = todayAppointments.filter((appointment) => /confirm|booked|scheduled/i.test(String(appointment.status ?? ""))).length;
const pending = todayAppointments.filter((appointment) => /pending|wait|new/i.test(String(appointment.status ?? ""))).length;
return (
<section className="admin-panel shop-overview-today-panel">
<div className="admin-panel__header">
<h3 className="admin-panel__title">Lịch hẹn hôm nay</h3>
</div>
<div className="admin-panel__body shop-overview-status-row">
<span className="admin-status-badge admin-status-badge--online"><span className="admin-status-badge__dot" />Đã xác nhận: {appointmentCount}</span>
<span className="admin-status-badge admin-status-badge--setup"><span className="admin-status-badge__dot" />Chờ: 0</span>
<span className="admin-status-badge admin-status-badge--online"><span className="admin-status-badge__dot" />Đã xác nhận: {confirmed}</span>
<span className="admin-status-badge admin-status-badge--setup"><span className="admin-status-badge__dot" />Chờ: {pending}</span>
<span className="admin-status-badge admin-status-badge--paused"><span className="admin-status-badge__dot" />Tổng: {todayAppointments.length}</span>
</div>
</section>
);
@@ -764,6 +1190,14 @@ function VerticalTodayPanel({ vertical, tables, appointmentCount }: { vertical:
return null;
}
function isToday(value: unknown) {
if (!value) return true;
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return false;
const now = new Date();
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
}
function StoreStat({ icon: Icon, color, value, label }: { icon: LucideIcon; color: string; value: string | number; label: string }) {
return (
<div className="admin-store-stat">
@@ -896,6 +1330,80 @@ function RevenueBars({ orders, period }: { orders: OrderSummary[]; period: "day"
);
}
function FinanceRevenueBars({ rows }: { rows: RevenueRow[] }) {
const ordered = [...rows].reverse().slice(-14);
const max = Math.max(1, ...ordered.map((row) => toNumber(row.revenue)));
return (
<div className="admin-finance-bars">
{ordered.map((row) => {
const revenue = toNumber(row.revenue);
return (
<div key={String(row.day)} className="admin-finance-bar">
<b>{money.format(revenue)}</b>
<div><span style={{ height: `${Math.max(6, revenue / max * 100)}%` }} /></div>
<small>{formatShortDate(row.day)}</small>
</div>
);
})}
</div>
);
}
function OrderStatusBadge({ order }: { order: OrderSummary }) {
const paid = isPaidOrder(order);
const cancelled = /cancel|void|hủy/i.test(order.status) || order.statusId === 4;
const className = cancelled
? "admin-status-badge admin-status-badge--setup"
: paid
? "admin-status-badge admin-status-badge--online"
: "admin-status-badge admin-status-badge--setup";
return (
<span className={className}>
<span className="admin-status-badge__dot" />
{orderStatusLabel(order)}
</span>
);
}
function orderStatusLabel(order: OrderSummary) {
if (isPaidOrder(order)) return "Đã thanh toán";
if (/validated|confirmed/i.test(order.status)) return "Đã xác nhận";
if (/cancel|void|hủy/i.test(order.status) || order.statusId === 4) return "Đã hủy";
return order.status || "Đang xử lý";
}
function isPaidOrder(order: OrderSummary) {
return order.statusId === 3 || order.statusId === 5 || /paid|succeeded/i.test(order.status);
}
function paymentMethodLabel(method?: string | null) {
const normalized = String(method ?? "").toLowerCase();
if (!normalized) return "Chưa thanh toán";
if (normalized === "cash") return "Tiền mặt";
if (normalized === "customer_order") return "QR khách hàng";
if (normalized === "kitchen_order") return "Bếp";
if (normalized === "room_fnb") return "Phòng karaoke";
return method ?? "Khác";
}
function toNumber(value: number | string | null | undefined) {
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatDateTime(value?: string | null) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("vi-VN", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function formatShortDate(value?: string | null) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString("vi-VN", { day: "2-digit", month: "2-digit" });
}
function StoreIcon({ vertical, size = 18 }: { vertical: VerticalKind; size?: number }) {
const map = { cafe: Coffee, restaurant: UtensilsCrossed, karaoke: Mic, spa: Sparkles, beauty: Scissors, retail: Store };
const Icon = map[vertical] ?? Store;

View File

@@ -51,12 +51,12 @@ import {
export type PortalKind = "admin" | "staff" | "superadmin" | "marketing";
export type VerticalKind = "cafe" | "restaurant" | "karaoke" | "spa" | "beauty" | "retail";
export const verticals: Array<{ id: VerticalKind; label: string; icon: typeof Coffee }> = [
export const verticals: Array<{ id: VerticalKind; label: string; icon: typeof Coffee; visibleInPosNav?: boolean }> = [
{ id: "karaoke", label: "Karaoke", icon: DoorOpen },
{ id: "restaurant", label: "Nhà hàng", icon: UtensilsCrossed },
{ id: "cafe", label: "Café", icon: Coffee },
{ id: "spa", label: "Spa", icon: Sparkles },
{ id: "beauty", label: "Beauty", icon: Heart },
{ id: "beauty", label: "Beauty", icon: Heart, visibleInPosNav: false },
{ id: "retail", label: "Bán lẻ", icon: ShoppingBag }
];
@@ -107,9 +107,9 @@ export const portalNav = {
export const shopSections = {
cafe: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Menu & Đồ uống", "menu", Coffee],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Menu & Đồ uống", "menu", Coffee],
["Nguyên liệu", "recipes", FlaskConical],
["Ca làm việc", "shifts", Clock4],
["Tồn kho", "inventory", Warehouse],
@@ -126,9 +126,9 @@ export const shopSections = {
["Thiết lập", "settings", Settings]
],
restaurant: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Menu & Món ăn", "menu", UtensilsCrossed],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Menu & Món ăn", "menu", UtensilsCrossed],
["Bàn / Table", "tables", Grid3X3],
["Đặt bàn", "reservations", CalendarCheck],
["Khu vực", "zones", MapPin],
@@ -147,9 +147,9 @@ export const shopSections = {
["Thiết lập", "settings", Settings]
],
karaoke: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Phòng", "rooms", DoorOpen],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Phòng", "rooms", DoorOpen],
["Menu / Bar", "menu", Wine],
["Happy Hour", "happy-hour", Clock],
["Tồn kho", "inventory", Warehouse],
@@ -166,9 +166,9 @@ export const shopSections = {
["Thiết lập", "settings", Settings]
],
spa: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Nhân viên trị liệu", "therapists", UserCheck],
["Dịch vụ", "services", Sparkles],
["Combo dịch vụ", "combos", Layers],
@@ -183,13 +183,13 @@ export const shopSections = {
["Báo cáo", "reports", BarChart3],
["AI Assistant", "ai-chat", Bot],
["Lưu trữ", "drive", HardDrive],
["Hoá đơn in", "receipt-templates", ReceiptText],
["Thiết lập", "settings", Settings]
["Thiết lập", "settings", Settings],
["Hoá đơn in", "receipt-templates", ReceiptText]
],
beauty: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Liệu trình", "treatments", ClipboardList],
["Cam kết KH", "consent", FileCheck],
["Bác sĩ / CK", "doctors", Stethoscope],
@@ -210,9 +210,9 @@ export const shopSections = {
["Thiết lập", "settings", Settings]
],
retail: [
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Sản phẩm", "menu", Package],
["Tổng quan", "overview", LayoutDashboard],
["POS Bán hàng", "pos", Monitor],
["Sản phẩm", "menu", Package],
["Tồn kho", "inventory", Warehouse],
["Tài chính", "finance", TrendingUp],
["Nhân sự", "staff", Users],
@@ -285,9 +285,9 @@ export const posWorkflows: Record<VerticalKind | "shared", Array<{ slug: string;
{ slug: "follow-up", title: "Follow-up", description: "Nhắc tái khám sau liệu trình.", icon: Bell }
],
retail: [
{ slug: "product-search", title: "Product search", description: "Tìm SKU/barcode và kiểm tra giá.", icon: Package },
{ slug: "stock-check", title: "Stock check", description: "Kiểm tra tồn kho realtime.", icon: Warehouse },
{ slug: "return-exchange", title: "Return / exchange", description: "Đổi trả và bù trừ bill.", icon: ReceiptText }
{ slug: "product-search", title: "Tìm sản phẩm/SKU", description: "Tìm SKU/barcode và kiểm tra giá.", icon: Package },
{ slug: "stock-check", title: "Kiểm tồn kho", description: "Kiểm tra tồn kho realtime.", icon: Warehouse },
{ slug: "return-exchange", title: "Đổi trả", description: "Đổi trả và bù trừ bill.", icon: ReceiptText }
],
shared: [
{ slug: "method-select", title: "Chọn phương thức", description: "Chọn tiền mặt, thẻ, QR, chuyển khoản hoặc gift card.", icon: CreditCard },
@@ -299,18 +299,23 @@ export const posWorkflows: Record<VerticalKind | "shared", Array<{ slug: string;
{ slug: "partial-payment", title: "Thanh toán một phần", description: "Tách nhiều phương thức thanh toán trên cùng hóa đơn.", icon: ReceiptText },
{ slug: "payment-pending", title: "Chờ thanh toán", description: "Theo dõi giao dịch đang chờ xác nhận.", icon: Clock },
{ slug: "payment-success", title: "Thanh toán thành công", description: "Hoàn tất hóa đơn, in biên lai và cập nhật đơn.", icon: CheckSquare },
{ slug: "receipt", title: "Biên lai", description: "In lại biên lai 80mm từ đơn đã thanh toán.", icon: ReceiptText },
{ slug: "tip", title: "Tip", description: "Ghi nhận tiền tip khi ledger ca được cấu hình.", icon: Gift },
{ slug: "order-edit", title: "Sửa đơn", description: "Điều chỉnh món, số lượng, ghi chú và phụ thu.", icon: ClipboardList },
{ slug: "discount", title: "Giảm giá", description: "Áp voucher, khuyến mãi hoặc chiết khấu thủ công.", icon: Tag },
{ slug: "customer-select", title: "Chọn khách hàng", description: "Gắn hội viên, tích điểm và lịch sử mua hàng.", icon: Heart },
{ slug: "table-transfer", title: "Chuyển bàn/phòng", description: "Chuyển order giữa bàn, phòng hoặc khu vực.", icon: Grid3X3 },
{ slug: "stock-check", title: "Kiểm kho", description: "Stock in/out/transfer và kiểm tồn nhanh.", icon: Warehouse },
{ slug: "product-search", title: "Tra SKU/giá", description: "Quét mã, kiểm giá và trạng thái bán.", icon: Package },
{ slug: "cash-drawer", title: "Cash drawer", description: "Mở két, kiểm ca, đối soát tiền mặt.", icon: CreditCard },
{ slug: "shift", title: "Shift management", description: "Mở/đóng ca bán.", icon: CalendarClock },
{ slug: "pending-orders", title: "Pending orders", description: "Đơn chờ xử lý.", icon: ClipboardList },
{ slug: "quick-sale", title: "Quick sale", description: "Bán nhanh không cần SKU.", icon: Monitor },
{ slug: "split-bill", title: "Split bill", description: "Tách/gộp hóa đơn.", icon: ReceiptText },
{ slug: "void-refund", title: "Void/refund", description: "Hủy, hoàn tiền và lý do.", icon: FileText },
{ slug: "stock-in", title: "Nhập kho", description: "Ghi nhận nhập kho và cập nhật tồn.", icon: Warehouse },
{ slug: "stock-out", title: "Xuất kho", description: "Ghi nhận xuất kho hoặc hao hụt.", icon: Warehouse },
{ slug: "stock-transfer", title: "Chuyển kho", description: "Chuyển tồn giữa kho/khu vực khi có mô hình kho.", icon: Warehouse },
{ slug: "product-search", title: "Tìm món/SKU", description: "Quét mã, kiểm giá và trạng thái bán.", icon: Package },
{ slug: "cash-drawer", title: "Két tiền", description: "Mở két, kiểm ca, đối soát tiền mặt.", icon: CreditCard },
{ slug: "shift", title: "Ca làm", description: "Mở/đóng ca bán.", icon: CalendarClock },
{ slug: "pending-orders", title: "Đơn chờ xử lý", description: "Đơn chờ xử lý.", icon: ClipboardList },
{ slug: "quick-sale", title: "Bán nhanh", description: "Bán nhanh không cần SKU.", icon: Monitor },
{ slug: "split-bill", title: "Tách hóa đơn", description: "Tách/gộp hóa đơn.", icon: ReceiptText },
{ slug: "void-refund", title: "Hủy / hoàn tiền", description: "Hủy, hoàn tiền và lý do.", icon: FileText },
{ slug: "tablet", title: "Tablet POS", description: "Biến thể giao diện tablet của POS gốc.", icon: Monitor },
{ slug: "mobile", title: "Mobile POS", description: "Biến thể giao diện mobile của POS gốc.", icon: Smartphone }
]

View File

@@ -0,0 +1,36 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSessionUser, sessionCookieName } from "@/server/services/parity";
type PortalRole = "admin" | "staff" | "superadmin" | "marketing";
type SessionUser = NonNullable<Awaited<ReturnType<typeof getSessionUser>>>;
export async function requirePortalRole(roles: PortalRole[], returnPath: string, shopId?: string | null) {
const token = (await cookies()).get(sessionCookieName())?.value ?? null;
const user = await getSessionUser(token);
const returnUrl = encodeURIComponent(returnPath || "/");
if (!user) redirect(`/auth/login?returnUrl=${returnUrl}`);
const hasRole = user.roles.some((role) => roles.includes(role.code as PortalRole) || role.code === "superadmin");
if (!hasRole) redirect(`/auth/login?returnUrl=${returnUrl}&error=forbidden`);
if (shopId && !canAccessPortalShop(user, shopId)) {
redirect(`/auth/login?returnUrl=${returnUrl}&error=shop`);
}
return user;
}
export function portalShopId(user: SessionUser, roleCode?: PortalRole) {
return user.roles.find((role) => (!roleCode || role.code === roleCode) && role.shop_id)?.shop_id ?? user.defaultShopId ?? null;
}
export function filterPortalShops<T extends { id: string }>(user: SessionUser, shops: T[]) {
if (user.roles.some((role) => role.code === "superadmin")) return shops;
const allowed = new Set(user.roles.map((role) => role.shop_id).filter(Boolean));
return shops.filter((shop) => allowed.has(shop.id));
}
function canAccessPortalShop(user: SessionUser, shopId: string) {
return user.roles.some((role) => role.code === "superadmin" || ((role.code === "admin" || role.code === "staff" || role.code === "marketing") && role.shop_id === shopId));
}

View File

@@ -31,8 +31,12 @@ const int = (value: unknown) => Number.parseInt(String(value ?? 0), 10);
const maybeDate = (value: unknown) => (value instanceof Date ? value.toISOString() : String(value ?? ""));
const immediatePaymentMethods = new Set(["cash", "customer_order", "kitchen_order", "room_fnb"]);
function normalizePaymentMethod(method: string | null | undefined) {
return (method ?? "cash").toLowerCase().replace(/-/g, "_");
}
function assertImmediatePayment(method: string | null | undefined) {
const normalized = (method ?? "cash").toLowerCase();
const normalized = normalizePaymentMethod(method);
if (!immediatePaymentMethods.has(normalized)) {
throw new Error(`${method} payment requires a configured external payment adapter`);
}
@@ -52,6 +56,10 @@ function slugify(value: string) {
function mapShop(row: Record<string, unknown>): Shop {
const categoryId = int(row.category_id);
const statusId = int(row.status_id);
const features = row.features_config && typeof row.features_config === "object" && !Array.isArray(row.features_config)
? row.features_config as Record<string, unknown>
: {};
const activeDays = Array.isArray(features.activeDays) ? features.activeDays.map(String) : null;
return {
id: String(row.id),
merchantId: String(row.merchant_id),
@@ -65,6 +73,12 @@ function mapShop(row: Record<string, unknown>): Shop {
description: row.description ? String(row.description) : null,
phone: row.phone ? String(row.phone) : null,
email: row.email ? String(row.email) : null,
address: features.address ? String(features.address) : null,
district: features.district ? String(features.district) : null,
city: features.city ? String(features.city) : null,
openTime: features.openTime ? String(features.openTime) : null,
closeTime: features.closeTime ? String(features.closeTime) : null,
activeDays,
createdAt: maybeDate(row.created_at)
};
}
@@ -991,25 +1005,91 @@ export async function adjustStock(
typeId = 3
) {
return withTransaction(async (client) => {
const current = await client.query(`SELECT * FROM inventory_items WHERE id = $1`, [input.inventoryId]);
const quantity = Math.trunc(input.quantity);
if (!Number.isFinite(quantity) || quantity < 0) {
throw new Error("Quantity must be greater than or equal to zero");
}
const current = await client.query(`SELECT * FROM inventory_items WHERE id = $1 FOR UPDATE`, [input.inventoryId]);
if (!current.rows[0]) throw new Error("Inventory item not found");
const item = current.rows[0] as Record<string, unknown>;
const delta = input.quantity - int(item.quantity);
const delta = quantity - int(item.quantity);
const result = await client.query(
`UPDATE inventory_items
SET quantity = $2, updated_at = now()
WHERE id = $1
RETURNING *`,
[input.inventoryId, input.quantity]
);
await client.query(
`INSERT INTO inventory_transactions (id, inventory_item_id, type_id, quantity, notes)
VALUES ($1, $2, $3, $4, $5)`,
[randomUUID(), input.inventoryId, typeId, Math.abs(delta), input.notes?.trim() || "Stock adjusted from Next MVP"]
[input.inventoryId, quantity]
);
if (delta !== 0) {
await client.query(
`INSERT INTO inventory_transactions (id, inventory_item_id, type_id, quantity, notes)
VALUES ($1, $2, $3, $4, $5)`,
[randomUUID(), input.inventoryId, typeId, Math.abs(delta), input.notes?.trim() || "Stock adjusted from Next MVP"]
);
}
await logActivity(client, "inventory.stock.adjusted", "inventory_item", input.inventoryId, String(item.shop_id), {
delta,
quantity: input.quantity
quantity
});
return mapInventory(result.rows[0]);
});
}
export async function increaseStock(
input: { inventoryId: string; quantity: number; notes?: string | null; referenceId?: string | null; unitCost?: number | null },
typeId = 1
) {
return mutateStockDelta(input, Math.trunc(input.quantity), typeId, "inventory.stock.increased");
}
export async function decreaseStock(
input: { inventoryId: string; quantity: number; notes?: string | null; referenceId?: string | null; unitCost?: number | null },
typeId = 2
) {
return mutateStockDelta(input, -Math.trunc(input.quantity), typeId, "inventory.stock.decreased");
}
async function mutateStockDelta(
input: { inventoryId: string; quantity: number; notes?: string | null; referenceId?: string | null; unitCost?: number | null },
delta: number,
typeId: number,
activity: string
) {
return withTransaction(async (client) => {
const quantity = Math.abs(Math.trunc(input.quantity));
if (!Number.isFinite(input.quantity) || quantity <= 0) {
throw new Error("Quantity must be greater than zero");
}
const result = await client.query(
`UPDATE inventory_items
SET quantity = quantity + $2, updated_at = now()
WHERE id = $1
AND quantity + $2 >= 0
RETURNING *`,
[input.inventoryId, delta]
);
if (!result.rows[0]) {
const current = await client.query(`SELECT quantity FROM inventory_items WHERE id = $1`, [input.inventoryId]);
if (!current.rows[0]) throw new Error("Inventory item not found");
throw new Error("Insufficient inventory quantity");
}
const item = result.rows[0] as Record<string, unknown>;
await client.query(
`INSERT INTO inventory_transactions (id, inventory_item_id, type_id, quantity, reference_id, notes, unit_cost)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
randomUUID(),
input.inventoryId,
typeId,
quantity,
input.referenceId ?? null,
input.notes?.trim() || (delta > 0 ? "Stock in from Next MVP" : "Stock out from Next MVP"),
input.unitCost ?? null
]
);
await logActivity(client, activity, "inventory_item", input.inventoryId, String(item.shop_id), {
delta,
quantity: int(item.quantity)
});
return mapInventory(result.rows[0]);
});
@@ -1320,7 +1400,21 @@ export async function getOrderById(orderId: string, shopId?: string | null) {
export async function cancelOrder(orderId: string, shopId?: string | null, reason?: string | null) {
return withTransaction(async (client) => {
const updated = await client.query(
const existing = await client.query<{ status_id: number }>(
`
SELECT status_id
FROM orders
WHERE id = $1 AND ($2::uuid IS NULL OR shop_id = $2::uuid)
FOR UPDATE
`,
[orderId, shopId || null]
);
if (!existing.rows[0]) throw new Error("Order not found");
const currentStatusId = int(existing.rows[0].status_id);
if (currentStatusId === 3 || currentStatusId === 5) {
throw new Error("Refund ledger is not configured for paid orders");
}
await client.query(
`
UPDATE orders
SET status_id = 6,
@@ -1330,7 +1424,6 @@ export async function cancelOrder(orderId: string, shopId?: string | null, reaso
`,
[orderId, reason ?? null, shopId || null]
);
if ((updated.rowCount ?? 0) === 0) throw new Error("Order not found");
await logActivity(client, "order.cancelled", "order", orderId, null, { reason: reason ?? null });
const refreshed = await client.query(
`
@@ -1440,13 +1533,13 @@ export async function payOrder(
[orderId, input.shopId || null]
);
if (!base.rows[0]) throw new Error("Order not found");
if (int(base.rows[0].status_id) === 3) {
if (int(base.rows[0].status_id) === 3 || int(base.rows[0].status_id) === 5) {
return refreshedOrder();
}
const total = money(base.rows[0].total_amount);
const amountTendered = input.amountTendered === undefined || input.amountTendered === null ? total : input.amountTendered;
const paymentMethod = input.paymentMethod ?? "cash";
const paymentMethod = normalizePaymentMethod(input.paymentMethod);
assertImmediatePayment(paymentMethod);
if (paymentMethod === "cash" && money(amountTendered) < total) {
throw new Error("Amount tendered must be greater than or equal to order total");
@@ -1514,7 +1607,7 @@ export async function payOrder(
`,
[
orderId,
input.paymentMethod ?? null,
paymentMethod,
amountTendered,
changeAmount,
input.shopId || null
@@ -1529,7 +1622,7 @@ export async function payOrder(
SELECT $1, id, shop_id, COALESCE($2, 'cash'), total_amount, $3, $4, 'Succeeded', transaction_id
FROM orders
WHERE id = $5`,
[randomUUID(), input.paymentMethod ?? null, amountTendered, changeAmount, orderId]
[randomUUID(), paymentMethod, amountTendered, changeAmount, orderId]
);
if (base.rows[0].table_id) {
@@ -1616,17 +1709,17 @@ export async function listActiveOrdersByTable(shopId?: string | null) {
export async function getPosDashboardMetrics(shopId?: string | null, period = "today") {
const today = new Date();
const periodTo = new Date(today.toISOString());
const periodFrom = (() => {
if (period === "week") {
const rangeFrom = (() => {
if (period === "week" || period === "7d") {
const date = new Date(today);
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - date.getDay());
date.setDate(date.getDate() - 6);
return date;
}
if (period === "month" || period === "30d") {
const date = new Date(today);
date.setDate(date.getDate() - 29);
date.setHours(0, 0, 0, 0);
date.setDate(1);
return date;
}
if (/^\d{4}-\d{2}-\d{2}T/i.test(period)) {
@@ -1639,75 +1732,160 @@ export async function getPosDashboardMetrics(shopId?: string | null, period = "t
return d;
})();
})();
const rangeFrom = (() => {
if (period === "30d") {
const d = new Date(today);
d.setDate(d.getDate() - 29);
d.setHours(0, 0, 0, 0);
return d;
}
return period === "today" || period === "week" || period === "month" ? periodFrom : periodFrom;
})();
const [ordersRow, topItems, allRows] = await Promise.all([
const [ordersRow, topItems, paymentRows, hourlyRows, recentRows] = await Promise.all([
query(
`
SELECT
COUNT(*)::int AS order_count,
COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE status_id IN (3, 5)
AND created_at >= $1
AND ($2::uuid IS NULL OR shop_id = $2::uuid)
COALESCE(SUM(o.total_amount), 0) AS revenue,
COALESCE(SUM(item_totals.items_sold), 0)::int AS items_sold
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(quantity)::int AS items_sold
FROM order_items
GROUP BY order_id
) item_totals ON item_totals.order_id = o.id
WHERE o.status_id IN (3, 5)
AND o.created_at >= $1
AND o.created_at <= $2
AND ($3::uuid IS NULL OR o.shop_id = $3::uuid)
`,
[rangeFrom, shopId || null]
[rangeFrom, periodTo, shopId || null]
),
query(
`
SELECT
oi.product_id AS product_id,
oi.product_name AS product_name,
SUM(oi.quantity)::int AS quantity
SUM(oi.quantity)::int AS quantity,
COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.status_id IN (3, 5)
AND o.created_at >= $1
AND ($2::uuid IS NULL OR o.shop_id = $2::uuid)
AND o.created_at <= $2
AND ($3::uuid IS NULL OR o.shop_id = $3::uuid)
GROUP BY oi.product_id, oi.product_name
ORDER BY SUM(oi.quantity) DESC, oi.product_name ASC
LIMIT 8
`,
[rangeFrom, shopId || null]
[rangeFrom, periodTo, shopId || null]
),
query(
`
SELECT o.*
SELECT
COALESCE(NULLIF(o.payment_method, ''), 'cash') AS method,
COUNT(*)::int AS count,
COALESCE(SUM(o.total_amount), 0) AS amount
FROM orders o
WHERE o.status_id IN (3,5)
WHERE o.status_id IN (3, 5)
AND o.created_at >= $1
AND ($2::uuid IS NULL OR o.shop_id = $2::uuid)
AND o.created_at <= $2
AND ($3::uuid IS NULL OR o.shop_id = $3::uuid)
GROUP BY COALESCE(NULLIF(o.payment_method, ''), 'cash')
ORDER BY COALESCE(SUM(o.total_amount), 0) DESC
`,
[rangeFrom, periodTo, shopId || null]
),
query(
`
WITH hours AS (
SELECT generate_series(0, 23) AS hour
),
revenue_by_hour AS (
SELECT
EXTRACT(HOUR FROM o.created_at)::int AS hour,
COALESCE(SUM(o.total_amount), 0) AS revenue,
COUNT(*)::int AS order_count
FROM orders o
WHERE o.status_id IN (3, 5)
AND o.created_at >= $1
AND o.created_at <= $2
AND ($3::uuid IS NULL OR o.shop_id = $3::uuid)
GROUP BY EXTRACT(HOUR FROM o.created_at)::int
)
SELECT
hours.hour,
COALESCE(revenue_by_hour.revenue, 0) AS revenue,
COALESCE(revenue_by_hour.order_count, 0)::int AS order_count
FROM hours
LEFT JOIN revenue_by_hour ON revenue_by_hour.hour = hours.hour
ORDER BY hours.hour
`,
[rangeFrom, periodTo, shopId || null]
),
query(
`
SELECT
o.id,
o.shop_id,
o.total_amount,
o.status_id,
os.name AS status_name,
o.payment_method,
o.created_at,
COALESCE(item_totals.item_count, 0)::int AS item_count
FROM orders o
LEFT JOIN order_statuses os ON os.id = o.status_id
LEFT JOIN (
SELECT order_id, SUM(quantity)::int AS item_count
FROM order_items
GROUP BY order_id
) item_totals ON item_totals.order_id = o.id
WHERE o.status_id IN (3, 5)
AND o.created_at >= $1
AND o.created_at <= $2
AND ($3::uuid IS NULL OR o.shop_id = $3::uuid)
ORDER BY o.created_at DESC
LIMIT 8
`,
[rangeFrom, shopId || null]
[rangeFrom, periodTo, shopId || null]
)
]);
const orderCount = int(ordersRow[0]?.order_count);
const revenue = money(ordersRow[0]?.revenue);
const itemsSold = int(ordersRow[0]?.items_sold);
return {
period,
fromDate: periodFrom.toISOString(),
fromDate: rangeFrom.toISOString(),
toDate: periodTo.toISOString(),
orderCount,
revenue,
averageTicket: orderCount > 0 ? revenue / orderCount : 0,
avgOrderValue: orderCount > 0 ? revenue / orderCount : 0,
itemsSold,
popularItems: topItems.map((item) => ({
productId: String(item.product_id),
productName: String(item.product_name),
quantity: int(item.quantity)
name: String(item.product_name),
quantity: int(item.quantity),
quantitySold: int(item.quantity),
qty: int(item.quantity),
revenue: money(item.revenue)
})),
recentPaidOrders: allRows.map((row) => ({
paymentBreakdown: paymentRows.map((row) => ({
method: String(row.method),
count: int(row.count),
amount: money(row.amount)
})),
hourlyRevenue: hourlyRows.map((row) => ({
hour: int(row.hour),
hourLabel: `${int(row.hour)}h`,
revenue: money(row.revenue),
orderCount: int(row.order_count)
})),
recentOrders: recentRows.map((row) => ({
id: String(row.id),
shopId: String(row.shop_id),
totalAmount: money(row.total_amount),
status: String(row.status_name ?? orderStatusNameById[int(row.status_id)] ?? "Paid"),
paymentMethod: row.payment_method ? String(row.payment_method) : null,
itemCount: int(row.item_count),
createdAt: maybeDate(row.created_at)
})),
recentPaidOrders: recentRows.map((row) => ({
id: String(row.id),
shopId: String(row.shop_id),
totalAmount: money(row.total_amount),
@@ -1848,9 +2026,9 @@ export async function createOrder(input: CreateOrderInput) {
const orderId = randomUUID();
const transactionId = deferPayment ? null : `POS-${Date.now()}-${orderId.slice(0, 8).toUpperCase()}`;
const amountTendered = deferPayment ? null : input.amountTendered ?? total;
const paymentMethod = input.paymentMethod ?? "cash";
if (!deferPayment) assertImmediatePayment(paymentMethod);
if (!deferPayment && paymentMethod === "cash" && money(amountTendered) < total) {
const paymentMethod = normalizePaymentMethod(input.paymentMethod);
if (!deferPayment) assertImmediatePayment(paymentMethod);
if (!deferPayment && paymentMethod === "cash" && money(amountTendered) < total) {
throw new Error("Amount tendered must be greater than or equal to order total");
}
const changeAmount = deferPayment ? null : Math.max(0, Number(amountTendered) - total);
@@ -1875,7 +2053,7 @@ export async function createOrder(input: CreateOrderInput) {
statusId,
total,
input.notes?.trim() || null,
input.paymentMethod || (deferPayment ? null : "cash"),
deferPayment && !input.paymentMethod ? null : paymentMethod,
transactionId,
amountTendered,
changeAmount,
@@ -1917,7 +2095,7 @@ export async function createOrder(input: CreateOrderInput) {
[line.id, orderId, line.productId, line.productName, line.productType, line.quantity, line.unitPrice, itemStatus]
);
if (line.productType === "PreparedFood" && input.paymentMethod !== "kitchen_order") {
if (line.productType === "PreparedFood" && paymentMethod !== "kitchen_order") {
await client.query(
`INSERT INTO barista_queue (id, shop_id, order_id, product_name, customer_name, status)
VALUES ($1, $2, $3, $4, $5, 'Pending')`,
@@ -1973,7 +2151,7 @@ export async function createOrder(input: CreateOrderInput) {
randomUUID(),
orderId,
input.shopId,
input.paymentMethod || "cash",
paymentMethod,
total,
amountTendered,
changeAmount,
@@ -1986,7 +2164,7 @@ export async function createOrder(input: CreateOrderInput) {
total,
subtotal,
discountAmount,
paymentMethod: input.paymentMethod,
paymentMethod,
itemCount: lines.length
});

View File

@@ -674,6 +674,22 @@ export async function createCoreSchema(pool: Pool) {
CREATE UNIQUE INDEX IF NOT EXISTS ix_tables_shop_table_number ON tables(shop_id, table_number);
CREATE INDEX IF NOT EXISTS ix_mvp_sessions_token_hash ON mvp_sessions(token_hash);
CREATE UNIQUE INDEX IF NOT EXISTS ux_mvp_user_roles_scope ON mvp_user_roles(user_id, role_id, COALESCE(shop_id, '00000000-0000-0000-0000-000000000000'::uuid));
WITH duplicate_staff_codes AS (
SELECT
id,
employee_code,
row_number() OVER (PARTITION BY shop_id, employee_code ORDER BY joined_at NULLS LAST, id) AS duplicate_rank
FROM staff_members
WHERE employee_code IS NOT NULL AND btrim(employee_code) <> ''
)
UPDATE staff_members staff
SET employee_code = left(duplicate_staff_codes.employee_code, 43) || '-' || substr(staff.id::text, 1, 6)
FROM duplicate_staff_codes
WHERE staff.id = duplicate_staff_codes.id
AND duplicate_staff_codes.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS ux_staff_members_shop_employee_code ON staff_members(shop_id, employee_code);
CREATE INDEX IF NOT EXISTS ix_staff_members_shop_id ON staff_members(shop_id);
CREATE INDEX IF NOT EXISTS ix_attendance_staff_id ON attendance_records(staff_id);
CREATE INDEX IF NOT EXISTS ix_members_shop_id ON members(shop_id);
@@ -688,6 +704,177 @@ export async function createCoreSchema(pool: Pool) {
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS ix_mvp_activity_created_at ON mvp_activity(created_at DESC);
DO $integrity_indexes$
BEGIN
IF to_regclass('public.ux_tables_qr_token_not_null') IS NULL THEN
IF NOT EXISTS (
SELECT 1
FROM tables
WHERE qr_token IS NOT NULL
GROUP BY qr_token
HAVING COUNT(*) > 1
) THEN
CREATE UNIQUE INDEX ux_tables_qr_token_not_null ON tables(qr_token) WHERE qr_token IS NOT NULL;
ELSE
RAISE NOTICE 'Skipping ux_tables_qr_token_not_null because duplicate qr_token values exist.';
END IF;
END IF;
IF to_regclass('public.ux_wallets_owner_currency') IS NULL THEN
IF NOT EXISTS (
SELECT 1
FROM wallets
GROUP BY owner_id, currency
HAVING COUNT(*) > 1
) THEN
CREATE UNIQUE INDEX ux_wallets_owner_currency ON wallets(owner_id, currency);
ELSE
RAISE NOTICE 'Skipping ux_wallets_owner_currency because duplicate owner/currency wallets exist.';
END IF;
END IF;
END
$integrity_indexes$;
DO $integrity_constraints$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_staff_members_user') THEN
ALTER TABLE staff_members
ADD CONSTRAINT fk_staff_members_user FOREIGN KEY (user_id) REFERENCES mvp_users(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_staff_members_shop') THEN
ALTER TABLE staff_members
ADD CONSTRAINT fk_staff_members_shop FOREIGN KEY (shop_id) REFERENCES shops(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_wallets_owner') THEN
ALTER TABLE wallets
ADD CONSTRAINT fk_wallets_owner FOREIGN KEY (owner_id) REFERENCES mvp_users(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_wallet_transactions_wallet') THEN
ALTER TABLE wallet_transactions
ADD CONSTRAINT fk_wallet_transactions_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_vouchers_campaign') THEN
ALTER TABLE vouchers
ADD CONSTRAINT fk_vouchers_campaign FOREIGN KEY (campaign_id) REFERENCES campaigns(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_vouchers_shop') THEN
ALTER TABLE vouchers
ADD CONSTRAINT fk_vouchers_shop FOREIGN KEY (shop_id) REFERENCES shops(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_vouchers_redeemed_order') THEN
ALTER TABLE vouchers
ADD CONSTRAINT fk_vouchers_redeemed_order FOREIGN KEY (redeemed_order_id) REFERENCES orders(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_storage_folders_shop') THEN
ALTER TABLE storage_folders
ADD CONSTRAINT fk_storage_folders_shop FOREIGN KEY (shop_id) REFERENCES shops(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_storage_folders_parent') THEN
ALTER TABLE storage_folders
ADD CONSTRAINT fk_storage_folders_parent FOREIGN KEY (parent_id) REFERENCES storage_folders(id) ON DELETE SET NULL NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_storage_files_shop') THEN
ALTER TABLE storage_files
ADD CONSTRAINT fk_storage_files_shop FOREIGN KEY (shop_id) REFERENCES shops(id) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_storage_files_folder') THEN
ALTER TABLE storage_files
ADD CONSTRAINT fk_storage_files_folder FOREIGN KEY (folder_id) REFERENCES storage_folders(id) ON DELETE SET NULL NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_products_price_nonnegative') THEN
ALTER TABLE products
ADD CONSTRAINT chk_products_price_nonnegative CHECK (price >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_items_quantity_nonnegative') THEN
ALTER TABLE inventory_items
ADD CONSTRAINT chk_inventory_items_quantity_nonnegative CHECK (quantity >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_items_reserved_quantity_nonnegative') THEN
ALTER TABLE inventory_items
ADD CONSTRAINT chk_inventory_items_reserved_quantity_nonnegative CHECK (reserved_quantity >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_items_reorder_level_nonnegative') THEN
ALTER TABLE inventory_items
ADD CONSTRAINT chk_inventory_items_reorder_level_nonnegative CHECK (reorder_level >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_items_cost_per_unit_nonnegative') THEN
ALTER TABLE inventory_items
ADD CONSTRAINT chk_inventory_items_cost_per_unit_nonnegative CHECK (cost_per_unit >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_transactions_quantity_nonnegative') THEN
ALTER TABLE inventory_transactions
ADD CONSTRAINT chk_inventory_transactions_quantity_nonnegative CHECK (quantity >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_inventory_transactions_unit_cost_nonnegative') THEN
ALTER TABLE inventory_transactions
ADD CONSTRAINT chk_inventory_transactions_unit_cost_nonnegative CHECK (unit_cost IS NULL OR unit_cost >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tables_capacity_nonnegative') THEN
ALTER TABLE tables
ADD CONSTRAINT chk_tables_capacity_nonnegative CHECK (capacity >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tables_hourly_rate_nonnegative') THEN
ALTER TABLE tables
ADD CONSTRAINT chk_tables_hourly_rate_nonnegative CHECK (hourly_rate >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_orders_amounts_nonnegative') THEN
ALTER TABLE orders
ADD CONSTRAINT chk_orders_amounts_nonnegative CHECK (
total_amount >= 0
AND discount_amount >= 0
AND (amount_tendered IS NULL OR amount_tendered >= 0)
AND (change_amount IS NULL OR change_amount >= 0)
) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_quantity_nonnegative') THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_quantity_nonnegative CHECK (quantity >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_unit_price_nonnegative') THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_unit_price_nonnegative CHECK (unit_price >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_payment_transactions_amounts_nonnegative') THEN
ALTER TABLE payment_transactions
ADD CONSTRAINT chk_payment_transactions_amounts_nonnegative CHECK (
amount >= 0
AND (amount_tendered IS NULL OR amount_tendered >= 0)
AND change_amount >= 0
) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_wallets_balance_nonnegative') THEN
ALTER TABLE wallets
ADD CONSTRAINT chk_wallets_balance_nonnegative CHECK (balance >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_campaigns_amounts_nonnegative') THEN
ALTER TABLE campaigns
ADD CONSTRAINT chk_campaigns_amounts_nonnegative CHECK (
face_value >= 0
AND discount_value >= 0
AND total_vouchers >= 0
AND issued_vouchers >= 0
) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_campaigns_date_range') THEN
ALTER TABLE campaigns
ADD CONSTRAINT chk_campaigns_date_range CHECK (start_date IS NULL OR end_date IS NULL OR end_date >= start_date) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_vouchers_discount_value_nonnegative') THEN
ALTER TABLE vouchers
ADD CONSTRAINT chk_vouchers_discount_value_nonnegative CHECK (discount_value >= 0) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_attendance_records_checkout_after_checkin') THEN
ALTER TABLE attendance_records
ADD CONSTRAINT chk_attendance_records_checkout_after_checkin CHECK (check_out_at IS NULL OR check_out_at >= check_in_at) NOT VALID;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_storage_files_byte_size_nonnegative') THEN
ALTER TABLE storage_files
ADD CONSTRAINT chk_storage_files_byte_size_nonnegative CHECK (byte_size >= 0) NOT VALID;
END IF;
END
$integrity_constraints$;
INSERT INTO mvp_roles (id, code, name, portal) VALUES
(gen_random_uuid(), 'superadmin', 'Super Admin', 'superadmin'),
(gen_random_uuid(), 'admin', 'Quản trị cửa hàng', 'admin'),

View File

@@ -11,6 +11,12 @@ export type Shop = {
description: string | null;
phone: string | null;
email: string | null;
address: string | null;
district: string | null;
city: string | null;
openTime: string | null;
closeTime: string | null;
activeDays: string[] | null;
createdAt: string;
};

View File

@@ -169,16 +169,65 @@ export async function uploadS3Object(key: string, file: File, accessLevel = "pub
return publicBase ? `${publicBase}/${key}` : `${endpoint}/${bucket}/${key}`;
}
export function s3DeleteConfigStatus() {
const required = ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"] as const;
const present = required.filter((name) => Boolean(process.env[name]));
return {
configured: present.length === required.length,
missing: required.filter((name) => !process.env[name]),
hasAny: present.length > 0
};
}
export async function deleteS3Object(key: string) {
const endpoint = requireEnv("S3_ENDPOINT").replace(/\/+$/, "");
const region = requireEnv("S3_REGION");
const bucket = requireEnv("S3_BUCKET");
const accessKey = requireEnv("S3_ACCESS_KEY_ID");
const secretKey = requireEnv("S3_SECRET_ACCESS_KEY");
const now = awsDate();
const date = now.slice(0, 8);
const path = `/${bucket}/${key}`;
const host = new URL(endpoint).host;
const payloadHash = sha256Hex("");
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonical = [
"DELETE",
path,
"",
`host:${host}`,
`x-amz-content-sha256:${payloadHash}`,
`x-amz-date:${now}`,
"",
signedHeaders,
payloadHash
].join("\n");
const scope = `${date}/${region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", now, scope, sha256Hex(canonical)].join("\n");
const signature = hmacHex(signingKey(secretKey, date, region, "s3"), stringToSign);
const response = await fetch(`${endpoint}${path}`, {
method: "DELETE",
headers: {
"x-amz-content-sha256": payloadHash,
"x-amz-date": now,
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
}
});
if (!response.ok) {
throw new Error(`S3 delete failed (${response.status}): ${await response.text()}`);
}
}
export function providerCredentialStatus() {
return {
openai: Boolean(process.env.OPENAI_API_KEY),
anthropic: Boolean(process.env.ANTHROPIC_API_KEY),
openrouter: Boolean(process.env.OPENROUTER_API_KEY),
s3: Boolean(process.env.S3_ENDPOINT && process.env.S3_BUCKET && process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY),
facebook: Boolean(process.env.FACEBOOK_PAGE_TOKEN),
s3: Boolean(process.env.S3_ENDPOINT && process.env.S3_REGION && process.env.S3_BUCKET && process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY),
facebook: Boolean(process.env.FACEBOOK_PAGE_TOKEN && process.env.FACEBOOK_PAGE_ID),
zalo: Boolean(process.env.ZALO_OA_ACCESS_TOKEN),
whatsapp: Boolean(process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_NUMBER_ID),
x: Boolean(process.env.X_API_KEY && process.env.X_API_SECRET && process.env.X_ACCESS_TOKEN && process.env.X_ACCESS_SECRET)
x: false
};
}

View File

@@ -2,7 +2,9 @@ import {
adjustStock,
createInventoryItem,
deleteInventoryItem,
decreaseStock,
getInventoryItem,
increaseStock,
listInventory,
listInventoryTransactions,
listInventory as _listInventory,
@@ -44,16 +46,15 @@ export async function getInventoryService(inventoryId: string) {
}
async function mutateStock(input: MutationInput, typeId: number) {
const current = await getInventoryItem(input.inventoryId);
if (!current) {
throw new Error("Inventory item not found");
const resolvedQuantity = Number(input.quantity);
if (!Number.isFinite(resolvedQuantity) || resolvedQuantity < 0) {
throw new Error("Quantity must be greater than or equal to zero");
}
const resolvedQuantity = Number.isFinite(input.quantity) ? input.quantity : 0;
const notes = input.notes && input.notes.trim().length > 0 ? input.notes.trim() : undefined;
const baseInput = {
inventoryId: input.inventoryId,
quantity: resolvedQuantity,
quantity: Math.trunc(resolvedQuantity),
...(notes ? { notes } : {})
};
@@ -61,34 +62,50 @@ async function mutateStock(input: MutationInput, typeId: number) {
return item;
}
function positiveQuantity(input: MutationInput) {
const quantity = Number(input.quantity);
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error("Quantity must be greater than zero");
}
return Math.trunc(quantity);
}
function mutationMetadata(input: MutationInput, fallbackNotes: string) {
const notes = input.notes && input.notes.trim().length > 0 ? input.notes.trim() : fallbackNotes;
return {
inventoryId: input.inventoryId,
quantity: positiveQuantity(input),
notes,
referenceId: input.referenceId ?? null,
unitCost: input.unitCost ?? null
};
}
export async function stockIn(input: MutationInput) {
const current = await getInventoryItem(input.inventoryId);
if (!current) throw new Error("Inventory item not found");
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
return mutateStock({ ...input, quantity: current.quantity + requested }, 1);
return increaseStock(mutationMetadata(input, "Stock in"));
}
export async function stockOut(input: MutationInput) {
const current = await getInventoryItem(input.inventoryId);
if (!current) throw new Error("Inventory item not found");
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
return mutateStock({ ...input, quantity: Math.max(0, current.quantity - requested) }, 2);
return decreaseStock(mutationMetadata(input, "Stock out"));
}
export async function inventoryAdjust(input: MutationInput) {
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
const requested = Number(input.quantity);
if (!Number.isFinite(requested) || requested < 0) {
throw new Error("Quantity must be greater than or equal to zero");
}
return mutateStock({ ...input, quantity: requested }, 3);
}
export async function recordWastage(input: MutationInput) {
const current = await getInventoryItem(input.inventoryId);
if (!current) throw new Error("Inventory item not found");
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
return mutateStock({ ...input, quantity: Math.max(0, current.quantity - requested) }, 3);
return decreaseStock(mutationMetadata(input, input.notes ?? "Wastage"), 2);
}
export async function stocktake(input: MutationInput) {
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
const requested = Number(input.quantity);
if (!Number.isFinite(requested) || requested < 0) {
throw new Error("Quantity must be greater than or equal to zero");
}
return mutateStock({ ...input, quantity: requested }, 3);
}

View File

@@ -11,7 +11,7 @@ import {
import type { OrderFilters, PagedOrderResult } from "../domain/types";
export type OrderListFilter = OrderFilters & {
filter?: "today" | "week" | "month" | "all" | "30d";
filter?: "today" | "week" | "7d" | "month" | "all" | "30d";
};
export type OrderCreateInput = Parameters<typeof createOrder>[0];
@@ -28,21 +28,14 @@ function normalizeDateWindow(filter?: OrderListFilter["filter"], fromDate?: stri
return { fromDate: start.toISOString(), toDate: now.toISOString() };
}
if (filter === "week") {
if (filter === "week" || filter === "7d") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() - start.getDay());
start.setDate(start.getDate() - 6);
return { fromDate: start.toISOString(), toDate: now.toISOString() };
}
if (filter === "month") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
start.setDate(1);
return { fromDate: start.toISOString(), toDate: now.toISOString() };
}
if (filter === "30d") {
if (filter === "month" || filter === "30d") {
const start = new Date(now);
start.setDate(start.getDate() - 29);
start.setHours(0, 0, 0, 0);
@@ -100,6 +93,6 @@ export async function listActiveOrdersByTableService(shopId?: string | null) {
return listActiveOrdersByTable(shopId);
}
export async function getPosDashboardService(shopId?: string | null, period: "today" | "week" | "month" | "30d" = "today") {
export async function getPosDashboardService(shopId?: string | null, period: "today" | "week" | "7d" | "month" | "30d" = "today") {
return getPosDashboardMetrics(shopId, period);
}

View File

@@ -24,6 +24,12 @@ function stringValue(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function requireStringValue(value: unknown, name: string) {
const text = stringValue(value);
if (!text) throw new Error(`${name} is required`);
return text;
}
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
@@ -67,6 +73,7 @@ export async function seedParityData(defaultShopId?: string) {
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email) DO UPDATE
SET display_name = EXCLUDED.display_name,
password_hash = EXCLUDED.password_hash,
default_shop_id = EXCLUDED.default_shop_id,
updated_at = now()`,
[userId, email, hashPassword(password), displayName, roleCode === "superadmin" ? null : shop.id]
@@ -84,10 +91,17 @@ export async function seedParityData(defaultShopId?: string) {
const staffUser = await query<{ id: string }>(`SELECT id FROM mvp_users WHERE email = 'staff@goodgo.vn'`);
await query(
`INSERT INTO staff_members (id, user_id, shop_id, employee_code, first_name, last_name, phone, email, role)
VALUES ($1, $2, $3, 'ST-001', 'Nhân viên', 'POS', '0901000001', 'staff@goodgo.vn', 'Thu ngân')
ON CONFLICT DO NOTHING`,
[randomUUID(), staffUser[0]?.id ?? randomUUID(), shop.id]
`INSERT INTO staff_members (id, user_id, shop_id, employee_code, first_name, last_name, phone, email, role, status)
VALUES ($1, $2, $3, 'ST-001', 'Nhân viên', 'POS', '0901000001', 'staff@goodgo.vn', 'Thu ngân', 'active')
ON CONFLICT (shop_id, employee_code) DO UPDATE
SET user_id = EXCLUDED.user_id,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
role = EXCLUDED.role,
status = 'active'`,
[randomUUID(), staffUser[0]?.id ?? null, shop.id]
);
const staff = await query<{ id: string }>(`SELECT id FROM staff_members WHERE shop_id = $1 ORDER BY joined_at LIMIT 1`, [shop.id]);
@@ -221,7 +235,7 @@ export async function seedParityData(defaultShopId?: string) {
}
}
export async function loginUser(email: string, password: string) {
export async function loginUser(email: string, password: string, requestedRole?: string | null) {
const users = await query<{
id: string;
email: string;
@@ -241,6 +255,10 @@ export async function loginUser(email: string, password: string) {
}
const roles = await getUserRoles(user.id);
const normalizedRole = requestedRole === "branch" || requestedRole === "owner" ? "admin" : stringValue(requestedRole);
if (normalizedRole && !roles.some((role) => role.code === normalizedRole || role.portal === normalizedRole)) {
throw new Error("Tài khoản không có quyền truy cập portal này");
}
const token = randomUUID() + randomUUID();
await query(
`INSERT INTO mvp_sessions (id, user_id, token_hash, expires_at)
@@ -274,6 +292,25 @@ export async function getUserRoles(userId: string) {
);
}
export async function assignUserShopRole(userId: string, shopId: string, roleCode = "admin") {
const role = await roleId(roleCode);
await query(
`INSERT INTO mvp_user_roles (user_id, role_id, shop_id)
VALUES ($1, $2, $3::uuid)
ON CONFLICT DO NOTHING`,
[userId, role, shopId]
);
await query(
`UPDATE mvp_users
SET default_shop_id = COALESCE(default_shop_id, $2::uuid),
updated_at = now()
WHERE id = $1`,
[userId, shopId]
);
await audit("auth.role.assigned", "shop", shopId, { userId, roleCode });
return { userId, shopId, roleCode };
}
export async function listRoles(portal?: string | null) {
return query<{ id: string; code: string; name: string; portal: string; created_at: string }>(
`SELECT id, code, name, portal, created_at
@@ -551,6 +588,11 @@ export async function listLeaveRequests(shopId?: string | null, staffId?: string
);
}
export async function getLeaveRequest(id: string) {
const rows = await query(`SELECT * FROM leave_requests WHERE id = $1 LIMIT 1`, [id]);
return rows[0] ?? null;
}
export async function createLeaveRequest(input: JsonRecord) {
const staffId = stringValue(input.staffId);
if (!staffId) throw new Error("staffId is required");
@@ -749,8 +791,14 @@ export async function addExperience(memberId: string, points: number, source = "
});
}
export async function listCampaigns() {
return query(`SELECT * FROM campaigns ORDER BY created_at DESC`);
export async function listCampaigns(shopId?: string | null) {
return query(
`SELECT *
FROM campaigns
WHERE ($1::uuid IS NULL OR shop_id = $1::uuid)
ORDER BY created_at DESC`,
[shopId || null]
);
}
export async function getCampaign(campaignId: string) {
@@ -828,6 +876,11 @@ export async function listVouchers(shopId?: string | null) {
);
}
export async function getVoucher(voucherId: string) {
const rows = await query(`SELECT * FROM vouchers WHERE id = $1 LIMIT 1`, [voucherId]);
return rows[0] ?? null;
}
export async function validateVoucher(code: string, shopId?: string | null) {
if (!shopId) return { valid: false, message: "Thiếu thông tin cửa hàng" };
const rows = await query(
@@ -874,13 +927,15 @@ export async function redeemVoucher(input: JsonRecord) {
return { voucherId, status: "redeemed" };
}
export async function revokeVoucher(voucherId: string) {
export async function revokeVoucher(voucherId: string, shopId?: string | null) {
const updated = await query(
`UPDATE vouchers
SET status = 'revoked'
WHERE id = $1 AND status <> 'revoked'
WHERE id = $1
AND ($2::uuid IS NULL OR shop_id = $2::uuid)
AND status <> 'revoked'
RETURNING id`,
[voucherId]
[voucherId, shopId || null]
);
if (!updated[0]) throw new Error("Voucher not found");
return { voucherId, status: "revoked" };
@@ -930,6 +985,11 @@ export async function listAppointments(shopId: string, date?: string | null) {
);
}
export async function getAppointment(id: string) {
const rows = await query(`SELECT * FROM appointments WHERE id = $1 LIMIT 1`, [id]);
return rows[0] ?? null;
}
export async function createAppointment(input: JsonRecord) {
const id = randomUUID();
const start = stringValue(input.startTime) ? new Date(String(input.startTime)) : new Date(Date.now() + DAY_MS);
@@ -987,6 +1047,11 @@ export async function listReservations(shopId: string, date?: string | null) {
);
}
export async function getReservation(id: string) {
const rows = await query(`SELECT * FROM reservations WHERE id = $1 LIMIT 1`, [id]);
return rows[0] ?? null;
}
export async function createReservation(input: JsonRecord) {
const id = randomUUID();
await query(
@@ -1023,6 +1088,11 @@ export async function listKitchenTickets(shopId?: string | null, status?: string
);
}
export async function getKitchenTicket(id: string) {
const rows = await query(`SELECT * FROM kitchen_tickets WHERE id = $1 LIMIT 1`, [id]);
return rows[0] ?? null;
}
export async function createKitchenTicket(input: JsonRecord) {
const id = randomUUID();
await query(
@@ -1050,6 +1120,11 @@ export async function listBaristaQueue(shopId: string) {
return query(`SELECT * FROM barista_queue WHERE shop_id = $1 ORDER BY created_at ASC`, [shopId]);
}
export async function getBaristaQueueItem(id: string) {
const rows = await query(`SELECT * FROM barista_queue WHERE id = $1 LIMIT 1`, [id]);
return rows[0] ?? null;
}
export async function baristaStats(shopId: string) {
const rows = await query(
`SELECT
@@ -1076,29 +1151,35 @@ export async function updateBaristaQueue(id: string, status: string, baristaName
return { id, status };
}
export async function listFiles(shopId?: string | null) {
export async function listFiles(shopId: string) {
return query(
`SELECT * FROM storage_files
WHERE ($1::uuid IS NULL OR shop_id = $1::uuid)
WHERE shop_id = $1::uuid
ORDER BY created_at DESC`,
[shopId || null]
[shopId]
);
}
export async function getFileRecord(fileId: string) {
const rows = await query(`SELECT * FROM storage_files WHERE id = $1 LIMIT 1`, [fileId]);
export async function getFileRecord(fileId: string, shopId: string) {
const rows = await query(`SELECT * FROM storage_files WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [fileId, shopId]);
return rows[0] ?? null;
}
export async function createFileRecord(input: JsonRecord) {
const id = randomUUID();
const shopId = requireStringValue(input.shopId, "shopId");
const folderId = stringValue(input.folderId);
if (folderId) {
const folder = await query(`SELECT id FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [folderId, shopId]);
if (!folder[0]) throw new Error("Folder not found for shop");
}
await query(
`INSERT INTO storage_files (id, shop_id, folder_id, file_name, content_type, byte_size, object_key, access_level, public_url, provider)
VALUES ($1, $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10)`,
[
id,
stringValue(input.shopId),
stringValue(input.folderId),
shopId,
folderId,
stringValue(input.fileName) ?? "file",
stringValue(input.contentType),
intValue(input.byteSize),
@@ -1111,54 +1192,78 @@ export async function createFileRecord(input: JsonRecord) {
return { id };
}
export async function updateFileRecord(fileId: string, input: JsonRecord) {
export async function updateFileRecord(fileId: string, shopId: string, input: JsonRecord) {
const folderId = stringValue(input.folderId);
if (folderId) {
const folder = await query(`SELECT id FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [folderId, shopId]);
if (!folder[0]) throw new Error("Folder not found for shop");
}
const updated = await query(
`UPDATE storage_files
SET file_name = COALESCE($2, file_name),
folder_id = COALESCE($3::uuid, folder_id),
access_level = COALESCE($4, access_level)
WHERE id = $1
WHERE id = $1 AND shop_id = $5::uuid
RETURNING *`,
[
fileId,
stringValue(input.fileName) ?? stringValue(input.name),
stringValue(input.folderId),
stringValue(input.accessLevel)
folderId,
stringValue(input.accessLevel),
shopId
]
);
if (!updated[0]) throw new Error("File not found");
return updated[0];
}
export async function deleteFileRecord(fileId: string) {
export async function deleteFileRecord(fileId: string, shopId: string) {
const deleted = await withTransaction(async (client) => {
const result = await client.query(`DELETE FROM storage_files WHERE id = $1 RETURNING id, object_key`, [fileId]);
const result = await client.query(`DELETE FROM storage_files WHERE id = $1 AND shop_id = $2::uuid RETURNING id, object_key`, [fileId, shopId]);
return result.rows[0] as { id: string; object_key: string } | undefined;
});
if (!deleted) throw new Error("File not found");
return { id: fileId, deleted: true, objectKey: deleted.object_key };
}
export async function listFolders(parentId?: string | null) {
export async function listFolders(shopId: string, parentId?: string | null) {
if (parentId) {
const parent = await query(`SELECT id FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [parentId, shopId]);
if (!parent[0]) throw new Error("Parent folder not found for shop");
}
return query(
`SELECT * FROM storage_folders WHERE ($1::uuid IS NULL OR parent_id = $1::uuid) ORDER BY name`,
[parentId || null]
`SELECT * FROM storage_folders
WHERE shop_id = $1::uuid
AND ($2::uuid IS NULL OR parent_id = $2::uuid)
ORDER BY name`,
[shopId, parentId || null]
);
}
export async function getFolder(folderId: string, shopId: string) {
const rows = await query(`SELECT * FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [folderId, shopId]);
return rows[0] ?? null;
}
export async function createFolder(input: JsonRecord) {
const id = randomUUID();
const shopId = requireStringValue(input.shopId, "shopId");
const parentId = stringValue(input.parentId);
if (parentId) {
const parent = await query(`SELECT id FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid LIMIT 1`, [parentId, shopId]);
if (!parent[0]) throw new Error("Parent folder not found for shop");
}
await query(
`INSERT INTO storage_folders (id, shop_id, parent_id, name)
VALUES ($1, $2, $3::uuid, $4)`,
[id, stringValue(input.shopId), stringValue(input.parentId), stringValue(input.name) ?? "Thư mục"]
[id, shopId, parentId, stringValue(input.name) ?? "Thư mục"]
);
return { id };
}
export async function deleteFolder(folderId: string) {
export async function deleteFolder(folderId: string, shopId: string) {
const deleted = await withTransaction(async (client) => {
const result = await client.query(`DELETE FROM storage_folders WHERE id = $1 RETURNING id`, [folderId]);
const result = await client.query(`DELETE FROM storage_folders WHERE id = $1 AND shop_id = $2::uuid RETURNING id`, [folderId, shopId]);
return result.rows[0] as { id: string } | undefined;
});
if (!deleted) throw new Error("Folder not found");