Harden TPOS MVP payment, stock, and portal parity
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 và 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 và rõ rà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>Đã có 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>
|
||||
|
||||
@@ -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>Mã đơ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 • ví 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} />Ví 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 ví gần đây</h3>
|
||||
</div>
|
||||
<div className="admin-panel__body admin-table-wrap">
|
||||
{walletTransactions.length ? (
|
||||
<table className="admin-data-table">
|
||||
<thead><tr><th>Mô 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>Mã đơ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;
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
36
microservices/apps/tpos-mvp-next/src/server/auth/portal.ts
Normal file
36
microservices/apps/tpos-mvp-next/src/server/auth/portal.ts
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user