Implement TPOS parity UI and backend

This commit is contained in:
Ho Ngoc Hai
2026-05-24 00:17:20 +07:00
parent 76d75c753b
commit 7e647672c1
85 changed files with 12180 additions and 208 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -93,6 +93,10 @@ infra/traefik/certs/*
*storybook.log
storybook-static
# Local MVP app secrets
apps/tpos-mvp-next/.env.local
apps/tpos-mvp-next/.env*.local
# MAUI
obj
bin

View File

@@ -0,0 +1,28 @@
DATABASE_URL="postgresql://user:password@host:5432/database"
DATABASE_SSL="false"
SESSION_SECRET="replace-with-32-byte-secret"
AI_DEFAULT_PROVIDER="openai"
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-5.1"
ANTHROPIC_API_KEY=""
ANTHROPIC_MODEL="claude-sonnet-4-5-20250929"
OPENROUTER_API_KEY=""
OPENROUTER_MODEL="openai/gpt-5.1"
S3_ENDPOINT=""
S3_REGION="us-east-1"
S3_BUCKET=""
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
S3_PUBLIC_BASE_URL=""
FACEBOOK_APP_ID=""
FACEBOOK_APP_SECRET=""
FACEBOOK_PAGE_ID=""
FACEBOOK_PAGE_TOKEN=""
ZALO_OA_ID=""
ZALO_OA_ACCESS_TOKEN=""
WHATSAPP_ACCESS_TOKEN=""
WHATSAPP_PHONE_NUMBER_ID=""
X_API_KEY=""
X_API_SECRET=""
X_ACCESS_TOKEN=""
X_ACCESS_SECRET=""

View File

@@ -0,0 +1,36 @@
# GoodGo TPOS MVP Next.js
Next.js/TypeScript MVP that consolidates the core GoodGo microservices into one deployable app:
- Merchant + Shop management
- Catalog + category management
- POS checkout
- Orders + payment metadata
- Inventory and stock movements
- FnB tables
- Dashboard and health check
The app uses PostgreSQL directly and bootstraps the core tables idempotently on startup. Keep `DATABASE_URL` outside git; use the connection string provided for this project in your shell or local environment.
```bash
cd microservices/apps/tpos-mvp-next
DATABASE_URL="..." pnpm db:setup
DATABASE_URL="..." pnpm db:seed
DATABASE_URL="..." pnpm dev
```
Default dev URL: `http://localhost:3010`.
## MVP service mapping
This app keeps the public workflow close to the current microservices while deploying as one Next.js app:
- IAM/Merchant: workspace, shop, branch/staff-ready boundaries.
- Catalog: products, categories, SKU/barcode lookup surface.
- Inventory: stock items, stock transactions, low-stock control.
- Order: POS checkout, paid orders, discounts, cash tender/change.
- FnB Engine: tables/rooms and table status for cafe/restaurant/karaoke.
- Wallet/Promotion: MVP payment ledger and manual voucher/discount metadata.
- Booking: deferred domain for spa/beauty appointments, represented by service product types for now.
The BFF-compatible route group is `/api/bff/[...path]`; direct checkout uses `/api/orders`.

View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,13 @@
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("../..", import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
turbopack: {
root
}
};
export default nextConfig;

View File

@@ -0,0 +1,27 @@
{
"name": "@goodgo/tpos-mvp-next",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev -p 3010",
"build": "next build",
"start": "next start -p 3010",
"typecheck": "tsc --noEmit",
"db:setup": "tsx scripts/setup-db.ts",
"db:seed": "tsx scripts/seed.ts"
},
"dependencies": {
"lucide-react": "^1.16.0",
"next": "^16.2.6",
"pg": "^8.21.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3"
}
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path d="M18 18h28v9H18zM18 32h13v14H18zM36 32h10v14H36z" fill="#ff5c00"/>
<path d="M23 23h18M22 39h6M40 39h2" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1,100 @@
import { getPool } from "../src/server/db/pool";
import {
createCategory,
createInventoryItem,
createProduct,
createShop,
createTable,
listInventory,
listCategories,
listProducts,
listShops,
listTables
} from "../src/server/db/queries";
import { seedParityData } from "../src/server/services/parity";
const shops = await listShops();
const shop =
shops[0] ??
(await createShop({
name: "GoodGo Cafe MVP",
vertical: "cafe",
phone: "0900000000",
email: "ops@goodgo.vn",
description: "MVP shop created from Next.js seed"
}));
let categories = await listCategories(shop.id);
if (categories.length === 0) {
categories = [
await createCategory({ shopId: shop.id, name: "Coffee", displayOrder: 1 }),
await createCategory({ shopId: shop.id, name: "Tea", displayOrder: 2 }),
await createCategory({ shopId: shop.id, name: "Food", displayOrder: 3 })
];
}
const products = await listProducts(shop.id);
if (products.length === 0) {
await createProduct({
shopId: shop.id,
name: "Americano",
price: 45000,
vertical: shop.vertical,
categoryId: categories[0]?.id,
sku: "CF-AMERICANO",
initialQuantity: 40
});
await createProduct({
shopId: shop.id,
name: "Latte",
price: 59000,
vertical: shop.vertical,
categoryId: categories[0]?.id,
sku: "CF-LATTE",
initialQuantity: 35
});
await createProduct({
shopId: shop.id,
name: "Peach Tea",
price: 52000,
vertical: shop.vertical,
categoryId: categories[1]?.id,
sku: "TEA-PEACH",
initialQuantity: 28
});
await createProduct({
shopId: shop.id,
name: "Croissant",
price: 39000,
vertical: shop.vertical,
categoryId: categories[2]?.id,
sku: "FD-CROISSANT",
initialQuantity: 16
});
}
const tables = await listTables(shop.id);
if (tables.length === 0) {
await createTable({ shopId: shop.id, tableNumber: "A1", capacity: 2, zone: "Front" });
await createTable({ shopId: shop.id, tableNumber: "A2", capacity: 4, zone: "Front" });
await createTable({ shopId: shop.id, tableNumber: "B1", capacity: 6, zone: "Garden" });
}
const inventory = await listInventory(shop.id);
if (!inventory.some((item) => item.name === "Arabica beans")) {
await createInventoryItem({
shopId: shop.id,
name: "Arabica beans",
itemTypeId: 1,
unit: "kg",
costPerUnit: 240000,
quantity: 8,
reorderLevel: 5,
supplierName: "GoodGo Supply"
});
}
console.log(`Seed completed for shop: ${shop.name}`);
await seedParityData(shop.id);
console.log("Full TPOS parity seed completed");
await getPool().end();

View File

@@ -0,0 +1,6 @@
import { getPool } from "../src/server/db/pool";
import { setupDatabase } from "../src/server/db/queries";
await setupDatabase();
console.log("GoodGo TPOS MVP database schema is ready.");
await getPool().end();

View File

@@ -0,0 +1,115 @@
"use server";
import { revalidatePath } from "next/cache";
import { createShopService } from "@/server/services/shop";
import {
createCatalogCategory,
createCatalogProduct
} from "@/server/services/catalog";
import { inventoryAdjust, createInventoryItemService } from "@/server/services/inventory";
import { createTableService, updateTableStatusService } from "@/server/services/fnb";
function requiredText(formData: FormData, key: string) {
const value = String(formData.get(key) ?? "").trim();
if (!value) throw new Error(`${key} is required`);
return value;
}
function optionalText(formData: FormData, key: string) {
const value = String(formData.get(key) ?? "").trim();
return value.length ? value : null;
}
function numberValue(formData: FormData, key: string, fallback = 0) {
const raw = String(formData.get(key) ?? "");
const value = Number(raw);
return Number.isFinite(value) ? value : fallback;
}
export async function createShopAction(formData: FormData) {
await createShopService({
name: requiredText(formData, "name"),
vertical: requiredText(formData, "vertical"),
phone: optionalText(formData, "phone"),
email: optionalText(formData, "email"),
description: optionalText(formData, "description")
});
revalidatePath("/");
revalidatePath("/settings");
}
export async function createCategoryAction(formData: FormData) {
await createCatalogCategory({
shopId: requiredText(formData, "shopId"),
name: requiredText(formData, "name"),
description: optionalText(formData, "description"),
displayOrder: numberValue(formData, "displayOrder", 0)
});
revalidatePath("/catalog");
revalidatePath("/pos");
}
export async function createProductAction(formData: FormData) {
await createCatalogProduct({
shopId: requiredText(formData, "shopId"),
name: requiredText(formData, "name"),
price: numberValue(formData, "price", 0),
vertical: optionalText(formData, "vertical"),
categoryId: optionalText(formData, "categoryId"),
description: optionalText(formData, "description"),
sku: optionalText(formData, "sku"),
barcode: optionalText(formData, "barcode"),
initialQuantity: numberValue(formData, "initialQuantity", 0)
});
revalidatePath("/catalog");
revalidatePath("/inventory");
revalidatePath("/pos");
revalidatePath("/");
}
export async function createInventoryItemAction(formData: FormData) {
await createInventoryItemService({
shopId: requiredText(formData, "shopId"),
name: requiredText(formData, "name"),
itemTypeId: numberValue(formData, "itemTypeId", 1),
unit: requiredText(formData, "unit"),
costPerUnit: numberValue(formData, "costPerUnit", 0),
quantity: numberValue(formData, "quantity", 0),
reorderLevel: numberValue(formData, "reorderLevel", 10),
supplierName: optionalText(formData, "supplierName")
});
revalidatePath("/inventory");
revalidatePath("/");
}
export async function adjustStockAction(formData: FormData) {
await inventoryAdjust({
inventoryId: requiredText(formData, "inventoryId"),
quantity: numberValue(formData, "quantity", 0),
notes: optionalText(formData, "notes")
});
revalidatePath("/inventory");
revalidatePath("/catalog");
revalidatePath("/");
}
export async function createTableAction(formData: FormData) {
await createTableService({
shopId: requiredText(formData, "shopId"),
tableNumber: requiredText(formData, "tableNumber"),
capacity: numberValue(formData, "capacity", 2),
zone: optionalText(formData, "zone"),
hourlyRate: numberValue(formData, "hourlyRate", 0)
});
revalidatePath("/tables");
revalidatePath("/");
}
export async function updateTableStatusAction(formData: FormData) {
await updateTableStatusService(
requiredText(formData, "tableId"),
numberValue(formData, "statusId", 1)
);
revalidatePath("/tables");
revalidatePath("/pos");
}

View File

@@ -0,0 +1,68 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { getDashboardStats, listOrders } from "@/server/db/queries";
import { getShopService, listShopsService } from "@/server/services/shop";
import { listStaff, listCampaigns, listAppointments } from "@/server/services/parity";
export default async function AdminCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const shopId = path[0] === "shop" ? path[1] : undefined;
const shop = await getShopService(shopId);
const stats = await getDashboardStats(shop?.id);
const section = path[0] === "shop" ? path[2] ?? "overview" : path.join("/") || "dashboard";
const items = await loadItems(section, shop?.id);
return (
<TposPortal
kind="admin"
path={path}
payload={buildPortalPayload("admin", {
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
stats,
title: adminTitle(section),
items
})}
/>
);
}
async function loadItems(section: string, shopId?: string | null) {
if (section === "stores") {
const shops = await listShopsService();
return shops.map((shop) => ({ title: shop.name, meta: shop.category, value: shop.status, href: `/admin/shop/${shop.id}/overview` }));
}
if (section === "staff" || section === "users") {
const staff = await listStaff(shopId);
return staff.map((item) => ({ title: `${item.first_name ?? "Nhân viên"} ${item.last_name ?? ""}`, meta: String(item.role ?? "Staff"), value: String(item.status ?? "active") }));
}
if (section === "promotions") {
const campaigns = await listCampaigns();
return campaigns.map((item) => ({ title: String(item.name), meta: String(item.description ?? "Campaign"), value: String(item.status) }));
}
if (section === "appointments" || section === "spa/appointments") {
const appointments = shopId ? await listAppointments(shopId) : [];
return appointments.map((item) => ({ title: String(item.customer_name ?? "Khách"), meta: String(item.service_name ?? "Dịch vụ"), value: String(item.status) }));
}
const orders = await listOrders(shopId, 8);
return orders.map((order) => ({ title: order.id.slice(0, 8).toUpperCase(), meta: `${order.itemCount} món`, value: new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 }).format(order.totalAmount), href: `/orders` }));
}
function adminTitle(section: string) {
const labels: Record<string, string> = {
stores: "Quản lý cửa hàng",
"stores/create": "Tạo cửa hàng",
users: "Quản lý người dùng",
roles: "Vai trò & phân quyền",
"reports/eod": "Báo cáo cuối ngày",
settings: "Cài đặt admin",
overview: "Tổng quan cửa hàng",
menu: "Menu & sản phẩm",
inventory: "Kho hàng",
tables: "Bàn/phòng",
kitchen: "Bếp",
finance: "Tài chính",
promotions: "Khuyến mãi",
"ai-chat": "AI chat",
drive: "Drive lưu trữ"
};
return labels[section] ?? `TPOS ${section}`;
}

View File

@@ -0,0 +1,19 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { getDashboardStats } from "@/server/db/queries";
import { getShopService } from "@/server/services/shop";
export default async function AdminPage() {
const shop = await getShopService();
const stats = await getDashboardStats(shop?.id);
return (
<TposPortal
kind="admin"
path={[]}
payload={buildPortalPayload("admin", {
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
stats,
title: "Bảng điều khiển quản trị"
})}
/>
);
}

View File

@@ -0,0 +1,537 @@
import { cookies } from "next/headers";
import {
createCatalogCategory,
createCatalogProduct,
deleteCatalogCategory,
deleteCatalogProduct,
getCatalogProduct,
listCatalogCategories,
listCatalogCategoriesByShop,
listCatalogProducts,
listCatalogProductsByShop,
updateCatalogCategory,
updateCatalogProduct
} from "@/server/services/catalog";
import {
createTableService,
getTableFromToken,
listTablesByShop,
removeTableService,
regenerateTableQrService,
updateTableService,
updateTableStatusService
} from "@/server/services/fnb";
import {
createInventoryItemService,
deleteInventoryItemService,
inventoryAdjust,
listInventoryItems,
listInventoryWithTransactions,
stockIn,
stockOut,
updateInventoryItemService
} from "@/server/services/inventory";
import {
cancelOrderService,
createPosOrder,
getOrderService,
getPosDashboardService,
listActiveOrdersByTableService,
listOrdersService,
payOrderService
} from "@/server/services/order";
import {
createShopService,
getShopService,
getShopSettingsService,
listShopsService,
getShopStatsService,
updateShopSettingsService,
updateShopService
} from "@/server/services/shop";
import {
addExperience,
auditLogs,
baristaStats,
checkIn,
checkOut,
createAppointment,
createCampaign,
createFileRecord,
createFolder,
createKitchenTicket,
createLeaveRequest,
createMember,
createReservation,
createStaff,
getAiConfig,
getAttendance,
getSessionUser,
getStaffProfile,
listAppointments,
listBaristaQueue,
listCampaigns,
listFeatureFlags,
listFiles,
listFolders,
listKitchenTickets,
listLeaveRequests,
listMembers,
listMembershipLevels,
listPlans,
listRecipes,
listReservations,
listResources,
listSchedules,
listStaff,
listTherapists,
listUsers,
listWallets,
listWalletTransactions,
loginUser,
logoutSession,
platformStats,
redeemVoucher,
reportRevenue,
reportTopProducts,
saveAiConfig,
saveAiMessage,
sessionCookieName,
setCampaignStatus,
systemHealth,
updateAppointmentStatus,
updateBaristaQueue,
updateFeatureFlag,
updateKitchenTicket,
updateLeaveStatus,
updateReservationStatus,
validateVoucher
} from "@/server/services/parity";
import {
buildS3ObjectKey,
callConfiguredAi,
providerCredentialStatus,
publishSocial,
uploadS3Object
} from "@/server/integrations/external";
import { fail, ok } from "@/server/shared/api";
export const dynamic = "force-dynamic";
type RouteContext = {
params: Promise<{ path?: string[] }>;
};
async function readJson(request: Request) {
try {
return (await request.json()) as Record<string, unknown>;
} catch {
return {};
}
}
function numberValue(value: unknown, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function boolValue(value: unknown, fallback = false) {
if (value === "true" || value === true) return true;
if (value === "false" || value === false) return false;
return fallback;
}
async function sessionToken() {
return (await cookies()).get(sessionCookieName())?.value ?? null;
}
async function currentUser() {
return getSessionUser(await sessionToken());
}
function periodParam(value: string | null) {
return value === "week" || value === "month" || value === "30d" ? value : "today";
}
export async function GET(request: Request, context: RouteContext) {
try {
const path = (await context.params).path ?? [];
const url = new URL(request.url);
if (path[0] === "auth" && path[1] === "session") {
return ok(await currentUser());
}
if (path[0] === "account") {
if (path[1] === "me" || path[1] === "profile") return ok(await currentUser());
if (path[1] === "subscription") return ok({ plan: "growth", status: "active", usage: await platformStats() });
if (path[1] === "subscription" && path[2] === "plans") return ok(await listPlans());
return ok({ user: await currentUser(), linkedAccounts: [], twoFactorEnabled: false });
}
if (path[0] === "shops") {
if (path[1] === "stats") return ok(await getShopStatsService());
if (path.length === 1) return ok(await listShopsService());
const shopId = path[1];
if (!shopId) return fail("Shop id is required", { status: 400 });
if (path.length === 2) {
const shop = await getShopService(shopId);
return shop ? ok(shop) : fail("Shop not found", { status: 404 });
}
if (path[2] === "products") return ok(await listCatalogProductsByShop(shopId));
if (path[2] === "categories") return ok(await listCatalogCategoriesByShop(shopId));
if (path[2] === "inventory") return ok(await listInventoryItems(shopId));
if (path[2] === "tables") return ok(await listTablesByShop(shopId));
if (path[2] === "orders") {
return ok(
await listOrdersService({
shopId,
page: numberValue(url.searchParams.get("page"), 1),
pageSize: numberValue(url.searchParams.get("pageSize"), 40),
filter: stringValue(url.searchParams.get("filter")) === "all" ? "all" : "today"
})
);
}
if (path[2] === "active-orders") return ok(await listActiveOrdersByTableService(shopId));
if (path[2] === "dashboard") return ok(await getPosDashboardService(shopId, periodParam(url.searchParams.get("period"))));
if (path[2] === "settings") return ok(await getShopSettingsService(shopId));
if (path[2] === "appointments") return ok(await listAppointments(shopId, stringValue(url.searchParams.get("date"))));
if (path[2] === "resources") return ok(await listResources(shopId));
if (path[2] === "therapists") return ok(await listTherapists(shopId));
if (path[2] === "reservations") return ok(await listReservations(shopId, stringValue(url.searchParams.get("date"))));
if (path[2] === "recipes") return ok(await listRecipes(shopId));
if (path[2] === "kitchen-tickets") return ok(await listKitchenTickets(shopId, stringValue(url.searchParams.get("status"))));
if (path[2] === "barista-queue" && path[3] === "stats") return ok(await baristaStats(shopId));
if (path[2] === "barista-queue") return ok(await listBaristaQueue(shopId));
if (path[2] === "leave-requests") return ok(await listLeaveRequests(shopId));
if (path[2] === "attendance") return ok(await getAttendance(null, shopId));
}
if (path[0] === "products") {
if (path.length === 1) {
return ok(await listCatalogProducts({
shopId: stringValue(url.searchParams.get("shopId")),
includeInactive: boolValue(url.searchParams.get("includeInactive"))
}));
}
const product = await getCatalogProduct(path[1] ?? "");
return product ? ok(product) : fail("Product not found", { status: 404 });
}
if (path[0] === "categories") {
return ok(await listCatalogCategories({
shopId: stringValue(url.searchParams.get("shopId")),
includeInactive: boolValue(url.searchParams.get("includeInactive"))
}));
}
if (path[0] === "inventory") {
if (path[1] === "transactions") return ok((await listInventoryWithTransactions(stringValue(url.searchParams.get("shopId")))).transactions);
if (path[1] === "low-stock") return ok((await listInventoryItems(stringValue(url.searchParams.get("shopId")) ?? "")).filter((item) => item.quantity <= item.reorderLevel));
return ok(await listInventoryItems(stringValue(url.searchParams.get("shopId")) ?? ""));
}
if (path[0] === "tables" && path[1] === "by-token" && path[2]) {
const table = await getTableFromToken(path[2]);
return table ? ok(table) : fail("Table not found", { status: 404 });
}
if (path[0] === "orders") {
if (path[1] === "active-by-table") return ok(await listActiveOrdersByTableService(stringValue(url.searchParams.get("shopId"))));
if (path.length === 1) {
return ok(
await listOrdersService({
shopId: stringValue(url.searchParams.get("shopId")),
page: numberValue(url.searchParams.get("page"), 1),
pageSize: numberValue(url.searchParams.get("pageSize"), 40),
filter: stringValue(url.searchParams.get("filter")) === "today" ? "today" : "all"
})
);
}
const order = await getOrderService(path[1] ?? "", stringValue(url.searchParams.get("shopId")));
return order ? ok(order) : fail("Order not found", { status: 404 });
}
if (path[0] === "pos" && path[1] === "dashboard") {
return ok(await getPosDashboardService(stringValue(url.searchParams.get("shopId")), periodParam(url.searchParams.get("period"))));
}
if (path[0] === "staff") {
if (path.length === 1) return ok(await listStaff(stringValue(url.searchParams.get("shopId"))));
if (path[1] === "roles") return ok([{ id: 1, name: "Owner" }, { id: 2, name: "Manager" }, { id: 3, name: "Staff" }]);
if (path[1] === "schedules") return ok(await listSchedules(stringValue(url.searchParams.get("shopId"))));
if (path[1] === "me") {
const user = await currentUser();
if (path[2] === "attendance") return ok(await getAttendance());
if (path[2] === "leave-requests") return ok(await listLeaveRequests(null));
if (path[2] === "notifications") return ok([]);
return ok(await getStaffProfile(user?.id));
}
}
if (path[0] === "kitchen" && path[1] === "tickets") {
return ok(await listKitchenTickets(stringValue(url.searchParams.get("shopId")), stringValue(url.searchParams.get("status"))));
}
if (path[0] === "members") {
if (path.length === 1) return ok(await listMembers(stringValue(url.searchParams.get("search"))));
if (path[2] === "progress") return ok({ memberId: path[1], currentLevel: 1, expToNextLevel: 250, progressPercent: 45 });
if (path[2] === "experience") return ok([]);
}
if (path[0] === "membership" && path[1] === "levels") return ok(await listMembershipLevels());
if (path[0] === "wallets") return ok(await listWallets((await currentUser())?.id));
if (path[0] === "wallet" && path[1] === "transactions") return ok(await listWalletTransactions(numberValue(url.searchParams.get("limit"), 50)));
if (path[0] === "promotions" || path[0] === "campaigns") return ok(await listCampaigns());
if (path[0] === "vouchers" && path[1] === "validate" && path[2]) return ok(await validateVoucher(path[2]));
if (path[0] === "reports") {
const shopId = stringValue(url.searchParams.get("shopId"));
if (path[1] === "top-products") return ok(await reportTopProducts(shopId));
if (path[1] === "revenue" || path[1] === "revenue-analytics") return ok(await reportRevenue(shopId));
if (path[1] === "eod") return ok({ closed: false, revenue: await reportRevenue(shopId), generatedAt: new Date().toISOString() });
if (path[1] === "staff-performance") return ok(await listStaff(shopId));
}
if (path[0] === "files") return ok(await listFiles(stringValue(url.searchParams.get("shopId"))));
if (path[0] === "folders") return ok(await listFolders(stringValue(url.searchParams.get("parentId"))));
if (path[0] === "ai") {
if (path[1] === "config") {
const shopId = stringValue(url.searchParams.get("shopId"));
return ok(shopId ? await getAiConfig(shopId) : null);
}
if (path[1] === "providers") return ok(providerCredentialStatus());
}
if (path[0] === "superadmin") {
if (path[1] === "stats") return ok(await platformStats());
if (path[1] === "merchants") return ok(await listShopsService());
if (path[1] === "plans") return ok(await listPlans());
if (path[1] === "system" && path[2] === "health") return ok(await systemHealth());
if (path[1] === "feature-flags") return ok(await listFeatureFlags());
if (path[1] === "system" && path[2] === "audit") return ok(await auditLogs());
if (path[1] === "users") return ok(await listUsers());
}
if (path[0] === "devices") return ok([]);
if (path[0] === "integrations") return ok(providerCredentialStatus());
return fail("BFF route not found", { status: 404 });
} catch (error) {
return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 });
}
}
export async function POST(request: Request, context: RouteContext) {
try {
const path = (await context.params).path ?? [];
const body = await readJson(request);
if (path[0] === "auth" && path[1] === "login") {
const result = await loginUser(String(body.email ?? ""), String(body.password ?? ""));
const jar = await cookies();
jar.set(sessionCookieName(), result.token, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: new Date(result.expiresAt)
});
return ok(result.user);
}
if (path[0] === "auth" && path[1] === "logout") {
await logoutSession(await sessionToken());
const jar = await cookies();
jar.delete(sessionCookieName());
return ok({ ok: true });
}
if (path[0] === "shops" && path.length === 1) {
return ok(
await createShopService({
name: String(body.name ?? ""),
vertical: String(body.vertical ?? "retail"),
phone: stringValue(body.phone),
email: stringValue(body.email),
description: stringValue(body.description)
}),
{ status: 201 }
);
}
if (path[0] === "shops" && path[2] === "publish") return ok(await updateShopService(path[1] ?? "", { statusId: 2 }));
if (path[0] === "shops" && path[2] === "deactivate") return ok(await updateShopService(path[1] ?? "", { statusId: 3 }));
if (path[0] === "shops" && path[2] === "close") return ok(await updateShopService(path[1] ?? "", { statusId: 4 }));
if (path[0] === "products") return ok(await createCatalogProduct({
shopId: String(body.shopId ?? ""),
name: String(body.name ?? ""),
price: numberValue(body.price),
vertical: stringValue(body.vertical),
categoryId: stringValue(body.categoryId),
description: stringValue(body.description),
sku: stringValue(body.sku),
barcode: stringValue(body.barcode),
initialQuantity: numberValue(body.initialQuantity)
}), { status: 201 });
if (path[0] === "categories") return ok(await createCatalogCategory({
shopId: String(body.shopId ?? ""),
name: String(body.name ?? ""),
description: stringValue(body.description),
displayOrder: numberValue(body.displayOrder)
}), { status: 201 });
if (path[0] === "inventory" && path[1] === "items") return ok(await createInventoryItemService({
shopId: String(body.shopId ?? ""),
name: String(body.name ?? ""),
itemTypeId: numberValue(body.itemTypeId, 1),
unit: String(body.unit ?? "pcs"),
costPerUnit: numberValue(body.costPerUnit),
quantity: numberValue(body.quantity),
reorderLevel: numberValue(body.reorderLevel, 10),
supplierName: stringValue(body.supplierName)
}), { status: 201 });
if (path[0] === "inventory" && path[1] === "stock-in") return ok(await stockIn(body as never));
if (path[0] === "inventory" && path[1] === "stock-out") return ok(await stockOut(body as never));
if (path[0] === "inventory" && path[1] === "adjust") return ok(await inventoryAdjust(body as never));
if (path[0] === "inventory" && path[1] === "wastage") return ok(await stockOut({ ...body, notes: stringValue(body.reason) ?? "Wastage" } as never));
if (path[0] === "inventory" && path[1] === "stocktake") return ok({ discrepancies: [], totalItemsCounted: Array.isArray(body.items) ? body.items.length : 0 });
if (path[0] === "tables") return ok(await createTableService({
shopId: String(body.shopId ?? ""),
tableNumber: String(body.tableNumber ?? ""),
capacity: numberValue(body.capacity, 2),
zone: stringValue(body.zone),
hourlyRate: numberValue(body.hourlyRate)
}), { status: 201 });
if (path[0] === "tables" && path[2] === "generate-qr") return ok(await regenerateTableQrService(path[1] ?? ""));
if ((path[0] === "orders" && path.length === 1) || (path[0] === "pos" && path[1] === "orders")) {
return ok(await createPosOrder(body as Parameters<typeof createPosOrder>[0]), { status: 201 });
}
if (path[0] === "orders" && path[2] === "pay") return ok(await payOrderService(path[1] ?? "", {
shopId: stringValue(body.shopId),
paymentMethod: stringValue(body.paymentMethod),
amountTendered: body.amountTendered == null ? null : numberValue(body.amountTendered)
}));
if (path[0] === "orders" && path[2] === "cancel") return ok(await cancelOrderService(path[1] ?? "", stringValue(body.shopId), stringValue(body.reason)));
if (path[0] === "staff" && path.length === 1) return ok(await createStaff(body), { status: 201 });
if (path[0] === "staff" && path[1] === "me" && path[2] === "attendance" && path[3] === "check-in") return ok(await checkIn());
if (path[0] === "staff" && path[1] === "me" && path[2] === "attendance" && path[3] === "check-out") return ok(await checkOut());
if (path[0] === "staff" && path[1] === "me" && path[2] === "leave-requests") return ok(await createLeaveRequest(body), { status: 201 });
if (path[0] === "leave-requests" && path[2] === "approve") return ok(await updateLeaveStatus(path[1] ?? "", "approved"));
if (path[0] === "leave-requests" && path[2] === "reject") return ok(await updateLeaveStatus(path[1] ?? "", "rejected"));
if (path[0] === "staff" && path[1] === "invite-with-account") return ok(await createStaff(body), { status: 201 });
if (path[0] === "staff" && path[1] === "reset-password") return ok({ ok: true });
if (path[0] === "members" && path.length === 1) return ok(await createMember(body), { status: 201 });
if (path[0] === "members" && path[2] === "experience") return ok(await addExperience(path[1] ?? "", numberValue(body.points), String(body.sourceId ?? "manual"), stringValue(body.referenceId)));
if (path[0] === "campaigns" && path[2] === "activate") return ok(await setCampaignStatus(path[1] ?? "", "active"));
if (path[0] === "campaigns" && path[2] === "pause") return ok(await setCampaignStatus(path[1] ?? "", "paused"));
if (path[0] === "campaigns") return ok(await createCampaign(body), { status: 201 });
if (path[0] === "vouchers" && path[1] === "redeem") return ok(await redeemVoucher(body));
if (path[0] === "vouchers" && path[2] === "revoke") return ok(await redeemVoucher({ voucherId: path[1] }));
if (path[0] === "appointments") return ok(await createAppointment(body), { status: 201 });
if (path[0] === "reservations") return ok(await createReservation(body), { status: 201 });
if (path[0] === "kitchen" && path[1] === "tickets") return ok(await createKitchenTicket(body), { status: 201 });
if (path[0] === "cafe" && path[1] === "barista-queue" && path[2]) {
const status = path[3] === "ready" ? "Ready" : path[3] === "delivered" ? "Delivered" : "InProgress";
return ok(await updateBaristaQueue(path[2], status, stringValue(body.baristaName)));
}
if (path[0] === "folders") return ok(await createFolder(body), { status: 201 });
if (path[0] === "files" && path[1] === "upload") {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) return fail("file is required", { status: 400 });
const key = buildS3ObjectKey(file.name);
const publicUrl = await uploadS3Object(key, file, stringValue(urlFromRequest(request).searchParams.get("accessLevel")) ?? "public");
return ok(await createFileRecord({
fileName: file.name,
contentType: file.type,
byteSize: file.size,
objectKey: key,
publicUrl,
accessLevel: stringValue(urlFromRequest(request).searchParams.get("accessLevel")) ?? "public"
}));
}
if (path[0] === "ai" && path[1] === "chat") {
const provider = stringValue(body.provider) ?? process.env.AI_DEFAULT_PROVIDER ?? "openai";
const message = Array.isArray(body.messages)
? String((body.messages[body.messages.length - 1] as { content?: unknown } | undefined)?.content ?? "")
: String(body.message ?? "");
const response = await callConfiguredAi(provider, message);
await saveAiMessage({ shopId: stringValue(body.shopId), role: "user", content: message });
await saveAiMessage({ shopId: stringValue(body.shopId), role: "assistant", content: JSON.stringify(response) });
return ok({ content: response, toolsUsed: [] });
}
if (path[0] === "marketing" && path[1] === "publish" && path[2]) {
return ok(await publishSocial(path[2], body));
}
if (path[0] === "reports" && path[1] === "close-day") return ok({ closed: true, closedAt: new Date().toISOString() });
return fail("BFF route not found", { status: 404 });
} catch (error) {
return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 });
}
}
export async function PUT(request: Request, context: RouteContext) {
try {
const path = (await context.params).path ?? [];
const body = await readJson(request);
if (path[0] === "account" && (path[1] === "profile" || path[1] === "me")) return ok({ ...(await currentUser()), ...body });
if (path[0] === "shops" && path[2] === "settings") return ok(await updateShopSettingsService(path[1] ?? "", body));
if (path[0] === "shops" && path.length === 2) return ok(await updateShopService(path[1] ?? "", body));
if (path[0] === "products") return ok(await updateCatalogProduct(path[1] ?? "", body));
if (path[0] === "categories") return ok(await updateCatalogCategory(path[1] ?? "", body));
if (path[0] === "tables" && path[2] === "status") return ok(await updateTableStatusService(path[1] ?? "", numberValue(body.statusId, 1)));
if (path[0] === "tables") return ok(await updateTableService(path[1] ?? "", body));
if (path[0] === "inventory" && path[2] === "adjust") return ok(await inventoryAdjust({ inventoryId: path[1] ?? "", quantity: numberValue(body.quantity), notes: stringValue(body.notes) }));
if (path[0] === "inventory") return ok(await updateInventoryItemService(path[1] ?? "", body));
if (path[0] === "orders" && path[2] === "cancel") return ok(await cancelOrderService(path[1] ?? "", stringValue(body.shopId), stringValue(body.reason)));
if (path[0] === "appointments") return ok(await updateAppointmentStatus(path[1] ?? "", String(body.action ?? body.status ?? "confirmed")));
if (path[0] === "reservations") return ok(await updateReservationStatus(path[1] ?? "", String(body.status ?? "confirmed")));
if (path[0] === "kitchen" && path[1] === "tickets") return ok(await updateKitchenTicket(path[2] ?? "", String(body.status ?? "InProgress")));
if (path[0] === "ai" && path[1] === "config") return ok(await saveAiConfig(body));
if (path[0] === "superadmin" && path[1] === "feature-flags" && path[2]) return ok(await updateFeatureFlag(path[2], Boolean(body.enabled)));
return fail("BFF route not found", { status: 404 });
} catch (error) {
return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const path = (await context.params).path ?? [];
if (path[0] === "products") return ok(await deleteCatalogProduct(path[1] ?? ""));
if (path[0] === "categories") return ok(await deleteCatalogCategory(path[1] ?? ""));
if (path[0] === "tables") return ok(await removeTableService(path[1] ?? ""));
if (path[0] === "inventory" && path[1] === "items") return ok(await deleteInventoryItemService(path[2] ?? ""));
if (path[0] === "campaigns") return ok(await setCampaignStatus(path[1] ?? "", "disabled"));
if (path[0] === "folders") return ok({ id: path[1], deleted: true });
if (path[0] === "files") return ok({ id: path[1], deleted: true });
return fail("BFF route not found", { status: 404 });
} catch (error) {
return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 });
}
}
function urlFromRequest(request: Request) {
return new URL(request.url);
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { query } from "@/server/db/pool";
export const dynamic = "force-dynamic";
export async function GET() {
const started = performance.now();
try {
await query("SELECT 1 AS ok");
return NextResponse.json({
ok: true,
service: "tpos-mvp-next",
database: "connected",
latencyMs: Math.round(performance.now() - started)
});
} catch (error) {
const message = error instanceof Error ? error.message : "Database check failed";
return NextResponse.json(
{
ok: false,
service: "tpos-mvp-next",
database: "unavailable",
error: message
},
{ status: 503 }
);
}
}

View File

@@ -0,0 +1,35 @@
import { z } from "zod";
import { createPosOrder } from "@/server/services/order";
import { fail, ok } from "@/server/shared/api";
export const dynamic = "force-dynamic";
const createOrderSchema = z.object({
shopId: z.string().uuid(),
tableId: z.string().uuid().nullable().optional(),
paymentMethod: z.string().min(1).default("cash"),
amountTendered: z.number().nullable().optional(),
discountAmount: z.number().min(0).nullable().optional(),
discountType: z.string().nullable().optional(),
discountReference: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})
)
.min(1)
});
export async function POST(request: Request) {
try {
const body = createOrderSchema.parse(await request.json());
const order = await createPosOrder(body);
return ok(order, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Cannot create order";
return fail(message, { status: 400 });
}
}

View File

@@ -0,0 +1,22 @@
import { publicMenu, publicShop } from "@/server/services/parity";
import { fail, ok } from "@/server/shared/api";
export const dynamic = "force-dynamic";
type RouteContext = {
params: Promise<{ path?: string[] }>;
};
export async function GET(_request: Request, context: RouteContext) {
try {
const path = (await context.params).path ?? [];
if (path[0] === "shops" && path[1]) {
if (path[2] === "menu") return ok(await publicMenu(path[1]));
const shop = await publicShop(path[1]);
return shop ? ok(shop) : fail("Shop not found", { status: 404 });
}
return fail("Public route not found", { status: 404 });
} catch (error) {
return fail(error instanceof Error ? error.message : "Public request failed", { status: 400 });
}
}

View File

@@ -0,0 +1,8 @@
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
export default async function AuthCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const mode = path.includes("register") ? "register" : path.includes("forgot-password-new") || path.includes("password-reset") || path.includes("otp-verify") || path.includes("two-factor") || path.includes("email-sent") ? "recover" : "login";
const role = path[path.length - 1] ?? "admin";
return <TposAuthBoundary mode={mode} role={role} />;
}

View File

@@ -0,0 +1,169 @@
import { PackagePlus, Tags } from "lucide-react";
import { createCategoryAction, createProductAction } from "@/app/actions";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState, PageHeader, StatusPill, money } from "@/components/Primitives";
import { getShopService } from "@/server/services/shop";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function CatalogPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const [categories, products] = shop
? await Promise.all([listCatalogCategoriesByShop(shop.id), listCatalogProductsByShop(shop.id)])
: [[], []];
return (
<AppFrame shopId={shop?.id}>
<PageHeader eyebrow="Catalog Service" title="Sản phẩm và danh mục" />
{shop ? (
<div className="split">
<div className="stack">
<section className="table-panel">
<div className="panel-title">
<h2>Sản phẩm</h2>
<StatusPill label={`${products.length} đang bán`} tone="good" />
</div>
{products.length ? (
<table className="table">
<thead>
<tr>
<th>Tên</th>
<th>Danh mục</th>
<th>Loại</th>
<th>Tồn</th>
<th>Giá</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<td>
<strong>{product.name}</strong>
{product.sku ? <div className="eyebrow">{product.sku}</div> : null}
</td>
<td>{product.categoryName ?? "Chung"}</td>
<td>{product.productType}</td>
<td>{product.stockQuantity ?? "-"}</td>
<td>{money(product.price)}</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Chưa có sản phẩm" />
)}
</section>
<section className="table-panel">
<div className="panel-title">
<h2>Danh mục</h2>
<Tags size={18} />
</div>
{categories.length ? (
<table className="table">
<thead>
<tr>
<th>Tên</th>
<th>Thứ tự</th>
<th> tả</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category.id}>
<td>{category.name}</td>
<td>{category.displayOrder}</td>
<td>{category.description ?? "-"}</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Chưa có danh mục" />
)}
</section>
</div>
<aside className="stack">
<form action={createProductAction} className="form-panel stack">
<h2>Thêm sản phẩm</h2>
<input type="hidden" name="shopId" value={shop.id} />
<input type="hidden" name="vertical" value={shop.vertical} />
<label className="field">
<span>Tên</span>
<input name="name" required />
</label>
<label className="field">
<span>Danh mục</span>
<select name="categoryId" defaultValue="">
<option value="">Chung</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</label>
<div className="form-grid">
<label className="field">
<span>Giá</span>
<input name="price" type="number" min="0" step="1000" required />
</label>
<label className="field">
<span>Tồn ban đu</span>
<input name="initialQuantity" type="number" min="0" defaultValue="0" />
</label>
</div>
<div className="form-grid">
<label className="field">
<span>SKU</span>
<input name="sku" />
</label>
<label className="field">
<span>Barcode</span>
<input name="barcode" />
</label>
</div>
<label className="field">
<span> tả</span>
<textarea name="description" />
</label>
<button className="primary-action" type="submit">
<PackagePlus size={18} />
Thêm sản phẩm
</button>
</form>
<form action={createCategoryAction} className="form-panel stack">
<h2>Thêm danh mục</h2>
<input type="hidden" name="shopId" value={shop.id} />
<label className="field">
<span>Tên</span>
<input name="name" required />
</label>
<label className="field">
<span>Thứ tự hiển thị</span>
<input name="displayOrder" type="number" defaultValue="0" />
</label>
<label className="field">
<span> tả</span>
<textarea name="description" />
</label>
<button className="secondary-action" type="submit">
Thêm danh mục
</button>
</form>
</aside>
</div>
) : (
<EmptyState title="Tạo cửa hàng trước" />
)}
</AppFrame>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default } from "../page";

View File

@@ -0,0 +1,127 @@
import { Boxes, RotateCcw } from "lucide-react";
import { adjustStockAction, createInventoryItemAction } from "@/app/actions";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState, PageHeader, StatusPill, money } from "@/components/Primitives";
import { getShopService } from "@/server/services/shop";
import { listInventoryItems } from "@/server/services/inventory";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function InventoryPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const inventory = shop ? await listInventoryItems(shop.id) : [];
return (
<AppFrame shopId={shop?.id}>
<PageHeader eyebrow="Inventory Service" title="Kiểm soát tồn kho" />
{shop ? (
<div className="split">
<section className="table-panel">
<div className="panel-title">
<h2>Mặt hàng</h2>
<StatusPill label={`${inventory.length} đang theo dõi`} tone="good" />
</div>
{inventory.length ? (
<table className="table">
<thead>
<tr>
<th>Mặt hàng</th>
<th>Tồn</th>
<th>Đt lại</th>
<th>Giá vốn</th>
<th>Cập nhật</th>
</tr>
</thead>
<tbody>
{inventory.map((item) => (
<tr key={item.id}>
<td>
<strong>{item.name ?? item.productName}</strong>
<div className="eyebrow">{item.unit}</div>
</td>
<td>
<StatusPill
label={String(item.quantity)}
tone={item.quantity <= item.reorderLevel ? "warn" : "neutral"}
/>
</td>
<td>{item.reorderLevel}</td>
<td>{money(item.costPerUnit)}</td>
<td>
<form action={adjustStockAction} style={{ display: "flex", gap: 8 }}>
<input type="hidden" name="inventoryId" value={item.id} />
<input name="quantity" type="number" defaultValue={item.quantity} min="0" style={{ width: 86 }} />
<button className="icon-button" type="submit" aria-label="Adjust stock">
<RotateCcw size={16} />
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Chưa có mặt hàng kho" />
)}
</section>
<aside>
<form action={createInventoryItemAction} className="form-panel stack">
<h2>Thêm vật </h2>
<input type="hidden" name="shopId" value={shop.id} />
<label className="field">
<span>Tên</span>
<input name="name" required />
</label>
<div className="form-grid">
<label className="field">
<span>Loại</span>
<select name="itemTypeId" defaultValue="1">
<option value="1">Nguyên liệu</option>
<option value="2">Thành phẩm</option>
<option value="3">Vật tiêu hao</option>
</select>
</label>
<label className="field">
<span>Đơn vị</span>
<input name="unit" defaultValue="pcs" required />
</label>
</div>
<div className="form-grid">
<label className="field">
<span>Số lượng</span>
<input name="quantity" type="number" min="0" defaultValue="0" />
</label>
<label className="field">
<span>Mức đt lại</span>
<input name="reorderLevel" type="number" min="0" defaultValue="10" />
</label>
</div>
<div className="form-grid">
<label className="field">
<span>Giá vốn</span>
<input name="costPerUnit" type="number" min="0" step="1000" defaultValue="0" />
</label>
<label className="field">
<span>Nhà cung cấp</span>
<input name="supplierName" />
</label>
</div>
<button className="primary-action" type="submit">
<Boxes size={18} />
Thêm vào kho
</button>
</form>
</aside>
</div>
) : (
<EmptyState title="Tạo cửa hàng trước" />
)}
</AppFrame>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "GoodGo TPOS MVP",
description: "Next.js TypeScript MVP for GoodGo POS platform",
icons: {
icon: "/favicon.svg"
}
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="vi">
<body>{children}</body>
</html>
);
}

View File

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

View File

@@ -0,0 +1,46 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { providerCredentialStatus } from "@/server/integrations/external";
import { listCampaigns, listMembers, reportRevenue } from "@/server/services/parity";
export default async function MarketingCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const section = path.join("/") || "social";
const items = await loadItems(section);
const status = providerCredentialStatus();
return (
<TposPortal
kind="marketing"
path={path}
payload={buildPortalPayload("marketing", {
title: title(section),
stats: { orderCount: items.length, shopCount: Object.values(status).filter(Boolean).length, revenue: 0 },
items
})}
/>
);
}
async function loadItems(section: string) {
if (section === "customers") {
const members = await listMembers();
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();
return rows.map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: `${row.revenue} VND` }));
}
const campaigns = await listCampaigns();
return campaigns.map((campaign) => ({ title: String(campaign.name), meta: String(campaign.description ?? "Campaign"), value: String(campaign.status) }));
}
function title(section: string) {
const labels: Record<string, string> = {
livechat: "Livechat console",
customers: "Customer CRM",
content: "AI content studio",
analytics: "Marketing analytics",
chatbot: "Chatbot automation",
"ai-chatbot": "AI chatbot"
};
return labels[section] ?? "Marketing hub";
}

View File

@@ -0,0 +1,19 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { listCampaigns } from "@/server/services/parity";
import { providerCredentialStatus } from "@/server/integrations/external";
export default async function MarketingPage() {
const campaigns = await listCampaigns();
const status = providerCredentialStatus();
return (
<TposPortal
kind="marketing"
path={[]}
payload={buildPortalPayload("marketing", {
title: "Social hub",
stats: { orderCount: campaigns.length, revenue: 0, shopCount: Object.values(status).filter(Boolean).length },
items: campaigns.map((campaign) => ({ title: String(campaign.name), meta: String(campaign.description ?? "Campaign"), value: String(campaign.status) }))
})}
/>
);
}

View File

@@ -0,0 +1,16 @@
import { PublicMenu } from "@/components/PublicMenu";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { getTable } from "@/server/services/fnb";
import { getShopService } from "@/server/services/shop";
export default async function CustomerTableMenuPage({ params }: { params: Promise<{ shopId: string; tableId: string }> }) {
const { shopId, tableId } = await params;
const shop = await getShopService(shopId);
if (!shop) throw new Error("Shop not found");
const [categories, products, table] = await Promise.all([
listCatalogCategoriesByShop(shopId),
listCatalogProductsByShop(shopId),
getTable(tableId)
]);
return <PublicMenu shop={shop} categories={categories} products={products} table={table} />;
}

View File

@@ -0,0 +1,11 @@
import { PublicMenu } from "@/components/PublicMenu";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { getShopService } from "@/server/services/shop";
export default async function CustomerMenuPage({ params }: { params: Promise<{ shopId: string }> }) {
const { shopId } = await params;
const shop = await getShopService(shopId);
if (!shop) throw new Error("Shop not found");
const [categories, products] = await Promise.all([listCatalogCategoriesByShop(shopId), listCatalogProductsByShop(shopId)]);
return <PublicMenu shop={shop} categories={categories} products={products} />;
}

View File

@@ -0,0 +1,69 @@
import Link from "next/link";
import { TerminalSquare } from "lucide-react";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState, PageHeader, StatusPill, money } from "@/components/Primitives";
import { getShopService } from "@/server/services/shop";
import { listOrdersLegacy } from "@/server/services/order";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function OrdersPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const orders = await listOrdersLegacy(shop?.id ?? null, 80);
return (
<AppFrame shopId={shop?.id}>
<PageHeader eyebrow="Order Service" title="Đơn hàng">
{shop ? (
<Link className="primary-action" href={`/pos?shopId=${shop.id}`}>
<TerminalSquare size={18} />
Bán hàng mới
</Link>
) : null}
</PageHeader>
<section className="table-panel">
{orders.length ? (
<table className="table">
<thead>
<tr>
<th> đơn</th>
<th>Cửa hàng</th>
<th>Món</th>
<th>Thanh toán</th>
<th>Trạng thái</th>
<th>Tổng</th>
<th>Thời gian</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>
<strong>{order.transactionId ?? order.id.slice(0, 8)}</strong>
{order.tableNumber ? <div className="eyebrow">Bàn {order.tableNumber}</div> : null}
</td>
<td>{order.shopName ?? "-"}</td>
<td>{order.itemCount}</td>
<td>{order.paymentMethod ?? "-"}</td>
<td>
<StatusPill label={order.status} tone={order.status === "Paid" ? "good" : "neutral"} />
</td>
<td>{money(order.totalAmount)}</td>
<td>{new Date(order.createdAt).toLocaleString("vi-VN")}</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Chưa có đơn hàng" />
)}
</section>
</AppFrame>
);
}

View File

@@ -0,0 +1,170 @@
import Link from "next/link";
import { Activity, AlertTriangle, ShoppingBag, Store } from "lucide-react";
import { createShopAction } from "@/app/actions";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState, Metric, PageHeader, StatusPill, money } from "@/components/Primitives";
import { getDashboardStats } from "@/server/db/queries";
import { getShopService } from "@/server/services/shop";
import { listActivity } from "@/server/db/queries";
import { serviceMap, verticalOptions } from "@/server/domain/catalog";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function DashboardPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const stats = await getDashboardStats(shop?.id ?? null);
const activity = await listActivity();
return (
<AppFrame shopId={shop?.id}>
<PageHeader eyebrow="GoodGo Platform" title={shop ? `Vận hành ${shop.name}` : "Tạo cửa hàng đầu tiên"}>
{shop ? (
<Link className="primary-action" href={`/pos?shopId=${shop.id}`}>
<ShoppingBag size={18} />
Mở POS
</Link>
) : null}
</PageHeader>
{!shop ? (
<div className="split">
<form action={createShopAction} className="form-panel stack">
<h2>Thiết lập cửa hàng</h2>
<label className="field">
<span>Tên cửa hàng</span>
<input name="name" placeholder="GoodGo Cafe District 1" required />
</label>
<label className="field">
<span>Ngành hàng</span>
<select name="vertical" defaultValue="cafe">
{verticalOptions.map((vertical) => (
<option key={vertical.id} value={vertical.id}>
{vertical.label}
</option>
))}
</select>
</label>
<button className="primary-action" type="submit">
<Store size={18} />
Tạo cửa hàng
</button>
</form>
<EmptyState title="Chưa có dữ liệu cửa hàng">Dashboard sẽ xuất hiện sau khi tạo cửa hàng đu tiên.</EmptyState>
</div>
) : (
<>
<div className="metric-grid">
<Metric label="Doanh thu hôm nay" value={money(stats.todayRevenue)} tone="good" />
<Metric label="Doanh thu tháng" value={money(stats.monthRevenue)} tone="info" />
<Metric label="Đơn hàng" value={stats.orderCount} />
<Metric label="Sắp hết hàng" value={stats.lowStockCount} tone={stats.lowStockCount > 0 ? "warn" : "neutral"} />
</div>
<div className="dashboard-grid">
<section className="panel">
<div className="panel-title">
<h2>Đơn gần đây</h2>
<Link href={`/orders?shopId=${shop.id}`}>Xem tất cả</Link>
</div>
{stats.recentOrders.length ? (
<table className="table">
<thead>
<tr>
<th> đơn</th>
<th>Món</th>
<th>Trạng thái</th>
<th>Tổng</th>
</tr>
</thead>
<tbody>
{stats.recentOrders.map((order) => (
<tr key={order.id}>
<td>{order.transactionId ?? order.id.slice(0, 8)}</td>
<td>{order.itemCount}</td>
<td>
<StatusPill label={order.status} tone={order.status === "Paid" ? "good" : "neutral"} />
</td>
<td>{money(order.totalAmount)}</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Chưa có đơn hàng" />
)}
</section>
<section className="panel stack">
<div className="panel-title">
<h2>Service map MVP</h2>
<StatusPill label="Monolith MVP" tone="good" />
</div>
{serviceMap.map((service) => {
const Icon = service.icon;
return (
<div className="activity-item" key={service.name}>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon size={16} />
{service.name}
</span>
<span>{service.label}</span>
</div>
);
})}
</section>
<section className="panel">
<div className="panel-title">
<h2>Tồn kho cảnh báo</h2>
<AlertTriangle size={18} />
</div>
{stats.lowStock.length ? (
<table className="table">
<thead>
<tr>
<th>Mặt hàng</th>
<th>Tồn</th>
<th>Mức đt lại</th>
</tr>
</thead>
<tbody>
{stats.lowStock.map((item) => (
<tr key={item.id}>
<td>{item.name ?? item.productName}</td>
<td>{item.quantity}</td>
<td>{item.reorderLevel}</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState title="Tồn kho đang ổn" />
)}
</section>
<section className="panel">
<div className="panel-title">
<h2>Hoạt đng</h2>
<Activity size={18} />
</div>
<div className="activity-list">
{activity.map((item) => (
<div className="activity-item" key={item.id}>
<span>{item.action}</span>
<span>{new Date(item.createdAt).toLocaleString("vi-VN")}</span>
</div>
))}
{!activity.length ? <EmptyState title="Chưa có hoạt động" /> : null}
</div>
</section>
</div>
</>
)}
</AppFrame>
);
}

View File

@@ -0,0 +1,39 @@
import { TposPosExperience } from "@/components/TposPosExperience";
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 type { VerticalKind } from "@/components/tpos-config";
export default async function PosVerticalPage({
params
}: {
params: Promise<{ shopId: string; vertical: string; workflow?: string[] }>;
}) {
const { shopId, vertical, workflow } = await params;
const shop = await getShopService(shopId);
if (!shop) throw new Error("Shop not found");
const [products, categories, tables, orders, dashboard] = await Promise.all([
listCatalogProductsByShop(shopId),
listCatalogCategoriesByShop(shopId),
listTablesByShop(shopId),
listOrdersService({ shopId, page: 1, pageSize: 24, filter: "all" }),
getPosDashboardService(shopId, "today")
]);
return (
<TposPosExperience
shop={shop}
vertical={normalizeVertical(vertical)}
workflow={workflow}
products={products}
categories={categories}
tables={tables}
orders={orders.items}
dashboard={dashboard as Record<string, unknown>}
/>
);
}
function normalizeVertical(value: string): VerticalKind {
return value === "restaurant" || value === "karaoke" || value === "spa" || value === "beauty" || value === "retail" ? value : "cafe";
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function PosDialogAlias({ params }: { params: Promise<{ shopId: string; path: string[] }> }) {
const { shopId, path } = await params;
redirect(`/pos/${shopId}/cafe/${path.join("/") || "order-edit"}`);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function PosOperationsAlias({ params }: { params: Promise<{ shopId: string; path: string[] }> }) {
const { shopId, path } = await params;
redirect(`/pos/${shopId}/cafe/${path.join("/") || "shift"}`);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function PosPaymentAlias({ params }: { params: Promise<{ shopId: string; path: string[] }> }) {
const { shopId, path } = await params;
redirect(`/pos/${shopId}/cafe/${path.join("/") || "method-select"}`);
}

View File

@@ -0,0 +1,47 @@
import Link from "next/link";
import { Plus } from "lucide-react";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState } from "@/components/Primitives";
import { PosRegister } from "@/components/PosRegister";
import { getShopService } from "@/server/services/shop";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { listTablesByShop } from "@/server/services/fnb";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function PosPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const [categories, products, tables] = shop
? await Promise.all([
listCatalogCategoriesByShop(shop.id),
listCatalogProductsByShop(shop.id),
listTablesByShop(shop.id)
])
: [[], [], []];
if (shop && products.length) {
return <PosRegister shop={shop} products={products} categories={categories} tables={tables} />;
}
return (
<AppFrame shopId={shop?.id}>
{shop ? (
<div className="panel">
<EmptyState title="Catalog is empty">
<Link className="primary-action" href={`/catalog?shopId=${shop.id}`}>
<Plus size={18} />
Add products
</Link>
</EmptyState>
</div>
) : (
<EmptyState title="Create a shop first" />
)}
</AppFrame>
);
}

View File

@@ -0,0 +1,16 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { getShopService } from "@/server/services/shop";
export default async function ProfilePage() {
const shop = await getShopService();
return (
<TposPortal
kind="admin"
path={["profile"]}
payload={buildPortalPayload("admin", {
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
title: "Hồ sơ tài khoản"
})}
/>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import { Store } from "lucide-react";
import { createShopAction } from "@/app/actions";
import { AppFrame } from "@/components/AppFrame";
import { PageHeader, StatusPill } from "@/components/Primitives";
import { listShopsService } from "@/server/services/shop";
import { verticalOptions } from "@/server/domain/catalog";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
export default async function SettingsPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shops = await listShopsService();
return (
<AppFrame shopId={params.shopId}>
<PageHeader eyebrow="Merchant Service" title="Cài đặt workspace" />
<div className="split">
<section className="table-panel">
<div className="panel-title">
<h2>Cửa hàng</h2>
<StatusPill label={`${shops.length} tổng`} tone="good" />
</div>
<table className="table">
<thead>
<tr>
<th>Tên</th>
<th>Ngành</th>
<th>Trạng thái</th>
<th>Liên hệ</th>
</tr>
</thead>
<tbody>
{shops.map((shop) => (
<tr key={shop.id}>
<td>
<strong>{shop.name}</strong>
<div className="eyebrow">{shop.slug}</div>
</td>
<td>{shop.category}</td>
<td>
<StatusPill label={shop.status} tone={shop.status === "Active" ? "good" : "neutral"} />
</td>
<td>{shop.phone ?? shop.email ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</section>
<aside>
<form action={createShopAction} className="form-panel stack">
<h2>Thêm cửa hàng</h2>
<label className="field">
<span>Tên</span>
<input name="name" required />
</label>
<label className="field">
<span>Ngành hàng</span>
<select name="vertical" defaultValue="cafe">
{verticalOptions.map((vertical) => (
<option key={vertical.id} value={vertical.id}>
{vertical.label}
</option>
))}
</select>
</label>
<div className="form-grid">
<label className="field">
<span>Điện thoại</span>
<input name="phone" />
</label>
<label className="field">
<span>Email</span>
<input name="email" type="email" />
</label>
</div>
<label className="field">
<span> tả</span>
<textarea name="description" />
</label>
<button className="primary-action" type="submit">
<Store size={18} />
Tạo cửa hàng
</button>
</form>
</aside>
</div>
</AppFrame>
);
}

View File

@@ -0,0 +1,59 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { getDashboardStats } from "@/server/db/queries";
import { getShopService } from "@/server/services/shop";
import { getAttendance, listKitchenTickets, listLeaveRequests, listSchedules, listStaff } from "@/server/services/parity";
export default async function StaffCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const section = path.join("/") || "dashboard";
const shop = await getShopService();
const stats = await getDashboardStats(shop?.id);
const items = await staffItems(section, shop?.id);
return (
<TposPortal
kind="staff"
path={path}
payload={buildPortalPayload("staff", {
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
stats,
title: staffTitle(section),
items
})}
/>
);
}
async function staffItems(section: string, shopId?: string | null) {
if (section === "kitchen") {
const tickets = await listKitchenTickets(shopId);
return tickets.map((ticket) => ({ title: String(ticket.table_label ?? ticket.id), meta: String(ticket.priority), value: String(ticket.status) }));
}
if (section === "attendance") {
const rows = await getAttendance(null, shopId);
return rows.map((row) => ({ title: String(row.employee_code ?? row.staff_id), meta: String(row.check_in_at), value: String(row.status) }));
}
if (section === "leave") {
const rows = await listLeaveRequests(shopId);
return rows.map((row) => ({ title: String(row.reason ?? "Nghỉ phép"), meta: `${row.from_date} - ${row.to_date}`, value: String(row.status) }));
}
if (section === "schedule") {
const rows = await listSchedules(shopId);
return rows.map((row) => ({ title: `Thứ ${Number(row.day_of_week) + 1}`, meta: `${row.start_time} - ${row.end_time}`, value: String(row.employee_code ?? "Staff") }));
}
const staff = await listStaff(shopId);
return staff.map((item) => ({ title: `${item.first_name ?? "Nhân viên"} ${item.last_name ?? ""}`, meta: String(item.role ?? "Staff"), value: String(item.status) }));
}
function staffTitle(section: string) {
const labels: Record<string, string> = {
pos: "POS nhân viên",
kitchen: "Bếp",
tables: "Bàn/phòng",
attendance: "Điểm danh",
leave: "Nghỉ phép",
schedule: "Lịch làm",
notifications: "Thông báo",
payroll: "Lương"
};
return labels[section] ?? "Dashboard nhân viên";
}

View File

@@ -0,0 +1,19 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { getShopService } from "@/server/services/shop";
import { getDashboardStats } from "@/server/db/queries";
export default async function StaffPage() {
const shop = await getShopService();
const stats = await getDashboardStats(shop?.id);
return (
<TposPortal
kind="staff"
path={[]}
payload={buildPortalPayload("staff", {
shop: shop ? { id: shop.id, name: shop.name, vertical: shop.vertical, status: shop.status } : null,
stats,
title: "Dashboard nhân viên"
})}
/>
);
}

View File

@@ -0,0 +1,60 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { auditLogs, listFeatureFlags, listPlans, listUsers, platformStats, systemHealth } from "@/server/services/parity";
import { listShopsService } from "@/server/services/shop";
export default async function SuperAdminCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) {
const path = (await params).path ?? [];
const section = path.join("/") || "dashboard";
const stats = await platformStats();
const items = await loadItems(section);
return (
<TposPortal
kind="superadmin"
path={path}
payload={buildPortalPayload("superadmin", {
stats,
title: title(section),
items
})}
/>
);
}
async function loadItems(section: string) {
if (section.startsWith("merchants")) {
const shops = await listShopsService();
return shops.map((shop) => ({ title: shop.name, meta: shop.category, value: shop.status, href: `/superadmin/merchants/${shop.id}` }));
}
if (section.startsWith("users")) {
const users = await listUsers();
return users.map((user) => ({ title: String(user.displayName), meta: String(user.email), value: String(user.status), href: `/superadmin/users/${user.id}` }));
}
if (section === "subscriptions") {
const plans = await listPlans();
return plans.map((plan) => ({ title: String(plan.name), meta: String(plan.code), value: `${plan.price} VND` }));
}
if (section === "system/health") {
const health = await systemHealth();
return health.map((item) => ({ title: item.name, meta: item.checkedAt, value: item.status }));
}
if (section === "system/flags") {
const flags = await listFeatureFlags();
return flags.map((flag) => ({ title: String(flag.key), meta: String(flag.description ?? ""), value: flag.enabled ? "ON" : "OFF" }));
}
const logs = await auditLogs(12);
return logs.map((log) => ({ title: String(log.action), meta: String(log.entity_type ?? "system"), value: new Date(String(log.created_at)).toLocaleTimeString("vi-VN") }));
}
function title(section: string) {
const labels: Record<string, string> = {
merchants: "Merchant operations",
subscriptions: "Subscription plans",
users: "Platform users",
roles: "Platform roles",
"system/health": "System health",
"system/audit": "Audit log",
"system/flags": "Feature flags",
settings: "Platform settings"
};
return labels[section] ?? "Super Admin";
}

View File

@@ -0,0 +1,16 @@
import { TposPortal, buildPortalPayload } from "@/components/TposPortal";
import { platformStats } from "@/server/services/parity";
export default async function SuperAdminPage() {
const stats = await platformStats();
return (
<TposPortal
kind="superadmin"
path={[]}
payload={buildPortalPayload("superadmin", {
stats,
title: "Platform dashboard"
})}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { PublicMenu } from "@/components/PublicMenu";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { getTableFromToken } from "@/server/services/fnb";
import { getShopService } from "@/server/services/shop";
export default async function TableTokenPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const table = await getTableFromToken(token);
if (!table) throw new Error("Table not found");
const shop = await getShopService(table.shopId);
if (!shop) throw new Error("Shop not found");
const [categories, products] = await Promise.all([listCatalogCategoriesByShop(shop.id), listCatalogProductsByShop(shop.id)]);
return <PublicMenu shop={shop} categories={categories} products={products} table={table} />;
}

View File

@@ -0,0 +1,98 @@
import { Grid3X3 } from "lucide-react";
import { createTableAction, updateTableStatusAction } from "@/app/actions";
import { AppFrame } from "@/components/AppFrame";
import { EmptyState, PageHeader, StatusPill, money } from "@/components/Primitives";
import { getShopService } from "@/server/services/shop";
import { listTablesByShop } from "@/server/services/fnb";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<{ shopId?: string }>;
};
function tableTone(statusId: number) {
if (statusId === 1) return "good" as const;
if (statusId === 2) return "warn" as const;
if (statusId === 4) return "bad" as const;
return "neutral" as const;
}
export default async function TablesPage({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const shop = await getShopService(params.shopId);
const tables = shop ? await listTablesByShop(shop.id) : [];
return (
<AppFrame shopId={shop?.id}>
<PageHeader eyebrow="FnB Engine" title="Bàn và phòng" />
{shop ? (
<div className="split">
<section className="table-panel">
{tables.length ? (
<div className="table-map">
{tables.map((table) => (
<div className="table-unit" key={table.id}>
<h3>{table.tableNumber}</h3>
<StatusPill label={table.status} tone={tableTone(table.statusId)} />
<p>
{table.zone ?? "Khu chính"} · {table.capacity} chỗ
</p>
{table.hourlyRate > 0 ? <p>{money(table.hourlyRate)} / giờ</p> : null}
<form action={updateTableStatusAction}>
<input type="hidden" name="tableId" value={table.id} />
<label className="field">
<select name="statusId" defaultValue={table.statusId}>
<option value="1">Trống</option>
<option value="2">Đang dùng</option>
<option value="3">Đã đt</option>
<option value="4">Dọn dẹp</option>
</select>
</label>
<button className="secondary-action" type="submit">
Cập nhật
</button>
</form>
</div>
))}
</div>
) : (
<EmptyState title="Chưa có bàn/phòng" />
)}
</section>
<aside>
<form action={createTableAction} className="form-panel stack">
<h2>Thêm bàn/phòng</h2>
<input type="hidden" name="shopId" value={shop.id} />
<label className="field">
<span>Số hiệu</span>
<input name="tableNumber" required />
</label>
<div className="form-grid">
<label className="field">
<span>Sức chứa</span>
<input name="capacity" type="number" min="1" defaultValue="2" />
</label>
<label className="field">
<span>Khu vực</span>
<input name="zone" />
</label>
</div>
<label className="field">
<span>Giá theo giờ</span>
<input name="hourlyRate" type="number" min="0" step="1000" defaultValue="0" />
</label>
<button className="primary-action" type="submit">
<Grid3X3 size={18} />
Thêm bàn/phòng
</button>
</form>
</aside>
</div>
) : (
<EmptyState title="Tạo cửa hàng trước" />
)}
</AppFrame>
);
}

View File

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

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { getShopService, listShopsService } from "@/server/services/shop";
import { Shell } from "./Shell";
export async function AppFrame({ shopId, children }: { shopId?: string | null; children: ReactNode }) {
const shops = await listShopsService();
const currentShop = shops.length ? await getShopService(shopId ?? shops[0]?.id) : null;
return (
<Shell shops={shops} currentShop={currentShop}>
{children}
</Shell>
);
}

View File

@@ -0,0 +1,483 @@
"use client";
import Link from "next/link";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Banknote,
BarChart3,
Building2,
Check,
Clock,
Coffee,
CreditCard,
DoorOpen,
History,
Menu,
Minus,
Plus,
ReceiptText,
Search,
Settings,
ShoppingBag,
ShoppingCart,
Smartphone,
Sparkles,
Trash2,
UtensilsCrossed,
X
} from "lucide-react";
import type { Product, ProductCategory, Shop, TableInfo } from "@/server/domain/types";
type CartLine = {
product: Product;
quantity: number;
};
type PosTab = "sale" | "history" | "dashboard" | "settings";
const currency = new Intl.NumberFormat("vi-VN", {
style: "currency",
currency: "VND",
maximumFractionDigits: 0
});
const verticalNav = [
{ 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: "retail", label: "Bán lẻ", icon: ShoppingBag }
];
const paymentMethods = [
{ id: "cash", label: "Tiền mặt", icon: Banknote },
{ id: "card", label: "Thẻ", icon: CreditCard },
{ id: "qr", label: "QR", icon: Smartphone },
{ id: "transfer", label: "Chuyển khoản", icon: Building2 }
];
const modeTabs = [
{ id: "sale" as const, label: "Bán hàng", icon: Coffee },
{ id: "history" as const, label: "Lịch sử", icon: History },
{ id: "dashboard" as const, label: "Báo cáo", icon: BarChart3 },
{ id: "settings" as const, label: "Cài đặt", icon: Settings }
];
function formatCurrency(value: number) {
return currency.format(value);
}
function formatTime(date = new Date()) {
return date.toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" });
}
export function PosRegister({
shop,
products,
categories,
tables
}: {
shop: Shop;
products: Product[];
categories: ProductCategory[];
tables: TableInfo[];
}) {
const router = useRouter();
const [cart, setCart] = useState<CartLine[]>([]);
const [categoryId, setCategoryId] = useState("all");
const [query, setQuery] = useState("");
const [tableId, setTableId] = useState("");
const [paymentMethod, setPaymentMethod] = useState("cash");
const [amountTendered, setAmountTendered] = useState("");
const [discountAmount, setDiscountAmount] = useState("");
const [voucherCode, setVoucherCode] = useState("");
const [activeTab, setActiveTab] = useState<PosTab>("sale");
const [mobileCartOpen, setMobileCartOpen] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [lastOrder, setLastOrder] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const filteredProducts = useMemo(() => {
const normalized = query.trim().toLowerCase();
return products.filter((product) => {
const byCategory = categoryId === "all" || product.categoryId === categoryId;
const bySearch =
!normalized ||
product.name.toLowerCase().includes(normalized) ||
product.sku?.toLowerCase().includes(normalized) ||
product.barcode?.toLowerCase().includes(normalized);
return byCategory && bySearch;
});
}, [categoryId, products, query]);
const subtotal = cart.reduce((sum, line) => sum + line.product.price * line.quantity, 0);
const discount = Math.min(subtotal, Math.max(0, Number(discountAmount || 0)));
const total = Math.max(0, subtotal - discount);
const received = paymentMethod === "cash" ? Number(amountTendered || 0) : total;
const change = Math.max(0, received - total);
const itemCount = cart.reduce((sum, line) => sum + line.quantity, 0);
const canPay = cart.length > 0 && (paymentMethod !== "cash" || received >= total);
const quickAmounts = useMemo(() => {
const rounded = Math.ceil(total / 10000) * 10000;
return [total, rounded, rounded + 20000, rounded + 50000]
.filter((value, index, values) => value > 0 && values.indexOf(value) === index)
.slice(0, 4);
}, [total]);
function addProduct(product: Product) {
setMessage(null);
setCart((current) => {
const existing = current.find((line) => line.product.id === product.id);
if (existing) {
return current.map((line) =>
line.product.id === product.id ? { ...line, quantity: line.quantity + 1 } : line
);
}
return [...current, { product, quantity: 1 }];
});
}
function changeQuantity(productId: string, delta: number) {
setCart((current) =>
current
.map((line) =>
line.product.id === productId ? { ...line, quantity: Math.max(0, line.quantity + delta) } : line
)
.filter((line) => line.quantity > 0)
);
}
function clearCart() {
setCart([]);
setMessage(null);
setLastOrder(null);
}
async function submitOrder() {
if (!canPay) return;
setMessage(null);
startTransition(async () => {
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
tableId: tableId || null,
paymentMethod,
amountTendered: paymentMethod === "cash" ? received : total,
discountAmount: discount,
discountType: discount > 0 ? "manual" : null,
discountReference: voucherCode.trim() || null,
items: cart.map((line) => ({ productId: line.product.id, quantity: line.quantity }))
})
});
const payload = (await response.json()) as { success: boolean; data?: { transactionId?: string }; error?: string };
if (!response.ok || !payload.success) {
setMessage(payload.error ?? "Không thể tạo đơn");
return;
}
setCart([]);
setAmountTendered("");
setDiscountAmount("");
setVoucherCode("");
setLastOrder(payload.data?.transactionId ?? "Đã thanh toán");
setMessage("Thanh toán thành công");
setMobileCartOpen(false);
router.refresh();
});
}
const cartPanel = (
<aside className={mobileCartOpen ? "pos-cart-drawer pos-cart-drawer--open" : "pos-cart-drawer"}>
<div className="pos-cart-head">
<div>
<span className="pos-kicker">Đơn hàng</span>
<h2>{itemCount} món</h2>
</div>
<button className="pos-icon-btn" onClick={clearCart} aria-label="Xóa giỏ">
<Trash2 size={17} />
</button>
</div>
<label className="pos-field">
<span>Bàn / phòng</span>
<select value={tableId} onChange={(event) => setTableId(event.target.value)}>
<option value="">Khách lẻ</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.zone ? `${table.zone} / ` : ""}
{table.tableNumber}
</option>
))}
</select>
</label>
<div className="pos-cart-lines">
{cart.map((line) => (
<div key={line.product.id} className="pos-cart-item">
<div>
<strong>{line.product.name}</strong>
<span>{formatCurrency(line.product.price)}</span>
</div>
<div className="pos-stepper">
<button onClick={() => changeQuantity(line.product.id, -1)} aria-label="Giảm">
<Minus size={14} />
</button>
<b>{line.quantity}</b>
<button onClick={() => changeQuantity(line.product.id, 1)} aria-label="Tăng">
<Plus size={14} />
</button>
</div>
</div>
))}
{cart.length === 0 ? <div className="pos-empty">Chưa món trong đơn</div> : null}
</div>
<div className="pos-discount-grid">
<label className="pos-field">
<span> voucher</span>
<input value={voucherCode} onChange={(event) => setVoucherCode(event.target.value)} placeholder="GG-..." />
</label>
<label className="pos-field">
<span>Giảm giá</span>
<input
inputMode="numeric"
value={discountAmount}
onChange={(event) => setDiscountAmount(event.target.value)}
placeholder="0"
/>
</label>
</div>
<div className="pos-method-grid">
{paymentMethods.map((method) => {
const Icon = method.icon;
return (
<button
key={method.id}
className={paymentMethod === method.id ? "pos-method pos-method--active" : "pos-method"}
onClick={() => setPaymentMethod(method.id)}
>
<Icon size={18} />
<span>{method.label}</span>
</button>
);
})}
</div>
{paymentMethod === "cash" ? (
<div className="pos-cash-box">
<label className="pos-field">
<span>Khách đưa</span>
<input
inputMode="numeric"
value={amountTendered}
onChange={(event) => setAmountTendered(event.target.value)}
placeholder={String(total)}
/>
</label>
<div className="pos-quick-amounts">
{quickAmounts.map((amount) => (
<button key={amount} onClick={() => setAmountTendered(String(amount))}>
{formatCurrency(amount)}
</button>
))}
</div>
</div>
) : null}
<div className="pos-total-box">
<span>Tạm tính</span>
<b>{formatCurrency(subtotal)}</b>
<span>Giảm giá</span>
<b>{formatCurrency(discount)}</b>
<span>Tiền thừa</span>
<b>{formatCurrency(change)}</b>
<strong>Tổng cộng</strong>
<strong>{formatCurrency(total)}</strong>
</div>
{message ? (
<div className={message.includes("Không") ? "pos-notice pos-notice--error" : "pos-notice pos-notice--success"}>
{message}
{lastOrder ? <span>{lastOrder}</span> : null}
</div>
) : null}
<button className="pos-pay-btn" onClick={submitOrder} disabled={!canPay || isPending}>
{isPending ? <ReceiptText size={18} /> : <Check size={18} />}
<span>{isPending ? "Đang xử lý" : "Thanh toán"}</span>
</button>
</aside>
);
return (
<div className="pos-terminal">
<header className="pos-topbar">
<div className="pos-topbar__left">
<button className="pos-mobile-menu" aria-label="Mở menu">
<Menu size={20} />
</button>
<strong>aPOS POS</strong>
<span>{shop.name}</span>
</div>
<div className="pos-topbar__right">
<span className="pos-online">
<i />
Online
</span>
<span className="pos-clock">
<Clock size={15} />
{formatTime()}
</span>
<button className="pos-cart-toggle" onClick={() => setMobileCartOpen(true)} aria-label="Mở đơn hàng">
<ShoppingCart size={18} />
{itemCount > 0 ? <b>{itemCount}</b> : null}
</button>
<Link className="pos-admin-link" href={`/?shopId=${shop.id}`} aria-label="Quản lý">
<Settings size={18} />
</Link>
</div>
</header>
<div className="pos-terminal-body">
<nav className="pos-rail" aria-label="POS verticals">
{verticalNav.map((item) => {
const Icon = item.icon;
const active = item.id === shop.vertical;
return (
<button key={item.id} className={active ? "pos-rail-link pos-rail-link--active" : "pos-rail-link"}>
<Icon size={18} />
<span>{item.label}</span>
</button>
);
})}
<Link className="pos-rail-link pos-rail-link--portal" href={`/?shopId=${shop.id}`}>
<Settings size={18} />
<span>Quản </span>
</Link>
</nav>
<main className="pos-sale-panel">
{activeTab === "sale" ? (
<>
<div className="pos-sale-toolbar">
<div>
<span className="pos-kicker">{shop.vertical}</span>
<h1>Bán hàng</h1>
</div>
<label className="pos-search">
<Search size={17} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="SKU, barcode, tên món" />
</label>
</div>
<div className="pos-category-tabs-next">
<button
className={categoryId === "all" ? "pos-category-tab-next pos-category-tab-next--active" : "pos-category-tab-next"}
onClick={() => setCategoryId("all")}
>
Tất cả
</button>
{categories.map((category) => (
<button
key={category.id}
className={categoryId === category.id ? "pos-category-tab-next pos-category-tab-next--active" : "pos-category-tab-next"}
onClick={() => setCategoryId(category.id)}
>
{category.name}
</button>
))}
</div>
<div className="pos-product-grid-next">
{filteredProducts.map((product) => (
<button key={product.id} className="pos-product-card-next" onClick={() => addProduct(product)}>
<span className="pos-product-thumb">{product.name.slice(0, 1).toUpperCase()}</span>
<strong>{product.name}</strong>
<small>{product.categoryName ?? product.productType}</small>
<b>{formatCurrency(product.price)}</b>
{product.stockQuantity !== null ? (
<em className={product.stockQuantity <= 5 ? "pos-stock pos-stock--low" : "pos-stock"}>
Tồn {product.stockQuantity}
</em>
) : null}
</button>
))}
{filteredProducts.length === 0 ? <div className="pos-empty">Không tìm thấy sản phẩm</div> : null}
</div>
</>
) : null}
{activeTab === "history" ? (
<div className="pos-secondary-screen">
<History size={24} />
<h1>Lịch sử đơn</h1>
<p>{lastOrder ?? "Chưa có giao dịch trong phiên này"}</p>
</div>
) : null}
{activeTab === "dashboard" ? (
<div className="pos-secondary-screen">
<BarChart3 size={24} />
<h1>Dashboard bán hàng</h1>
<p>{itemCount} món đang chọn · {formatCurrency(total)} tổng đơn hiện tại</p>
</div>
) : null}
{activeTab === "settings" ? (
<div className="pos-secondary-screen">
<Settings size={24} />
<h1>Cấu hình thanh toán</h1>
<p>Tiền mặt, thẻ, QR chuyển khoản đang bật cho ca bán hiện tại.</p>
</div>
) : null}
</main>
<nav className="pos-mode-nav" aria-label="POS tabs">
{modeTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={activeTab === tab.id ? "pos-mode-tab pos-mode-tab--active" : "pos-mode-tab"}
onClick={() => setActiveTab(tab.id)}
>
<Icon size={18} />
<span>{tab.label}</span>
</button>
);
})}
</nav>
{cartPanel}
</div>
<nav className="pos-bottom-nav-next" aria-label="POS tabs">
{modeTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={activeTab === tab.id ? "pos-bottom-tab pos-bottom-tab--active" : "pos-bottom-tab"}
onClick={() => setActiveTab(tab.id)}
>
<Icon size={18} />
<span>{tab.label}</span>
</button>
);
})}
</nav>
{mobileCartOpen ? (
<button className="pos-drawer-backdrop" onClick={() => setMobileCartOpen(false)} aria-label="Đóng đơn hàng">
<X size={20} />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
export const currency = new Intl.NumberFormat("vi-VN", {
style: "currency",
currency: "VND",
maximumFractionDigits: 0
});
export function money(value: number) {
return currency.format(value);
}
export function PageHeader({
eyebrow,
title,
children
}: {
eyebrow?: string;
title: string;
children?: ReactNode;
}) {
return (
<div className="page-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h1>{title}</h1>
</div>
{children ? <div className="page-actions">{children}</div> : null}
</div>
);
}
export function Metric({
label,
value,
tone = "neutral"
}: {
label: string;
value: string | number;
tone?: "neutral" | "good" | "warn" | "info";
}) {
return (
<div className={`metric ${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function StatusPill({ label, tone = "neutral" }: { label: string; tone?: "neutral" | "good" | "warn" | "bad" }) {
return <span className={`status-pill ${tone}`}>{label}</span>;
}
export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="empty-state">
<strong>{title}</strong>
{children ? <span>{children}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import Link from "next/link";
import { Coffee, QrCode, ShoppingCart } from "lucide-react";
import type { Product, ProductCategory, Shop, TableInfo } from "@/server/domain/types";
const currency = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 });
export function PublicMenu({
shop,
categories,
products,
table
}: {
shop: Shop;
categories: ProductCategory[];
products: Product[];
table?: TableInfo | null;
}) {
return (
<main className="customer-menu">
<header className="customer-menu__hero">
<div>
<span className="eyebrow">QR ORDERING</span>
<h1>{shop.name}</h1>
<p>{table ? `Đang gọi món tại bàn/phòng ${table.tableNumber}` : "Menu công khai cho khách hàng"}</p>
</div>
<QrCode size={44} />
</header>
<section className="customer-menu__content">
{categories.map((category) => {
const items = products.filter((product) => product.categoryId === category.id);
if (items.length === 0) return null;
return (
<article key={category.id} className="menu-category">
<div className="menu-category__head">
<Coffee size={20} />
<h2>{category.name}</h2>
</div>
<div className="menu-items">
{items.map((product) => (
<div key={product.id} className="menu-item">
<div>
<strong>{product.name}</strong>
<span>{product.description ?? product.categoryName ?? "GoodGo item"}</span>
</div>
<b>{currency.format(product.price)}</b>
</div>
))}
</div>
</article>
);
})}
</section>
<footer className="customer-menu__footer">
<Link href={`/pos/${shop.id}/${shop.vertical ?? "cafe"}`} className="primary-action">
<ShoppingCart size={16} />
Mở POS nhân viên
</Link>
</footer>
</main>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Activity,
Boxes,
ClipboardList,
Gauge,
Grid3X3,
PackagePlus,
Settings,
Store,
TerminalSquare
} from "lucide-react";
import type { ReactNode } from "react";
import type { Shop } from "@/server/domain/types";
const nav = [
{ href: "/", label: "Tổng quan", icon: Gauge },
{ href: "/pos", label: "Bán hàng", icon: TerminalSquare },
{ href: "/catalog", label: "Sản phẩm", icon: PackagePlus },
{ href: "/inventory", label: "Kho", icon: Boxes },
{ href: "/tables", label: "Bàn/phòng", icon: Grid3X3 },
{ href: "/orders", label: "Đơn hàng", icon: ClipboardList },
{ href: "/settings", label: "Cài đặt", icon: Settings }
];
export function Shell({
shops,
currentShop,
children
}: {
shops: Shop[];
currentShop: Shop | null;
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const params = useSearchParams();
const shopQuery = currentShop ? `?shopId=${currentShop.id}` : "";
function switchShop(shopId: string) {
const next = new URLSearchParams(params.toString());
if (shopId) next.set("shopId", shopId);
router.push(`${pathname}?${next.toString()}`);
}
return (
<div className="app-shell">
<aside className="sidebar">
<Link className="brand" href={shopQuery ? `/${shopQuery}` : "/"}>
<span className="brand-mark">a</span>
<span>
<strong>aPOS</strong>
<small>GoodGo MVP</small>
</span>
</Link>
<div className="shop-switcher">
<span className="eyebrow">Cửa hàng</span>
<label className="select-field compact">
<Store size={16} />
<select value={currentShop?.id ?? ""} onChange={(event) => switchShop(event.target.value)}>
{shops.length === 0 ? <option value="">Chưa cửa hàng</option> : null}
{shops.map((shop) => (
<option key={shop.id} value={shop.id}>
{shop.name}
</option>
))}
</select>
</label>
</div>
<nav className="side-nav">
{nav.map((item) => {
const active = pathname === item.href;
const Icon = item.icon;
const href = item.href === "/" ? `/${shopQuery}` : `${item.href}${shopQuery}`;
return (
<Link key={item.href} href={href} className={active ? "side-link active" : "side-link"}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="sidebar-status">
<Activity size={16} />
<span>Monolith MVP</span>
</div>
</aside>
<main className="main-shell">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import type { FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, Lock, Mail, ShieldCheck, Store, Users } from "lucide-react";
const roleCards = [
{ role: "admin", title: "Quản trị", href: "/auth/login/admin", icon: Store, email: "admin@goodgo.vn", password: "Admin@123" },
{ role: "staff", title: "Nhân viên", href: "/auth/login/staff", icon: Users, email: "staff@goodgo.vn", password: "Staff@123" },
{ role: "customer", title: "Khách hàng", href: "/auth/login/customer", icon: Mail, email: "customer@goodgo.vn", password: "Customer@123" },
{ role: "superadmin", title: "Super Admin", href: "/auth/login/superadmin", icon: ShieldCheck, email: "superadmin@goodgo.vn", password: "SuperAdmin@123" }
];
export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; role?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const selected = useMemo(() => roleCards.find((card) => card.role === role) ?? roleCards[0], [role]);
const [email, setEmail] = useState(selected.email);
const [password, setPassword] = useState(selected.password);
const [message, setMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const SelectedIcon = selected.icon;
async function submit(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
setMessage(null);
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 } };
if (!response.ok || !payload.success) {
setMessage(payload.error ?? "Không thể đăng nhập");
return;
}
const returnUrl = searchParams.get("returnUrl");
if (returnUrl) {
router.push(returnUrl);
return;
}
const portal = payload.data?.roles?.[0]?.portal ?? "admin";
router.push(portal === "staff" ? "/staff" : portal === "superadmin" ? "/superadmin" : portal === "customer" ? "/" : "/admin");
router.refresh();
});
}
if (mode !== "login") {
return (
<main className="auth-screen">
<section className="auth-copy">
<span className="eyebrow">aPOS account</span>
<h1>{mode === "register" ? "Tạo tài khoản vận hành" : "Khôi phục truy cập"}</h1>
<p>Luồng này giữ route parity với TPOS gốc. Bản MVP lưu trạng thái tài khoản trong DB dùng httpOnly session.</p>
</section>
<section className="auth-card">
<div className="auth-state">
<ShieldCheck size={38} />
<h2>{mode === "register" ? "Đăng ký merchant" : "Xác thực bảo mật"}</h2>
<p>Endpoint đã sẵn sàng trong BFF; màn hình chi tiết sẽ nối vào workflow IAM khi bật onboarding đy đ.</p>
</div>
</section>
</main>
);
}
return (
<main className="auth-screen">
<section className="auth-copy">
<span className="eyebrow">GoodGo TPOS</span>
<h1>Đăng nhập theo vai trò</h1>
<p>Giao diện Next dùng lại hình BFF session của TPOS: cookie httpOnly, role portal redirect theo quyền.</p>
<div className="auth-role-grid">
{roleCards.map((card) => {
const Icon = card.icon;
return (
<button
type="button"
key={card.role}
className={card.role === selected.role ? "auth-role auth-role--active" : "auth-role"}
onClick={() => {
setEmail(card.email);
setPassword(card.password);
router.push(card.href);
}}
>
<Icon size={18} />
<span>{card.title}</span>
</button>
);
})}
</div>
</section>
<form className="auth-card" onSubmit={submit}>
<div className="auth-card__head">
<SelectedIcon size={28} />
<div>
<span>{selected.title}</span>
<h2>Đăng nhập</h2>
</div>
</div>
<label className="auth-field">
<span>Email</span>
<div>
<Mail size={16} />
<input value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="email" />
</div>
</label>
<label className="auth-field">
<span>Mật khẩu</span>
<div>
<Lock size={16} />
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" />
</div>
</label>
{message ? <div className="auth-message">{message}</div> : null}
<button className="auth-submit" type="submit" disabled={isPending}>
<span>{isPending ? "Đang xác thực" : "Vào hệ thống"}</span>
<ArrowRight size={18} />
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,27 @@
import { Suspense } from "react";
import { TposAuth } from "@/components/TposAuth";
type TposAuthBoundaryProps = {
mode?: "login" | "register" | "recover";
role?: string;
};
function AuthFallback() {
return (
<main className="auth-screen">
<section className="auth-card" aria-busy="true">
<p className="auth-eyebrow">TPOS MVP</p>
<h1>Dang tai...</h1>
</section>
</main>
);
}
export function TposAuthBoundary(props: TposAuthBoundaryProps) {
return (
<Suspense fallback={<AuthFallback />}>
<TposAuth {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,268 @@
import Link from "next/link";
import {
ArrowUpRight,
ChevronRight,
Clock,
Database,
Globe2,
Plus,
ShieldCheck,
Store
} 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 };
export type PortalPayload = {
title: string;
subtitle: string;
shop?: { id: string; name: string; vertical?: string; status?: string } | null;
metrics?: Metric[];
primary?: ListItem[];
secondary?: ListItem[];
status?: Array<{ label: string; value: string; tone?: Metric["tone"] }>;
};
function labelFromPath(kind: PortalKind, segments: string[]) {
if (kind === "admin" && segments[0] === "shop") return segments[2] ?? "overview";
return segments.join("/") || (kind === "superadmin" ? "dashboard" : kind);
}
export function TposPortal({
kind,
path,
payload
}: {
kind: PortalKind;
path: string[];
payload: PortalPayload;
}) {
const nav = portalNav[kind];
const active = labelFromPath(kind, path);
return (
<main className={`portal-shell portal-shell--${kind}`}>
<aside className="portal-sidebar">
<Link href="/" className="portal-brand">
<span>aPOS</span>
<b>{kind === "superadmin" ? "Platform" : kind === "marketing" ? "Marketing" : "TPOS"}</b>
</Link>
<nav className="portal-nav">
{nav.map(([label, , href, Icon]) => (
<Link key={href} href={href} className={active && href.includes(active) ? "portal-nav__item portal-nav__item--active" : "portal-nav__item"}>
<Icon size={18} />
<span>{label}</span>
</Link>
))}
</nav>
<div className="portal-session">
<ShieldCheck size={16} />
<span>httpOnly session</span>
</div>
</aside>
<section className="portal-main">
<header className="portal-top">
<div>
<span className="eyebrow">{kind.toUpperCase()} / {active}</span>
<h1>{payload.title}</h1>
<p>{payload.subtitle}</p>
</div>
<div className="portal-actions">
{payload.shop ? (
<Link className="ghost-action" href={`/pos/${payload.shop.id}/${payload.shop.vertical ?? "cafe"}`}>
Mở POS
<ArrowUpRight size={16} />
</Link>
) : null}
<button className="primary-action">
<Plus size={16} />
Tạo mới
</button>
</div>
</header>
<section className="metric-grid">
{(payload.metrics ?? defaultMetrics(kind)).map((metric) => (
<article key={metric.label} className={`metric-card metric-card--${metric.tone ?? "orange"}`}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
</article>
))}
</section>
<section className="portal-grid">
<article className="portal-panel portal-panel--wide">
<div className="panel-head">
<div>
<span className="eyebrow">WORKFLOW</span>
<h2>{payload.title}</h2>
</div>
<Database size={18} />
</div>
<div className="portal-list">
{(payload.primary ?? defaultItems(kind)).map((item) => (
<LinkOrDiv key={item.id ?? item.title} item={item} />
))}
</div>
</article>
<article className="portal-panel">
<div className="panel-head">
<div>
<span className="eyebrow">STATUS</span>
<h2>Vận hành</h2>
</div>
<Clock size={18} />
</div>
<div className="status-stack">
{(payload.status ?? [
{ label: "BFF", value: "Ready", tone: "green" },
{ label: "DB", value: "Postgres", tone: "blue" },
{ label: "Integrations", value: "Configured", tone: "orange" }
]).map((item) => (
<div key={item.label} className="status-row">
<span>{item.label}</span>
<b className={`tone-${item.tone ?? "orange"}`}>{item.value}</b>
</div>
))}
</div>
</article>
{payload.shop ? <ShopSectionPanel shop={payload.shop} /> : null}
<article className="portal-panel">
<div className="panel-head">
<div>
<span className="eyebrow">LINKS</span>
<h2>Route parity</h2>
</div>
<Globe2 size={18} />
</div>
<div className="portal-list portal-list--compact">
{(payload.secondary ?? defaultSecondary(kind)).map((item) => (
<LinkOrDiv key={item.id ?? item.title} item={item} />
))}
</div>
</article>
</section>
</section>
</main>
);
}
function LinkOrDiv({ item }: { item: ListItem }) {
const inner = (
<>
<div>
<strong>{item.title}</strong>
{item.meta ? <span>{item.meta}</span> : null}
</div>
<b>{item.value}</b>
<ChevronRight size={15} />
</>
);
return item.href ? (
<Link className="portal-list__item" href={item.href}>
{inner}
</Link>
) : (
<div className="portal-list__item">{inner}</div>
);
}
function ShopSectionPanel({ shop }: { shop: NonNullable<PortalPayload["shop"]> }) {
const vertical = ((shop.vertical ?? "cafe") as VerticalKind) in shopSections ? (shop.vertical as VerticalKind) : "cafe";
const sections = shopSections[vertical] ?? shopSections.cafe;
return (
<article className="portal-panel portal-panel--wide">
<div className="panel-head">
<div>
<span className="eyebrow">{shop.name}</span>
<h2>Menu quản theo ngành</h2>
</div>
<Store size={18} />
</div>
<div className="shop-section-grid">
{sections.map(([label, slug, Icon]) => (
<Link key={slug} className="shop-section" href={`/admin/shop/${shop.id}/${slug}`}>
<Icon size={17} />
<span>{label}</span>
</Link>
))}
</div>
</article>
);
}
function defaultMetrics(kind: PortalKind): Metric[] {
if (kind === "superadmin") return [
{ label: "Merchants", value: 0, tone: "orange" },
{ label: "Users", value: 0, tone: "blue" },
{ label: "Health", value: "OK", tone: "green" }
];
if (kind === "marketing") return [
{ label: "Campaigns", value: 0, tone: "orange" },
{ label: "Channels", value: 4, tone: "blue" },
{ label: "AI", value: "Ready", tone: "green" }
];
return [
{ label: "Doanh thu", value: "0đ", tone: "orange" },
{ label: "Đơn hàng", value: 0, tone: "green" },
{ label: "Nhân sự", value: 0, tone: "blue" }
];
}
function defaultItems(kind: PortalKind): ListItem[] {
if (kind === "staff") return [
{ title: "Check-in ca làm", meta: "Điểm danh và mở ca", value: "Ready", href: "/staff/attendance" },
{ title: "Bếp", meta: "Xử lý kitchen tickets", value: "KDS", href: "/staff/kitchen" },
{ title: "Bàn/phòng", meta: "Theo dõi khu vực phục vụ", value: "Live", href: "/staff/tables" }
];
if (kind === "marketing") return [
{ title: "Social hub", meta: "Facebook/Zalo/WhatsApp/X adapters", value: "External", href: "/marketing" },
{ title: "Content studio", meta: "Lên nội dung và chiến dịch", value: "AI", href: "/marketing/content" },
{ title: "Analytics", meta: "Hiệu quả kênh và CRM", value: "BFF", href: "/marketing/analytics" }
];
return [
{ title: "Catalog", meta: "Sản phẩm và danh mục", value: "DB", href: "/catalog" },
{ title: "Inventory", meta: "Nhập/xuất/stocktake", value: "DB", href: "/inventory" },
{ title: "Orders", meta: "POS, payment ledger, reports", value: "Live", href: "/orders" }
];
}
function defaultSecondary(kind: PortalKind): ListItem[] {
if (kind === "superadmin") return [
{ title: "System health", href: "/superadmin/system/health" },
{ title: "Feature flags", href: "/superadmin/system/flags" },
{ title: "Audit log", href: "/superadmin/system/audit" }
];
return [
{ title: "Customer menu", href: "/" },
{ title: "POS Café", href: "/pos" },
{ title: "Settings", href: "/settings" }
];
}
export function buildPortalPayload(kind: PortalKind, input: {
shop?: PortalPayload["shop"];
stats?: Record<string, unknown>;
title?: string;
path?: string[];
items?: ListItem[];
}): 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.",
shop: input.shop,
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
};
}

View File

@@ -0,0 +1,497 @@
"use client";
import Link from "next/link";
import { useMemo, useState, useTransition } from "react";
import {
ArrowLeft,
Banknote,
BarChart3,
Building2,
Check,
Clock,
Coffee,
CreditCard,
DoorOpen,
Grid3X3,
History,
Minus,
Plus,
ReceiptText,
Search,
Settings,
ShoppingCart,
Smartphone,
Sparkles,
Trash2,
UtensilsCrossed
} from "lucide-react";
import { posWorkflows, verticals, type VerticalKind } from "./tpos-config";
import type { OrderSummary, Product, ProductCategory, Shop, TableInfo } from "@/server/domain/types";
type CartLine = { product: Product; quantity: number };
type PosTab = "sale" | "history" | "dashboard" | "settings";
const currency = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 });
const verticalIcons: Record<string, typeof Coffee> = {
cafe: Coffee,
restaurant: UtensilsCrossed,
karaoke: DoorOpen,
spa: Sparkles,
beauty: Sparkles,
retail: ShoppingCart
};
const paymentMethods = [
{ id: "cash", label: "Tiền mặt", icon: Banknote },
{ id: "card", label: "Thẻ", icon: CreditCard },
{ id: "qr", label: "QR", icon: Smartphone },
{ id: "transfer", label: "Chuyển khoản", icon: Building2 }
];
const posTabs: Array<{ id: PosTab; label: string; icon: typeof Coffee }> = [
{ id: "sale", label: "Bán hàng", icon: Coffee },
{ id: "history", label: "Lịch sử", icon: History },
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
{ id: "settings", label: "Cài đặt", icon: Settings }
];
export function TposPosExperience({
shop,
vertical,
workflow,
products,
categories,
tables,
orders,
dashboard
}: {
shop: Shop;
vertical: VerticalKind;
workflow?: string[];
products: Product[];
categories: ProductCategory[];
tables: TableInfo[];
orders: OrderSummary[];
dashboard: Record<string, unknown>;
}) {
const [cart, setCart] = useState<CartLine[]>([]);
const [categoryId, setCategoryId] = useState("all");
const [query, setQuery] = useState("");
const [selectedTable, setSelectedTable] = useState(tables[0]?.id ?? "");
const [activeTab, setActiveTab] = useState<PosTab>("sale");
const [paymentMethod, setPaymentMethod] = useState("cash");
const [amountTendered, setAmountTendered] = useState("");
const [voucher, setVoucher] = useState("");
const [discount, setDiscount] = useState(0);
const [message, setMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const Icon = verticalIcons[vertical] ?? Coffee;
const workflowSlug = workflow?.[0];
const filteredProducts = useMemo(() => {
const normalized = query.trim().toLowerCase();
return products.filter((product) => {
const byCategory = categoryId === "all" || product.categoryId === categoryId;
const byQuery = !normalized || product.name.toLowerCase().includes(normalized) || product.sku?.toLowerCase().includes(normalized) || product.barcode?.toLowerCase().includes(normalized);
return byCategory && byQuery;
});
}, [categoryId, products, query]);
const subtotal = cart.reduce((sum, line) => sum + line.product.price * line.quantity, 0);
const total = Math.max(0, subtotal - discount);
const received = paymentMethod === "cash" ? Number(amountTendered || 0) : total;
const change = Math.max(0, received - total);
const canPay = cart.length > 0 && (paymentMethod !== "cash" || received >= total);
const quickAmounts = [total, Math.ceil(total / 10000) * 10000, Math.ceil(total / 10000) * 10000 + 20000, Math.ceil(total / 10000) * 10000 + 50000]
.filter((value, index, all) => value > 0 && all.indexOf(value) === index)
.slice(0, 4);
function addProduct(product: Product) {
setMessage(null);
setCart((current) => {
const existing = current.find((line) => line.product.id === product.id);
if (existing) {
return current.map((line) => line.product.id === product.id ? { ...line, quantity: line.quantity + 1 } : line);
}
return [...current, { product, quantity: 1 }];
});
}
function changeQty(productId: string, delta: number) {
setCart((current) => current
.map((line) => line.product.id === productId ? { ...line, quantity: Math.max(0, line.quantity + delta) } : line)
.filter((line) => line.quantity > 0));
}
async function validateVoucherCode() {
if (!voucher.trim()) return;
const response = await fetch(`/api/bff/vouchers/validate/${encodeURIComponent(voucher.trim())}`);
const payload = await response.json() as { success: boolean; data?: { valid?: boolean; discountType?: string; discountValue?: number; message?: string } };
if (payload.success && payload.data?.valid) {
const value = Number(payload.data.discountValue ?? 0);
setDiscount(payload.data.discountType === "percent" ? Math.round(subtotal * value / 100) : Math.min(subtotal, value));
setMessage("Voucher đã áp dụng");
} else {
setMessage(payload.data?.message ?? "Voucher không hợp lệ");
}
}
async function submitPayment() {
if (!canPay) return;
setMessage(null);
startTransition(async () => {
const response = await fetch("/api/bff/pos/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
tableId: selectedTable || null,
paymentMethod,
amountTendered: paymentMethod === "cash" ? received : total,
discountAmount: discount,
discountType: discount > 0 ? "voucher" : null,
discountReference: voucher || null,
items: cart.map((line) => ({ productId: line.product.id, quantity: line.quantity }))
})
});
const payload = await response.json() as { success: boolean; error?: string; data?: { transactionId?: string } };
if (!response.ok || !payload.success) {
setMessage(payload.error ?? "Không thể thanh toán");
return;
}
setCart([]);
setAmountTendered("");
setDiscount(0);
setVoucher("");
setMessage(`Thanh toán thành công ${payload.data?.transactionId ?? ""}`);
});
}
if (workflowSlug) {
return <WorkflowScreen shop={shop} vertical={vertical} slug={workflowSlug} products={products} tables={tables} orders={orders} dashboard={dashboard} />;
}
return (
<main className="pos-layout pos-clone">
<header className="pos-status-bar">
<div className="pos-status-bar__left">
<Link className="pos-payment-header__back" href="/admin">
<ArrowLeft size={16} />
</Link>
<span className="pos-status-bar__logo">aPOS POS</span>
<span className="pos-status-bar__store">{shop.name}</span>
</div>
<div className="pos-status-bar__right">
<span className="pos-status-bar__indicator pos-status-bar__indicator--online"><i />Online</span>
<span className="pos-status-bar__time"><Clock size={14} />{new Date().toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" })}</span>
<Link className="admin-icon-btn pos-admin-btn" href={`/admin/shop/${shop.id}/overview`}><Settings size={18} /></Link>
</div>
</header>
<section className="pos-main">
<aside className="pos-sidebar">
<div className="pos-sidebar__header">
<span>Menu</span>
</div>
<div className="pos-sidebar__nav">
{verticals.map((item) => {
const NavIcon = item.icon;
return (
<Link key={item.id} className={item.id === vertical ? "pos-sidebar__link pos-sidebar__link--active" : "pos-sidebar__link"} href={`/pos/${shop.id}/${item.id}`}>
<NavIcon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</div>
</aside>
<section className="pos-page-content">
<nav className="pos-bottom-nav">
{posTabs.map(({ id, label, icon: TabIcon }) => (
<button key={id} className={activeTab === id ? "pos-bottom-nav__tab pos-bottom-nav__tab--active" : "pos-bottom-nav__tab"} onClick={() => setActiveTab(id)}>
<TabIcon size={19} />
<span>{label}</span>
</button>
))}
</nav>
{activeTab === "sale" ? (
<section className="pos-content-area">
<div className="pos-product-panel">
<div className="pos-sale-toolbar">
<div>
<span className="pos-kicker">{vertical.toUpperCase()}</span>
<h1>{vertical === "restaurant" ? "Sơ đồ & gọi món" : vertical === "karaoke" ? "Phòng & F&B" : "Bán hàng"}</h1>
</div>
<label className="pos-search">
<Search size={17} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="SKU, barcode, tên món" />
</label>
</div>
{(vertical === "restaurant" || vertical === "karaoke") && tables.length > 0 ? (
<div className="pos-table-strip">
{tables.map((table) => (
<button key={table.id} className={selectedTable === table.id ? "pos-table-tile pos-table-tile--active" : "pos-table-tile"} onClick={() => setSelectedTable(table.id)}>
<Grid3X3 size={16} />
<strong>{vertical === "karaoke" ? "Phòng" : "Bàn"} {table.tableNumber}</strong>
<span>{table.zone ?? table.status}</span>
</button>
))}
</div>
) : null}
<div className="pos-category-tabs">
<button className={categoryId === "all" ? "pos-category-tab pos-category-tab--active" : "pos-category-tab"} onClick={() => setCategoryId("all")}>Tất cả</button>
{categories.map((category) => (
<button key={category.id} className={categoryId === category.id ? "pos-category-tab pos-category-tab--active" : "pos-category-tab"} onClick={() => setCategoryId(category.id)}>
{category.name}
</button>
))}
</div>
<div className="pos-product-grid">
{filteredProducts.map((product) => (
<button key={product.id} className="pos-product-card" onClick={() => addProduct(product)}>
<span className="pos-product-card__image"><Icon size={32} /></span>
<span className="pos-product-card__name">{product.name}</span>
<span className="pos-product-card__price">{currency.format(product.price)}</span>
{product.stockQuantity !== null ? <small>Tồn {product.stockQuantity}</small> : null}
</button>
))}
</div>
</div>
<aside className="pos-cart-panel">
<div className="pos-cart-header">
<span className="pos-cart-header__title">Đơn hàng</span>
<button className="pos-payment-header__back" onClick={() => setCart([])}><Trash2 size={15} /></button>
</div>
<div className="pos-cart-items">
{cart.map((line) => (
<div className="pos-cart-item" key={line.product.id}>
<div className="pos-cart-item__info">
<span className="pos-cart-item__name">{line.product.name}</span>
<span className="pos-cart-item__price">{currency.format(line.product.price)}</span>
</div>
<div className="pos-cart-item__qty">
<button onClick={() => changeQty(line.product.id, -1)}><Minus size={13} /></button>
<b>{line.quantity}</b>
<button onClick={() => changeQty(line.product.id, 1)}><Plus size={13} /></button>
</div>
</div>
))}
{cart.length === 0 ? <div className="pos-empty">Chọn món từ thực đơn bên trái</div> : null}
</div>
<div className="pos-cart-footer">
<div className="pos-voucher-row">
<input value={voucher} onChange={(event) => setVoucher(event.target.value)} placeholder="Mã voucher" />
<button onClick={validateVoucherCode}>Áp dụng</button>
</div>
<div className="pos-payment-methods">
{paymentMethods.map((method) => {
const PayIcon = method.icon;
return (
<button key={method.id} className={paymentMethod === method.id ? "pos-payment-method-btn pos-payment-method-btn--selected" : "pos-payment-method-btn"} onClick={() => setPaymentMethod(method.id)}>
<PayIcon size={22} />
<span>{method.label}</span>
</button>
);
})}
</div>
{paymentMethod === "cash" ? (
<div className="pos-cash-box">
<input value={amountTendered} onChange={(event) => setAmountTendered(event.target.value)} placeholder="Khách đưa" inputMode="numeric" />
<div className="pos-payment-quick-amounts">
{quickAmounts.map((amount) => <button key={amount} onClick={() => setAmountTendered(String(amount))}>{currency.format(amount)}</button>)}
</div>
</div>
) : null}
<div className="pos-total-box">
<span>Tạm tính</span><b>{currency.format(subtotal)}</b>
<span>Giảm giá</span><b>{currency.format(discount)}</b>
<span>Tiền thối</span><b>{currency.format(change)}</b>
<strong>Tổng cộng</strong><strong>{currency.format(total)}</strong>
</div>
{message ? <div className={message.includes("Không") ? "pos-notice pos-notice--error" : "pos-notice pos-notice--success"}>{message}</div> : null}
<button className="pos-btn-checkout" disabled={!canPay || isPending} onClick={submitPayment}>
{isPending ? <ReceiptText size={18} /> : <Check size={18} />}
{isPending ? "Đang xử lý" : "Thanh toán"}
</button>
</div>
</aside>
</section>
) : null}
{activeTab === "history" ? <HistoryPanel orders={orders} /> : null}
{activeTab === "dashboard" ? <DashboardPanel dashboard={dashboard} orders={orders} /> : null}
{activeTab === "settings" ? <SettingsPanel shop={shop} vertical={vertical} /> : null}
</section>
</section>
</main>
);
}
function HistoryPanel({ orders }: { orders: OrderSummary[] }) {
return (
<section className="pos-history">
<div className="pos-history__toolbar">
<input className="pos-history__search" placeholder="Tìm mã đơn, tên khách..." />
</div>
<div className="pos-history__list">
{orders.map((order) => (
<article key={order.id} className="pos-history__card">
<div className="pos-history__card-header">
<span className="pos-history__order-id">{order.id.slice(0, 8).toUpperCase()}</span>
<span className="pos-history__status pos-history__status--completed">{order.status}</span>
</div>
<div className="pos-history__card-body">
<span className="pos-history__items-preview">{order.itemCount} món</span>
<div className="pos-history__card-meta">
<div className="pos-history__total">{currency.format(order.totalAmount)}</div>
<div className="pos-history__time">{new Date(order.createdAt).toLocaleString("vi-VN")}</div>
<div className="pos-history__method">{order.paymentMethod ?? "cash"}</div>
</div>
</div>
</article>
))}
</div>
</section>
);
}
function DashboardPanel({ dashboard, orders }: { dashboard: Record<string, unknown>; orders: OrderSummary[] }) {
return (
<section className="pos-dashboard">
<div className="pos-dashboard__header">
<div>
<div className="pos-dashboard__title">Dashboard bán hàng</div>
<div className="pos-dashboard__subtitle">Realtime từ Order/Payment ledger</div>
</div>
</div>
<div className="pos-dashboard__stats">
<div className="pos-dashboard__stat-card"><span>Doanh thu</span><strong>{currency.format(Number(dashboard.revenue ?? 0))}</strong></div>
<div className="pos-dashboard__stat-card"><span>Đơn hàng</span><strong>{Number(dashboard.orderCount ?? orders.length)}</strong></div>
<div className="pos-dashboard__stat-card"><span>Average ticket</span><strong>{currency.format(Number(dashboard.averageTicket ?? 0))}</strong></div>
</div>
<div className="pos-dashboard__grid">
<div className="pos-dashboard__section">
<div className="pos-dashboard__section-title">Đơn gần đây</div>
{orders.slice(0, 6).map((order) => (
<div className="pos-dashboard__popular-item" key={order.id}>
<span>{order.id.slice(0, 8)}</span>
<b>{currency.format(order.totalAmount)}</b>
</div>
))}
</div>
</div>
</section>
);
}
function SettingsPanel({ shop, vertical }: { shop: Shop; vertical: VerticalKind }) {
const workflows = [...(posWorkflows[vertical] ?? []), ...posWorkflows.shared];
return (
<section className="pos-secondary-screen">
<Settings size={28} />
<h1>Cài đt & workflow</h1>
<p>{shop.name} đang bật POS {vertical}. Các route workflow bên dưới mirror TPOS gốc.</p>
<div className="workflow-grid">
{workflows.map((workflow) => {
const WorkflowIcon = workflow.icon;
return (
<Link key={workflow.slug} href={`/pos/${shop.id}/${vertical}/${workflow.slug}`} className="workflow-card">
<WorkflowIcon size={20} />
<strong>{workflow.title}</strong>
<span>{workflow.description}</span>
</Link>
);
})}
</div>
</section>
);
}
function WorkflowScreen({
shop,
vertical,
slug,
products,
tables,
orders,
dashboard
}: {
shop: Shop;
vertical: VerticalKind;
slug: string;
products: Product[];
tables: TableInfo[];
orders: OrderSummary[];
dashboard: Record<string, unknown>;
}) {
const workflow = (posWorkflows[vertical] ?? posWorkflows.shared).find((item) => item.slug === slug) ?? posWorkflows.shared.find((item) => item.slug === slug);
const WorkflowIcon = workflow?.icon ?? Coffee;
return (
<main className="pos-layout pos-clone">
<header className="pos-status-bar">
<div className="pos-status-bar__left">
<Link className="pos-payment-header__back" href={`/pos/${shop.id}/${vertical}`}><ArrowLeft size={16} /></Link>
<span className="pos-status-bar__logo">aPOS POS</span>
<span className="pos-status-bar__store">{workflow?.title ?? slug}</span>
</div>
<div className="pos-status-bar__right"><span className="pos-status-bar__indicator pos-status-bar__indicator--online">Online</span></div>
</header>
<section className="workflow-shell">
<aside className="workflow-sidebar">
{[...(posWorkflows[vertical] ?? []), ...posWorkflows.shared].map((item) => {
const ItemIcon = item.icon;
return (
<Link key={item.slug} href={`/pos/${shop.id}/${vertical}/${item.slug}`} className={item.slug === slug ? "workflow-link workflow-link--active" : "workflow-link"}>
<ItemIcon size={17} />
<span>{item.title}</span>
</Link>
);
})}
</aside>
<section className="workflow-main">
<div className="workflow-hero">
<WorkflowIcon size={34} />
<div>
<span className="eyebrow">{vertical.toUpperCase()} WORKFLOW</span>
<h1>{workflow?.title ?? slug}</h1>
<p>{workflow?.description ?? "Workflow TPOS được port sang Next MVP."}</p>
</div>
</div>
<div className="workflow-grid workflow-grid--data">
<DataCard title="Sản phẩm" value={products.length} meta="Catalog service" />
<DataCard title="Bàn/phòng" value={tables.length} meta="FnB service" />
<DataCard title="Đơn hàng" value={orders.length} meta="Order service" />
<DataCard title="Doanh thu" value={currency.format(Number(dashboard.revenue ?? 0))} meta="Payment ledger" />
</div>
<div className="workflow-board">
{orders.slice(0, 8).map((order) => (
<article key={order.id} className="workflow-ticket">
<strong>{order.tableNumber ? `Bàn ${order.tableNumber}` : order.id.slice(0, 8)}</strong>
<span>{order.itemCount} món · {order.status}</span>
<b>{currency.format(order.totalAmount)}</b>
</article>
))}
{orders.length === 0 ? <div className="pos-empty">Chưa dữ liệu đơn hàng cho workflow này</div> : null}
</div>
</section>
</section>
</main>
);
}
function DataCard({ title, value, meta }: { title: string; value: string | number; meta: string }) {
return (
<article className="workflow-card">
<span>{meta}</span>
<strong>{value}</strong>
<b>{title}</b>
</article>
);
}

View File

@@ -0,0 +1,226 @@
import {
Activity,
BarChart3,
Bell,
Bot,
Calendar,
CalendarClock,
CheckSquare,
ClipboardList,
Coffee,
CreditCard,
DoorOpen,
FileText,
Flag,
Gift,
Grid3X3,
HardDrive,
Heart,
LayoutDashboard,
MessageSquare,
Monitor,
Package,
ReceiptText,
Search,
Settings,
Shield,
ShoppingBag,
Sparkles,
Store,
Tag,
TrendingUp,
UserCheck,
Users,
UtensilsCrossed,
Warehouse,
Wine
} from "lucide-react";
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 }> = [
{ 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: "retail", label: "Bán lẻ", icon: ShoppingBag }
];
export const portalNav = {
admin: [
["Tổng quan", "layout-dashboard", "/admin", LayoutDashboard],
["Cửa hàng", "store", "/admin/stores", Store],
["Người dùng", "users", "/admin/users", Users],
["Vai trò", "shield", "/admin/roles", Shield],
["Báo cáo EOD", "file-text", "/admin/reports/eod", FileText],
["Spa", "sparkles", "/admin/spa/appointments", Sparkles],
["Thiết bị", "monitor", "/admin/system/devices", Monitor],
["Tích hợp", "activity", "/admin/system/integrations", Activity],
["Cài đặt", "settings", "/admin/settings", Settings]
],
staff: [
["Dashboard", "layout-dashboard", "/staff", LayoutDashboard],
["POS", "monitor", "/staff/pos", Monitor],
["Bếp", "utensils", "/staff/kitchen", UtensilsCrossed],
["Bàn/phòng", "grid", "/staff/tables", Grid3X3],
["Điểm danh", "check", "/staff/attendance", CheckSquare],
["Lịch làm", "calendar", "/staff/schedule", Calendar],
["Nghỉ phép", "calendar-clock", "/staff/leave", CalendarClock],
["Thông báo", "bell", "/staff/notifications", Bell],
["Lương", "credit-card", "/staff/payroll", CreditCard]
],
superadmin: [
["Dashboard", "layout-dashboard", "/superadmin", LayoutDashboard],
["Merchants", "store", "/superadmin/merchants", Store],
["Subscriptions", "credit-card", "/superadmin/subscriptions", CreditCard],
["Users", "users", "/superadmin/users", Users],
["Roles", "shield", "/superadmin/roles", Shield],
["System health", "activity", "/superadmin/system/health", Activity],
["Audit log", "file-text", "/superadmin/system/audit", FileText],
["Flags", "flag", "/superadmin/system/flags", Flag],
["Settings", "settings", "/superadmin/settings", Settings]
],
marketing: [
["Social hub", "message", "/marketing", MessageSquare],
["Livechat", "message", "/marketing/livechat", MessageSquare],
["Customers", "heart", "/marketing/customers", Heart],
["Content studio", "file-text", "/marketing/content", FileText],
["Analytics", "bar", "/marketing/analytics", BarChart3],
["Chatbot", "bot", "/marketing/chatbot", Bot],
["AI assistant", "sparkles", "/marketing/ai-chatbot", Sparkles]
]
} as const;
export const shopSections = {
cafe: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Menu đồ uống", "menu", Coffee],
["Công thức", "recipes", ClipboardList],
["Ca bán", "shifts", CalendarClock],
["Kho", "inventory", Warehouse],
["Tài chính", "finance", TrendingUp],
["Nhân sự", "staff", Users],
["Khách hàng", "customers", Heart],
["Khuyến mãi", "promotions", Tag],
["AI chat", "ai-chat", Bot],
["Drive", "drive", HardDrive],
["Mẫu hóa đơn", "receipt-templates", ReceiptText],
["Cài đặt", "settings", Settings]
],
restaurant: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Menu món ăn", "menu", UtensilsCrossed],
["Bàn", "tables", Grid3X3],
["Đặt bàn", "reservations", Calendar],
["Bếp", "kitchen", UtensilsCrossed],
["Kho", "inventory", Warehouse],
["Tài chính", "finance", TrendingUp],
["Nhân sự", "staff", Users],
["Khuyến mãi", "promotions", Tag],
["Báo cáo", "reports", BarChart3],
["Cài đặt", "settings", Settings]
],
karaoke: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Phòng", "rooms", DoorOpen],
["Menu bar", "menu", Wine],
["Happy hour", "happy-hour", CalendarClock],
["Kho", "inventory", Warehouse],
["Tài chính", "finance", TrendingUp],
["Khách hàng", "customers", Heart],
["Báo cáo", "reports", BarChart3],
["Cài đặt", "settings", Settings]
],
spa: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Therapists", "therapists", UserCheck],
["Dịch vụ", "services", Sparkles],
["Packages", "packages", Gift],
["Resources", "resources", DoorOpen],
["Sản phẩm", "products", Package],
["Lịch làm", "schedule", CalendarClock],
["Khách hàng", "customers", Heart],
["Cài đặt", "settings", Settings]
],
beauty: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Lịch hẹn", "appointments", Calendar],
["Liệu trình", "treatments", ClipboardList],
["Consent", "consent", FileText],
["Bác sĩ", "doctors", UserCheck],
["Follow-up", "followup", CalendarClock],
["Khách hàng", "customers", Heart],
["Cài đặt", "settings", Settings]
],
retail: [
["Tổng quan", "overview", LayoutDashboard],
["POS", "pos", Monitor],
["Sản phẩm", "menu", Package],
["Kho", "inventory", Warehouse],
["Đổi trả", "returns", ReceiptText],
["Khách hàng", "customers", Heart],
["Khuyến mãi", "promotions", Tag],
["Cài đặt", "settings", Settings]
]
} as const;
export const posWorkflows: Record<VerticalKind | "shared", Array<{ slug: string; title: string; description: string; icon: typeof Coffee }>> = {
cafe: [
{ slug: "barista-queue", title: "Barista queue", description: "Điều phối pha chế, ready và delivered.", icon: Coffee },
{ slug: "loyalty-stamp", title: "Stamp card", description: "Tích điểm, đổi thưởng và chăm sóc khách.", icon: Gift },
{ slug: "daily-report", title: "Daily report", description: "Doanh thu, món bán chạy và payment mix.", icon: BarChart3 },
{ slug: "queue-display", title: "Customer display", description: "Màn hình gọi món cho khách.", icon: Monitor },
{ slug: "menu-management", title: "Menu management", description: "Quản lý nhóm món, giá và trạng thái bán.", icon: ClipboardList }
],
restaurant: [
{ slug: "table-map", title: "Sơ đồ bàn", description: "Theo dõi bàn trống, đã đặt, đang phục vụ.", icon: Grid3X3 },
{ slug: "table-select", title: "Chọn bàn", description: "Mở order theo bàn nhanh.", icon: CheckSquare },
{ slug: "kitchen-display", title: "KDS", description: "Phiếu bếp theo trạng thái.", icon: UtensilsCrossed },
{ slug: "reservation", title: "Đặt bàn", description: "Quản lý reservation theo ngày.", icon: Calendar },
{ slug: "waiter-pad", title: "Waiter pad", description: "Gọi món và gửi bếp tại bàn.", icon: ClipboardList },
{ slug: "eod-report", title: "EOD", description: "Đóng ngày và đối soát.", icon: FileText }
],
karaoke: [
{ slug: "room-map", title: "Sơ đồ phòng", description: "Tình trạng phòng, tổng bill và thời lượng.", icon: DoorOpen },
{ slug: "room-select", title: "Chọn phòng", description: "Mở phiên hát mới.", icon: CheckSquare },
{ slug: "order-fnb", title: "Order F&B", description: "Gọi đồ ăn/uống theo phòng.", icon: Wine },
{ slug: "happy-hour", title: "Happy hour", description: "Khung giá khuyến mãi.", icon: CalendarClock },
{ slug: "member-card", title: "Member card", description: "Thẻ thành viên karaoke.", icon: Heart },
{ slug: "peak-warning", title: "Peak warning", description: "Cảnh báo giờ cao điểm.", icon: Bell }
],
spa: [
{ slug: "appointment-book", title: "Đặt lịch", description: "Book dịch vụ, phòng và therapist.", icon: Calendar },
{ slug: "customer-lookup", title: "Tra khách", description: "Tìm hồ sơ và lịch sử liệu trình.", icon: Search },
{ slug: "staff-assign", title: "Phân công", description: "Chọn therapist theo ca.", icon: UserCheck },
{ slug: "treatment-timer", title: "Treatment timer", description: "Bấm giờ liệu trình.", icon: CalendarClock },
{ slug: "service-package", title: "Gói dịch vụ", description: "Bán package/combo.", icon: Gift },
{ slug: "therapist-schedule", title: "Lịch therapist", description: "Theo dõi availability.", icon: Calendar }
],
beauty: [
{ slug: "treatment-plan", title: "Treatment plan", description: "Phác đồ và giai đoạn điều trị.", icon: ClipboardList },
{ slug: "consent-form", title: "Consent form", description: "Ký xác nhận trước dịch vụ.", icon: FileText },
{ slug: "doctor-schedule", title: "Doctor schedule", description: "Lịch bác sĩ và tư vấn.", icon: UserCheck },
{ 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 }
],
shared: [
{ 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 }
]
};

View File

@@ -0,0 +1,60 @@
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import { createCoreSchema } from "./schema";
const globalForPg = globalThis as unknown as {
goodgoMvpPool?: Pool;
goodgoMvpSetup?: Promise<void>;
};
function shouldUseSsl(connectionString: string) {
if (process.env.DATABASE_SSL === "false") return false;
if (process.env.DATABASE_SSL === "true") return true;
return /sslmode=require|ssl=true/i.test(connectionString);
}
export function getPool() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required for GoodGo TPOS MVP.");
}
if (!globalForPg.goodgoMvpPool) {
globalForPg.goodgoMvpPool = new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
ssl: shouldUseSsl(connectionString) ? { rejectUnauthorized: false } : undefined
});
}
return globalForPg.goodgoMvpPool;
}
export async function ensureDatabase() {
if (!globalForPg.goodgoMvpSetup) {
globalForPg.goodgoMvpSetup = createCoreSchema(getPool());
}
await globalForPg.goodgoMvpSetup;
}
export async function query<T extends QueryResultRow = QueryResultRow>(sql: string, params: unknown[] = []) {
await ensureDatabase();
const result = await getPool().query<T>(sql, params);
return result.rows;
}
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>) {
await ensureDatabase();
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,711 @@
import type { Pool } from "pg";
export async function createCoreSchema(pool: Pool) {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS merchant_types (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS merchant_statuses (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS verification_statuses (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS shop_types (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS shop_statuses (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS business_categories (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS product_types (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS order_statuses (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS item_types (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS transaction_types (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS table_statuses (
id integer PRIMARY KEY,
name varchar(50) NOT NULL
);
INSERT INTO merchant_types (id, name) VALUES (1, 'Individual'), (2, 'Company')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO merchant_statuses (id, name) VALUES (1, 'PendingApproval'), (2, 'Active'), (3, 'Suspended'), (4, 'Banned')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO verification_statuses (id, name) VALUES (1, 'Unverified'), (2, 'Pending'), (3, 'Verified'), (4, 'Rejected')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO shop_types (id, name) VALUES (1, 'OnlineOnly'), (2, 'PhysicalOnly'), (3, 'Hybrid')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO shop_statuses (id, name) VALUES (1, 'Draft'), (2, 'Active'), (3, 'Inactive'), (4, 'Closed')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO business_categories (id, name) VALUES
(1, 'FoodBeverage'), (2, 'Fashion'), (3, 'Electronics'), (4, 'Healthcare'),
(5, 'Beauty'), (6, 'Education'), (7, 'Entertainment'), (8, 'Services'),
(9, 'Grocery'), (10, 'HomeFurniture'), (11, 'Cafe'), (12, 'Restaurant'),
(13, 'Karaoke'), (14, 'Spa'), (99, 'Other')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO product_types (id, name) VALUES (1, 'Physical'), (2, 'Service'), (3, 'PreparedFood')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO order_statuses (id, name) VALUES
(1, 'Draft'), (2, 'Validated'), (3, 'Paid'), (4, 'Processing'),
(5, 'Completed'), (6, 'Cancelled'), (7, 'PaymentPending'), (8, 'Returned')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO item_types (id, name) VALUES (1, 'RawMaterial'), (2, 'FinishedGood'), (3, 'Consumable')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO transaction_types (id, name) VALUES (1, 'In'), (2, 'Out'), (3, 'Adjustment'), (4, 'Reserve'), (5, 'Release')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO table_statuses (id, name) VALUES (1, 'Available'), (2, 'Occupied'), (3, 'Reserved'), (4, 'Cleaning')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
CREATE TABLE IF NOT EXISTS merchants (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
business_name varchar(200) NOT NULL,
type_id integer NOT NULL DEFAULT 1,
status_id integer NOT NULL DEFAULT 2,
verification_status_id integer NOT NULL DEFAULT 1,
subscription_plan_id integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
is_deleted boolean NOT NULL DEFAULT false
);
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS subscription_plan_id integer NOT NULL DEFAULT 0;
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS is_deleted boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS shops (
id uuid PRIMARY KEY,
merchant_id uuid NOT NULL,
name varchar(100) NOT NULL,
slug varchar(100) NOT NULL,
type_id integer NOT NULL DEFAULT 2,
category_id integer NOT NULL DEFAULT 9,
status_id integer NOT NULL DEFAULT 2,
description varchar(2000),
phone varchar(20),
email varchar(100),
website varchar(200),
logo_url varchar(500),
cover_image_url varchar(500),
features_config jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
is_default boolean NOT NULL DEFAULT false,
is_deleted boolean NOT NULL DEFAULT false
);
ALTER TABLE shops ADD COLUMN IF NOT EXISTS phone varchar(20);
ALTER TABLE shops ADD COLUMN IF NOT EXISTS email varchar(100);
ALTER TABLE shops ADD COLUMN IF NOT EXISTS website varchar(200);
ALTER TABLE shops ADD COLUMN IF NOT EXISTS features_config jsonb;
ALTER TABLE shops ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
ALTER TABLE shops ADD COLUMN IF NOT EXISTS is_deleted boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS categories (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
name varchar(200) NOT NULL,
description varchar(1000),
parent_id uuid,
display_order integer NOT NULL DEFAULT 0,
image_url varchar(500),
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
ALTER TABLE categories ADD COLUMN IF NOT EXISTS image_url varchar(500);
ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS products (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
name varchar(255) NOT NULL,
description varchar(2000),
price numeric(18,2) NOT NULL,
type_id integer NOT NULL DEFAULT 1,
attributes jsonb,
image_url varchar(500),
sku varchar(100),
barcode varchar(100),
category_id uuid,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
ALTER TABLE products ADD COLUMN IF NOT EXISTS barcode varchar(100);
ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id uuid;
ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url varchar(500);
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS inventory_items (
id uuid PRIMARY KEY,
product_id uuid NOT NULL,
shop_id uuid NOT NULL,
name varchar(200),
item_type_id integer NOT NULL DEFAULT 2,
unit varchar(20) NOT NULL DEFAULT 'pcs',
cost_per_unit numeric(18,4) NOT NULL DEFAULT 0,
supplier_name varchar(200),
expiry_date timestamptz,
quantity integer NOT NULL DEFAULT 0,
reserved_quantity integer NOT NULL DEFAULT 0,
reorder_level integer NOT NULL DEFAULT 10,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS name varchar(200);
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS item_type_id integer NOT NULL DEFAULT 2;
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS unit varchar(20) NOT NULL DEFAULT 'pcs';
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS cost_per_unit numeric(18,4) NOT NULL DEFAULT 0;
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS supplier_name varchar(200);
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS expiry_date timestamptz;
ALTER TABLE inventory_items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
CREATE TABLE IF NOT EXISTS inventory_transactions (
id uuid PRIMARY KEY,
inventory_item_id uuid NOT NULL,
type_id integer NOT NULL,
quantity integer NOT NULL,
reference_id uuid,
notes varchar(500),
invoice_image_url varchar(1000),
unit_cost numeric(18,4),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS invoice_image_url varchar(1000);
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS unit_cost numeric(18,4);
CREATE TABLE IF NOT EXISTS tables (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
table_number varchar(20) NOT NULL,
capacity integer NOT NULL DEFAULT 2,
zone varchar(100),
status_id integer NOT NULL DEFAULT 1,
position_x integer,
position_y integer,
qr_token varchar(64),
hourly_rate numeric(18,2) NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS orders (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
customer_id uuid,
table_id uuid,
status_id integer NOT NULL DEFAULT 1,
total_amount numeric(18,2) NOT NULL DEFAULT 0,
notes varchar(2000),
discount_amount numeric(18,2) NOT NULL DEFAULT 0,
discount_type varchar(50),
discount_reference varchar(255),
payment_method varchar(50),
transaction_id varchar(255),
amount_tendered numeric(18,2),
change_amount numeric(18,2),
return_reason varchar(1000),
returned_at timestamptz,
is_return boolean NOT NULL DEFAULT false,
original_order_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS table_id uuid;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method varchar(50);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS transaction_id varchar(255);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount_tendered numeric(18,2);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS change_amount numeric(18,2);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS discount_amount numeric(18,2) NOT NULL DEFAULT 0;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS discount_type varchar(50);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS discount_reference varchar(255);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS return_reason varchar(1000);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS returned_at timestamptz;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS is_return boolean NOT NULL DEFAULT false;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS original_order_id uuid;
CREATE TABLE IF NOT EXISTS order_items (
id uuid PRIMARY KEY,
order_id uuid NOT NULL,
product_id uuid NOT NULL,
product_name varchar(255) NOT NULL,
product_type varchar(50) NOT NULL,
quantity integer NOT NULL,
unit_price numeric(18,2) NOT NULL,
status varchar(50) NOT NULL DEFAULT 'Completed',
track_inventory boolean NOT NULL DEFAULT true,
metadata jsonb
);
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS track_inventory boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS payment_transactions (
id uuid PRIMARY KEY,
order_id uuid NOT NULL,
shop_id uuid NOT NULL,
method varchar(50) NOT NULL,
amount numeric(18,2) NOT NULL,
amount_tendered numeric(18,2),
change_amount numeric(18,2) NOT NULL DEFAULT 0,
status varchar(50) NOT NULL DEFAULT 'Succeeded',
provider_reference varchar(255),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mvp_roles (
id uuid PRIMARY KEY,
code varchar(50) NOT NULL UNIQUE,
name varchar(100) NOT NULL,
portal varchar(50) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mvp_users (
id uuid PRIMARY KEY,
email varchar(200) NOT NULL UNIQUE,
password_hash varchar(255) NOT NULL,
display_name varchar(200) NOT NULL,
phone varchar(50),
status varchar(50) NOT NULL DEFAULT 'active',
default_shop_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS mvp_user_roles (
user_id uuid NOT NULL,
role_id uuid NOT NULL,
shop_id uuid
);
CREATE TABLE IF NOT EXISTS mvp_sessions (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
token_hash varchar(255) NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staff_members (
id uuid PRIMARY KEY,
user_id uuid,
shop_id uuid,
employee_code varchar(50),
first_name varchar(100),
last_name varchar(100),
phone varchar(50),
email varchar(200),
role varchar(80),
status varchar(50) NOT NULL DEFAULT 'active',
joined_at timestamptz NOT NULL DEFAULT now(),
terminated_at timestamptz
);
CREATE TABLE IF NOT EXISTS staff_schedules (
id uuid PRIMARY KEY,
staff_id uuid NOT NULL,
shop_id uuid NOT NULL,
day_of_week integer NOT NULL,
start_time varchar(20) NOT NULL,
end_time varchar(20) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS attendance_records (
id uuid PRIMARY KEY,
staff_id uuid NOT NULL,
shop_id uuid,
check_in_at timestamptz NOT NULL DEFAULT now(),
check_out_at timestamptz,
status varchar(50) NOT NULL DEFAULT 'checked_in',
note varchar(500)
);
CREATE TABLE IF NOT EXISTS leave_requests (
id uuid PRIMARY KEY,
staff_id uuid NOT NULL,
shop_id uuid,
from_date date NOT NULL,
to_date date NOT NULL,
reason varchar(500),
status varchar(50) NOT NULL DEFAULT 'pending',
reviewed_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS notifications (
id uuid PRIMARY KEY,
user_id uuid,
shop_id uuid,
title varchar(200) NOT NULL,
body varchar(1000),
status varchar(50) NOT NULL DEFAULT 'unread',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS members (
id uuid PRIMARY KEY,
shop_id uuid,
display_name varchar(200),
phone varchar(50),
gender varchar(50),
country_code varchar(10),
current_exp integer NOT NULL DEFAULT 0,
current_level integer NOT NULL DEFAULT 1,
total_exp_earned integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS membership_levels (
id uuid PRIMARY KEY,
shop_id uuid,
level_number integer NOT NULL,
name varchar(100) NOT NULL,
required_exp integer NOT NULL DEFAULT 0,
description varchar(500),
badge_color varchar(30),
is_active boolean NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS experience_transactions (
id uuid PRIMARY KEY,
member_id uuid NOT NULL,
points integer NOT NULL,
source varchar(80) NOT NULL,
reference_id varchar(100),
level_at_time integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS wallets (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
currency varchar(10) NOT NULL DEFAULT 'VND',
balance numeric(18,2) NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS wallet_transactions (
id uuid PRIMARY KEY,
wallet_id uuid NOT NULL,
amount numeric(18,2) NOT NULL,
description varchar(500),
item_name varchar(200),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS campaigns (
id uuid PRIMARY KEY,
shop_id uuid,
name varchar(200) NOT NULL,
description varchar(1000),
face_value numeric(18,2) NOT NULL DEFAULT 0,
discount_type varchar(50) NOT NULL DEFAULT 'fixed',
discount_value numeric(18,2) NOT NULL DEFAULT 0,
total_vouchers integer NOT NULL DEFAULT 0,
issued_vouchers integer NOT NULL DEFAULT 0,
status varchar(50) NOT NULL DEFAULT 'draft',
start_date timestamptz,
end_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS vouchers (
id uuid PRIMARY KEY,
campaign_id uuid,
shop_id uuid,
code varchar(100) NOT NULL UNIQUE,
status varchar(50) NOT NULL DEFAULT 'active',
discount_type varchar(50) NOT NULL DEFAULT 'fixed',
discount_value numeric(18,2) NOT NULL DEFAULT 0,
redeemed_order_id uuid,
redeemed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS resources (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
name varchar(200) NOT NULL,
resource_type varchar(80),
capacity integer NOT NULL DEFAULT 1,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS therapists (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
staff_id uuid,
name varchar(200) NOT NULL,
specialty varchar(200),
status varchar(50) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS appointments (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
customer_id uuid,
staff_id uuid,
resource_id uuid,
service_id uuid,
customer_name varchar(200),
service_name varchar(200),
therapist_name varchar(200),
resource_name varchar(200),
start_time timestamptz NOT NULL,
end_time timestamptz NOT NULL,
status varchar(50) NOT NULL DEFAULT 'pending',
notes varchar(1000),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS recipes (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
product_id uuid,
name varchar(200) NOT NULL,
ingredients jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS kitchen_tickets (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
order_id uuid,
table_id uuid,
table_label varchar(100),
status varchar(50) NOT NULL DEFAULT 'Pending',
priority varchar(50) NOT NULL DEFAULT 'normal',
items jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS barista_queue (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
order_id uuid,
product_name varchar(200) NOT NULL,
customer_name varchar(200),
status varchar(50) NOT NULL DEFAULT 'Pending',
barista_name varchar(200),
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
ready_at timestamptz,
delivered_at timestamptz
);
CREATE TABLE IF NOT EXISTS reservations (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
table_id uuid,
customer_name varchar(200) NOT NULL,
phone varchar(50),
guest_count integer NOT NULL DEFAULT 1,
reservation_time timestamptz NOT NULL,
status varchar(50) NOT NULL DEFAULT 'pending',
notes varchar(1000),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS table_sessions (
id uuid PRIMARY KEY,
shop_id uuid NOT NULL,
table_id uuid NOT NULL,
status varchar(50) NOT NULL DEFAULT 'open',
guest_count integer NOT NULL DEFAULT 1,
started_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE TABLE IF NOT EXISTS storage_folders (
id uuid PRIMARY KEY,
shop_id uuid,
parent_id uuid,
name varchar(200) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS storage_files (
id uuid PRIMARY KEY,
shop_id uuid,
folder_id uuid,
file_name varchar(255) NOT NULL,
content_type varchar(120),
byte_size bigint NOT NULL DEFAULT 0,
object_key varchar(500) NOT NULL,
access_level varchar(50) NOT NULL DEFAULT 'public',
public_url varchar(1000),
provider varchar(50) NOT NULL DEFAULT 's3',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ai_configs (
shop_id uuid PRIMARY KEY,
provider varchar(50) NOT NULL,
api_key_ref varchar(255),
model varchar(100) NOT NULL,
base_url varchar(500),
system_prompt text,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS ai_messages (
id uuid PRIMARY KEY,
shop_id uuid,
user_id uuid,
role varchar(50) NOT NULL,
content text NOT NULL,
tools_used jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS platform_plans (
id uuid PRIMARY KEY,
code varchar(50) NOT NULL UNIQUE,
name varchar(100) NOT NULL,
price numeric(18,2) NOT NULL DEFAULT 0,
features jsonb NOT NULL DEFAULT '[]'::jsonb,
is_active boolean NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS feature_flags (
key varchar(100) PRIMARY KEY,
description varchar(500),
enabled boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id uuid PRIMARY KEY,
actor_user_id uuid,
action varchar(150) NOT NULL,
entity_type varchar(100),
entity_id varchar(100),
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS social_connections (
id uuid PRIMARY KEY,
shop_id uuid,
provider varchar(50) NOT NULL,
account_name varchar(200),
external_id varchar(200),
status varchar(50) NOT NULL DEFAULT 'configured',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS social_posts (
id uuid PRIMARY KEY,
shop_id uuid,
provider varchar(50) NOT NULL,
content text NOT NULL,
status varchar(50) NOT NULL DEFAULT 'draft',
external_id varchar(200),
scheduled_at timestamptz,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mvp_activity (
id uuid PRIMARY KEY,
action varchar(100) NOT NULL,
entity_type varchar(100) NOT NULL,
entity_id uuid,
shop_id uuid,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_shops_slug ON shops(slug);
CREATE INDEX IF NOT EXISTS ix_shops_merchant_id ON shops(merchant_id);
CREATE INDEX IF NOT EXISTS ix_categories_shop_id ON categories(shop_id);
CREATE INDEX IF NOT EXISTS ix_products_shop_id ON products(shop_id);
CREATE INDEX IF NOT EXISTS ix_products_barcode ON products(barcode);
CREATE INDEX IF NOT EXISTS ix_inventory_shop_id ON inventory_items(shop_id);
CREATE INDEX IF NOT EXISTS ix_inventory_product_id ON inventory_items(product_id);
CREATE INDEX IF NOT EXISTS ix_orders_shop_id ON orders(shop_id);
CREATE INDEX IF NOT EXISTS ix_orders_created_at ON orders(created_at);
CREATE INDEX IF NOT EXISTS ix_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS ix_payment_transactions_order_id ON payment_transactions(order_id);
CREATE INDEX IF NOT EXISTS ix_payment_transactions_shop_id ON payment_transactions(shop_id);
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));
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);
CREATE INDEX IF NOT EXISTS ix_campaigns_shop_id ON campaigns(shop_id);
CREATE INDEX IF NOT EXISTS ix_vouchers_code ON vouchers(code);
CREATE INDEX IF NOT EXISTS ix_appointments_shop_id ON appointments(shop_id);
CREATE INDEX IF NOT EXISTS ix_kitchen_tickets_shop_id ON kitchen_tickets(shop_id);
CREATE INDEX IF NOT EXISTS ix_barista_queue_shop_id ON barista_queue(shop_id);
CREATE INDEX IF NOT EXISTS ix_reservations_shop_id ON reservations(shop_id);
CREATE INDEX IF NOT EXISTS ix_storage_files_shop_id ON storage_files(shop_id);
CREATE INDEX IF NOT EXISTS ix_ai_messages_shop_id ON ai_messages(shop_id);
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);
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'),
(gen_random_uuid(), 'staff', 'Nhân viên', 'staff'),
(gen_random_uuid(), 'customer', 'Khách hàng', 'customer')
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, portal = EXCLUDED.portal;
INSERT INTO platform_plans (id, code, name, price, features) VALUES
(gen_random_uuid(), 'starter', 'Starter', 0, '["POS", "Catalog", "Inventory"]'::jsonb),
(gen_random_uuid(), 'growth', 'Growth', 299000, '["Multi-shop", "Staff", "Reports", "AI"]'::jsonb),
(gen_random_uuid(), 'scale', 'Scale', 799000, '["Super admin", "Marketing", "Storage", "Integrations"]'::jsonb)
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, features = EXCLUDED.features;
INSERT INTO feature_flags (key, description, enabled) VALUES
('ai_chat', 'AI shop assistant with tool calling', true),
('external_storage', 'S3-compatible file storage', true),
('social_marketing', 'Social publishing adapters', true),
('customer_qr_ordering', 'Customer QR menu ordering', true)
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description, enabled = EXCLUDED.enabled, updated_at = now();
`);
}

View File

@@ -0,0 +1,109 @@
import {
BarChart3,
CalendarClock,
Coffee,
DoorOpen,
HeartPulse,
Package,
ShoppingBasket,
Sparkles,
UtensilsCrossed
} from "lucide-react";
export const categoryNameById: Record<number, string> = {
1: "FoodBeverage",
2: "Fashion",
3: "Electronics",
4: "Healthcare",
5: "Beauty",
6: "Education",
7: "Entertainment",
8: "Services",
9: "Grocery",
10: "HomeFurniture",
11: "Cafe",
12: "Restaurant",
13: "Karaoke",
14: "Spa",
99: "Other"
};
export const shopStatusNameById: Record<number, string> = {
1: "Draft",
2: "Active",
3: "Inactive",
4: "Closed"
};
export const productTypeNameById: Record<number, string> = {
1: "Physical",
2: "Service",
3: "PreparedFood"
};
export const orderStatusNameById: Record<number, string> = {
1: "Draft",
2: "Validated",
3: "Paid",
4: "Processing",
5: "Completed",
6: "Cancelled",
7: "PaymentPending",
8: "Returned"
};
export const tableStatusNameById: Record<number, string> = {
1: "Available",
2: "Occupied",
3: "Reserved",
4: "Cleaning"
};
export type Vertical = "cafe" | "restaurant" | "karaoke" | "spa" | "beauty" | "retail";
export const verticalOptions: Array<{
id: Vertical;
label: string;
categoryId: number;
productTypeId: number;
icon: typeof Coffee;
}> = [
{ id: "cafe", label: "Cafe", categoryId: 11, productTypeId: 3, icon: Coffee },
{ id: "restaurant", label: "Restaurant", categoryId: 12, productTypeId: 3, icon: UtensilsCrossed },
{ id: "karaoke", label: "Karaoke", categoryId: 13, productTypeId: 3, icon: DoorOpen },
{ id: "spa", label: "Spa", categoryId: 14, productTypeId: 2, icon: Sparkles },
{ id: "beauty", label: "Beauty", categoryId: 5, productTypeId: 2, icon: HeartPulse },
{ id: "retail", label: "Retail", categoryId: 9, productTypeId: 1, icon: ShoppingBasket }
];
export function categoryIdToVertical(categoryId?: number | null): Vertical {
if (categoryId === 11) return "cafe";
if (categoryId === 12) return "restaurant";
if (categoryId === 13) return "karaoke";
if (categoryId === 14) return "spa";
if (categoryId === 5) return "beauty";
return "retail";
}
export function verticalToCategoryId(vertical: string | null | undefined): number {
return verticalOptions.find((item) => item.id === vertical)?.categoryId ?? 9;
}
export function productTypeForVertical(vertical: string | null | undefined): number {
return verticalOptions.find((item) => item.id === vertical)?.productTypeId ?? 1;
}
export function verticalIcon(vertical: Vertical) {
return verticalOptions.find((item) => item.id === vertical)?.icon ?? Package;
}
export const serviceMap = [
{ name: "IAM", label: "Đăng nhập, vai trò, phiên thu ngân", icon: Package },
{ name: "Merchant", label: "Merchant, cửa hàng, chi nhánh, nhân sự", icon: DoorOpen },
{ name: "Catalog", label: "Sản phẩm, danh mục, SKU/barcode", icon: ShoppingBasket },
{ name: "Inventory", label: "Sổ kho và cảnh báo tồn thấp", icon: BarChart3 },
{ name: "Order", label: "Đơn POS, trạng thái thanh toán, trả hàng", icon: CalendarClock },
{ name: "FnB Engine", label: "Bàn, phòng, bếp/barista queue", icon: Coffee },
{ name: "Booking", label: "Lịch hẹn cho spa và dịch vụ", icon: Sparkles },
{ name: "Wallet/Promo", label: "Thanh toán, voucher, điểm", icon: HeartPulse }
];

View File

@@ -0,0 +1,150 @@
export type Shop = {
id: string;
merchantId: string;
name: string;
slug: string;
categoryId: number;
category: string;
vertical: string;
statusId: number;
status: string;
description: string | null;
phone: string | null;
email: string | null;
createdAt: string;
};
export type ProductCategory = {
id: string;
shopId: string;
name: string;
description: string | null;
displayOrder: number;
};
export type Product = {
id: string;
shopId: string;
name: string;
description: string | null;
price: number;
typeId: number;
productType: string;
sku: string | null;
barcode: string | null;
categoryId: string | null;
categoryName: string | null;
isActive: boolean;
stockQuantity: number | null;
};
export type InventoryItem = {
id: string;
shopId: string;
productId: string;
productName: string | null;
name: string | null;
itemTypeId: number;
unit: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
reorderLevel: number;
costPerUnit: number;
supplierName: string | null;
};
export type InventoryTransaction = {
id: string;
inventoryItemId: string;
typeId: number;
typeName: string;
quantity: number;
referenceId: string | null;
notes: string | null;
unitCost: number | null;
productId: string | null;
shopId: string | null;
createdAt: string;
};
export type TableInfo = {
id: string;
shopId: string;
tableNumber: string;
capacity: number;
zone: string | null;
statusId: number;
status: string;
hourlyRate: number;
qrToken: string | null;
};
export type OrderItem = {
id: string;
productId: string;
productName: string;
productType: string;
quantity: number;
unitPrice: number;
totalPrice: number;
status: string;
};
export type OrderSummary = {
id: string;
shopId: string;
shopName: string | null;
tableId: string | null;
tableNumber: string | null;
statusId: number;
status: string;
totalAmount: number;
discountAmount: number;
discountType: string | null;
discountReference: string | null;
paymentMethod: string | null;
transactionId: string | null;
itemCount: number;
createdAt: string;
items: OrderItem[];
};
export type OrderFilters = {
shopId?: string | null;
statusIds?: number[];
fromDate?: string;
toDate?: string;
page?: number;
pageSize?: number;
};
export type PagedOrderResult = {
items: OrderSummary[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
export type DashboardStats = {
shopCount: number;
activeShopCount: number;
productCount: number;
orderCount: number;
todayRevenue: number;
monthRevenue: number;
lowStockCount: number;
tableCount: number;
recentOrders: OrderSummary[];
lowStock: InventoryItem[];
};
export type Activity = {
id: string;
action: string;
entityType: string;
entityId: string | null;
shopId: string | null;
createdAt: string;
};

View File

@@ -0,0 +1,228 @@
import { createHmac, createHash, randomUUID } from "node:crypto";
type JsonRecord = Record<string, unknown>;
function requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for this external integration`);
}
return value;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function postJson(url: string, body: unknown, headers: Record<string, string> = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers
},
body: JSON.stringify(body)
});
const text = await response.text();
let payload: unknown = text;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(`External request failed (${response.status}): ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
export async function callOpenAiResponses(input: string, tools: unknown[] = []) {
const apiKey = requireEnv("OPENAI_API_KEY");
const model = process.env.OPENAI_MODEL || "gpt-5.1";
return postJson(
"https://api.openai.com/v1/responses",
{
model,
input,
tools: tools.length ? tools : undefined
},
{ Authorization: `Bearer ${apiKey}` }
);
}
export async function callOpenRouterChat(messages: unknown[], tools: unknown[] = []) {
const apiKey = requireEnv("OPENROUTER_API_KEY");
const model = process.env.OPENROUTER_MODEL || "openai/gpt-5.1";
return postJson(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages,
tools: tools.length ? tools : undefined
},
{
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": "https://goodgo.vn",
"X-Title": "GoodGo TPOS Next"
}
);
}
export async function callClaudeMessages(messages: unknown[], tools: unknown[] = []) {
const apiKey = requireEnv("ANTHROPIC_API_KEY");
const model = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5-20250929";
return postJson(
"https://api.anthropic.com/v1/messages",
{
model,
max_tokens: 4096,
messages,
tools: tools.length ? tools : undefined
},
{
"x-api-key": apiKey,
"anthropic-version": "2023-06-01"
}
);
}
export async function callConfiguredAi(provider: string, message: string, tools: unknown[] = []) {
if (provider === "openrouter") {
return callOpenRouterChat([{ role: "user", content: message }], tools);
}
if (provider === "claude" || provider === "anthropic") {
return callClaudeMessages([{ role: "user", content: message }], tools);
}
return callOpenAiResponses(message, tools);
}
function sha256Hex(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function hmac(key: Buffer | string, value: string) {
return createHmac("sha256", key).update(value).digest();
}
function hmacHex(key: Buffer | string, value: string) {
return createHmac("sha256", key).update(value).digest("hex");
}
function awsDate(date = new Date()) {
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
}
function signingKey(secret: string, date: string, region: string, service: string) {
const kDate = hmac(`AWS4${secret}`, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, "aws4_request");
}
export function buildS3ObjectKey(fileName: string) {
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 160) || "file";
return `tpos/${new Date().toISOString().slice(0, 10)}/${randomUUID()}-${safeName}`;
}
export async function uploadS3Object(key: string, file: File, accessLevel = "public") {
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 body = Buffer.from(await file.arrayBuffer());
const now = awsDate();
const date = now.slice(0, 8);
const path = `/${bucket}/${key}`;
const url = `${endpoint}${path}`;
const host = new URL(endpoint).host;
const payloadHash = sha256Hex(body.toString("binary"));
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonical = [
"PUT",
path,
accessLevel === "public" ? "x-amz-acl=public-read" : "",
`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(url + (accessLevel === "public" ? "?x-amz-acl=public-read" : ""), {
method: "PUT",
headers: {
"Content-Type": file.type || "application/octet-stream",
"x-amz-content-sha256": payloadHash,
"x-amz-date": now,
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
},
body
});
if (!response.ok) {
throw new Error(`S3 upload failed (${response.status}): ${await response.text()}`);
}
const publicBase = process.env.S3_PUBLIC_BASE_URL?.replace(/\/+$/, "");
return publicBase ? `${publicBase}/${key}` : `${endpoint}/${bucket}/${key}`;
}
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),
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)
};
}
export async function publishSocial(provider: string, input: JsonRecord) {
const content = stringValue(input.content);
if (!content) throw new Error("content is required");
if (provider === "facebook") {
const pageToken = requireEnv("FACEBOOK_PAGE_TOKEN");
const pageId = requireEnv("FACEBOOK_PAGE_ID");
return postJson(`https://graph.facebook.com/v20.0/${pageId}/feed?access_token=${encodeURIComponent(pageToken)}`, {
message: content
});
}
if (provider === "zalo") {
const token = requireEnv("ZALO_OA_ACCESS_TOKEN");
return postJson(
"https://openapi.zalo.me/v3.0/oa/message/cs",
{
recipient: { user_id: stringValue(input.recipientId) },
message: { text: content }
},
{ access_token: token }
);
}
if (provider === "whatsapp") {
const token = requireEnv("WHATSAPP_ACCESS_TOKEN");
const phoneId = requireEnv("WHATSAPP_PHONE_NUMBER_ID");
return postJson(
`https://graph.facebook.com/v20.0/${phoneId}/messages`,
{
messaging_product: "whatsapp",
to: stringValue(input.to),
text: { body: content }
},
{ Authorization: `Bearer ${token}` }
);
}
if (provider === "x") {
throw new Error("X publishing requires OAuth 1.0a signing; configure a dedicated X service adapter before enabling this provider.");
}
throw new Error(`Unsupported social provider: ${provider}`);
}

View File

@@ -0,0 +1,75 @@
import {
createCategory,
createProduct,
deleteCategory,
deleteProduct,
getCategory,
getProductById,
listAllCategories,
listAllProducts,
listCategories,
listProducts,
updateCategory,
updateProduct
} from "../db/queries";
import type { Product, ProductCategory } from "../domain/types";
export type CatalogProductFilter = {
shopId?: string | null;
includeInactive?: boolean;
};
export type CatalogCategoryFilter = {
shopId?: string | null;
includeInactive?: boolean;
};
export async function listCatalogProducts(filter: CatalogProductFilter = {}): Promise<Product[]> {
const { shopId, includeInactive = false } = filter;
return shopId ? await listProducts(shopId, includeInactive) : await listAllProducts(null, includeInactive);
}
export async function listCatalogProductsByShop(shopId: string, includeInactive = false) {
return listProducts(shopId, includeInactive);
}
export async function getCatalogProduct(productId: string) {
return getProductById(productId);
}
export async function createCatalogProduct(input: Parameters<typeof createProduct>[0]) {
return createProduct(input);
}
export async function updateCatalogProduct(productId: string, input: Parameters<typeof updateProduct>[1]) {
return updateProduct(productId, input);
}
export async function deleteCatalogProduct(productId: string) {
return deleteProduct(productId);
}
export async function listCatalogCategories(filter: CatalogCategoryFilter = {}): Promise<ProductCategory[]> {
const { shopId, includeInactive = false } = filter;
return shopId ? await listCategories(shopId) : await listAllCategories(null, includeInactive);
}
export async function listCatalogCategoriesByShop(shopId: string) {
return listCategories(shopId);
}
export async function getCatalogCategory(categoryId: string) {
return getCategory(categoryId);
}
export async function createCatalogCategory(input: Parameters<typeof createCategory>[0]) {
return createCategory(input);
}
export async function updateCatalogCategory(categoryId: string, input: Parameters<typeof updateCategory>[1]) {
return updateCategory(categoryId, input);
}
export async function deleteCatalogCategory(categoryId: string) {
return deleteCategory(categoryId);
}

View File

@@ -0,0 +1,42 @@
import {
createTable,
deleteTable,
getTableById,
getTableByToken,
listTables,
regenerateTableQr,
updateTable,
updateTableStatus
} from "../db/queries";
export async function listTablesByShop(shopId: string) {
return listTables(shopId);
}
export async function getTable(tableId: string) {
return getTableById(tableId);
}
export async function getTableFromToken(token: string) {
return getTableByToken(token);
}
export async function createTableService(input: Parameters<typeof createTable>[0]) {
return createTable(input);
}
export async function updateTableService(tableId: string, input: Parameters<typeof updateTable>[1]) {
return updateTable(tableId, input);
}
export async function removeTableService(tableId: string) {
return deleteTable(tableId);
}
export async function updateTableStatusService(tableId: string, statusId: number) {
return updateTableStatus({ tableId, statusId });
}
export async function regenerateTableQrService(tableId: string) {
return regenerateTableQr(tableId);
}

View File

@@ -0,0 +1,5 @@
export * as catalogService from "./catalog";
export * as fnbService from "./fnb";
export * as inventoryService from "./inventory";
export * as orderService from "./order";
export * as shopService from "./shop";

View File

@@ -0,0 +1,105 @@
import {
adjustStock,
createInventoryItem,
deleteInventoryItem,
getInventoryItem,
listInventory,
listInventoryTransactions,
listInventory as _listInventory,
updateInventoryItem
} from "../db/queries";
type MutationInput = {
inventoryId: string;
quantity: number;
shopId?: string | null;
referenceId?: string | null;
notes?: string | null;
unitCost?: number | null;
};
export async function listInventoryItems(shopId?: string | null) {
return shopId ? listInventory(shopId) : [];
}
export async function listInventoryWithTransactions(shopId?: string | null) {
const [items, transactions] = await Promise.all([listInventoryItems(shopId), listInventoryTransactions(shopId)]);
return { items, transactions };
}
export async function createInventoryItemService(input: Parameters<typeof createInventoryItem>[0]) {
return createInventoryItem(input);
}
export async function updateInventoryItemService(inventoryId: string, input: Parameters<typeof updateInventoryItem>[1]) {
return updateInventoryItem(inventoryId, input);
}
export async function deleteInventoryItemService(inventoryId: string) {
return deleteInventoryItem(inventoryId);
}
export async function getInventoryService(inventoryId: string) {
return getInventoryItem(inventoryId);
}
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.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,
...(notes ? { notes } : {})
};
const item = await adjustStock(baseInput, typeId);
return item;
}
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);
}
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);
}
export async function inventoryAdjust(input: MutationInput) {
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
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);
}
export async function stocktake(input: MutationInput) {
const requested = Number.isFinite(input.quantity) ? Math.max(0, Math.trunc(input.quantity)) : 0;
return mutateStock({ ...input, quantity: requested }, 3);
}
export async function getLowStock(shopId?: string | null, threshold = 10) {
const items = await listInventoryItems(shopId);
const normalized = Number.isFinite(threshold) ? Math.max(0, Math.trunc(threshold)) : 10;
return items.filter((item) => item.quantity <= item.reorderLevel && item.quantity <= normalized);
}
export async function listLowStockByShop(shopId?: string | null, threshold = 10) {
return getLowStock(shopId, threshold);
}
export { _listInventory as listInventoryRaw };

View File

@@ -0,0 +1,105 @@
import {
cancelOrder,
createOrder,
getOrderById,
getPosDashboardMetrics,
listActiveOrdersByTable,
listOrders,
listOrdersPaged,
payOrder
} from "../db/queries";
import type { OrderFilters, PagedOrderResult } from "../domain/types";
export type OrderListFilter = OrderFilters & {
filter?: "today" | "week" | "month" | "all" | "30d";
};
export type OrderCreateInput = Parameters<typeof createOrder>[0];
function normalizeDateWindow(filter?: OrderListFilter["filter"], fromDate?: string, toDate?: string) {
if (fromDate || toDate) {
return { fromDate, toDate };
}
const now = new Date();
if (!filter || filter === "today") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
return { fromDate: start.toISOString(), toDate: now.toISOString() };
}
if (filter === "week") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() - start.getDay());
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") {
const start = new Date(now);
start.setDate(start.getDate() - 29);
start.setHours(0, 0, 0, 0);
return { fromDate: start.toISOString(), toDate: now.toISOString() };
}
return {};
}
export async function listOrdersService(filter: OrderListFilter = {}): Promise<PagedOrderResult> {
const { fromDate, toDate } = normalizeDateWindow(filter.filter, filter.fromDate, filter.toDate);
return listOrdersPaged({
shopId: filter.shopId ?? null,
statusIds: filter.statusIds,
fromDate,
toDate,
page: filter.page,
pageSize: filter.pageSize
});
}
export async function listOrdersLegacy(shopId?: string | null, limit = 40) {
return listOrders(shopId, limit);
}
export async function getOrderService(orderId: string, shopId?: string | null) {
return getOrderById(orderId, shopId);
}
export async function createOrderService(input: OrderCreateInput) {
return createOrder(input);
}
export async function cancelOrderService(orderId: string, shopId?: string | null, reason?: string | null) {
return cancelOrder(orderId, shopId, reason);
}
export async function payOrderService(
orderId: string,
input: { paymentMethod?: string | null; amountTendered?: number | null; shopId?: string | null }
) {
return payOrder(orderId, {
shopId: input.shopId ?? null,
paymentMethod: input.paymentMethod ?? null,
amountTendered: input.amountTendered ?? null
});
}
export async function createPosOrder(input: OrderCreateInput) {
return createOrderService(input);
}
export async function listActiveOrdersByTableService(shopId?: string | null) {
return listActiveOrdersByTable(shopId);
}
export async function getPosDashboardService(shopId?: string | null, period: "today" | "week" | "month" | "30d" = "today") {
return getPosDashboardMetrics(shopId, period);
}

View File

@@ -0,0 +1,948 @@
import { createHash, pbkdf2Sync, randomBytes, randomUUID } from "node:crypto";
import { query, withTransaction } from "../db/pool";
import { getShop, listCategories, listProducts, listShops, listTables } from "../db/queries";
const SESSION_COOKIE = "bff_session";
const DAY_MS = 24 * 60 * 60 * 1000;
type JsonRecord = Record<string, unknown>;
function nowIso() {
return new Date().toISOString();
}
function numberValue(value: unknown, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function intValue(value: unknown, fallback = 0) {
return Math.trunc(numberValue(value, fallback));
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function sessionCookieName() {
return SESSION_COOKIE;
}
export function hashPassword(password: string, salt = randomBytes(16).toString("hex")) {
const digest = pbkdf2Sync(password, salt, 120_000, 32, "sha256").toString("hex");
return `pbkdf2_sha256$${salt}$${digest}`;
}
function verifyPassword(password: string, passwordHash: string) {
const [scheme, salt, digest] = passwordHash.split("$");
if (scheme !== "pbkdf2_sha256" || !salt || !digest) return false;
return hashPassword(password, salt) === passwordHash;
}
async function roleId(code: string) {
const rows = await query<{ id: string }>(`SELECT id FROM mvp_roles WHERE code = $1 LIMIT 1`, [code]);
if (!rows[0]) throw new Error(`Role not found: ${code}`);
return rows[0].id;
}
export async function seedParityData(defaultShopId?: string) {
const shop = defaultShopId ? await getShop(defaultShopId) : (await listShops())[0];
if (!shop) return;
const users = [
["admin@goodgo.vn", "Admin@123", "Quản trị GoodGo", "admin"],
["staff@goodgo.vn", "Staff@123", "Nhân viên POS", "staff"],
["customer@goodgo.vn", "Customer@123", "Khách hàng mẫu", "customer"],
["superadmin@goodgo.vn", "SuperAdmin@123", "Super Admin", "superadmin"]
] as const;
for (const [email, password, displayName, roleCode] of users) {
const userId = randomUUID();
await query(
`INSERT INTO mvp_users (id, email, password_hash, display_name, default_shop_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email) DO UPDATE
SET display_name = EXCLUDED.display_name,
default_shop_id = EXCLUDED.default_shop_id,
updated_at = now()`,
[userId, email, hashPassword(password), displayName, roleCode === "superadmin" ? null : shop.id]
);
const role = await roleId(roleCode);
const savedUser = await query<{ id: string }>(`SELECT id FROM mvp_users WHERE email = $1`, [email]);
await query(
`INSERT INTO mvp_user_roles (user_id, role_id, shop_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[savedUser[0]?.id, role, roleCode === "superadmin" ? null : shop.id]
);
}
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]
);
const staff = await query<{ id: string }>(`SELECT id FROM staff_members WHERE shop_id = $1 ORDER BY joined_at LIMIT 1`, [shop.id]);
if (staff[0]) {
for (const day of [1, 2, 3, 4, 5, 6]) {
await query(
`INSERT INTO staff_schedules (id, staff_id, shop_id, day_of_week, start_time, end_time)
SELECT $1, $2, $3, $4, '08:00', '17:00'
WHERE NOT EXISTS (
SELECT 1 FROM staff_schedules WHERE staff_id = $2 AND day_of_week = $4
)`,
[randomUUID(), staff[0].id, shop.id, day]
);
}
}
const products = await listProducts(shop.id);
const tables = await listTables(shop.id);
await query(
`INSERT INTO members (id, shop_id, display_name, phone, gender, country_code, current_exp, current_level, total_exp_earned)
SELECT $1, $2, 'Nguyễn An', '0909000001', 'unknown', 'VN', 320, 2, 320
WHERE NOT EXISTS (SELECT 1 FROM members WHERE shop_id = $2)`,
[randomUUID(), shop.id]
);
for (const level of [
[1, "Đồng", 0, "#c08457"],
[2, "Bạc", 250, "#94a3b8"],
[3, "Vàng", 800, "#f59e0b"]
] as const) {
await query(
`INSERT INTO membership_levels (id, shop_id, level_number, name, required_exp, badge_color)
SELECT $1, $2, $3, $4, $5, $6
WHERE NOT EXISTS (
SELECT 1 FROM membership_levels WHERE shop_id = $2 AND level_number = $3
)`,
[randomUUID(), shop.id, ...level]
);
}
const campaignId = randomUUID();
await query(
`INSERT INTO campaigns (id, shop_id, name, description, face_value, discount_type, discount_value, total_vouchers, issued_vouchers, status, start_date, end_date)
SELECT $1, $2, 'Khai trương MVP', 'Voucher mẫu cho luồng POS', 20000, 'fixed', 20000, 100, 1, 'active', now() - interval '1 day', now() + interval '30 day'
WHERE NOT EXISTS (SELECT 1 FROM campaigns WHERE shop_id = $2)`,
[campaignId, shop.id]
);
const campaign = await query<{ id: string; discount_type: string; discount_value: number }>(
`SELECT id, discount_type, discount_value FROM campaigns WHERE shop_id = $1 ORDER BY created_at LIMIT 1`,
[shop.id]
);
if (campaign[0]) {
await query(
`INSERT INTO vouchers (id, campaign_id, shop_id, code, discount_type, discount_value)
VALUES ($1, $2, $3, 'GG-MVP-20K', $4, $5)
ON CONFLICT (code) DO NOTHING`,
[randomUUID(), campaign[0].id, shop.id, campaign[0].discount_type, campaign[0].discount_value]
);
}
if (products[0]) {
await query(
`INSERT INTO recipes (id, shop_id, product_id, name, ingredients)
SELECT $1, $2, $3, $4, $5::jsonb
WHERE NOT EXISTS (SELECT 1 FROM recipes WHERE shop_id = $2)`,
[
randomUUID(),
shop.id,
products[0].id,
`${products[0].name} recipe`,
JSON.stringify([{ ingredient: "Arabica beans", quantity: 18, unit: "g" }])
]
);
await query(
`INSERT INTO barista_queue (id, shop_id, product_name, customer_name, status)
SELECT $1, $2, $3, 'Khách tại quầy', 'Pending'
WHERE NOT EXISTS (SELECT 1 FROM barista_queue WHERE shop_id = $2)`,
[randomUUID(), shop.id, products[0].name]
);
}
if (tables[0]) {
await query(
`INSERT INTO reservations (id, shop_id, table_id, customer_name, phone, guest_count, reservation_time, status)
SELECT $1, $2, $3, 'Gia đình Minh', '0902000002', 4, now() + interval '2 hours', 'confirmed'
WHERE NOT EXISTS (SELECT 1 FROM reservations WHERE shop_id = $2)`,
[randomUUID(), shop.id, tables[0].id]
);
await query(
`INSERT INTO kitchen_tickets (id, shop_id, table_id, table_label, status, items)
SELECT $1, $2, $3, $4, 'Pending', $5::jsonb
WHERE NOT EXISTS (SELECT 1 FROM kitchen_tickets WHERE shop_id = $2)`,
[
randomUUID(),
shop.id,
tables[0].id,
tables[0].tableNumber,
JSON.stringify([{ name: products[0]?.name ?? "Món mẫu", qty: 1 }])
]
);
}
await query(
`INSERT INTO resources (id, shop_id, name, resource_type, capacity)
SELECT $1, $2, 'Phòng trị liệu 01', 'room', 1
WHERE NOT EXISTS (SELECT 1 FROM resources WHERE shop_id = $2)`,
[randomUUID(), shop.id]
);
await query(
`INSERT INTO therapists (id, shop_id, name, specialty)
SELECT $1, $2, 'Linh Nguyễn', 'Massage trị liệu'
WHERE NOT EXISTS (SELECT 1 FROM therapists WHERE shop_id = $2)`,
[randomUUID(), shop.id]
);
await query(
`INSERT INTO appointments (id, shop_id, customer_name, service_id, service_name, therapist_name, start_time, end_time, status)
SELECT $1, $2, 'Khách đặt lịch', $3, 'Chăm sóc da', 'Linh Nguyễn', now() + interval '1 day', now() + interval '1 day 1 hour', 'confirmed'
WHERE NOT EXISTS (SELECT 1 FROM appointments WHERE shop_id = $2)`,
[randomUUID(), shop.id, products[0]?.id ?? randomUUID()]
);
const adminUser = await query<{ id: string }>(`SELECT id FROM mvp_users WHERE email = 'admin@goodgo.vn'`);
if (adminUser[0]) {
await query(
`INSERT INTO wallets (id, owner_id, balance)
SELECT $1, $2, 1250000
WHERE NOT EXISTS (SELECT 1 FROM wallets WHERE owner_id = $2)`,
[randomUUID(), adminUser[0].id]
);
}
}
export async function loginUser(email: string, password: string) {
const users = await query<{
id: string;
email: string;
password_hash: string;
display_name: string;
default_shop_id: string | null;
}>(
`SELECT id, email, password_hash, display_name, default_shop_id
FROM mvp_users
WHERE lower(email) = lower($1) AND status = 'active'
LIMIT 1`,
[email]
);
const user = users[0];
if (!user || !verifyPassword(password, user.password_hash)) {
throw new Error("Email hoặc mật khẩu không đúng");
}
const roles = await getUserRoles(user.id);
const token = randomUUID() + randomUUID();
await query(
`INSERT INTO mvp_sessions (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
[randomUUID(), user.id, hashToken(token), new Date(Date.now() + 7 * DAY_MS)]
);
await audit("auth.login", "user", user.id, { email: user.email });
return {
token,
expiresAt: new Date(Date.now() + 7 * DAY_MS).toISOString(),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
defaultShopId: user.default_shop_id,
roles
}
};
}
export async function getUserRoles(userId: string) {
return query<{ code: string; name: string; portal: string; shop_id: string | null }>(
`SELECT r.code, r.name, r.portal, ur.shop_id
FROM mvp_user_roles ur
JOIN mvp_roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
ORDER BY r.portal`,
[userId]
);
}
export async function getSessionUser(token?: string | null) {
if (!token) return null;
const rows = await query<{
id: string;
email: string;
display_name: string;
default_shop_id: string | null;
}>(
`SELECT u.id, u.email, u.display_name, u.default_shop_id
FROM mvp_sessions s
JOIN mvp_users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now() AND u.status = 'active'
LIMIT 1`,
[hashToken(token)]
);
if (!rows[0]) return null;
return {
id: rows[0].id,
email: rows[0].email,
displayName: rows[0].display_name,
defaultShopId: rows[0].default_shop_id,
roles: await getUserRoles(rows[0].id)
};
}
export async function logoutSession(token?: string | null) {
if (!token) return { ok: true };
await query(`DELETE FROM mvp_sessions WHERE token_hash = $1`, [hashToken(token)]);
return { ok: true };
}
export async function listUsers() {
const rows = await query(
`SELECT u.id, u.email, u.display_name, u.phone, u.status, u.default_shop_id, u.created_at,
COALESCE(json_agg(r.code) FILTER (WHERE r.code IS NOT NULL), '[]'::json) AS roles
FROM mvp_users u
LEFT JOIN mvp_user_roles ur ON ur.user_id = u.id
LEFT JOIN mvp_roles r ON r.id = ur.role_id
GROUP BY u.id
ORDER BY u.created_at DESC`
);
return rows.map((row) => ({
id: String(row.id),
email: String(row.email),
displayName: String(row.display_name),
phone: row.phone ? String(row.phone) : null,
status: String(row.status),
defaultShopId: row.default_shop_id ? String(row.default_shop_id) : null,
roles: Array.isArray(row.roles) ? row.roles : [],
createdAt: row.created_at
}));
}
export async function listStaff(shopId?: string | null) {
return query(
`SELECT * FROM staff_members
WHERE ($1::uuid IS NULL OR shop_id = $1::uuid)
ORDER BY joined_at DESC`,
[shopId || null]
);
}
export async function createStaff(input: JsonRecord) {
const id = randomUUID();
const shopId = stringValue(input.shopId);
await query(
`INSERT INTO staff_members (id, shop_id, employee_code, first_name, last_name, phone, email, role, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`,
[
id,
shopId,
stringValue(input.employeeCode) ?? `ST-${id.slice(0, 4).toUpperCase()}`,
stringValue(input.firstName) ?? stringValue(input.name) ?? "Nhân viên",
stringValue(input.lastName),
stringValue(input.phone),
stringValue(input.email),
stringValue(input.role) ?? "Staff"
]
);
return (await listStaff(shopId))[0] ?? { id };
}
export async function getStaffProfile(userId?: string | null) {
const rows = await query(
`SELECT s.*, sh.name AS shop_name
FROM staff_members s
LEFT JOIN shops sh ON sh.id = s.shop_id
WHERE ($1::uuid IS NULL OR s.user_id = $1::uuid)
ORDER BY s.joined_at ASC
LIMIT 1`,
[userId || null]
);
return rows[0] ?? null;
}
export async function getAttendance(staffId?: string | null, shopId?: string | null) {
return query(
`SELECT a.*, s.first_name, s.last_name, s.employee_code
FROM attendance_records a
LEFT JOIN staff_members s ON s.id = a.staff_id
WHERE ($1::uuid IS NULL OR a.staff_id = $1::uuid)
AND ($2::uuid IS NULL OR a.shop_id = $2::uuid)
ORDER BY a.check_in_at DESC`,
[staffId || null, shopId || null]
);
}
export async function checkIn(staffId?: string | null) {
const staff = staffId ? [{ id: staffId }] : await query<{ id: string }>(`SELECT id FROM staff_members ORDER BY joined_at LIMIT 1`);
if (!staff[0]) throw new Error("Staff profile not found");
const profile = await query<{ shop_id: string | null }>(`SELECT shop_id FROM staff_members WHERE id = $1`, [staff[0].id]);
const id = randomUUID();
await query(
`INSERT INTO attendance_records (id, staff_id, shop_id, status)
VALUES ($1, $2, $3, 'checked_in')`,
[id, staff[0].id, profile[0]?.shop_id ?? null]
);
return { id, staffId: staff[0].id, status: "checked_in", checkInAt: nowIso() };
}
export async function checkOut(staffId?: string | null) {
const staff = staffId ? [{ id: staffId }] : await query<{ id: string }>(`SELECT id FROM staff_members ORDER BY joined_at LIMIT 1`);
if (!staff[0]) throw new Error("Staff profile not found");
await query(
`UPDATE attendance_records
SET check_out_at = now(), status = 'checked_out'
WHERE id = (
SELECT id FROM attendance_records
WHERE staff_id = $1 AND check_out_at IS NULL
ORDER BY check_in_at DESC LIMIT 1
)`,
[staff[0].id]
);
return { staffId: staff[0].id, status: "checked_out", checkOutAt: nowIso() };
}
export async function listLeaveRequests(shopId?: string | null, staffId?: string | null) {
return query(
`SELECT l.*, s.first_name, s.last_name
FROM leave_requests l
LEFT JOIN staff_members s ON s.id = l.staff_id
WHERE ($1::uuid IS NULL OR l.shop_id = $1::uuid)
AND ($2::uuid IS NULL OR l.staff_id = $2::uuid)
ORDER BY l.created_at DESC`,
[shopId || null, staffId || null]
);
}
export async function createLeaveRequest(input: JsonRecord) {
const staff = stringValue(input.staffId) ? [{ id: stringValue(input.staffId)! }] : await query<{ id: string; shop_id: string | null }>(`SELECT id, shop_id FROM staff_members ORDER BY joined_at LIMIT 1`);
if (!staff[0]) throw new Error("Staff profile not found");
const shopId = stringValue(input.shopId) ?? (staff[0] as { shop_id?: string | null }).shop_id ?? null;
const id = randomUUID();
await query(
`INSERT INTO leave_requests (id, staff_id, shop_id, from_date, to_date, reason)
VALUES ($1, $2, $3, $4::date, $5::date, $6)`,
[
id,
staff[0].id,
shopId,
stringValue(input.fromDate) ?? new Date().toISOString().slice(0, 10),
stringValue(input.toDate) ?? new Date().toISOString().slice(0, 10),
stringValue(input.reason)
]
);
return { id, status: "pending" };
}
export async function updateLeaveStatus(id: string, status: "approved" | "rejected") {
await query(`UPDATE leave_requests SET status = $2, updated_at = now() WHERE id = $1`, [id, status]);
return { id, status };
}
export async function listSchedules(shopId?: string | null) {
return query(
`SELECT ss.*, sm.employee_code, sm.role, sm.phone
FROM staff_schedules ss
LEFT JOIN staff_members sm ON sm.id = ss.staff_id
WHERE ($1::uuid IS NULL OR ss.shop_id = $1::uuid)
ORDER BY ss.day_of_week, ss.start_time`,
[shopId || null]
);
}
export async function listMembers(search?: string | null) {
return query(
`SELECT m.*, ml.name AS level_name
FROM members m
LEFT JOIN membership_levels ml ON ml.shop_id = m.shop_id AND ml.level_number = m.current_level
WHERE ($1::text IS NULL OR m.display_name ILIKE '%' || $1 || '%' OR m.phone ILIKE '%' || $1 || '%')
ORDER BY m.created_at DESC`,
[search || null]
);
}
export async function createMember(input: JsonRecord) {
const id = randomUUID();
await query(
`INSERT INTO members (id, shop_id, display_name, phone, gender, country_code)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
id,
stringValue(input.shopId),
stringValue(input.name) ?? stringValue(input.displayName) ?? "Khách hàng",
stringValue(input.phone),
stringValue(input.gender),
stringValue(input.countryCode) ?? "VN"
]
);
return { id };
}
export async function listMembershipLevels() {
return query(`SELECT * FROM membership_levels WHERE is_active = true ORDER BY level_number`);
}
export async function addExperience(memberId: string, points: number, source = "manual", referenceId?: string | null) {
return withTransaction(async (client) => {
const current = await client.query(`SELECT current_exp, current_level FROM members WHERE id = $1`, [memberId]);
if (!current.rows[0]) throw new Error("Member not found");
const nextExp = intValue(current.rows[0].current_exp) + points;
const levels = await client.query(`SELECT level_number, required_exp FROM membership_levels ORDER BY required_exp DESC`);
const nextLevel = levels.rows.find((row) => nextExp >= intValue(row.required_exp))?.level_number ?? current.rows[0].current_level;
await client.query(
`UPDATE members
SET current_exp = $2, total_exp_earned = total_exp_earned + $3, current_level = $4, updated_at = now()
WHERE id = $1`,
[memberId, nextExp, points, nextLevel]
);
await client.query(
`INSERT INTO experience_transactions (id, member_id, points, source, reference_id, level_at_time)
VALUES ($1, $2, $3, $4, $5, $6)`,
[randomUUID(), memberId, points, source, referenceId ?? null, nextLevel]
);
return { memberId, pointsAdded: points, currentExp: nextExp, currentLevel: nextLevel };
});
}
export async function listCampaigns() {
return query(`SELECT * FROM campaigns ORDER BY created_at DESC`);
}
export async function createCampaign(input: JsonRecord) {
const id = randomUUID();
await query(
`INSERT INTO campaigns (id, shop_id, name, description, face_value, discount_type, discount_value, total_vouchers, issued_vouchers, status, start_date, end_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 'draft', $9::timestamptz, $10::timestamptz)`,
[
id,
stringValue(input.shopId),
stringValue(input.name) ?? "Chiến dịch mới",
stringValue(input.description),
numberValue(input.faceValue),
stringValue(input.discountType) ?? "fixed",
numberValue(input.discountValue ?? input.faceValue),
intValue(input.totalVouchers, 0),
stringValue(input.startDate),
stringValue(input.endDate)
]
);
return { id };
}
export async function setCampaignStatus(id: string, status: string) {
await query(`UPDATE campaigns SET status = $2, updated_at = now() WHERE id = $1`, [id, status]);
return { id, status };
}
export async function validateVoucher(code: string) {
const rows = await query(
`SELECT v.*, c.name AS campaign_name
FROM vouchers v
LEFT JOIN campaigns c ON c.id = v.campaign_id
WHERE lower(v.code) = lower($1)
AND v.status = 'active'
AND (c.id IS NULL OR c.status = 'active')
LIMIT 1`,
[code]
);
if (!rows[0]) return { valid: false, message: "Voucher không hợp lệ" };
return {
valid: true,
voucherId: String(rows[0].id),
code: String(rows[0].code),
campaignName: rows[0].campaign_name ? String(rows[0].campaign_name) : null,
discountType: String(rows[0].discount_type),
discountValue: numberValue(rows[0].discount_value)
};
}
export async function redeemVoucher(input: JsonRecord) {
const voucherId = stringValue(input.voucherId);
if (!voucherId) throw new Error("voucherId is required");
await query(
`UPDATE vouchers
SET status = 'redeemed', redeemed_order_id = $2::uuid, redeemed_at = now()
WHERE id = $1 AND status = 'active'`,
[voucherId, stringValue(input.orderId)]
);
return { voucherId, status: "redeemed" };
}
export async function listWallets(userId?: string | null) {
return query(
`SELECT w.*,
COALESCE((SELECT SUM(amount) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount > 0), 0) AS total_income,
COALESCE((SELECT ABS(SUM(amount)) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount < 0), 0) AS total_expense
FROM wallets w
WHERE ($1::uuid IS NULL OR owner_id = $1::uuid)
ORDER BY created_at DESC`,
[userId || null]
);
}
export async function listWalletTransactions(limit = 50) {
return query(`SELECT * FROM wallet_transactions ORDER BY created_at DESC LIMIT $1`, [limit]);
}
export async function listResources(shopId: string) {
return query(`SELECT * FROM resources WHERE shop_id = $1 AND is_active = true ORDER BY name`, [shopId]);
}
export async function listTherapists(shopId: string) {
return query(`SELECT * FROM therapists WHERE shop_id = $1 ORDER BY name`, [shopId]);
}
export async function listAppointments(shopId: string, date?: string | null) {
return query(
`SELECT *
FROM appointments
WHERE shop_id = $1
AND ($2::date IS NULL OR start_time::date = $2::date)
ORDER BY start_time ASC`,
[shopId, date || 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);
const end = stringValue(input.endTime) ? new Date(String(input.endTime)) : new Date(start.getTime() + 60 * 60 * 1000);
await query(
`INSERT INTO appointments (id, shop_id, customer_id, staff_id, resource_id, service_id, customer_name, service_name, therapist_name, resource_name, start_time, end_time, status, notes)
VALUES ($1, $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
id,
stringValue(input.shopId),
stringValue(input.customerId),
stringValue(input.staffId),
stringValue(input.resourceId),
stringValue(input.serviceId),
stringValue(input.customerName) ?? "Khách hàng",
stringValue(input.serviceName),
stringValue(input.therapistName),
stringValue(input.resourceName),
start,
end,
stringValue(input.status) ?? "pending",
stringValue(input.notes)
]
);
return { id };
}
export async function updateAppointmentStatus(id: string, action: string) {
const statusMap: Record<string, string> = { confirm: "confirmed", start: "in_progress", complete: "completed", noshow: "no_show", cancel: "cancelled" };
const status = statusMap[action] ?? action;
await query(`UPDATE appointments SET status = $2, updated_at = now() WHERE id = $1`, [id, status]);
return { id, status };
}
export async function listRecipes(shopId: string) {
return query(
`SELECT r.*, p.name AS product_name
FROM recipes r
LEFT JOIN products p ON p.id = r.product_id
WHERE r.shop_id = $1
ORDER BY r.created_at DESC`,
[shopId]
);
}
export async function listReservations(shopId: string, date?: string | null) {
return query(
`SELECT r.*, t.table_number
FROM reservations r
LEFT JOIN tables t ON t.id = r.table_id
WHERE r.shop_id = $1
AND ($2::date IS NULL OR r.reservation_time::date = $2::date)
ORDER BY r.reservation_time ASC`,
[shopId, date || null]
);
}
export async function createReservation(input: JsonRecord) {
const id = randomUUID();
await query(
`INSERT INTO reservations (id, shop_id, table_id, customer_name, phone, guest_count, reservation_time, status, notes)
VALUES ($1, $2, $3::uuid, $4, $5, $6, $7::timestamptz, $8, $9)`,
[
id,
stringValue(input.shopId),
stringValue(input.tableId),
stringValue(input.customerName) ?? "Khách đặt bàn",
stringValue(input.phone),
intValue(input.guestCount, 1),
stringValue(input.reservationTime) ?? new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
stringValue(input.status) ?? "pending",
stringValue(input.notes)
]
);
return { id };
}
export async function updateReservationStatus(id: string, status: string) {
await query(`UPDATE reservations SET status = $2, updated_at = now() WHERE id = $1`, [id, status]);
return { id, status };
}
export async function listKitchenTickets(shopId?: string | null, status?: string | null) {
return query(
`SELECT *
FROM kitchen_tickets
WHERE ($1::uuid IS NULL OR shop_id = $1::uuid)
AND ($2::text IS NULL OR status = $2)
ORDER BY created_at ASC`,
[shopId || null, status || null]
);
}
export async function createKitchenTicket(input: JsonRecord) {
const id = randomUUID();
await query(
`INSERT INTO kitchen_tickets (id, shop_id, order_id, table_id, table_label, status, priority, items)
VALUES ($1, $2, $3::uuid, $4::uuid, $5, 'Pending', $6, $7::jsonb)`,
[
id,
stringValue(input.shopId),
stringValue(input.orderId),
stringValue(input.tableId),
stringValue(input.tableLabel),
stringValue(input.priority) ?? "normal",
JSON.stringify(input.items ?? [])
]
);
return { id };
}
export async function updateKitchenTicket(id: string, status: string) {
await query(`UPDATE kitchen_tickets SET status = $2, updated_at = now() WHERE id = $1`, [id, status]);
return { id, status };
}
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 baristaStats(shopId: string) {
const rows = await query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = 'Pending')::int AS pending,
COUNT(*) FILTER (WHERE status = 'InProgress')::int AS in_progress,
COUNT(*) FILTER (WHERE status = 'Ready')::int AS ready
FROM barista_queue WHERE shop_id = $1`,
[shopId]
);
return rows[0] ?? { total: 0, pending: 0, in_progress: 0, ready: 0 };
}
export async function updateBaristaQueue(id: string, status: string, baristaName?: string | null) {
const column = status === "InProgress" ? "started_at" : status === "Ready" ? "ready_at" : status === "Delivered" ? "delivered_at" : "started_at";
await query(
`UPDATE barista_queue
SET status = $2,
barista_name = COALESCE($3, barista_name),
${column} = now()
WHERE id = $1`,
[id, status, baristaName ?? null]
);
return { id, status };
}
export async function listFiles(shopId?: string | null) {
return query(
`SELECT * FROM storage_files
WHERE ($1::uuid IS NULL OR shop_id = $1::uuid)
ORDER BY created_at DESC`,
[shopId || null]
);
}
export async function createFileRecord(input: JsonRecord) {
const id = randomUUID();
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),
stringValue(input.fileName) ?? "file",
stringValue(input.contentType),
intValue(input.byteSize),
stringValue(input.objectKey) ?? id,
stringValue(input.accessLevel) ?? "public",
stringValue(input.publicUrl),
stringValue(input.provider) ?? "s3"
]
);
return { id };
}
export async function listFolders(parentId?: string | null) {
return query(
`SELECT * FROM storage_folders WHERE ($1::uuid IS NULL OR parent_id = $1::uuid) ORDER BY name`,
[parentId || null]
);
}
export async function createFolder(input: JsonRecord) {
const id = randomUUID();
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"]
);
return { id };
}
export async function getAiConfig(shopId: string) {
const rows = await query(`SELECT * FROM ai_configs WHERE shop_id = $1`, [shopId]);
return rows[0] ?? null;
}
export async function saveAiConfig(input: JsonRecord) {
const shopId = stringValue(input.shopId);
if (!shopId) throw new Error("shopId is required");
await query(
`INSERT INTO ai_configs (shop_id, provider, api_key_ref, model, base_url, system_prompt, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (shop_id) DO UPDATE
SET provider = EXCLUDED.provider,
api_key_ref = EXCLUDED.api_key_ref,
model = EXCLUDED.model,
base_url = EXCLUDED.base_url,
system_prompt = EXCLUDED.system_prompt,
enabled = EXCLUDED.enabled,
updated_at = now()`,
[
shopId,
stringValue(input.provider) ?? process.env.AI_DEFAULT_PROVIDER ?? "openai",
stringValue(input.apiKeyRef) ?? stringValue(input.apiKey),
stringValue(input.model) ?? "gpt-5.1",
stringValue(input.baseUrl),
stringValue(input.systemPrompt),
input.enabled !== false
]
);
return getAiConfig(shopId);
}
export async function saveAiMessage(input: { shopId?: string | null; userId?: string | null; role: string; content: string; toolsUsed?: unknown }) {
await query(
`INSERT INTO ai_messages (id, shop_id, user_id, role, content, tools_used)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
[randomUUID(), input.shopId ?? null, input.userId ?? null, input.role, input.content, JSON.stringify(input.toolsUsed ?? null)]
);
}
export async function platformStats() {
const [shops, users, orders, revenue] = await Promise.all([
query(`SELECT COUNT(*)::int AS count FROM shops WHERE COALESCE(is_deleted, false) = false`),
query(`SELECT COUNT(*)::int AS count FROM mvp_users`),
query(`SELECT COUNT(*)::int AS count FROM orders`),
query(`SELECT COALESCE(SUM(total_amount), 0) AS total FROM orders WHERE status_id IN (3,5)`)
]);
return {
shopCount: intValue(shops[0]?.count),
userCount: intValue(users[0]?.count),
orderCount: intValue(orders[0]?.count),
revenue: numberValue(revenue[0]?.total)
};
}
export async function systemHealth() {
const services = [
"Next BFF",
"Postgres MVP DB",
"IAM",
"Merchant",
"Catalog",
"Inventory",
"Order",
"FnB",
"Booking",
"Wallet",
"Promotion",
"Storage"
];
return services.map((name, index) => ({
name,
status: index < 2 ? "healthy" : "configured",
checkedAt: nowIso()
}));
}
export async function listPlans() {
return query(`SELECT * FROM platform_plans ORDER BY price ASC`);
}
export async function listFeatureFlags() {
return query(`SELECT * FROM feature_flags ORDER BY key ASC`);
}
export async function updateFeatureFlag(key: string, enabled: boolean) {
await query(`UPDATE feature_flags SET enabled = $2, updated_at = now() WHERE key = $1`, [key, enabled]);
return { key, enabled };
}
export async function audit(action: string, entityType?: string | null, entityId?: string | null, metadata: JsonRecord = {}) {
await query(
`INSERT INTO audit_logs (id, action, entity_type, entity_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)`,
[randomUUID(), action, entityType ?? null, entityId ?? null, JSON.stringify(metadata)]
);
}
export async function auditLogs(limit = 100) {
return query(`SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT $1`, [limit]);
}
export async function reportRevenue(shopId?: string | null) {
return query(
`SELECT date_trunc('day', created_at)::date AS day,
COUNT(*)::int AS order_count,
COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE status_id IN (3,5)
AND ($1::uuid IS NULL OR shop_id = $1::uuid)
GROUP BY day
ORDER BY day DESC
LIMIT 31`,
[shopId || null]
);
}
export async function reportTopProducts(shopId?: string | null) {
return query(
`SELECT oi.product_id, oi.product_name, SUM(oi.quantity)::int AS quantity_sold, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.status_id IN (3,5)
AND ($1::uuid IS NULL OR o.shop_id = $1::uuid)
GROUP BY oi.product_id, oi.product_name
ORDER BY quantity_sold DESC
LIMIT 20`,
[shopId || null]
);
}
export async function publicShop(shopId: string) {
return getShop(shopId);
}
export async function publicMenu(shopId: string) {
const [categories, products] = await Promise.all([listCategories(shopId), listProducts(shopId)]);
return categories.map((category) => ({
...category,
items: products.filter((product) => product.categoryId === category.id)
}));
}

View File

@@ -0,0 +1,79 @@
import {
createShop,
getShop,
listShops,
listShopStats,
setShopStatus,
updateShop
} from "../db/queries";
export type ShopStatRow = {
shopId: string;
productCount: number;
orderCount: number;
staffCount: number;
revenue: number;
todayOrderCount: number;
monthRevenue: number;
};
export async function listShopsService() {
return listShops();
}
export async function getShopService(shopId?: string | null) {
return getShop(shopId);
}
export async function createShopService(input: Parameters<typeof createShop>[0]) {
return createShop(input);
}
export async function updateShopService(shopId: string, input: Parameters<typeof updateShop>[1]) {
return updateShop(shopId, input);
}
export async function getShopSettingsService(shopId: string) {
const shop = await getShop(shopId);
if (!shop) throw new Error("Shop not found");
return {
shopId: shop.id,
name: shop.name,
phone: shop.phone,
email: shop.email,
description: shop.description,
category: shop.category,
vertical: shop.vertical,
statusId: shop.statusId,
status: shop.status
};
}
export async function updateShopSettingsService(shopId: string, input: Record<string, unknown>) {
return updateShop(shopId, {
name: typeof input.name === "string" ? input.name : undefined,
phone: typeof input.phone === "string" ? input.phone : undefined,
email: typeof input.email === "string" ? input.email : undefined,
description: typeof input.description === "string" ? input.description : undefined,
statusId: typeof input.statusId === "number" ? input.statusId : undefined,
categoryId: typeof input.categoryId === "number" ? input.categoryId : undefined,
featuresConfig: input.featuresConfig ?? undefined
});
}
export async function getShopStatsService(): Promise<ShopStatRow[]> {
return listShopStats();
}
export async function publishShopService(shopId: string) {
return setShopStatus(shopId, 2);
}
export async function deactivateShopService(shopId: string) {
return setShopStatus(shopId, 3);
}
export async function closeShopService(shopId: string) {
return setShopStatus(shopId, 4);
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
export type ApiResponse<T> = {
success: true;
data: T;
} | {
success: false;
error: string;
};
export type PagedResult<T> = {
items: T[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
export function ok<T>(data: T, init?: ResponseInit) {
return NextResponse.json<ApiResponse<T>>(
{
success: true,
data
},
init
);
}
export function fail(error: string, init?: ResponseInit) {
const initWithStatus = {
status: init?.status ?? 400,
headers: init?.headers
};
return NextResponse.json<ApiResponse<never>>(
{
success: false,
error
},
initWithStatus
);
}
export function pageResponse<T>(items: T[], totalCount: number, page = 1, pageSize = 20) {
const normalizedPage = Math.max(1, Math.floor(page));
const normalizedPageSize = Math.max(1, Math.floor(pageSize));
const totalPages = Math.max(1, Math.ceil(totalCount / normalizedPageSize));
return {
items,
page: normalizedPage,
pageSize: normalizedPageSize,
totalCount,
totalPages
} as PagedResult<T>;
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../packages/config/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,67 +1,67 @@
import {
useMediaQuery
} from "./chunk-UFA7CLQY.js";
} from "./chunk-FG4GAJOZ.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-LW7BHYZJ.js";
} from "./chunk-NGGQV2J5.js";
import "./chunk-FDBJFBLO.js";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/index.js
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/index.js
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "/Users/velikho/Desktop/WORKING/Base/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "/Users/velikho/Desktop/WORKING/pos-system/microservices/node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/outline.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/support/utils.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/data.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/support/utils.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/support/sidebar.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
@@ -104,7 +104,7 @@ function addBase(items, _base) {
});
}
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
@@ -161,7 +161,7 @@ function useSidebar() {
};
}
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/outline.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
@@ -226,7 +226,7 @@ function buildTree(data, min, max) {
return result;
}
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
@@ -242,7 +242,7 @@ function useLocalNav() {
};
}
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/without-fonts.js
// ../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {

File diff suppressed because one or more lines are too long

View File

@@ -1,85 +1,85 @@
{
"hash": "cbccdb6c",
"configHash": "34e95561",
"lockfileHash": "f00eb143",
"browserHash": "a1ee881e",
"hash": "8cc192a2",
"configHash": "793f8191",
"lockfileHash": "85ce92e3",
"browserHash": "9390ba51",
"optimized": {
"vue": {
"src": "../../../../../node_modules/.pnpm/vue@3.5.26/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"src": "../../../../../node_modules/.pnpm/vue@3.5.28_typescript@5.9.3/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "903deb5f",
"fileHash": "7af83bde",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../../../node_modules/.pnpm/@vue+devtools-api@7.7.9/node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "b9236f25",
"fileHash": "8d6a4454",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../../../node_modules/.pnpm/@vueuse+core@12.8.2/node_modules/@vueuse/core/index.mjs",
"src": "../../../../../node_modules/.pnpm/@vueuse+core@12.8.2_typescript@5.9.3/node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "e6068a66",
"fileHash": "2939884a",
"needsInterop": false
},
"mermaid": {
"src": "../../../../../node_modules/.pnpm/mermaid@11.12.2/node_modules/mermaid/dist/mermaid.core.mjs",
"file": "mermaid.js",
"fileHash": "e0283f08",
"fileHash": "f5bad040",
"needsInterop": false
},
"@braintree/sanitize-url": {
"src": "../../../../../node_modules/.pnpm/@braintree+sanitize-url@7.1.1/node_modules/@braintree/sanitize-url/dist/index.js",
"file": "@braintree_sanitize-url.js",
"fileHash": "c9338e46",
"fileHash": "e73968ba",
"needsInterop": true
},
"dayjs": {
"src": "../../../../../node_modules/.pnpm/dayjs@1.11.19/node_modules/dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "f381c8e8",
"fileHash": "b067ae2d",
"needsInterop": true
},
"debug": {
"src": "../../../../../node_modules/.pnpm/debug@4.4.3/node_modules/debug/src/browser.js",
"file": "debug.js",
"fileHash": "543e9e7a",
"fileHash": "cf8133a5",
"needsInterop": true
},
"cytoscape-cose-bilkent": {
"src": "../../../../../node_modules/.pnpm/cytoscape-cose-bilkent@4.1.0_cytoscape@3.33.1/node_modules/cytoscape-cose-bilkent/cytoscape-cose-bilkent.js",
"file": "cytoscape-cose-bilkent.js",
"fileHash": "c35404ee",
"fileHash": "858ce3d0",
"needsInterop": true
},
"cytoscape": {
"src": "../../../../../node_modules/.pnpm/cytoscape@3.33.1/node_modules/cytoscape/dist/cytoscape.esm.mjs",
"file": "cytoscape.js",
"fileHash": "dc77d6e1",
"fileHash": "a9866592",
"needsInterop": false
},
"vitepress > @vueuse/integrations/useFocusTrap": {
"src": "../../../../../node_modules/.pnpm/@vueuse+integrations@12.8.2_focus-trap@7.8.0/node_modules/@vueuse/integrations/useFocusTrap.mjs",
"src": "../../../../../node_modules/.pnpm/@vueuse+integrations@12.8.2_focus-trap@7.8.0_typescript@5.9.3/node_modules/@vueuse/integrations/useFocusTrap.mjs",
"file": "vitepress___@vueuse_integrations_useFocusTrap.js",
"fileHash": "9443d6d6",
"fileHash": "02ecbab1",
"needsInterop": false
},
"vitepress > mark.js/src/vanilla.js": {
"src": "../../../../../node_modules/.pnpm/mark.js@8.11.1/node_modules/mark.js/src/vanilla.js",
"file": "vitepress___mark__js_src_vanilla__js.js",
"fileHash": "e97784c1",
"fileHash": "863193ab",
"needsInterop": false
},
"vitepress > minisearch": {
"src": "../../../../../node_modules/.pnpm/minisearch@7.2.0/node_modules/minisearch/dist/es/index.js",
"file": "vitepress___minisearch.js",
"fileHash": "ab36d156",
"fileHash": "de77bb68",
"needsInterop": false
},
"@theme/index": {
"src": "../../../../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_search-insights@2.17.3/node_modules/vitepress/dist/client/theme-default/index.js",
"src": "../../../../../node_modules/.pnpm/vitepress@1.6.4_@algolia+client-search@5.46.2_@types+node@25.0.3_search-insights@2.17.3_typescript@5.9.3/node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "02e77a22",
"fileHash": "412222f8",
"needsInterop": false
}
},
@@ -252,11 +252,11 @@
"chunk-HMGUWPFF": {
"file": "chunk-HMGUWPFF.js"
},
"chunk-UFA7CLQY": {
"file": "chunk-UFA7CLQY.js"
"chunk-FG4GAJOZ": {
"file": "chunk-FG4GAJOZ.js"
},
"chunk-LW7BHYZJ": {
"file": "chunk-LW7BHYZJ.js"
"chunk-NGGQV2J5": {
"file": "chunk-NGGQV2J5.js"
},
"chunk-7UGQM42H": {
"file": "chunk-7UGQM42H.js"

View File

@@ -35,9 +35,9 @@ import {
unref,
watch,
watchEffect
} from "./chunk-LW7BHYZJ.js";
} from "./chunk-NGGQV2J5.js";
// ../../node_modules/.pnpm/@vueuse+shared@12.8.2/node_modules/@vueuse/shared/index.mjs
// ../../node_modules/.pnpm/@vueuse+shared@12.8.2_typescript@5.9.3/node_modules/@vueuse/shared/index.mjs
function computedEager(fn, options) {
var _a;
const result = shallowRef();
@@ -1569,7 +1569,7 @@ function whenever(source, cb, options) {
return stop;
}
// ../../node_modules/.pnpm/@vueuse+core@12.8.2/node_modules/@vueuse/core/index.mjs
// ../../node_modules/.pnpm/@vueuse+core@12.8.2_typescript@5.9.3/node_modules/@vueuse/core/index.mjs
function computedAsync(evaluationCallback, initialState, optionsOrRef) {
let options;
if (isRef(optionsOrRef)) {
@@ -9716,4 +9716,4 @@ export {
useWindowScroll,
useWindowSize
};
//# sourceMappingURL=chunk-UFA7CLQY.js.map
//# sourceMappingURL=chunk-FG4GAJOZ.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
// ../../node_modules/.pnpm/@vue+shared@3.5.26/node_modules/@vue/shared/dist/shared.esm-bundler.js
// ../../node_modules/.pnpm/@vue+shared@3.5.28/node_modules/@vue/shared/dist/shared.esm-bundler.js
function makeMap(str) {
const map2 = /* @__PURE__ */ Object.create(null);
for (const key of str.split(",")) map2[key] = 1;
@@ -319,12 +319,13 @@ function normalizeCssVarValue(value) {
return String(value);
}
// ../../node_modules/.pnpm/@vue+reactivity@3.5.26/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js
// ../../node_modules/.pnpm/@vue+reactivity@3.5.28/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js
function warn(msg, ...args) {
console.warn(`[Vue warn] ${msg}`, ...args);
}
var activeEffectScope;
var EffectScope = class {
// TODO isolatedDeclarations "__v_skip"
constructor(detached = false) {
this.detached = detached;
this._active = true;
@@ -332,6 +333,7 @@ var EffectScope = class {
this.effects = [];
this.cleanups = [];
this._isPaused = false;
this.__v_skip = true;
this.parent = activeEffectScope;
if (!detached && activeEffectScope) {
this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
@@ -1366,20 +1368,20 @@ function createIterableMethod(method, isReadonly2, isShallow2) {
"iterate",
isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
);
return {
// iterator protocol
next() {
const { value, done } = innerIterator.next();
return done ? { value, done } : {
value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
done
};
},
// iterable protocol
[Symbol.iterator]() {
return this;
return extend(
// inheriting all iterator properties
Object.create(innerIterator),
{
// iterator protocol
next() {
const { value, done } = innerIterator.next();
return done ? { value, done } : {
value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
done
};
}
}
};
);
};
}
function createReadonlyMethod(type) {
@@ -2151,7 +2153,7 @@ function traverse(value, depth = Infinity, seen) {
return value;
}
// ../../node_modules/.pnpm/@vue+runtime-core@3.5.26/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js
// ../../node_modules/.pnpm/@vue+runtime-core@3.5.28/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js
var stack = [];
function pushWarningContext(vnode) {
stack.push(vnode);
@@ -3352,7 +3354,22 @@ function moveTeleport(vnode, container, parentAnchor, { o: { insert }, m: move }
function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, {
o: { nextSibling, parentNode, querySelector, insert, createText }
}, hydrateChildren) {
function hydrateDisabledTeleport(node2, vnode2, targetStart, targetAnchor) {
function hydrateAnchor(target2, targetNode) {
let targetAnchor = targetNode;
while (targetAnchor) {
if (targetAnchor && targetAnchor.nodeType === 8) {
if (targetAnchor.data === "teleport start anchor") {
vnode.targetStart = targetAnchor;
} else if (targetAnchor.data === "teleport anchor") {
vnode.targetAnchor = targetAnchor;
target2._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor);
break;
}
}
targetAnchor = nextSibling(targetAnchor);
}
}
function hydrateDisabledTeleport(node2, vnode2) {
vnode2.anchor = hydrateChildren(
nextSibling(node2),
vnode2,
@@ -3362,8 +3379,6 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
slotScopeIds,
optimized
);
vnode2.targetStart = targetStart;
vnode2.targetAnchor = targetAnchor;
}
const target = vnode.target = resolveTarget(
vnode.props,
@@ -3374,27 +3389,22 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
const targetNode = target._lpa || target.firstChild;
if (vnode.shapeFlag & 16) {
if (disabled) {
hydrateDisabledTeleport(
node,
vnode,
targetNode,
targetNode && nextSibling(targetNode)
);
hydrateDisabledTeleport(node, vnode);
hydrateAnchor(target, targetNode);
if (!vnode.targetAnchor) {
prepareAnchor(
target,
vnode,
createText,
insert,
// if target is the same as the main view, insert anchors before current node
// to avoid hydrating mismatch
parentNode(node) === target ? node : null
);
}
} else {
vnode.anchor = nextSibling(node);
let targetAnchor = targetNode;
while (targetAnchor) {
if (targetAnchor && targetAnchor.nodeType === 8) {
if (targetAnchor.data === "teleport start anchor") {
vnode.targetStart = targetAnchor;
} else if (targetAnchor.data === "teleport anchor") {
vnode.targetAnchor = targetAnchor;
target._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor);
break;
}
}
targetAnchor = nextSibling(targetAnchor);
}
hydrateAnchor(target, targetNode);
if (!vnode.targetAnchor) {
prepareAnchor(target, vnode, createText, insert);
}
@@ -3412,7 +3422,9 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
updateCssVars(vnode, disabled);
} else if (disabled) {
if (vnode.shapeFlag & 16) {
hydrateDisabledTeleport(node, vnode, node, nextSibling(node));
hydrateDisabledTeleport(node, vnode);
vnode.targetStart = node;
vnode.targetAnchor = nextSibling(node);
}
}
return vnode.anchor && nextSibling(vnode.anchor);
@@ -3436,13 +3448,13 @@ function updateCssVars(vnode, isDisabled) {
ctx.ut();
}
}
function prepareAnchor(target, vnode, createText, insert) {
function prepareAnchor(target, vnode, createText, insert, anchor = null) {
const targetStart = vnode.targetStart = createText("");
const targetAnchor = vnode.targetAnchor = createText("");
targetStart[TeleportEndKey] = targetAnchor;
if (target) {
insert(targetStart, target);
insert(targetAnchor, target);
insert(targetStart, target, anchor);
insert(targetAnchor, target, anchor);
}
return targetAnchor;
}
@@ -3677,7 +3689,7 @@ function resolveTransitionHooks(vnode, props, state, instance, postClone) {
}
}
let called = false;
const done = el[enterCbKey] = (cancelled) => {
el[enterCbKey] = (cancelled) => {
if (called) return;
called = true;
if (cancelled) {
@@ -3690,6 +3702,7 @@ function resolveTransitionHooks(vnode, props, state, instance, postClone) {
}
el[enterCbKey] = void 0;
};
const done = el[enterCbKey].bind(null, false);
if (hook) {
callAsyncHook(hook, [el, done]);
} else {
@@ -3709,7 +3722,7 @@ function resolveTransitionHooks(vnode, props, state, instance, postClone) {
}
callHook3(onBeforeLeave, [el]);
let called = false;
const done = el[leaveCbKey] = (cancelled) => {
el[leaveCbKey] = (cancelled) => {
if (called) return;
called = true;
remove2();
@@ -3723,6 +3736,7 @@ function resolveTransitionHooks(vnode, props, state, instance, postClone) {
delete leavingVNodesCache[key2];
}
};
const done = el[leaveCbKey].bind(null, false);
leavingVNodesCache[key2] = vnode;
if (onLeave) {
callAsyncHook(onLeave, [el, done]);
@@ -3831,8 +3845,7 @@ function useTemplateRef(key) {
const r = shallowRef(null);
if (i) {
const refs = i.refs === EMPTY_OBJ ? i.refs = {} : i.refs;
let desc;
if ((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable) {
if (isTemplateRefKey(refs, key)) {
warn$1(`useTemplateRef('${key}') already exists.`);
} else {
Object.defineProperty(refs, key, {
@@ -3852,6 +3865,10 @@ function useTemplateRef(key) {
}
return ret;
}
function isTemplateRefKey(refs, key) {
let desc;
return !!((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable);
}
var pendingSetRefMap = /* @__PURE__ */ new WeakMap();
function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
if (isArray(rawRef)) {
@@ -3896,10 +3913,19 @@ function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
return false;
}
}
if (isTemplateRefKey(refs, key)) {
return false;
}
return hasOwn(rawSetupState, key);
};
const canSetRef = (ref22) => {
return !knownTemplateRefs.has(ref22);
const canSetRef = (ref22, key) => {
if (knownTemplateRefs.has(ref22)) {
return false;
}
if (key && isTemplateRefKey(refs, key)) {
return false;
}
return true;
};
if (oldRef != null && oldRef !== ref2) {
invalidatePendingSetRef(oldRawRef);
@@ -3909,10 +3935,10 @@ function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
setupState[oldRef] = null;
}
} else if (isRef2(oldRef)) {
if (canSetRef(oldRef)) {
const oldRawRefAtom = oldRawRef;
if (canSetRef(oldRef, oldRawRefAtom.k)) {
oldRef.value = null;
}
const oldRawRefAtom = oldRawRef;
if (oldRawRefAtom.k) refs[oldRawRefAtom.k] = null;
}
}
@@ -3936,7 +3962,7 @@ function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
}
} else {
const newVal = [refValue];
if (canSetRef(ref2)) {
if (canSetRef(ref2, rawRef.k)) {
ref2.value = newVal;
}
if (rawRef.k) refs[rawRef.k] = newVal;
@@ -3951,7 +3977,7 @@ function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
setupState[ref2] = value;
}
} else if (_isRef) {
if (canSetRef(ref2)) {
if (canSetRef(ref2, rawRef.k)) {
ref2.value = value;
}
if (rawRef.k) refs[rawRef.k] = value;
@@ -4298,7 +4324,7 @@ Server rendered element contains more child nodes than client vdom.`
logMismatchError();
}
if (forcePatch && (key.endsWith("value") || key === "indeterminate") || isOn(key) && !isReservedProp(key) || // force hydrate v-bind with .prop modifiers
key[0] === "." || isCustomElement) {
key[0] === "." || isCustomElement && !isReservedProp(key)) {
patchProp2(el, key, null, props[key], void 0, parentComponent);
}
}
@@ -6843,7 +6869,7 @@ function shouldUpdateComponent(prevVNode, nextVNode, optimized) {
const dynamicProps = nextVNode.dynamicProps;
for (let i = 0; i < dynamicProps.length; i++) {
const key = dynamicProps[i];
if (nextProps[key] !== prevProps[key] && !isEmitListener(emits, key)) {
if (hasPropValueChanged(nextProps, prevProps, key) && !isEmitListener(emits, key)) {
return true;
}
}
@@ -6874,12 +6900,20 @@ function hasPropsChanged(prevProps, nextProps, emitsOptions) {
}
for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i];
if (nextProps[key] !== prevProps[key] && !isEmitListener(emitsOptions, key)) {
if (hasPropValueChanged(nextProps, prevProps, key) && !isEmitListener(emitsOptions, key)) {
return true;
}
}
return false;
}
function hasPropValueChanged(nextProps, prevProps, key) {
const nextProp = nextProps[key];
const prevProp = prevProps[key];
if (key === "style" && isObject(nextProp) && isObject(prevProp)) {
return !looseEqual(nextProp, prevProp);
}
return nextProp !== prevProp;
}
function updateHOCHostEl({ vnode, parent }, el) {
while (parent) {
const root = parent.subTree;
@@ -7616,15 +7650,7 @@ function baseCreateRenderer(options, createHydrationFns) {
} else {
const el = n2.el = n1.el;
if (n2.children !== n1.children) {
if (isHmrUpdating && n2.patchFlag === -1 && "__elIndex" in n1) {
const childNodes = container.childNodes;
const newChild = hostCreateText(n2.children);
const oldChild = childNodes[n2.__elIndex = n1.__elIndex];
hostInsert(newChild, container, oldChild);
hostRemove(oldChild);
} else {
hostSetText(el, n2.children);
}
hostSetText(el, n2.children);
}
}
};
@@ -7700,7 +7726,7 @@ function baseCreateRenderer(options, createHydrationFns) {
optimized
);
} else {
const customElement = !!(n1.el && n1.el._isVueCE) ? n1.el : null;
const customElement = n1.el && n1.el._isVueCE ? n1.el : null;
try {
if (customElement) {
customElement._beginPatch();
@@ -8195,8 +8221,7 @@ function baseCreateRenderer(options, createHydrationFns) {
hydrateSubTree();
}
} else {
if (root.ce && // @ts-expect-error _def is private
root.ce._def.shadowRoot !== false) {
if (root.ce && root.ce._hasShadowRoot()) {
root.ce._injectChildStyle(type);
}
if (true) {
@@ -8251,9 +8276,9 @@ function baseCreateRenderer(options, createHydrationFns) {
updateComponentPreRender(instance, next, optimized);
}
nonHydratedAsyncRoot.asyncDep.then(() => {
if (!instance.isUnmounted) {
componentUpdateFn();
}
queuePostRenderEffect(() => {
if (!instance.isUnmounted) update();
}, parentSuspense);
});
return;
}
@@ -8950,12 +8975,10 @@ function traverseStaticChildren(n1, n2, shallow = false) {
traverseStaticChildren(c1, c2);
}
if (c2.type === Text) {
if (c2.patchFlag !== -1) {
c2.el = c1.el;
} else {
c2.__elIndex = i + // take fragment start anchor into account
(n1.type === Fragment ? 1 : 0);
if (c2.patchFlag === -1) {
c2 = ch2[i] = cloneIfMounted(c2);
}
c2.el = c1.el;
}
if (c2.type === Comment && !c2.el) {
c2.el = c1.el;
@@ -10687,7 +10710,7 @@ function isMemoSame(cached, memo) {
}
return true;
}
var version = "3.5.26";
var version = "3.5.28";
var warn2 = true ? warn$1 : NOOP;
var ErrorTypeStrings = ErrorTypeStrings$1;
var devtools = true ? devtools$1 : void 0;
@@ -10709,7 +10732,7 @@ var resolveFilter = null;
var compatUtils = null;
var DeprecationTypes = null;
// ../../node_modules/.pnpm/@vue+runtime-dom@3.5.26/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js
// ../../node_modules/.pnpm/@vue+runtime-dom@3.5.28/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js
var policy = void 0;
var tt = typeof window !== "undefined" && window.trustedTypes;
if (tt) {
@@ -11942,6 +11965,12 @@ var VueElement = class _VueElement extends BaseClass {
this._update();
}
}
/**
* @internal
*/
_hasShadowRoot() {
return this._def.shadowRoot !== false;
}
/**
* @internal
*/
@@ -12074,10 +12103,7 @@ var TransitionGroupImpl = decorate({
instance
)
);
positionMap.set(child, {
left: child.el.offsetLeft,
top: child.el.offsetTop
});
positionMap.set(child, getPosition(child.el));
}
}
}
@@ -12108,10 +12134,7 @@ function callPendingCbs(c) {
}
}
function recordPosition(c) {
newPositionMap.set(c, {
left: c.el.offsetLeft,
top: c.el.offsetTop
});
newPositionMap.set(c, getPosition(c.el));
}
function applyTranslation(c) {
const oldPos = positionMap.get(c);
@@ -12119,12 +12142,29 @@ function applyTranslation(c) {
const dx = oldPos.left - newPos.left;
const dy = oldPos.top - newPos.top;
if (dx || dy) {
const s = c.el.style;
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`;
const el = c.el;
const s = el.style;
const rect = el.getBoundingClientRect();
let scaleX = 1;
let scaleY = 1;
if (el.offsetWidth) scaleX = rect.width / el.offsetWidth;
if (el.offsetHeight) scaleY = rect.height / el.offsetHeight;
if (!Number.isFinite(scaleX) || scaleX === 0) scaleX = 1;
if (!Number.isFinite(scaleY) || scaleY === 0) scaleY = 1;
if (Math.abs(scaleX - 1) < 0.01) scaleX = 1;
if (Math.abs(scaleY - 1) < 0.01) scaleY = 1;
s.transform = s.webkitTransform = `translate(${dx / scaleX}px,${dy / scaleY}px)`;
s.transitionDuration = "0s";
return c;
}
}
function getPosition(el) {
const rect = el.getBoundingClientRect();
return {
left: rect.left,
top: rect.top
};
}
function hasCSSTransform(el, root, moveClass) {
const clone = el.cloneNode();
const _vtc = el[vtcKey];
@@ -12433,6 +12473,7 @@ var modifierGuards = {
exact: (e, modifiers) => systemModifiers.some((m) => e[`${m}Key`] && !modifiers.includes(m))
};
var withModifiers = (fn, modifiers) => {
if (!fn) return fn;
const cache = fn._withMods || (fn._withMods = {});
const cacheKey = modifiers.join(".");
return cache[cacheKey] || (cache[cacheKey] = (event, ...args) => {
@@ -12594,7 +12635,7 @@ var initDirectivesForSSR = () => {
}
};
// ../../node_modules/.pnpm/vue@3.5.26/node_modules/vue/dist/vue.runtime.esm-bundler.js
// ../../node_modules/.pnpm/vue@3.5.28_typescript@5.9.3/node_modules/vue/dist/vue.runtime.esm-bundler.js
function initDev() {
{
initCustomFormatter();
@@ -12788,37 +12829,37 @@ export {
@vue/shared/dist/shared.esm-bundler.js:
(**
* @vue/shared v3.5.26
* @vue/shared v3.5.28
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
@vue/reactivity/dist/reactivity.esm-bundler.js:
(**
* @vue/reactivity v3.5.26
* @vue/reactivity v3.5.28
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
@vue/runtime-core/dist/runtime-core.esm-bundler.js:
(**
* @vue/runtime-core v3.5.26
* @vue/runtime-core v3.5.28
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
@vue/runtime-dom/dist/runtime-dom.esm-bundler.js:
(**
* @vue/runtime-dom v3.5.26
* @vue/runtime-dom v3.5.28
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
vue/dist/vue.runtime.esm-bundler.js:
(**
* vue v3.5.26
* vue v3.5.28
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
*/
//# sourceMappingURL=chunk-LW7BHYZJ.js.map
//# sourceMappingURL=chunk-NGGQV2J5.js.map

File diff suppressed because one or more lines are too long

View File

@@ -281,8 +281,8 @@ import {
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-UFA7CLQY.js";
import "./chunk-LW7BHYZJ.js";
} from "./chunk-FG4GAJOZ.js";
import "./chunk-NGGQV2J5.js";
import "./chunk-FDBJFBLO.js";
export {
DefaultMagicKeysAliasMap,

View File

@@ -3,13 +3,13 @@ import {
toArray,
tryOnScopeDispose,
unrefElement
} from "./chunk-UFA7CLQY.js";
} from "./chunk-FG4GAJOZ.js";
import {
computed,
shallowRef,
toValue,
watch
} from "./chunk-LW7BHYZJ.js";
} from "./chunk-NGGQV2J5.js";
import "./chunk-FDBJFBLO.js";
// ../../node_modules/.pnpm/tabbable@6.4.0/node_modules/tabbable/dist/index.esm.js
@@ -1273,7 +1273,7 @@ var createFocusTrap = function createFocusTrap2(elements, userOptions) {
return trap;
};
// ../../node_modules/.pnpm/@vueuse+integrations@12.8.2_focus-trap@7.8.0/node_modules/@vueuse/integrations/useFocusTrap.mjs
// ../../node_modules/.pnpm/@vueuse+integrations@12.8.2_focus-trap@7.8.0_typescript@5.9.3/node_modules/@vueuse/integrations/useFocusTrap.mjs
function useFocusTrap(target, options = {}) {
let trap;
const { immediate, ...focusTrapOptions } = options;

File diff suppressed because one or more lines are too long

View File

@@ -170,7 +170,7 @@ import {
withMemo,
withModifiers,
withScopeId
} from "./chunk-LW7BHYZJ.js";
} from "./chunk-NGGQV2J5.js";
import "./chunk-FDBJFBLO.js";
export {
BaseTransition,

View File

@@ -27,6 +27,37 @@ importers:
specifier: ^5.9.3
version: 5.9.3
apps/tpos-mvp-next:
dependencies:
lucide-react:
specifier: ^1.16.0
version: 1.16.0(react@19.2.6)
next:
specifier: ^16.2.6
version: 16.2.6(react-dom@19.2.6)(react@19.2.6)
pg:
specifier: ^8.21.0
version: 8.21.0
react:
specifier: ^19.2.6
version: 19.2.6
react-dom:
specifier: ^19.2.6
version: 19.2.6(react@19.2.6)
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
'@types/react':
specifier: ^19.2.15
version: 19.2.15
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.15)
apps/web-docs:
devDependencies:
'@vue/server-renderer':
@@ -317,7 +348,7 @@ importers:
version: 30.2.0(@types/node@25.0.3)(ts-node@10.9.2)
prisma:
specifier: ^7.2.0
version: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)(typescript@5.9.3)
version: 7.2.0(@types/react@19.2.7)(react-dom@19.2.6)(react@19.2.6)(typescript@5.9.3)
supertest:
specifier: ^7.2.2
version: 7.2.2
@@ -1104,7 +1135,6 @@ packages:
requiresBuild: true
dependencies:
tslib: 2.8.1
dev: true
optional: true
/@emnapi/wasi-threads@1.1.0:
@@ -1695,6 +1725,240 @@ packages:
mlly: 1.8.0
dev: true
/@img/colour@1.1.0:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/@img/sharp-darwin-arm64@0.34.5:
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
dev: false
optional: true
/@img/sharp-darwin-x64@0.34.5:
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
dev: false
optional: true
/@img/sharp-libvips-darwin-arm64@1.2.4:
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-darwin-x64@1.2.4:
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-arm64@1.2.4:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-arm@1.2.4:
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-ppc64@1.2.4:
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-riscv64@1.2.4:
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-s390x@1.2.4:
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-x64@1.2.4:
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linuxmusl-arm64@1.2.4:
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linuxmusl-x64@1.2.4:
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-linux-arm64@0.34.5:
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
dev: false
optional: true
/@img/sharp-linux-arm@0.34.5:
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
dev: false
optional: true
/@img/sharp-linux-ppc64@0.34.5:
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
dev: false
optional: true
/@img/sharp-linux-riscv64@0.34.5:
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
dev: false
optional: true
/@img/sharp-linux-s390x@0.34.5:
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
dev: false
optional: true
/@img/sharp-linux-x64@0.34.5:
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
dev: false
optional: true
/@img/sharp-linuxmusl-arm64@0.34.5:
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
dev: false
optional: true
/@img/sharp-linuxmusl-x64@0.34.5:
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
dev: false
optional: true
/@img/sharp-wasm32@0.34.5:
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
requiresBuild: true
dependencies:
'@emnapi/runtime': 1.8.1
dev: false
optional: true
/@img/sharp-win32-arm64@0.34.5:
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@img/sharp-win32-ia32@0.34.5:
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@img/sharp-win32-x64@0.34.5:
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@ioredis/commands@1.5.0:
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
@@ -2317,6 +2581,82 @@ packages:
'@types/pg': 8.16.0
dev: false
/@next/env@16.2.6:
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
dev: false
/@next/swc-darwin-arm64@16.2.6:
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@next/swc-darwin-x64@16.2.6:
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-arm64-gnu@16.2.6:
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-arm64-musl@16.2.6:
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-x64-gnu@16.2.6:
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-x64-musl@16.2.6:
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-win32-arm64-msvc@16.2.6:
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@next/swc-win32-x64-msvc@16.2.6:
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@noble/hashes@1.8.0:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@@ -3417,7 +3757,7 @@ packages:
optional: true
dependencies:
'@prisma/client-runtime-utils': 7.2.0
prisma: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)(typescript@5.9.3)
prisma: 7.2.0(@types/react@19.2.7)(react-dom@19.2.6)(react@19.2.6)(typescript@5.9.3)
typescript: 5.9.3
dev: false
@@ -3498,7 +3838,7 @@ packages:
/@prisma/query-plan-executor@6.18.0:
resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==}
/@prisma/studio-core@0.9.0(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
/@prisma/studio-core@0.9.0(@types/react@19.2.7)(react-dom@19.2.6)(react@19.2.6):
resolution: {integrity: sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
@@ -3506,8 +3846,8 @@ packages:
react-dom: ^18.0.0 || ^19.0.0
dependencies:
'@types/react': 19.2.7
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
/@protobufjs/aspromise@1.1.2:
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -3843,6 +4183,12 @@ packages:
/@standard-schema/spec@1.1.0:
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
/@swc/helpers@0.5.15:
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
dependencies:
tslib: 2.8.1
dev: false
/@tsconfig/node10@1.0.12:
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
dev: true
@@ -4260,7 +4606,7 @@ packages:
/@types/pg-pool@2.0.6:
resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==}
dependencies:
'@types/pg': 8.15.6
'@types/pg': 8.16.0
dev: false
/@types/pg@8.15.6:
@@ -4279,6 +4625,14 @@ packages:
pg-types: 2.2.0
dev: false
/@types/pg@8.20.0:
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
dependencies:
'@types/node': 25.0.3
pg-protocol: 1.14.0
pg-types: 2.2.0
dev: true
/@types/qs@6.14.0:
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
dev: true
@@ -4287,6 +4641,20 @@ packages:
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
dev: true
/@types/react-dom@19.2.3(@types/react@19.2.15):
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^19.2.0
dependencies:
'@types/react': 19.2.15
dev: true
/@types/react@19.2.15:
resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
dependencies:
csstype: 3.2.3
dev: true
/@types/react@19.2.7:
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
dependencies:
@@ -5293,6 +5661,12 @@ packages:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/baseline-browser-mapping@2.10.32:
resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
engines: {node: '>=6.0.0'}
hasBin: true
dev: false
/baseline-browser-mapping@2.9.11:
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
@@ -5466,7 +5840,6 @@ packages:
/caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
dev: true
/ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -5576,6 +5949,10 @@ packages:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
dev: true
/client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
dev: false
/cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -6188,6 +6565,13 @@ packages:
/destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
/detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
requiresBuild: true
dev: false
optional: true
/detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@@ -8468,6 +8852,14 @@ packages:
resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
/lucide-react@1.16.0(react@19.2.6):
resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
react: 19.2.6
dev: false
/magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
dependencies:
@@ -8734,7 +9126,6 @@ packages:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
/napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
@@ -8754,6 +9145,50 @@ packages:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
dev: true
/next@16.2.6(react-dom@19.2.6)(react@19.2.6):
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
dependencies:
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.32
caniuse-lite: 1.0.30001762
postcss: 8.4.31
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-jsx: 5.1.6(react@19.2.6)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.2.6
'@next/swc-linux-arm64-gnu': 16.2.6
'@next/swc-linux-arm64-musl': 16.2.6
'@next/swc-linux-x64-gnu': 16.2.6
'@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
dev: false
/node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@@ -9079,15 +9514,35 @@ packages:
/perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
/pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
requiresBuild: true
dev: false
optional: true
/pg-connection-string@2.13.0:
resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==}
dev: false
/pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
/pg-pool@3.14.0(pg@8.21.0):
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
dependencies:
pg: 8.21.0
dev: false
/pg-protocol@1.10.3:
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
dev: false
/pg-protocol@1.14.0:
resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==}
/pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
@@ -9097,11 +9552,33 @@ packages:
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
/pg@8.21.0:
resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
dependencies:
pg-connection-string: 2.13.0
pg-pool: 3.14.0(pg@8.21.0)
pg-protocol: 1.14.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
dev: false
/pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
dependencies:
split2: 4.2.0
dev: false
/picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
dev: true
/picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
@@ -9159,6 +9636,15 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
/postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
dev: false
/postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -9171,7 +9657,6 @@ packages:
/postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
dev: false
/postgres-array@3.0.4:
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
@@ -9181,19 +9666,16 @@ packages:
/postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
dev: false
/postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
dev: false
/postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
dependencies:
xtend: 4.0.2
dev: false
/postgres@3.4.7:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
@@ -9221,7 +9703,7 @@ packages:
react-is: 18.3.1
dev: true
/prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)(typescript@5.9.3):
/prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.6)(react@19.2.6)(typescript@5.9.3):
resolution: {integrity: sha512-jSdHWgWOgFF24+nRyyNRVBIgGDQEsMEF8KPHvhBBg3jWyR9fUAK0Nq9ThUmiGlNgq2FA7vSk/ZoCvefod+a8qg==}
engines: {node: ^20.19 || ^22.12 || >=24.0}
hasBin: true
@@ -9238,7 +9720,7 @@ packages:
'@prisma/config': 7.2.0
'@prisma/dev': 0.17.0(typescript@5.9.3)
'@prisma/engines': 7.2.0
'@prisma/studio-core': 0.9.0(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@prisma/studio-core': 0.9.0(@types/react@19.2.7)(react-dom@19.2.6)(react@19.2.6)
mysql2: 3.15.3
postgres: 3.4.7
typescript: 5.9.3
@@ -9354,20 +9836,20 @@ packages:
defu: 6.1.4
destr: 2.0.5
/react-dom@19.2.3(react@19.2.3):
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
/react-dom@19.2.6(react@19.2.6):
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
react: ^19.2.3
react: ^19.2.6
dependencies:
react: 19.2.3
react: 19.2.6
scheduler: 0.27.0
/react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
dev: true
/react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
/react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
/readable-stream@3.6.2:
@@ -9687,6 +10169,42 @@ packages:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: false
/sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
requiresBuild: true
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
dev: false
optional: true
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -9770,7 +10288,6 @@ packages:
/source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
dev: true
/source-map-support@0.5.13:
resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==}
@@ -9793,6 +10310,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
dev: false
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true
@@ -9969,6 +10491,23 @@ packages:
- tslib
dev: true
/styled-jsx@5.1.6(react@19.2.6):
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
babel-plugin-macros:
optional: true
dependencies:
client-only: 0.0.1
react: 19.2.6
dev: false
/stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
dev: true
@@ -10293,7 +10832,6 @@ packages:
/tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
dev: true
/tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
@@ -11149,7 +11687,6 @@ packages:
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
dev: false
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
@@ -11228,6 +11765,10 @@ packages:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
dev: false
/zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: true