Implement TPOS parity UI and backend
This commit is contained in:
4
microservices/.gitignore
vendored
4
microservices/.gitignore
vendored
@@ -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
|
||||
|
||||
28
microservices/apps/tpos-mvp-next/.env.example
Normal file
28
microservices/apps/tpos-mvp-next/.env.example
Normal 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=""
|
||||
36
microservices/apps/tpos-mvp-next/README.md
Normal file
36
microservices/apps/tpos-mvp-next/README.md
Normal 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`.
|
||||
6
microservices/apps/tpos-mvp-next/next-env.d.ts
vendored
Normal file
6
microservices/apps/tpos-mvp-next/next-env.d.ts
vendored
Normal 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.
|
||||
13
microservices/apps/tpos-mvp-next/next.config.mjs
Normal file
13
microservices/apps/tpos-mvp-next/next.config.mjs
Normal 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;
|
||||
27
microservices/apps/tpos-mvp-next/package.json
Normal file
27
microservices/apps/tpos-mvp-next/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
microservices/apps/tpos-mvp-next/public/favicon.svg
Normal file
5
microservices/apps/tpos-mvp-next/public/favicon.svg
Normal 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 |
100
microservices/apps/tpos-mvp-next/scripts/seed.ts
Normal file
100
microservices/apps/tpos-mvp-next/scripts/seed.ts
Normal 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();
|
||||
6
microservices/apps/tpos-mvp-next/scripts/setup-db.ts
Normal file
6
microservices/apps/tpos-mvp-next/scripts/setup-db.ts
Normal 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();
|
||||
115
microservices/apps/tpos-mvp-next/src/app/actions.ts
Normal file
115
microservices/apps/tpos-mvp-next/src/app/actions.ts
Normal 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");
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
19
microservices/apps/tpos-mvp-next/src/app/admin/page.tsx
Normal file
19
microservices/apps/tpos-mvp-next/src/app/admin/page.tsx
Normal 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ị"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
28
microservices/apps/tpos-mvp-next/src/app/api/health/route.ts
Normal file
28
microservices/apps/tpos-mvp-next/src/app/api/health/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
microservices/apps/tpos-mvp-next/src/app/api/orders/route.ts
Normal file
35
microservices/apps/tpos-mvp-next/src/app/api/orders/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
169
microservices/apps/tpos-mvp-next/src/app/catalog/page.tsx
Normal file
169
microservices/apps/tpos-mvp-next/src/app/catalog/page.tsx
Normal 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>Mô 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>Mô 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>Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <TposAuthBoundary mode="recover" role="admin" />;
|
||||
}
|
||||
2739
microservices/apps/tpos-mvp-next/src/app/globals.css
Normal file
2739
microservices/apps/tpos-mvp-next/src/app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
1
microservices/apps/tpos-mvp-next/src/app/home/page.tsx
Normal file
1
microservices/apps/tpos-mvp-next/src/app/home/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page";
|
||||
127
microservices/apps/tpos-mvp-next/src/app/inventory/page.tsx
Normal file
127
microservices/apps/tpos-mvp-next/src/app/inventory/page.tsx
Normal 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 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 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>
|
||||
);
|
||||
}
|
||||
18
microservices/apps/tpos-mvp-next/src/app/layout.tsx
Normal file
18
microservices/apps/tpos-mvp-next/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
microservices/apps/tpos-mvp-next/src/app/login/page.tsx
Normal file
5
microservices/apps/tpos-mvp-next/src/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
|
||||
|
||||
export default function LoginAliasPage() {
|
||||
return <TposAuthBoundary mode="login" role="admin" />;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
19
microservices/apps/tpos-mvp-next/src/app/marketing/page.tsx
Normal file
19
microservices/apps/tpos-mvp-next/src/app/marketing/page.tsx
Normal 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) }))
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
69
microservices/apps/tpos-mvp-next/src/app/orders/page.tsx
Normal file
69
microservices/apps/tpos-mvp-next/src/app/orders/page.tsx
Normal 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>Mã đơ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>
|
||||
);
|
||||
}
|
||||
170
microservices/apps/tpos-mvp-next/src/app/page.tsx
Normal file
170
microservices/apps/tpos-mvp-next/src/app/page.tsx
Normal 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>Mã đơ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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
47
microservices/apps/tpos-mvp-next/src/app/pos/page.tsx
Normal file
47
microservices/apps/tpos-mvp-next/src/app/pos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
microservices/apps/tpos-mvp-next/src/app/profile/page.tsx
Normal file
16
microservices/apps/tpos-mvp-next/src/app/profile/page.tsx
Normal 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"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <TposAuthBoundary mode="register" role="admin" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <TposAuthBoundary mode="recover" role="admin" />;
|
||||
}
|
||||
94
microservices/apps/tpos-mvp-next/src/app/settings/page.tsx
Normal file
94
microservices/apps/tpos-mvp-next/src/app/settings/page.tsx
Normal 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>Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
19
microservices/apps/tpos-mvp-next/src/app/staff/page.tsx
Normal file
19
microservices/apps/tpos-mvp-next/src/app/staff/page.tsx
Normal 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"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
16
microservices/apps/tpos-mvp-next/src/app/superadmin/page.tsx
Normal file
16
microservices/apps/tpos-mvp-next/src/app/superadmin/page.tsx
Normal 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"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
98
microservices/apps/tpos-mvp-next/src/app/tables/page.tsx
Normal file
98
microservices/apps/tpos-mvp-next/src/app/tables/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TposAuthBoundary } from "@/components/TposAuthBoundary";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return <TposAuthBoundary mode="recover" role="admin" />;
|
||||
}
|
||||
14
microservices/apps/tpos-mvp-next/src/components/AppFrame.tsx
Normal file
14
microservices/apps/tpos-mvp-next/src/components/AppFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
483
microservices/apps/tpos-mvp-next/src/components/PosRegister.tsx
Normal file
483
microservices/apps/tpos-mvp-next/src/components/PosRegister.tsx
Normal 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 có món trong đơn</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="pos-discount-grid">
|
||||
<label className="pos-field">
|
||||
<span>Mã 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 lý</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 và 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
98
microservices/apps/tpos-mvp-next/src/components/Shell.tsx
Normal file
98
microservices/apps/tpos-mvp-next/src/components/Shell.tsx
Normal 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ó 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>
|
||||
);
|
||||
}
|
||||
126
microservices/apps/tpos-mvp-next/src/components/TposAuth.tsx
Normal file
126
microservices/apps/tpos-mvp-next/src/components/TposAuth.tsx
Normal 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 và 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 mô hình BFF session của TPOS: cookie httpOnly, role portal và 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
268
microservices/apps/tpos-mvp-next/src/components/TposPortal.tsx
Normal file
268
microservices/apps/tpos-mvp-next/src/components/TposPortal.tsx
Normal 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 lý 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
|
||||
};
|
||||
}
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
226
microservices/apps/tpos-mvp-next/src/components/tpos-config.ts
Normal file
226
microservices/apps/tpos-mvp-next/src/components/tpos-config.ts
Normal 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 }
|
||||
]
|
||||
};
|
||||
60
microservices/apps/tpos-mvp-next/src/server/db/pool.ts
Normal file
60
microservices/apps/tpos-mvp-next/src/server/db/pool.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1893
microservices/apps/tpos-mvp-next/src/server/db/queries.ts
Normal file
1893
microservices/apps/tpos-mvp-next/src/server/db/queries.ts
Normal file
File diff suppressed because it is too large
Load Diff
711
microservices/apps/tpos-mvp-next/src/server/db/schema.ts
Normal file
711
microservices/apps/tpos-mvp-next/src/server/db/schema.ts
Normal 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();
|
||||
`);
|
||||
}
|
||||
109
microservices/apps/tpos-mvp-next/src/server/domain/catalog.ts
Normal file
109
microservices/apps/tpos-mvp-next/src/server/domain/catalog.ts
Normal 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 }
|
||||
];
|
||||
150
microservices/apps/tpos-mvp-next/src/server/domain/types.ts
Normal file
150
microservices/apps/tpos-mvp-next/src/server/domain/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
42
microservices/apps/tpos-mvp-next/src/server/services/fnb.ts
Normal file
42
microservices/apps/tpos-mvp-next/src/server/services/fnb.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
105
microservices/apps/tpos-mvp-next/src/server/services/order.ts
Normal file
105
microservices/apps/tpos-mvp-next/src/server/services/order.ts
Normal 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);
|
||||
}
|
||||
948
microservices/apps/tpos-mvp-next/src/server/services/parity.ts
Normal file
948
microservices/apps/tpos-mvp-next/src/server/services/parity.ts
Normal 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)
|
||||
}));
|
||||
}
|
||||
79
microservices/apps/tpos-mvp-next/src/server/services/shop.ts
Normal file
79
microservices/apps/tpos-mvp-next/src/server/services/shop.ts
Normal 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);
|
||||
}
|
||||
54
microservices/apps/tpos-mvp-next/src/server/shared/api.ts
Normal file
54
microservices/apps/tpos-mvp-next/src/server/shared/api.ts
Normal 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>;
|
||||
}
|
||||
16
microservices/apps/tpos-mvp-next/tsconfig.json
Normal file
16
microservices/apps/tpos-mvp-next/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
||||
7
microservices/apps/web-docs/.vitepress/cache/deps/chunk-NGGQV2J5.js.map
vendored
Normal file
7
microservices/apps/web-docs/.vitepress/cache/deps/chunk-NGGQV2J5.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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
@@ -170,7 +170,7 @@ import {
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
} from "./chunk-LW7BHYZJ.js";
|
||||
} from "./chunk-NGGQV2J5.js";
|
||||
import "./chunk-FDBJFBLO.js";
|
||||
export {
|
||||
BaseTransition,
|
||||
|
||||
591
microservices/pnpm-lock.yaml
generated
591
microservices/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user