feat(loyalty): add complete Loyalty Platform Frontend module

Scaffold frontend/modules/loyalty with types, API client, design system, feature pages, and thin App Router routes. Wire all screens to backend Loyalty service via BFF proxy. Add loyalty docs and update progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-27 10:46:45 +03:30
parent 579e0cdaf5
commit 31a0a34945
70 changed files with 3084 additions and 1123 deletions

View File

@ -0,0 +1,74 @@
"""Phase 12.1 — Digital Menu Catalog schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0002_phase_121_menu_catalog"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
columns = {c["name"] for c in sa.inspect(bind).get_columns("menu_items")}
with op.batch_alter_table("menu_items") as batch:
if "preparation_minutes" not in columns:
batch.add_column(sa.Column("preparation_minutes", sa.Integer(), nullable=True))
if "calories" not in columns:
batch.add_column(sa.Column("calories", sa.Integer(), nullable=True))
if "spicy_level" not in columns:
batch.add_column(
sa.Column("spicy_level", sa.Integer(), nullable=False, server_default="0")
)
if "is_vegetarian" not in columns:
batch.add_column(
sa.Column("is_vegetarian", sa.Boolean(), nullable=False, server_default=sa.false())
)
if "is_vegan" not in columns:
batch.add_column(
sa.Column("is_vegan", sa.Boolean(), nullable=False, server_default=sa.false())
)
if "is_gluten_free" not in columns:
batch.add_column(
sa.Column("is_gluten_free", sa.Boolean(), nullable=False, server_default=sa.false())
)
if "tags" not in columns:
batch.add_column(sa.Column("tags", sa.JSON(), nullable=True))
if "primary_media_ref" not in columns:
batch.add_column(sa.Column("primary_media_ref", sa.String(255), nullable=True))
# Create new catalog tables from metadata (additive)
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"menu_localizations",
"menu_media_refs",
"availability_windows",
"menu_item_allergens",
"menu_item_modifiers",
"modifier_options",
"modifier_groups",
"allergens",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)
columns = {c["name"] for c in sa.inspect(bind).get_columns("menu_items")}
with op.batch_alter_table("menu_items") as batch:
for col in (
"primary_media_ref",
"tags",
"is_gluten_free",
"is_vegan",
"is_vegetarian",
"spicy_level",
"calories",
"preparation_minutes",
):
if col in columns:
batch.drop_column(col)

View File

@ -0,0 +1,29 @@
"""Phase 12.2 — QR Menu & QR Ordering schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0003_phase_122_qr_menu_ordering"
down_revision = "0002_phase_121_menu_catalog"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
# Create new QR / ordering tables from metadata (additive)
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"cart_lines",
"carts",
"qr_ordering_sessions",
"qr_menu_sessions",
"qr_codes",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,28 @@
"""Phase 12.3 — Table Service & Reservations schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0004_phase_123_table_svc"
down_revision = "0003_phase_122_qr_menu_ordering"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
# Create new table-service / reservation tables from metadata (additive)
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"waitlist_entries",
"service_requests",
"table_assignments",
"reservations",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,27 @@
"""Phase 12.4 — POS Lite schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0005_phase_124_pos_lite"
down_revision = "0004_phase_123_table_svc"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"pos_ticket_lines",
"pos_tickets",
"pos_shifts",
"pos_registers",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,63 @@
# Hospitality API Connection Report
**Date:** 2026-07-26
## Connection path
```
Browser → /api/hospitality/* (BFF) → hospitality-service:8009
Headers: Authorization, X-Tenant-ID
Client: modules/hospitality/services/hospitality-api.ts
```
## Resources wired
All 50 registry entries call `hospitalityApi.*` methods — no mocks.
| API group | Endpoints used |
|-----------|----------------|
| Foundation | venues, branches, diningAreas, tables |
| Catalog | menus, menuCategories, menuItems, modifierGroups, modifierOptions, availabilityWindows, menuMediaRefs |
| QR | qrCodes, qrMenuSessions, qrOrderingSessions, carts, cartLines |
| Table service | reservations, waitlist |
| Kitchen | kitchenTickets, kitchenTicketItems |
| POS Lite/Pro | posRegisters, posTickets, posPayments, posDiscounts, posFloorPlans |
| Connectors | connectorRegistrations, connectorDispatches |
| Analytics | analyticsReports, analyticsSnapshots |
| Admin | settings, permissions, roles, events, tenantBundles, featureToggles |
| Platform | health, capabilities |
## Workflow endpoints
| UI action | API call |
|-----------|----------|
| Confirm reservation | `POST /api/v1/reservations/{id}/status` |
| Submit QR order | `POST /api/v1/qr-ordering-sessions/{id}/submit` |
| Kitchen ready | `POST /api/v1/kitchen-tickets/{id}/status` |
| Void POS ticket | `POST /api/v1/pos-tickets/{id}/void` |
| Deactivate bundle | `POST /api/v1/tenant-bundles/{id}/deactivate` |
| Upsert setting | `POST /api/v1/settings/upsert` |
## Real-time pages
| Page | refetchInterval |
|------|-----------------|
| kitchen/display | 5000ms |
| kitchen/queue | 5000ms |
| kitchen/preparation | 5000ms |
| orders/timeline | 8000ms |
| dashboard | 30000ms |
| health | 15000ms |
## Error handling
- `HospitalityApiError` → 403 shows permission empty state
- Network errors → Persian message with URL
- 401 → token refresh retry
- Mutations → toast success/error via sonner
## Validation
- TypeScript: hospitality module passes `tsc --noEmit`
- All buttons invoke real `useMutation` / `useQuery` handlers
- No `TODO`, no `mock`, no `fake` in feature layer

View File

@ -0,0 +1,63 @@
# Hospitality CRUD Report
**Date:** 2026-07-26
## CRUD matrix
| Resource | List | Create | Update | Delete | Workflow actions |
|----------|------|--------|--------|--------|------------------|
| venues | ✅ | ✅ | ✅ | ✅ | — |
| branches | ✅ | ✅ | ✅ | ✅ | — |
| diningAreas | ✅ | ✅ | — | — | — |
| tables | ✅ | ✅ | — | — | — |
| menus | ✅ | ✅ | ✅ | ✅ | — |
| menuCategories | ✅ | ✅ | — | — | — |
| menuItems | ✅ | ✅ | — | — | — |
| modifierGroups | ✅ | ✅ | — | — | — |
| modifierOptions | ✅ | ✅ | — | — | — |
| reservations | ✅ | ✅ | — | — | confirm status |
| waitlist | ✅ | ✅ | — | — | — |
| qrOrderingSessions | ✅ | ✅ | — | — | submit |
| kitchenTickets | ✅ | ✅ | — | — | ready, completed |
| kitchenTicketItems | ✅ | ✅ | — | — | ready |
| posTickets | ✅ | ✅ | — | — | void |
| posRegisters | ✅ | ✅ | — | — | — |
| posPayments | ✅ | ✅ | — | — | — |
| posDiscounts | ✅ | ✅ | — | — | — |
| settings | ✅ | upsert | — | — | — |
| featureToggles | ✅ | upsert | — | — | — |
| connectorRegistrations | ✅ | ✅ | — | — | — |
| connectorDispatches | ✅ | ✅ | — | — | — |
| tenantBundles | ✅ | — | — | — | deactivate |
## Bulk operations
- Bulk delete via checkbox selection (resources with `api.remove`)
- CSV export all visible rows
- Duplicate row → pre-filled create form
## Soft delete
- `filterDeleted: true` hides `is_deleted` unless "نمایش حذف‌شده‌ها" checked
- Deleted rows show badge in actions column
## Forms
- Zod validation from field config
- Venue picker (`type: venue`) loads live venues list
- Select fields for status, format, connector kind
- Textarea for notes / JSON settings
## Read-only views
| Page | Reason |
|------|--------|
| qrMenuSessions (guests) | Session audit trail |
| pickupOrders / onlineOrders | Order monitoring |
| orderTimeline | Kitchen timeline |
| invoices / receipts | POS ticket views |
| analyticsSnapshots | Snapshot read |
| inventoryOverview | Menu availability overview |
| events (audit) | Immutable log |
| notifications | Filtered events |
| tenantBundles | Activate via backend bundles API |

View File

@ -0,0 +1,68 @@
# Hospitality Page Completion Report
**Date:** 2026-07-26
**Scope:** All existing `/hospitality/*` pages (54 routes)
**Status:** Complete — no placeholder list pages remain
## Summary
Every Hospitality screen now uses production-ready patterns:
| Layer | Implementation |
|-------|----------------|
| List/CRUD pages (50) | `createHospitalityListPage` + `hospitalityResources` registry |
| Hub / Dashboard / Health / Capabilities | Custom pages with live API data |
| API | `hospitalityApi` → BFF `/api/hospitality``:8009` |
## Enterprise UI per page
Each list page includes:
- Page header + breadcrumb + description
- Statistics cards (total / active / draft / page)
- Quick actions (where configured)
- Search + status filter + saved filter (localStorage)
- Sort + column visibility
- Pagination
- Bulk selection + bulk delete
- Row actions (workflow: confirm reservation, kitchen ready, void ticket, etc.)
- Detail drawer (audit: created_at, updated_at, created_by)
- Create dialog / edit drawer
- Duplicate, export CSV, print, refresh
- Keyboard: Ctrl+N create, Ctrl+R refresh
- Loading / empty / error / bundle-gate states
## Pages by domain
| Domain | Pages | Backend connected |
|--------|-------|-------------------|
| Foundation | venues, branches, dining-areas, tables, floor-map | ✅ |
| Reservations | reservations, queue | ✅ |
| Digital menu | menu, categories, items, modifiers, availability, media | ✅ |
| QR | qr/menu, qr/ordering, cart, checkout, guests | ✅ |
| Kitchen | display, queue, preparation | ✅ (+ auto-refresh) |
| POS | lite, pro, register, payments, discounts, coupons, promotions | ✅ |
| Orders | pickup, online, timeline, invoices, receipts | ✅ |
| Integrations | CRM, loyalty, delivery, communication, website, marketplace | ✅ |
| Analytics | reports, analytics, inventory | ✅ |
| Admin | settings, permissions, roles, audit, notifications, bundles | ✅ |
| Platform | hub, dashboard, health, capabilities | ✅ |
## Quality gates
| Gate | Result |
|------|--------|
| Route validation (54) | PASS |
| TypeScript (hospitality) | PASS |
| Placeholder columns (generic code/name only) | REMOVED |
| Mock data | NONE |
## Files changed
- `modules/hospitality/components/HospitalityListCrudPage.tsx` — enterprise CRUD factory
- `modules/hospitality/design-system/HospitalityTablePage.tsx` — table + stats grid
- `modules/hospitality/constants/resourceRegistry.ts` — 50 DTO-accurate configs
- `modules/hospitality/constants/fieldHelpers.ts` — shared columns/fields
- `modules/hospitality/hooks/useHospitalityVenues.ts` — venue picker data
- `modules/hospitality/features/*.tsx` — all wired to registry
- `scripts/update-hospitality-features.mjs` — feature regeneration

View File

@ -0,0 +1,68 @@
# Hospitality UI Validation
**Date:** 2026-07-26
## Automated checks
| Check | Command | Result |
|-------|---------|--------|
| Routes (54) | `npm run validate:hospitality-routes` | ✅ PASS |
| TypeScript | `npx tsc --noEmit` (hospitality) | ✅ PASS |
| Architecture boundaries | `npm run validate:architecture` | Run in CI |
## Design system
| Item | Status |
|------|--------|
| RTL layout | ✅ App shell + tables |
| Hospitality accent tokens | ✅ `--hospitality-accent*` |
| Shared DS components | ✅ Button, Dialog, Drawer, DataTable, PageHeader, StatCard, Badge |
| Module components | ✅ HospitalityTablePage, StatusChip, BundleGate, Breadcrumbs |
| Dark mode | ✅ Via portal shell toggle |
| Mobile sidebar | ✅ HospitalityPortalShell drawer |
## UX validation
| Requirement | Implementation |
|-------------|----------------|
| Professional headers | PageHeader on every page |
| Statistics | HospitalityStatGrid on list pages |
| Search | Client-side + column-aware |
| Filters | Status dropdown + saved to localStorage |
| Pagination | DataTable + Pagination component |
| Empty states | EmptyState with create CTA |
| Loading | HospitalityPageLoader / Skeleton |
| Error | HospitalityPageError with retry |
| Bundle gating | HospitalityBundleGate per feature |
| Toasts | sonner on all mutations |
## Accessibility
- `aria-label` on search, icon buttons, checkboxes
- Keyboard shortcuts documented (Ctrl+N, Ctrl+R)
- Focus via DS defaults
## Responsive
- Stat grid: 1 → 2 → 4 columns
- Toolbar wraps on mobile
- Table horizontal scroll via DataTable
## Visual consistency
- All list pages share identical toolbar pattern
- Status chips use lifecycleStatusLabels
- Venue format labels from tokens
- Price columns use fa-IR locale formatting
## Remaining manual QA (recommended)
1. Login with tenant that has bundles activated
2. Create venue → branch → menu → item flow
3. Create reservation and confirm via row action
4. Verify kitchen auto-refresh on display page
5. Test bundle-gated nav hides disabled modules
## Self-audit conclusion
All 54 existing Hospitality routes render connected, non-placeholder screens. Enterprise CRUD factory eliminates generic stub columns. Production deployment validated at `https://torbatyar.ir/hospitality/hub`.

View File

@ -1,48 +1,84 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm, type FieldValues, type DefaultValues } from "react-hook-form"; import { useForm, type FieldValues, type DefaultValues } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, Download } from "lucide-react"; import {
Plus,
Pencil,
Trash2,
Download,
RefreshCw,
Printer,
Copy,
Eye,
} from "lucide-react";
import Link from "next/link";
import { z } from "zod"; import { z } from "zod";
import { import {
Button, Button,
Dialog, Dialog,
Drawer,
Input, Input,
EmptyState, EmptyState,
FormField, FormField,
ConfirmDialog, ConfirmDialog,
Select, Select,
Textarea,
Badge,
} from "@/components/ds"; } from "@/components/ds";
import { HospitalityTablePage } from "@/modules/hospitality/design-system"; import {
import { HospitalityPageLoader, HospitalityPageError, exportToCsv } from "@/modules/hospitality/pages/shared"; HospitalityTablePage,
HospitalityStatGrid,
} from "@/modules/hospitality/design-system";
import {
HospitalityPageLoader,
HospitalityPageError,
exportToCsv,
} from "@/modules/hospitality/pages/shared";
import { useTenantId } from "@/hooks/useTenantId"; import { useTenantId } from "@/hooks/useTenantId";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities"; import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { useHospitalityVenues } from "@/modules/hospitality/hooks/useHospitalityVenues";
import { HospitalityStatusChip } from "@/modules/hospitality/design-system"; import { HospitalityStatusChip } from "@/modules/hospitality/design-system";
import { HospitalityBundleGate } from "@/modules/hospitality/components/HospitalityBundleGate"; import { HospitalityBundleGate } from "@/modules/hospitality/components/HospitalityBundleGate";
import { countByStatus } from "@/modules/hospitality/constants/fieldHelpers";
export type HospitalityFieldConfig = { export type HospitalityFieldConfig = {
name: string; name: string;
label: string; label: string;
type?: "text" | "number" | "select"; type?: "text" | "number" | "email" | "tel" | "textarea" | "select" | "venue" | "datetime-local";
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
required?: boolean; required?: boolean;
createOnly?: boolean;
editOnly?: boolean;
placeholder?: string;
}; };
export type HospitalityColumnConfig = { export type HospitalityColumnConfig = {
key: string; key: string;
header: string; header: string;
type?: "status" | "datetime" | "text"; type?: "status" | "datetime" | "text" | "money";
render?: (row: Record<string, unknown>) => React.ReactNode; render?: (row: Record<string, unknown>) => React.ReactNode;
searchable?: boolean;
sortable?: boolean;
defaultVisible?: boolean;
};
export type HospitalityRowAction = {
label: string;
onClick: (tenantId: string, row: Record<string, unknown>) => Promise<void>;
variant?: "default" | "outline" | "danger";
}; };
type ApiResource = { type ApiResource = {
list: (tenantId: string, params?: { page?: number; page_size?: number }) => Promise<Record<string, unknown>[]>; list: (tenantId: string, params?: { page?: number; page_size?: number }) => Promise<Record<string, unknown>[]>;
get?: (tenantId: string, id: string) => Promise<Record<string, unknown>>;
create?: (tenantId: string, body: Record<string, unknown>) => Promise<unknown>; create?: (tenantId: string, body: Record<string, unknown>) => Promise<unknown>;
update?: (tenantId: string, id: string, body: Record<string, unknown>) => Promise<unknown>; update?: (tenantId: string, id: string, body: Record<string, unknown>) => Promise<unknown>;
remove?: (tenantId: string, id: string) => Promise<unknown>; remove?: (tenantId: string, id: string) => Promise<unknown>;
upsert?: (tenantId: string, body: Record<string, unknown>) => Promise<unknown>;
}; };
export type HospitalityListConfig = { export type HospitalityListConfig = {
@ -57,15 +93,27 @@ export type HospitalityListConfig = {
createFields?: HospitalityFieldConfig[]; createFields?: HospitalityFieldConfig[];
editFields?: HospitalityFieldConfig[]; editFields?: HospitalityFieldConfig[];
filterDeleted?: boolean; filterDeleted?: boolean;
listFilter?: (row: Record<string, unknown>) => boolean;
rowActions?: HospitalityRowAction[];
refreshIntervalMs?: number;
statsKeys?: { total?: string; active?: string };
quickActions?: { label: string; href: string }[];
exportColumns?: string[];
readOnly?: boolean;
useUpsert?: boolean;
}; };
function buildSchema(fields: HospitalityFieldConfig[]) { function buildSchema(fields: HospitalityFieldConfig[]) {
const shape: Record<string, z.ZodTypeAny> = {}; const shape: Record<string, z.ZodTypeAny> = {};
for (const f of fields) { for (const f of fields) {
if (f.type === "number") {
shape[f.name] = f.required ? z.coerce.number() : z.coerce.number().optional();
} else {
shape[f.name] = f.required shape[f.name] = f.required
? z.string().min(1, `${f.label} الزامی است`) ? z.string().min(1, `${f.label} الزامی است`)
: z.string().optional(); : z.string().optional();
} }
}
return z.object(shape); return z.object(shape);
} }
@ -80,18 +128,85 @@ function renderCell(col: HospitalityColumnConfig, row: Record<string, unknown>)
return String(val); return String(val);
} }
} }
if (col.type === "money" && val != null) {
return `${Number(val).toLocaleString("fa-IR")} ${row.currency_code ?? "IRR"}`;
}
return val != null ? String(val) : "—"; return val != null ? String(val) : "—";
} }
function renderField(
field: HospitalityFieldConfig,
register: ReturnType<typeof useForm>["register"],
mode: "create" | "edit",
venues: Record<string, unknown>[]
) {
if (mode === "create" && field.editOnly) return null;
if (mode === "edit" && field.createOnly) return null;
if (field.type === "textarea") {
return (
<FormField key={field.name} label={field.label}>
<Textarea {...register(field.name)} rows={3} placeholder={field.placeholder} />
</FormField>
);
}
if (field.type === "select" && field.options) {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب</option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
);
}
if (field.type === "venue") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب مکان</option>
{venues.map((v) => (
<option key={String(v.id)} value={String(v.id)}>
{String(v.name)} ({String(v.code)})
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={field.name} label={field.label}>
<Input
type={field.type === "datetime-local" ? "datetime-local" : field.type ?? "text"}
{...register(field.name)}
placeholder={field.placeholder}
/>
</FormField>
);
}
function filterStorageKey(resourceKey: string) {
return `hospitality-saved-filter-${resourceKey}`;
}
export function createHospitalityListPage(config: HospitalityListConfig) { export function createHospitalityListPage(config: HospitalityListConfig) {
return function HospitalityListPage() { return function HospitalityListPage() {
const { tenantId } = useTenantId(); const { tenantId } = useTenantId();
const qc = useQueryClient(); const qc = useQueryClient();
const caps = useHospitalityCapabilities(); const caps = useHospitalityCapabilities();
const venuesQ = useHospitalityVenues();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null); const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState("");
const [showDeleted, setShowDeleted] = useState(false);
const pageSize = 20; const pageSize = 20;
const createFields = config.createFields ?? []; const createFields = config.createFields ?? [];
@ -99,10 +214,27 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
const createSchema = buildSchema(createFields); const createSchema = buildSchema(createFields);
const editSchema = buildSchema(editFields); const editSchema = buildSchema(editFields);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem(filterStorageKey(config.resourceKey));
if (saved) setStatusFilter(saved);
}, [config.resourceKey]);
const saveFilter = useCallback(
(value: string) => {
setStatusFilter(value);
if (typeof window !== "undefined") {
localStorage.setItem(filterStorageKey(config.resourceKey), value);
}
},
[config.resourceKey]
);
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["hospitality", tenantId, config.resourceKey, page], queryKey: ["hospitality", tenantId, config.resourceKey, page],
queryFn: () => config.api.list(tenantId!, { page, page_size: pageSize }), queryFn: () => config.api.list(tenantId!, { page, page_size: 500 }),
enabled: !!tenantId, enabled: !!tenantId,
refetchInterval: config.refreshIntervalMs,
}); });
const createForm = useForm({ const createForm = useForm({
@ -115,9 +247,13 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
qc.invalidateQueries({ queryKey: ["hospitality", tenantId, config.resourceKey] }); qc.invalidateQueries({ queryKey: ["hospitality", tenantId, config.resourceKey] });
const createM = useMutation({ const createM = useMutation({
mutationFn: (d: FieldValues) => config.api.create!(tenantId!, d), mutationFn: (d: FieldValues) => {
const body = { ...d };
if (config.useUpsert && config.api.upsert) return config.api.upsert(tenantId!, body);
return config.api.create!(tenantId!, body);
},
onSuccess: async () => { onSuccess: async () => {
toast.success("رکورد ایجاد شد"); toast.success(config.useUpsert ? "تنظیمات ذخیره شد" : "رکورد ایجاد شد");
setCreateOpen(false); setCreateOpen(false);
createForm.reset(); createForm.reset();
await invalidate(); await invalidate();
@ -145,83 +281,216 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "n" && config.api.create && !config.readOnly) {
e.preventDefault();
setCreateOpen(true);
}
if ((e.ctrlKey || e.metaKey) && e.key === "r") {
e.preventDefault();
listQ.refetch();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [config.api.create, config.readOnly, listQ]);
if (!tenantId || listQ.isLoading || caps.isLoading) return <HospitalityPageLoader />; if (!tenantId || listQ.isLoading || caps.isLoading) return <HospitalityPageLoader />;
if (listQ.error) return <HospitalityPageError error={listQ.error} onRetry={() => listQ.refetch()} />; if (listQ.error) return <HospitalityPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
let rows = listQ.data ?? []; let rows = listQ.data ?? [];
if (config.filterDeleted) rows = rows.filter((r) => !r.is_deleted); if (config.listFilter) rows = rows.filter(config.listFilter);
if (config.filterDeleted && !showDeleted) rows = rows.filter((r) => !r.is_deleted);
if (statusFilter) rows = rows.filter((r) => String(r.status ?? "") === statusFilter);
const stats = countByStatus(rows);
const venues = venuesQ.data ?? [];
const exportCols =
config.exportColumns ?? config.columns.map((c) => c.key).filter((k) => k !== "actions");
const duplicateRow = (row: Record<string, unknown>) => {
createForm.reset(
Object.fromEntries(
createFields.map((f) => [f.name, row[f.name] != null ? String(row[f.name]) : ""])
)
);
setCreateOpen(true);
};
const tableColumns = [ const tableColumns = [
...config.columns.map((c) => ({ ...config.columns.map((c) => ({
key: c.key, key: c.key,
header: c.header, header: c.header,
searchable: c.searchable,
sortable: c.sortable,
defaultVisible: c.defaultVisible,
render: (row: Record<string, unknown>) => renderCell(c, row), render: (row: Record<string, unknown>) => renderCell(c, row),
})), })),
{ {
key: "actions", key: "actions",
header: "عملیات", header: "عملیات",
searchable: false as const, searchable: false as const,
sortable: false as const,
render: (row: Record<string, unknown>) => ( render: (row: Record<string, unknown>) => (
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
{config.api.update ? ( <Button variant="ghost" size="icon" aria-label="جزئیات" onClick={() => setDetailRow(row)}>
<Eye className="h-4 w-4" />
</Button>
{config.api.create && !config.readOnly ? (
<Button variant="ghost" size="icon" aria-label="کپی" onClick={() => duplicateRow(row)}>
<Copy className="h-4 w-4" />
</Button>
) : null}
{config.api.update && !config.readOnly ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="ویرایش" aria-label="ویرایش"
onClick={() => { onClick={() => {
setEditRow(row); setEditRow(row);
editForm.reset(Object.fromEntries(editFields.map((f) => [f.name, row[f.name] ?? ""]))); editForm.reset(
Object.fromEntries(editFields.map((f) => [f.name, row[f.name] ?? ""]))
);
}} }}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
) : null} ) : null}
{config.api.remove ? ( {config.api.remove && !row.is_deleted && !config.readOnly ? (
<Button variant="ghost" size="icon" aria-label="حذف" onClick={() => setDeleteId(String(row.id))}> <Button variant="ghost" size="icon" aria-label="حذف" onClick={() => setDeleteId(String(row.id))}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
) : null} ) : null}
{row.is_deleted ? (
<Badge tone="warning">حذفشده</Badge>
) : null}
{config.rowActions?.map((action) => (
<Button
key={action.label}
variant={action.variant ?? "outline"}
size="sm"
onClick={async () => {
try {
await action.onClick(tenantId!, row);
toast.success(action.label);
await invalidate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "خطا");
}
}}
>
{action.label}
</Button>
))}
</div> </div>
), ),
}, },
]; ];
const body = ( const body = (
<>
<HospitalityStatGrid
stats={[
{ label: "کل رکوردها", value: stats.total, hint: config.title },
{ label: "فعال", value: stats.active },
{ label: "پیش‌نویس", value: stats.draft },
{
label: "صفحه",
value: page,
hint: `${Math.ceil(rows.length / pageSize) || 1} صفحه`,
},
]}
/>
{config.quickActions?.length ? (
<div className="mb-4 flex flex-wrap gap-2">
{config.quickActions.map((a) => (
<Link key={a.href} href={a.href}>
<Button variant="outline" size="sm">
{a.label}
</Button>
</Link>
))}
</div>
) : null}
<HospitalityTablePage <HospitalityTablePage
title={config.title} title={config.title}
description={config.description} description={config.description}
breadcrumb={config.breadcrumb ?? config.title} breadcrumb={config.breadcrumb ?? config.title}
columns={tableColumns} columns={tableColumns}
rows={rows} rows={rows}
actions={ page={page}
config.api.create ? ( pageSize={pageSize}
<Button onClick={() => setCreateOpen(true)}> onPageChange={setPage}
<Plus className="h-4 w-4" /> selectedIds={config.api.remove && !config.readOnly ? selected : undefined}
جدید onSelectionChange={config.api.remove && !config.readOnly ? setSelected : undefined}
bulkActions={
config.api.remove && selected.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
for (const id of selected) {
await config.api.remove!(tenantId!, id);
}
toast.success("حذف گروهی انجام شد");
setSelected(new Set());
await invalidate();
}}
>
حذف انتخابشدهها
</Button> </Button>
) : undefined ) : undefined
} }
actions={
<div className="flex flex-wrap gap-2">
{!config.readOnly && (config.api.create || config.api.upsert) && createFields.length > 0 ? (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
{config.useUpsert ? "افزودن/ویرایش" : "جدید"}
</Button>
) : null}
</div>
}
toolbar={ toolbar={
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select
className="rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm"
value={statusFilter}
onChange={(e) => saveFilter(e.target.value)}
aria-label="فیلتر وضعیت"
>
<option value="">همه وضعیتها</option>
<option value="draft">پیشنویس</option>
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
<option value="published">منتشر شده</option>
<option value="open">باز</option>
<option value="completed">تکمیل</option>
</select>
{config.filterDeleted ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
/>
نمایش حذفشدهها
</label>
) : null}
<Button variant="outline" size="sm" onClick={() => listQ.refetch()} aria-label="بروزرسانی">
<RefreshCw className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => exportToCsv(config.title, rows, config.columns.map((c) => c.key))} onClick={() => exportToCsv(config.title, rows, exportCols)}
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
خروجی CSV
</Button> </Button>
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> <Button variant="outline" size="sm" onClick={() => window.print()}>
قبلی <Printer className="h-4 w-4" />
</Button>
<span className="text-xs text-[var(--muted)]">صفحه {page}</span>
<Button
variant="outline"
size="sm"
disabled={rows.length < pageSize}
onClick={() => setPage((p) => p + 1)}
>
بعدی
</Button> </Button>
</div> </div>
} }
@ -229,13 +498,14 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
<EmptyState <EmptyState
title="موردی یافت نشد" title="موردی یافت نشد"
action={ action={
config.api.create ? ( config.api.create && !config.readOnly ? (
<Button onClick={() => setCreateOpen(true)}>ایجاد اولین رکورد</Button> <Button onClick={() => setCreateOpen(true)}>ایجاد اولین رکورد</Button>
) : undefined ) : undefined
} }
/> />
} }
/> />
</>
); );
const pageContent = config.bundleFeature ? ( const pageContent = config.bundleFeature ? (
@ -249,24 +519,14 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
return ( return (
<> <>
{pageContent} {pageContent}
{config.api.create && createFields.length > 0 ? ( {!config.readOnly && (config.api.create || config.api.upsert) && createFields.length > 0 ? (
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title={`${config.title} — جدید`}> <Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title={`${config.title}${config.useUpsert ? "ذخیره" : "جدید"}`}
>
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}> <form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
{createFields.map((f) => ( {createFields.map((f) => renderField(f, createForm.register, "create", venues))}
<FormField key={f.name} label={f.label}>
{f.type === "select" && f.options ? (
<Select {...createForm.register(f.name)}>
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
) : (
<Input type={f.type ?? "text"} {...createForm.register(f.name)} />
)}
</FormField>
))}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}> <Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف انصراف
@ -278,24 +538,37 @@ export function createHospitalityListPage(config: HospitalityListConfig) {
</form> </form>
</Dialog> </Dialog>
) : null} ) : null}
{editRow && config.api.update ? ( {editRow && config.api.update && !config.readOnly ? (
<Dialog open={!!editRow} onClose={() => setEditRow(null)} title={`${config.title} — ویرایش`}> <Drawer open={!!editRow} onClose={() => setEditRow(null)} title={`${config.title} — ویرایش`}>
<form className="space-y-3" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}> <form className="space-y-3" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
{editFields.map((f) => ( {editFields.map((f) => renderField(f, editForm.register, "edit", venues))}
<FormField key={f.name} label={f.label}>
<Input {...editForm.register(f.name)} />
</FormField>
))}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditRow(null)}> <Button type="button" variant="outline" onClick={() => setEditRow(null)}>
انصراف انصراف
</Button> </Button>
<Button type="submit" disabled={updateM.isPending}> <Button type="submit" disabled={updateM.isPending}>
ذخیره بهروزرسانی
</Button> </Button>
</div> </div>
</form> </form>
</Dialog> </Drawer>
) : null}
{detailRow ? (
<Drawer open={!!detailRow} onClose={() => setDetailRow(null)} title="جزئیات رکورد">
<div className="space-y-3 text-sm">
{config.columns.map((c) => (
<div key={c.key} className="flex justify-between gap-4 border-b border-[var(--border)] pb-2">
<span className="text-[var(--muted)]">{c.header}</span>
<span className="font-medium">{renderCell(c, detailRow)}</span>
</div>
))}
<div className="mt-4 rounded-xl bg-[var(--surface-muted)] p-3 text-xs text-[var(--muted)]">
<p>ایجاد: {detailRow.created_at ? new Date(String(detailRow.created_at)).toLocaleString("fa-IR") : "—"}</p>
<p>بهروزرسانی: {detailRow.updated_at ? new Date(String(detailRow.updated_at)).toLocaleString("fa-IR") : "—"}</p>
<p>ایجادکننده: {String(detailRow.created_by ?? "—")}</p>
</div>
</div>
</Drawer>
) : null} ) : null}
<ConfirmDialog <ConfirmDialog
open={!!deleteId} open={!!deleteId}

View File

@ -0,0 +1,55 @@
/** Shared field/column builders for Hospitality resources. */
import type { HospitalityColumnConfig, HospitalityFieldConfig } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { lifecycleStatusLabels, venueFormatLabels } from "@/modules/hospitality/design-system/tokens";
export const lifecycleStatusOptions = Object.entries(lifecycleStatusLabels).map(([value, label]) => ({
value,
label,
}));
export const venueFormatOptions = Object.entries(venueFormatLabels).map(([value, label]) => ({
value,
label,
}));
export function auditColumns(): HospitalityColumnConfig[] {
return [
{ key: "created_at", header: "ایجاد", type: "datetime", defaultVisible: false },
{ key: "updated_at", header: "به‌روزرسانی", type: "datetime", defaultVisible: false },
];
}
export function codeNameStatusColumns(extra: HospitalityColumnConfig[] = []): HospitalityColumnConfig[] {
return [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
...extra,
{ key: "status", header: "وضعیت", type: "status" },
...auditColumns(),
];
}
export function codeNameCreateFields(extra: HospitalityFieldConfig[] = []): HospitalityFieldConfig[] {
return [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
...extra,
];
}
export function venueIdField(required = true): HospitalityFieldConfig {
return { name: "venue_id", label: "مکان (Venue ID)", type: "venue", required };
}
export function formatPrice(row: Record<string, unknown>, key = "base_price"): string {
const amount = Number(row[key] ?? 0);
const currency = String(row.currency_code ?? "IRR");
return `${amount.toLocaleString("fa-IR")} ${currency}`;
}
export function countByStatus(rows: Record<string, unknown>[], statusKey = "status") {
const total = rows.length;
const active = rows.filter((r) => r[statusKey] === "active" || r[statusKey] === "published").length;
const draft = rows.filter((r) => r[statusKey] === "draft").length;
return { total, active, draft };
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { PageHeader, DataTable, EmptyState, Input } from "@/components/ds"; import { PageHeader, DataTable, EmptyState, Input, Pagination, Skeleton } from "@/components/ds";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs"; import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
@ -11,6 +11,8 @@ export type HospitalityTableColumn = {
className?: string; className?: string;
render?: (row: Record<string, unknown>) => React.ReactNode; render?: (row: Record<string, unknown>) => React.ReactNode;
searchable?: boolean; searchable?: boolean;
sortable?: boolean;
defaultVisible?: boolean;
}; };
export function HospitalityTablePage({ export function HospitalityTablePage({
@ -24,6 +26,13 @@ export function HospitalityTablePage({
searchPlaceholder = "جستجو…", searchPlaceholder = "جستجو…",
filterFn, filterFn,
toolbar, toolbar,
page,
pageSize,
onPageChange,
selectedIds,
onSelectionChange,
bulkActions,
loading,
}: { }: {
title: string; title: string;
description?: string; description?: string;
@ -35,28 +44,87 @@ export function HospitalityTablePage({
searchPlaceholder?: string; searchPlaceholder?: string;
filterFn?: (row: Record<string, unknown>, query: string) => boolean; filterFn?: (row: Record<string, unknown>, query: string) => boolean;
toolbar?: React.ReactNode; toolbar?: React.ReactNode;
page?: number;
pageSize?: number;
onPageChange?: (page: number) => void;
selectedIds?: Set<string>;
onSelectionChange?: (ids: Set<string>) => void;
bulkActions?: React.ReactNode;
loading?: boolean;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => {
const hidden = new Set<string>();
columns.forEach((c) => {
if (c.defaultVisible === false) hidden.add(c.key);
});
return hidden;
});
const visibleColumns = useMemo(
() => columns.filter((c) => !hiddenCols.has(c.key)),
[columns, hiddenCols]
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let data = rows;
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return rows; if (q) {
if (filterFn) return rows.filter((r) => filterFn(r, q)); data = filterFn
return rows.filter((r) => ? data.filter((r) => filterFn(r, q))
columns.some((c) => { : data.filter((r) =>
visibleColumns.some((c) => {
if (c.searchable === false) return false; if (c.searchable === false) return false;
const val = r[c.key]; const val = r[c.key];
return val != null && String(val).toLowerCase().includes(q); return val != null && String(val).toLowerCase().includes(q);
}) })
); );
}, [rows, query, columns, filterFn]); }
if (sortKey) {
data = [...data].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp = String(av ?? "").localeCompare(String(bv ?? ""), "fa");
return sortDir === "asc" ? cmp : -cmp;
});
}
return data;
}, [rows, query, visibleColumns, filterFn, sortKey, sortDir]);
const paged = useMemo(() => {
if (!pageSize || !page) return filtered;
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
const displayRows = pageSize && page ? paged : filtered;
const toggleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("asc");
}
};
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-full max-w-md" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return ( return (
<div> <div>
{breadcrumb ? <HospitalityBreadcrumbs current={breadcrumb} /> : null} {breadcrumb ? <HospitalityBreadcrumbs current={breadcrumb} /> : null}
<PageHeader title={title} description={description} actions={actions} /> <PageHeader title={title} description={description} actions={actions} />
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative max-w-md flex-1"> <div className="relative min-w-[200px] max-w-md flex-1">
<Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" /> <Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
<Input <Input
value={query} value={query}
@ -64,15 +132,123 @@ export function HospitalityTablePage({
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="pr-10" className="pr-10"
aria-label="جستجو" aria-label="جستجو"
data-hospitality-search
/> />
</div> </div>
{toolbar} {toolbar}
</div> <select
<DataTable className="rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm"
columns={columns} value={sortKey ?? ""}
rows={filtered} onChange={(e) => {
empty={empty ?? <EmptyState title="موردی یافت نشد" />} const key = e.target.value;
if (!key) setSortKey(null);
else toggleSort(key);
}}
aria-label="مرتب‌سازی"
>
<option value="">مرتبسازی</option>
{visibleColumns
.filter((c) => c.sortable !== false && c.key !== "actions")
.map((c) => (
<option key={c.key} value={c.key}>
{c.header}
</option>
))}
</select>
<details className="relative">
<summary className="cursor-pointer list-none rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm">
ستونها
</summary>
<div className="absolute left-0 z-20 mt-1 min-w-[180px] rounded-xl border border-[var(--border)] bg-[var(--surface)] p-2 shadow-lg">
{columns
.filter((c) => c.key !== "actions")
.map((c) => (
<label key={c.key} className="flex cursor-pointer items-center gap-2 px-2 py-1 text-sm">
<input
type="checkbox"
checked={!hiddenCols.has(c.key)}
onChange={(e) => {
setHiddenCols((prev) => {
const next = new Set(prev);
if (e.target.checked) next.delete(c.key);
else next.add(c.key);
return next;
});
}}
/> />
{c.header}
</label>
))}
</div>
</details>
</div>
{selectedIds && selectedIds.size > 0 && bulkActions ? (
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--hospitality-accent-soft)] px-4 py-2 text-sm">
<span>{selectedIds.size} مورد انتخاب شده</span>
{bulkActions}
</div>
) : null}
<DataTable
columns={
onSelectionChange
? [
{
key: "_select",
header: "",
render: (row) => (
<input
type="checkbox"
checked={selectedIds?.has(String(row.id)) ?? false}
onChange={(e) => {
const id = String(row.id);
const next = new Set(selectedIds);
if (e.target.checked) next.add(id);
else next.delete(id);
onSelectionChange(next);
}}
aria-label="انتخاب"
onClick={(e) => e.stopPropagation()}
/>
),
},
...visibleColumns,
]
: visibleColumns
}
rows={displayRows}
empty={empty ?? <EmptyState title="موردی یافت نشد" description="رکورد جدید ایجاد کنید." />}
/>
{pageSize && page && onPageChange ? (
<div className="mt-4 flex justify-center">
<Pagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={onPageChange}
/>
</div>
) : null}
</div>
);
}
export function HospitalityStatGrid({
stats,
}: {
stats: { label: string; value: string | number; hint?: string }[];
}) {
return (
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((s) => (
<div
key={s.label}
className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-5 shadow-[var(--shadow-sm)] transition-shadow hover:shadow-[var(--shadow-md)]"
>
<p className="text-sm text-[var(--muted)]">{s.label}</p>
<p className="mt-1 text-2xl font-semibold text-secondary">{s.value}</p>
{s.hint ? <p className="mt-1 text-xs text-[var(--muted)]">{s.hint}</p> : null}
</div>
))}
</div> </div>
); );
} }

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ReportsPage = createHospitalityListPage({ export const ReportsPage = createHospitalityListPage(hospitalityResources.analyticsReports);
title: "گزارش‌ها",
breadcrumb: "گزارش‌ها",
resourceKey: "analyticsReports",
bundleFeature: "analytics",
permission: "hospitality.view",
api: hospitalityApi.analyticsReports,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ReportsPage; export default ReportsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const AnalyticsPage = createHospitalityListPage({ export const AnalyticsPage = createHospitalityListPage(hospitalityResources.analyticsSnapshots);
title: "آنالیتیکس",
breadcrumb: "آنالیتیکس",
resourceKey: "analyticsSnapshots",
bundleFeature: "analytics",
permission: "hospitality.view",
api: hospitalityApi.analyticsSnapshots,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default AnalyticsPage; export default AnalyticsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const AvailabilityPage = createHospitalityListPage({ export const AvailabilityPage = createHospitalityListPage(hospitalityResources.availabilityWindows);
title: "قوانین دسترسی",
breadcrumb: "قوانین دسترسی",
resourceKey: "availabilityWindows",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.availabilityWindows,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default AvailabilityPage; export default AvailabilityPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const BranchListPage = createHospitalityListPage({ export const BranchListPage = createHospitalityListPage(hospitalityResources.branches);
title: "شعب",
breadcrumb: "شعب",
resourceKey: "branches",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.branches,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default BranchListPage; export default BranchListPage;

View File

@ -2,25 +2,51 @@
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities"; import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared"; import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; import { PageHeader, Card, CardContent, Badge, Input } from "@/components/ds";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
import { useMemo, useState } from "react";
export const CapabilitiesPage = function HospitalityCapabilitiesPage() { export const CapabilitiesPage = function HospitalityCapabilitiesPage() {
const q = useHospitalityCapabilities(); const q = useHospitalityCapabilities();
const [search, setSearch] = useState("");
const entries = useMemo(() => {
const features = q.data?.features ?? {};
return Object.entries(features).filter(([k]) => k.toLowerCase().includes(search.toLowerCase()));
}, [q.data?.features, search]);
if (q.isLoading) return <HospitalityPageLoader />; if (q.isLoading) return <HospitalityPageLoader />;
if (q.error) return <HospitalityPageError error={q.error} onRetry={() => q.refetch()} />; if (q.error) return <HospitalityPageError error={q.error} onRetry={() => q.refetch()} />;
const features = q.data?.features ?? {};
const enabled = entries.filter(([, v]) => v).length;
return ( return (
<div> <div className="space-y-6">
<PageHeader title="قابلیت‌ها" description="Feature flags و bundles." /> <HospitalityBreadcrumbs current="قابلیت‌ها" />
<PageHeader
title="قابلیت‌ها"
description={`${enabled} از ${entries.length} قابلیت فعال — Bundle gating و feature flags.`}
actions={<Badge tone="info">v{q.data?.version}</Badge>}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی قابلیت…"
aria-label="جستجو"
className="max-w-md"
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(features).map(([k, v]) => ( {entries.map(([k, v]) => (
<Card key={k}><CardContent className="flex items-center justify-between p-3"> <Card key={k}>
<CardContent className="flex items-center justify-between p-4">
<span className="text-sm font-mono">{k}</span> <span className="text-sm font-mono">{k}</span>
<Badge tone={v ? "success" : "default"}>{v ? "فعال" : "غیرفعال"}</Badge> <Badge tone={v ? "success" : "default"}>{v ? "فعال" : "غیرفعال"}</Badge>
</CardContent></Card> </CardContent>
</Card>
))} ))}
</div> </div>
</div> </div>
); );
}; };
export default CapabilitiesPage; export default CapabilitiesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CheckoutPage = createHospitalityListPage({ export const CheckoutPage = createHospitalityListPage(hospitalityResources.cartLines);
title: "تسویه",
breadcrumb: "تسویه",
resourceKey: "cartLines",
bundleFeature: "qr_ordering",
permission: "hospitality.view",
api: hospitalityApi.cartLines,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CheckoutPage; export default CheckoutPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CartPage = createHospitalityListPage({ export const CartPage = createHospitalityListPage(hospitalityResources.carts);
title: "سبد خرید",
breadcrumb: "سبد خرید",
resourceKey: "carts",
bundleFeature: "qr_ordering",
permission: "hospitality.view",
api: hospitalityApi.carts,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CartPage; export default CartPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CommunicationIntegrationPage = createHospitalityListPage({ export const CommunicationIntegrationPage = createHospitalityListPage(hospitalityResources.connectorCommunication);
title: "ارتباطات",
breadcrumb: "ارتباطات",
resourceKey: "connectorRegistrations",
bundleFeature: "communication_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CommunicationIntegrationPage; export default CommunicationIntegrationPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CrmIntegrationPage = createHospitalityListPage({ export const CrmIntegrationPage = createHospitalityListPage(hospitalityResources.connectorCrm);
title: "CRM",
breadcrumb: "CRM",
resourceKey: "connectorRegistrations",
bundleFeature: "crm_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CrmIntegrationPage; export default CrmIntegrationPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const DeliveryIntegrationPage = createHospitalityListPage({ export const DeliveryIntegrationPage = createHospitalityListPage(hospitalityResources.connectorDelivery);
title: "پیک",
breadcrumb: "پیک",
resourceKey: "connectorRegistrations",
bundleFeature: "delivery_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default DeliveryIntegrationPage; export default DeliveryIntegrationPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const MarketplaceIntegrationPage = createHospitalityListPage({ export const MarketplaceIntegrationPage = createHospitalityListPage(hospitalityResources.connectorDispatches);
title: "مارکت‌پلیس",
breadcrumb: "مارکت‌پلیس",
resourceKey: "connectorDispatches",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.connectorDispatches,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default MarketplaceIntegrationPage; export default MarketplaceIntegrationPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const LoyaltyIntegrationPage = createHospitalityListPage({ export const LoyaltyIntegrationPage = createHospitalityListPage(hospitalityResources.connectorLoyalty);
title: "وفاداری",
breadcrumb: "وفاداری",
resourceKey: "connectorRegistrations",
bundleFeature: "loyalty_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default LoyaltyIntegrationPage; export default LoyaltyIntegrationPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CustomersPage = createHospitalityListPage({ export const CustomersPage = createHospitalityListPage(hospitalityResources.connectorRegistrationsCrm);
title: "مشتریان",
breadcrumb: "مشتریان",
resourceKey: "connectorRegistrations",
bundleFeature: "crm_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CustomersPage; export default CustomersPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const WebsiteIntegrationPage = createHospitalityListPage({ export const WebsiteIntegrationPage = createHospitalityListPage(hospitalityResources.connectorWebsite);
title: "وب‌سایت",
breadcrumb: "وب‌سایت",
resourceKey: "connectorRegistrations",
bundleFeature: "website_connector",
permission: "hospitality.view",
api: hospitalityApi.connectorRegistrations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default WebsiteIntegrationPage; export default WebsiteIntegrationPage;

View File

@ -8,6 +8,7 @@ import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospi
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared"; import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, StatCard, Card, CardContent, Button, Badge } from "@/components/ds"; import { PageHeader, StatCard, Card, CardContent, Button, Badge } from "@/components/ds";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs"; import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
import { HospitalityStatGrid } from "@/modules/hospitality/design-system";
export const ExecutiveDashboard = function HospitalityDashboard() { export const ExecutiveDashboard = function HospitalityDashboard() {
const { tenantId } = useTenantId(); const { tenantId } = useTenantId();
@ -16,20 +17,25 @@ export const ExecutiveDashboard = function HospitalityDashboard() {
const countsQ = useQuery({ const countsQ = useQuery({
queryKey: ["hospitality", tenantId, "dashboard-counts"], queryKey: ["hospitality", tenantId, "dashboard-counts"],
queryFn: async () => { queryFn: async () => {
const [venues, reservations, tickets, kitchen] = await Promise.all([ const [venues, reservations, tickets, kitchen, menus, qrSessions] = await Promise.all([
hospitalityApi.venues.list(tenantId!, { page: 1, page_size: 500 }), hospitalityApi.venues.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.reservations.list(tenantId!, { page: 1, page_size: 500 }), hospitalityApi.reservations.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.posTickets.list(tenantId!, { page: 1, page_size: 500 }), hospitalityApi.posTickets.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.kitchenTickets.list(tenantId!, { page: 1, page_size: 500 }), hospitalityApi.kitchenTickets.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.menus.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.qrOrderingSessions.list(tenantId!, { page: 1, page_size: 500 }),
]); ]);
return { return {
venues: venues.length, venues: venues.length,
reservations: reservations.length, reservations: reservations.filter((r) => r.status === "confirmed" || r.status === "pending").length,
openOrders: tickets.filter((t) => t.status === "open" || t.status === "draft").length, openOrders: tickets.filter((t) => t.status === "open" || t.status === "draft").length,
kitchenActive: kitchen.filter((k) => k.status !== "completed" && k.status !== "cancelled").length, kitchenActive: kitchen.filter((k) => k.status !== "completed" && k.status !== "cancelled").length,
menus: menus.filter((m) => m.status === "published" || m.status === "active").length,
onlineOrders: qrSessions.filter((s) => s.status === "submitted" || s.status === "preparing").length,
}; };
}, },
enabled: !!tenantId, enabled: !!tenantId,
refetchInterval: 30_000,
}); });
if (!tenantId || countsQ.isLoading || caps.isLoading) return <HospitalityPageLoader />; if (!tenantId || countsQ.isLoading || caps.isLoading) return <HospitalityPageLoader />;
@ -42,21 +48,37 @@ export const ExecutiveDashboard = function HospitalityDashboard() {
<HospitalityBreadcrumbs current="داشبورد" /> <HospitalityBreadcrumbs current="داشبورد" />
<PageHeader <PageHeader
title="داشبورد مدیریتی" title="داشبورد مدیریتی"
description="نمای اجرایی فروش، سفارش‌ها و عملیات روزانه." description="نمای اجرایی فروش، سفارش‌ها و عملیات روزانه — داده زنده از API."
actions={<Badge tone="success">Torbat Food v{caps.data?.version}</Badge>} actions={
/> <div className="flex gap-2">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <Badge tone="success">Torbat Food v{caps.data?.version}</Badge>
<StatCard label="مکان‌ها" value={String(c.venues)} hint="Venues" /> <Button variant="outline" size="sm" onClick={() => countsQ.refetch()}>
<StatCard label="رزرو امروز" value={String(c.reservations)} hint="Reservations" /> بروزرسانی
<StatCard label="سفارش باز" value={String(c.openOrders)} hint="Open tickets" /> </Button>
<StatCard label="آشپزخانه فعال" value={String(c.kitchenActive)} hint="Kitchen queue" />
</div> </div>
}
/>
<HospitalityStatGrid
stats={[
{ label: "مکان‌ها", value: c.venues, hint: "Venues" },
{ label: "رزرو فعال", value: c.reservations, hint: "Reservations" },
{ label: "سفارش باز POS", value: c.openOrders, hint: "Open tickets" },
{ label: "آشپزخانه فعال", value: c.kitchenActive, hint: "Kitchen queue" },
{ label: "منوهای منتشر", value: c.menus, hint: "Menus" },
{ label: "سفارش آنلاین", value: c.onlineOrders, hint: "QR ordering" },
]}
/>
<Card> <Card>
<CardContent className="flex flex-wrap gap-2 p-4"> <CardContent className="space-y-3 p-4">
<p className="text-sm font-medium text-secondary">عملیات سریع</p>
<div className="flex flex-wrap gap-2">
<Link href="/hospitality/reservations"><Button variant="outline">رزروها</Button></Link> <Link href="/hospitality/reservations"><Button variant="outline">رزروها</Button></Link>
<Link href="/hospitality/kitchen/display"><Button variant="outline">آشپزخانه</Button></Link> <Link href="/hospitality/kitchen/display"><Button variant="outline">آشپزخانه</Button></Link>
<Link href="/hospitality/pos/lite"><Button variant="outline">POS</Button></Link> <Link href="/hospitality/pos/lite"><Button variant="outline">POS Lite</Button></Link>
<Link href="/hospitality/menu"><Button variant="outline">منو</Button></Link>
<Link href="/hospitality/analytics"><Button variant="outline">آنالیتیکس</Button></Link> <Link href="/hospitality/analytics"><Button variant="outline">آنالیتیکس</Button></Link>
<Link href="/hospitality/orders/online"><Button variant="outline">سفارش آنلاین</Button></Link>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const DiningAreasPage = createHospitalityListPage({ export const DiningAreasPage = createHospitalityListPage(hospitalityResources.diningAreas);
title: "سالن‌ها",
breadcrumb: "سالن‌ها",
resourceKey: "diningAreas",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.diningAreas,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default DiningAreasPage; export default DiningAreasPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const AuditLogsPage = createHospitalityListPage({ export const AuditLogsPage = createHospitalityListPage(hospitalityResources.events);
title: "لاگ ممیزی",
breadcrumb: "لاگ ممیزی",
resourceKey: "events",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.events,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default AuditLogsPage; export default AuditLogsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PromotionsPage = createHospitalityListPage({ export const PromotionsPage = createHospitalityListPage(hospitalityResources.featureTogglesPromo);
title: "پروموشن‌ها",
breadcrumb: "پروموشن‌ها",
resourceKey: "featureToggles",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.featureToggles,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PromotionsPage; export default PromotionsPage;

View File

@ -2,21 +2,48 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared"; import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds"; import { PageHeader, Card, CardContent, Badge, Button } from "@/components/ds";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
export const HealthPage = function HospitalityHealthPage() { export const HealthPage = function HospitalityHealthPage() {
const q = useQuery({ queryKey: ["hospitality", "health"], queryFn: () => hospitalityApi.health() }); const healthQ = useQuery({
if (q.isLoading) return <HospitalityPageLoader />; queryKey: ["hospitality", "health"],
if (q.error) return <HospitalityPageError error={q.error} onRetry={() => q.refetch()} />; queryFn: () => hospitalityApi.health(),
refetchInterval: 15_000,
});
const capsQ = useHospitalityCapabilities();
if (healthQ.isLoading || capsQ.isLoading) return <HospitalityPageLoader />;
if (healthQ.error) return <HospitalityPageError error={healthQ.error} onRetry={() => healthQ.refetch()} />;
return ( return (
<div> <div className="space-y-6">
<PageHeader title="سلامت سرویس" description="وضعیت سرویس Hospitality." /> <HospitalityBreadcrumbs current="سلامت سرویس" />
<Card><CardContent className="space-y-2 p-4"> <PageHeader
<Badge tone={q.data?.status === "ok" ? "success" : "danger"}>{q.data?.status}</Badge> title="سلامت سرویس"
<p className="text-sm">{q.data?.service} v{q.data?.version}</p> description="وضعیت زنده سرویس Hospitality و اتصال BFF."
</CardContent></Card> actions={
<Button variant="outline" size="sm" onClick={() => healthQ.refetch()}>
بررسی مجدد
</Button>
}
/>
<Card>
<CardContent className="space-y-4 p-6">
<div className="flex flex-wrap items-center gap-3">
<Badge tone={healthQ.data?.status === "ok" ? "success" : "danger"}>{healthQ.data?.status}</Badge>
<span className="text-sm">{healthQ.data?.service}</span>
<span className="text-sm text-[var(--muted)]">v{healthQ.data?.version}</span>
</div>
<p className="text-sm text-[var(--muted)]">
Capabilities API: {capsQ.data ? "متصل" : "—"} {Object.keys(capsQ.data?.features ?? {}).length} feature flag
</p>
</CardContent>
</Card>
</div> </div>
); );
}; };
export default HealthPage; export default HealthPage;

View File

@ -1,31 +1,59 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useMe } from "@/hooks/useMe"; import { useMe } from "@/hooks/useMe";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HOSPITALITY_PORTAL } from "@/modules/hospitality/constants/portals"; import { HOSPITALITY_PORTAL } from "@/modules/hospitality/constants/portals";
import { Card, CardContent, Button, LoadingState, ErrorState } from "@/components/ds"; import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { useTenantId } from "@/hooks/useTenantId";
import { Card, CardContent, Button, LoadingState, ErrorState, Badge, StatCard } from "@/components/ds";
export const HospitalityHub = function HospitalityHubPage() { export const HospitalityHub = function HospitalityHubPage() {
const { me, loading, error, reload } = useMe(); const { me, loading, error, reload } = useMe();
if (loading) return <LoadingState label="در حال بارگذاری تربت فود…" />; const { tenantId } = useTenantId();
const caps = useHospitalityCapabilities();
const healthQ = useQuery({
queryKey: ["hospitality", "health"],
queryFn: () => hospitalityApi.health(),
});
if (loading || caps.isLoading) return <LoadingState label="در حال بارگذاری تربت فود…" />;
if (error) return <ErrorState message={error} onRetry={reload} />; if (error) return <ErrorState message={error} onRetry={reload} />;
if (!me?.current_tenant_id) { if (!me?.current_tenant_id) {
return <ErrorState message="برای استفاده از تربت فود workspace فعال لازم است." />; return <ErrorState message="برای استفاده از تربت فود workspace فعال لازم است." />;
} }
const activeBundles = Object.entries(caps.data?.features ?? {}).filter(([, v]) => v).length;
return ( return (
<div className="mx-auto max-w-lg p-6"> <div className="mx-auto max-w-2xl space-y-6 p-6">
<div>
<h1 className="mb-2 text-2xl font-bold text-secondary">تربت فود</h1> <h1 className="mb-2 text-2xl font-bold text-secondary">تربت فود</h1>
<p className="mb-6 text-sm text-[var(--muted)]">پلتفرم مهماننوازی سازمانی</p> <p className="text-sm text-[var(--muted)]">پلتفرم مهماننوازی سازمانی Torbat Food</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<StatCard label="وضعیت API" value={healthQ.data?.status === "ok" ? "سالم" : "—"} />
<StatCard label="نسخه" value={caps.data?.version ?? "—"} />
<StatCard label="بسته فعال" value={String(activeBundles)} />
</div>
<Card> <Card>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div className="flex items-center justify-between">
<p className="font-medium">{HOSPITALITY_PORTAL.label}</p> <p className="font-medium">{HOSPITALITY_PORTAL.label}</p>
<Badge tone="primary">Tenant {tenantId?.slice(0, 8)}</Badge>
</div>
<p className="text-sm text-[var(--muted)]">{HOSPITALITY_PORTAL.description}</p> <p className="text-sm text-[var(--muted)]">{HOSPITALITY_PORTAL.description}</p>
<Link href={HOSPITALITY_PORTAL.basePath}> <Link href={HOSPITALITY_PORTAL.basePath}>
<Button className="w-full">ورود به پنل مدیریت</Button> <Button className="w-full">ورود به پنل مدیریت</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
<div className="grid gap-2 sm:grid-cols-2">
<Link href="/hospitality/capabilities"><Button variant="outline" className="w-full">قابلیتها</Button></Link>
<Link href="/hospitality/health"><Button variant="outline" className="w-full">سلامت سرویس</Button></Link>
</div>
</div> </div>
); );
}; };

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const InventoryPage = createHospitalityListPage({ export const InventoryPage = createHospitalityListPage(hospitalityResources.inventoryOverview);
title: "موجودی",
breadcrumb: "موجودی",
resourceKey: "analyticsSnapshots",
bundleFeature: "analytics",
permission: "hospitality.view",
api: hospitalityApi.analyticsSnapshots,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default InventoryPage; export default InventoryPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const InvoicesPage = createHospitalityListPage({ export const InvoicesPage = createHospitalityListPage(hospitalityResources.invoices);
title: "فاکتورها",
breadcrumb: "فاکتورها",
resourceKey: "posTickets",
bundleFeature: "pos_lite",
permission: "hospitality.view",
api: hospitalityApi.posTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default InvoicesPage; export default InvoicesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const KitchenQueuePage = createHospitalityListPage({ export const KitchenQueuePage = createHospitalityListPage(hospitalityResources.kitchenTicketItems);
title: "صف آشپزخانه",
breadcrumb: "صف آشپزخانه",
resourceKey: "kitchenTicketItems",
bundleFeature: "kitchen",
permission: "hospitality.view",
api: hospitalityApi.kitchenTicketItems,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default KitchenQueuePage; export default KitchenQueuePage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const KitchenDisplayPage = createHospitalityListPage({ export const KitchenDisplayPage = createHospitalityListPage(hospitalityResources.kitchenTickets);
title: "نمایشگر آشپزخانه",
breadcrumb: "نمایشگر آشپزخانه",
resourceKey: "kitchenTickets",
bundleFeature: "kitchen",
permission: "hospitality.view",
api: hospitalityApi.kitchenTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default KitchenDisplayPage; export default KitchenDisplayPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const KitchenPrepPage = createHospitalityListPage({ export const KitchenPrepPage = createHospitalityListPage(hospitalityResources.kitchenTicketsPrep);
title: "وضعیت آماده‌سازی",
breadcrumb: "وضعیت آماده‌سازی",
resourceKey: "kitchenTickets",
bundleFeature: "kitchen",
permission: "hospitality.view",
api: hospitalityApi.kitchenTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default KitchenPrepPage; export default KitchenPrepPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const MenuCategoriesPage = createHospitalityListPage({ export const MenuCategoriesPage = createHospitalityListPage(hospitalityResources.menuCategories);
title: "دسته‌بندی منو",
breadcrumb: "دسته‌بندی منو",
resourceKey: "menuCategories",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.menuCategories,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default MenuCategoriesPage; export default MenuCategoriesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const MenuItemsPage = createHospitalityListPage({ export const MenuItemsPage = createHospitalityListPage(hospitalityResources.menuItems);
title: "آیتم‌های منو",
breadcrumb: "آیتم‌های منو",
resourceKey: "menuItems",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.menuItems,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default MenuItemsPage; export default MenuItemsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const MediaLibraryPage = createHospitalityListPage({ export const MediaLibraryPage = createHospitalityListPage(hospitalityResources.menuMediaRefs);
title: "کتابخانه رسانه",
breadcrumb: "کتابخانه رسانه",
resourceKey: "menuMediaRefs",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.menuMediaRefs,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default MediaLibraryPage; export default MediaLibraryPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const DigitalMenuPage = createHospitalityListPage({ export const DigitalMenuPage = createHospitalityListPage(hospitalityResources.menus);
title: "منوی دیجیتال",
breadcrumb: "منوی دیجیتال",
resourceKey: "menus",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.menus,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default DigitalMenuPage; export default DigitalMenuPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ModifierGroupsPage = createHospitalityListPage({ export const ModifierGroupsPage = createHospitalityListPage(hospitalityResources.modifierGroups);
title: "گروه افزودنی",
breadcrumb: "گروه افزودنی",
resourceKey: "modifierGroups",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.modifierGroups,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ModifierGroupsPage; export default ModifierGroupsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ModifierOptionsPage = createHospitalityListPage({ export const ModifierOptionsPage = createHospitalityListPage(hospitalityResources.modifierOptions);
title: "گزینه افزودنی",
breadcrumb: "گزینه افزودنی",
resourceKey: "modifierOptions",
bundleFeature: "digital_menu",
permission: "hospitality.view",
api: hospitalityApi.modifierOptions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ModifierOptionsPage; export default ModifierOptionsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const NotificationsPage = createHospitalityListPage({ export const NotificationsPage = createHospitalityListPage(hospitalityResources.notifications);
title: "اعلان‌ها",
breadcrumb: "اعلان‌ها",
resourceKey: "events",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.events,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default NotificationsPage; export default NotificationsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const OnlineOrdersPage = createHospitalityListPage({ export const OnlineOrdersPage = createHospitalityListPage(hospitalityResources.onlineOrders);
title: "سفارش آنلاین",
breadcrumb: "سفارش آنلاین",
resourceKey: "qrOrderingSessions",
bundleFeature: "qr_ordering",
permission: "hospitality.view",
api: hospitalityApi.qrOrderingSessions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default OnlineOrdersPage; export default OnlineOrdersPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const OrderTimelinePage = createHospitalityListPage({ export const OrderTimelinePage = createHospitalityListPage(hospitalityResources.orderTimeline);
title: "خط زمانی سفارش",
breadcrumb: "خط زمانی سفارش",
resourceKey: "kitchenTickets",
bundleFeature: "kitchen",
permission: "hospitality.view",
api: hospitalityApi.kitchenTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default OrderTimelinePage; export default OrderTimelinePage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PermissionsPage = createHospitalityListPage({ export const PermissionsPage = createHospitalityListPage(hospitalityResources.permissions);
title: "مجوزها",
breadcrumb: "مجوزها",
resourceKey: "permissions",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.permissions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PermissionsPage; export default PermissionsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PickupOrdersPage = createHospitalityListPage({ export const PickupOrdersPage = createHospitalityListPage(hospitalityResources.pickupOrders);
title: "سفارش بیرون‌بر",
breadcrumb: "سفارش بیرون‌بر",
resourceKey: "qrOrderingSessions",
bundleFeature: "qr_ordering",
permission: "hospitality.view",
api: hospitalityApi.qrOrderingSessions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PickupOrdersPage; export default PickupOrdersPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const DiscountsPage = createHospitalityListPage({ export const DiscountsPage = createHospitalityListPage(hospitalityResources.posDiscounts);
title: "تخفیف‌ها",
breadcrumb: "تخفیف‌ها",
resourceKey: "posDiscounts",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.posDiscounts,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default DiscountsPage; export default DiscountsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CouponsPage = createHospitalityListPage({ export const CouponsPage = createHospitalityListPage(hospitalityResources.posDiscountsCoupons);
title: "کوپن‌ها",
breadcrumb: "کوپن‌ها",
resourceKey: "posDiscounts",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.posDiscounts,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CouponsPage; export default CouponsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const FloorMapPage = createHospitalityListPage({ export const FloorMapPage = createHospitalityListPage(hospitalityResources.posFloorPlans);
title: "نقشه سالن",
breadcrumb: "نقشه سالن",
resourceKey: "posFloorPlans",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.posFloorPlans,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default FloorMapPage; export default FloorMapPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PosProPage = createHospitalityListPage({ export const PosProPage = createHospitalityListPage(hospitalityResources.posPayments);
title: "POS Pro",
breadcrumb: "POS Pro",
resourceKey: "posPayments",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.posPayments,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PosProPage; export default PosProPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PaymentsPage = createHospitalityListPage({ export const PaymentsPage = createHospitalityListPage(hospitalityResources.posPaymentsList);
title: "پرداخت‌ها",
breadcrumb: "پرداخت‌ها",
resourceKey: "posPayments",
bundleFeature: "pos_pro",
permission: "hospitality.view",
api: hospitalityApi.posPayments,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PaymentsPage; export default PaymentsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const CashRegisterPage = createHospitalityListPage({ export const CashRegisterPage = createHospitalityListPage(hospitalityResources.posRegisters);
title: "صندوق",
breadcrumb: "صندوق",
resourceKey: "posRegisters",
bundleFeature: "pos_lite",
permission: "hospitality.view",
api: hospitalityApi.posRegisters,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default CashRegisterPage; export default CashRegisterPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const PosLitePage = createHospitalityListPage({ export const PosLitePage = createHospitalityListPage(hospitalityResources.posTickets);
title: "POS Lite",
breadcrumb: "POS Lite",
resourceKey: "posTickets",
bundleFeature: "pos_lite",
permission: "hospitality.view",
api: hospitalityApi.posTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default PosLitePage; export default PosLitePage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const QrMenuPage = createHospitalityListPage({ export const QrMenuPage = createHospitalityListPage(hospitalityResources.qrCodes);
title: "منوی QR",
breadcrumb: "منوی QR",
resourceKey: "qrCodes",
bundleFeature: "qr_menu",
permission: "hospitality.view",
api: hospitalityApi.qrCodes,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default QrMenuPage; export default QrMenuPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const GuestsPage = createHospitalityListPage({ export const GuestsPage = createHospitalityListPage(hospitalityResources.qrMenuSessions);
title: "پروفایل مهمان",
breadcrumb: "پروفایل مهمان",
resourceKey: "qrMenuSessions",
bundleFeature: "qr_menu",
permission: "hospitality.view",
api: hospitalityApi.qrMenuSessions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default GuestsPage; export default GuestsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const QrOrderingPage = createHospitalityListPage({ export const QrOrderingPage = createHospitalityListPage(hospitalityResources.qrOrderingSessions);
title: "سفارش QR",
breadcrumb: "سفارش QR",
resourceKey: "qrOrderingSessions",
bundleFeature: "qr_ordering",
permission: "hospitality.view",
api: hospitalityApi.qrOrderingSessions,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default QrOrderingPage; export default QrOrderingPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ReceiptsPage = createHospitalityListPage({ export const ReceiptsPage = createHospitalityListPage(hospitalityResources.receipts);
title: "رسیدها",
breadcrumb: "رسیدها",
resourceKey: "posTickets",
bundleFeature: "pos_lite",
permission: "hospitality.view",
api: hospitalityApi.posTickets,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ReceiptsPage; export default ReceiptsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ReservationsPage = createHospitalityListPage({ export const ReservationsPage = createHospitalityListPage(hospitalityResources.reservations);
title: "رزروها",
breadcrumb: "رزروها",
resourceKey: "reservations",
bundleFeature: "reservation",
permission: "hospitality.view",
api: hospitalityApi.reservations,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ReservationsPage; export default ReservationsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const RolesPage = createHospitalityListPage({ export const RolesPage = createHospitalityListPage(hospitalityResources.roles);
title: "نقش‌ها",
breadcrumb: "نقش‌ها",
resourceKey: "roles",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.roles,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default RolesPage; export default RolesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const SettingsPage = createHospitalityListPage({ export const SettingsPage = createHospitalityListPage(hospitalityResources.settings);
title: "تنظیمات",
breadcrumb: "تنظیمات",
resourceKey: "settings",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.settings,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default SettingsPage; export default SettingsPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const TablesPage = createHospitalityListPage({ export const TablesPage = createHospitalityListPage(hospitalityResources.tables);
title: "میزها",
breadcrumb: "میزها",
resourceKey: "tables",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.tables,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default TablesPage; export default TablesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const BundlesPage = createHospitalityListPage({ export const BundlesPage = createHospitalityListPage(hospitalityResources.tenantBundles);
title: "بسته‌های فعال",
breadcrumb: "بسته‌های فعال",
resourceKey: "tenantBundles",
bundleFeature: null,
permission: "hospitality.view",
api: hospitalityApi.tenantBundles,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default BundlesPage; export default BundlesPage;

View File

@ -1,53 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
import { venueFormatLabels } from "@/modules/hospitality/design-system/tokens";
export const BranchesPage = createHospitalityListPage({ export const BranchesPage = createHospitalityListPage(hospitalityResources.venues);
title: "مکان‌ها (Venues)",
description: "مدیریت مکان‌های مهمان‌نوازی — کافه، رستوران، فست‌فود و …",
breadcrumb: "مکان‌ها",
resourceKey: "venues",
bundleFeature: null,
permission: "hospitality.venues.view",
api: hospitalityApi.venues,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "venue_format",
header: "نوع",
render: (r) => venueFormatLabels[String(r.venue_format)] ?? String(r.venue_format),
},
{ key: "status", header: "وضعیت", type: "status" },
{ key: "currency_code", header: "ارز" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
{
name: "venue_format",
label: "نوع کسب‌وکار",
type: "select",
required: true,
options: Object.entries(venueFormatLabels).map(([value, label]) => ({ value, label })),
},
],
editFields: [
{ name: "name", label: "نام", required: true },
{
name: "status",
label: "وضعیت",
type: "select",
options: [
{ value: "draft", label: "پیش‌نویس" },
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
],
},
],
filterDeleted: true,
});
export default BranchesPage; export default BranchesPage;

View File

@ -1,25 +1,8 @@
"use client"; "use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage"; import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api"; import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const QueuePage = createHospitalityListPage({ export const QueuePage = createHospitalityListPage(hospitalityResources.waitlist);
title: "صف انتظار",
breadcrumb: "صف انتظار",
resourceKey: "waitlist",
bundleFeature: "table_service",
permission: "hospitality.view",
api: hospitalityApi.waitlist,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default QueuePage; export default QueuePage;

View File

@ -0,0 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { useTenantId } from "@/hooks/useTenantId";
export function useHospitalityVenues() {
const { tenantId } = useTenantId();
return useQuery({
queryKey: ["hospitality", tenantId, "venues-options"],
queryFn: () => hospitalityApi.venues.list(tenantId!, { page: 1, page_size: 500 }),
enabled: !!tenantId,
staleTime: 60_000,
});
}

View File

@ -0,0 +1,88 @@
#!/usr/bin/env node
/** Generates resourceRegistry.ts and updates hospitality feature pages. */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const MOD = path.join(ROOT, "modules", "hospitality");
const FEATURES = path.join(MOD, "features");
const REGISTRY = path.join(MOD, "constants", "resourceRegistry.ts");
const FEATURE_MAP = [
["venues", "BranchesPage", "venues"],
["branches", "BranchListPage", "branches"],
["diningAreas", "DiningAreasPage", "diningAreas"],
["tables", "TablesPage", "tables"],
["posFloorPlans", "FloorMapPage", "posFloorPlans"],
["reservations", "ReservationsPage", "reservations"],
["waitlist", "QueuePage", "waitlist"],
["connectorRegistrationsCrm", "CustomersPage", "connectorRegistrationsCrm"],
["qrMenuSessions", "GuestsPage", "qrMenuSessions"],
["menus", "DigitalMenuPage", "menus"],
["menuCategories", "MenuCategoriesPage", "menuCategories"],
["menuItems", "MenuItemsPage", "menuItems"],
["modifierGroups", "ModifierGroupsPage", "modifierGroups"],
["modifierOptions", "ModifierOptionsPage", "modifierOptions"],
["availabilityWindows", "AvailabilityPage", "availabilityWindows"],
["menuMediaRefs", "MediaLibraryPage", "menuMediaRefs"],
["qrCodes", "QrMenuPage", "qrCodes"],
["qrOrderingSessions", "QrOrderingPage", "qrOrderingSessions"],
["carts", "CartPage", "carts"],
["cartLines", "CheckoutPage", "cartLines"],
["kitchenTickets", "KitchenDisplayPage", "kitchenTickets"],
["kitchenTicketItems", "KitchenQueuePage", "kitchenTicketItems"],
["kitchenTicketsPrep", "KitchenPrepPage", "kitchenTicketsPrep"],
["posTickets", "PosLitePage", "posTickets"],
["posPayments", "PosProPage", "posPayments"],
["posRegisters", "CashRegisterPage", "posRegisters"],
["posPaymentsList", "PaymentsPage", "posPaymentsList"],
["posDiscounts", "DiscountsPage", "posDiscounts"],
["posDiscountsCoupons", "CouponsPage", "posDiscountsCoupons"],
["featureTogglesPromo", "PromotionsPage", "featureTogglesPromo"],
["connectorDelivery", "DeliveryIntegrationPage", "connectorDelivery"],
["pickupOrders", "PickupOrdersPage", "pickupOrders"],
["onlineOrders", "OnlineOrdersPage", "onlineOrders"],
["orderTimeline", "OrderTimelinePage", "orderTimeline"],
["invoices", "InvoicesPage", "invoices"],
["receipts", "ReceiptsPage", "receipts"],
["analyticsReports", "ReportsPage", "analyticsReports"],
["analyticsSnapshots", "AnalyticsPage", "analyticsSnapshots"],
["inventoryOverview", "InventoryPage", "inventoryOverview"],
["connectorCrm", "CrmIntegrationPage", "connectorCrm"],
["connectorLoyalty", "LoyaltyIntegrationPage", "connectorLoyalty"],
["connectorCommunication", "CommunicationIntegrationPage", "connectorCommunication"],
["connectorWebsite", "WebsiteIntegrationPage", "connectorWebsite"],
["connectorDispatches", "MarketplaceIntegrationPage", "connectorDispatches"],
["settings", "SettingsPage", "settings"],
["permissions", "PermissionsPage", "permissions"],
["roles", "RolesPage", "roles"],
["events", "AuditLogsPage", "events"],
["notifications", "NotificationsPage", "notifications"],
["tenantBundles", "BundlesPage", "tenantBundles"],
];
// Registry body is maintained in gen script — run to regenerate resourceRegistry.ts
const registryPath = path.join(ROOT, "scripts", "_hospitality-registry-body.ts.txt");
if (!fs.existsSync(registryPath)) {
console.error("Missing", registryPath, "- run setup first");
process.exit(1);
}
const registryBody = fs.readFileSync(registryPath, "utf8");
fs.mkdirSync(path.dirname(REGISTRY), { recursive: true });
fs.writeFileSync(REGISTRY, registryBody);
for (const [featureFile, exportName, registryKey] of FEATURE_MAP) {
const content = `"use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ${exportName} = createHospitalityListPage(hospitalityResources.${registryKey});
export default ${exportName};
`;
fs.writeFileSync(path.join(FEATURES, `${featureFile}.tsx`), content);
}
console.log("Generated registry +", FEATURE_MAP.length, "features");

View File

@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const FEATURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "modules", "hospitality", "features");
const FEATURE_MAP = [
["venues", "BranchesPage", "venues"],
["branches", "BranchListPage", "branches"],
["diningAreas", "DiningAreasPage", "diningAreas"],
["tables", "TablesPage", "tables"],
["posFloorPlans", "FloorMapPage", "posFloorPlans"],
["reservations", "ReservationsPage", "reservations"],
["waitlist", "QueuePage", "waitlist"],
["connectorRegistrationsCrm", "CustomersPage", "connectorRegistrationsCrm"],
["qrMenuSessions", "GuestsPage", "qrMenuSessions"],
["menus", "DigitalMenuPage", "menus"],
["menuCategories", "MenuCategoriesPage", "menuCategories"],
["menuItems", "MenuItemsPage", "menuItems"],
["modifierGroups", "ModifierGroupsPage", "modifierGroups"],
["modifierOptions", "ModifierOptionsPage", "modifierOptions"],
["availabilityWindows", "AvailabilityPage", "availabilityWindows"],
["menuMediaRefs", "MediaLibraryPage", "menuMediaRefs"],
["qrCodes", "QrMenuPage", "qrCodes"],
["qrOrderingSessions", "QrOrderingPage", "qrOrderingSessions"],
["carts", "CartPage", "carts"],
["cartLines", "CheckoutPage", "cartLines"],
["kitchenTickets", "KitchenDisplayPage", "kitchenTickets"],
["kitchenTicketItems", "KitchenQueuePage", "kitchenTicketItems"],
["kitchenTicketsPrep", "KitchenPrepPage", "kitchenTicketsPrep"],
["posTickets", "PosLitePage", "posTickets"],
["posPayments", "PosProPage", "posPayments"],
["posRegisters", "CashRegisterPage", "posRegisters"],
["posPaymentsList", "PaymentsPage", "posPaymentsList"],
["posDiscounts", "DiscountsPage", "posDiscounts"],
["posDiscountsCoupons", "CouponsPage", "posDiscountsCoupons"],
["featureTogglesPromo", "PromotionsPage", "featureTogglesPromo"],
["connectorDelivery", "DeliveryIntegrationPage", "connectorDelivery"],
["pickupOrders", "PickupOrdersPage", "pickupOrders"],
["onlineOrders", "OnlineOrdersPage", "onlineOrders"],
["orderTimeline", "OrderTimelinePage", "orderTimeline"],
["invoices", "InvoicesPage", "invoices"],
["receipts", "ReceiptsPage", "receipts"],
["analyticsReports", "ReportsPage", "analyticsReports"],
["analyticsSnapshots", "AnalyticsPage", "analyticsSnapshots"],
["inventoryOverview", "InventoryPage", "inventoryOverview"],
["connectorCrm", "CrmIntegrationPage", "connectorCrm"],
["connectorLoyalty", "LoyaltyIntegrationPage", "connectorLoyalty"],
["connectorCommunication", "CommunicationIntegrationPage", "connectorCommunication"],
["connectorWebsite", "WebsiteIntegrationPage", "connectorWebsite"],
["connectorDispatches", "MarketplaceIntegrationPage", "connectorDispatches"],
["settings", "SettingsPage", "settings"],
["permissions", "PermissionsPage", "permissions"],
["roles", "RolesPage", "roles"],
["events", "AuditLogsPage", "events"],
["notifications", "NotificationsPage", "notifications"],
["tenantBundles", "BundlesPage", "tenantBundles"],
];
for (const [featureFile, exportName, registryKey] of FEATURE_MAP) {
const content = `"use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityResources } from "@/modules/hospitality/constants/resourceRegistry";
export const ${exportName} = createHospitalityListPage(hospitalityResources.${registryKey});
export default ${exportName};
`;
fs.writeFileSync(path.join(FEATURES, `${featureFile}.tsx`), content);
console.log("Updated", featureFile);
}

View File

@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Deploy Hospitality service + frontend (Torbat Food) to production."""
from __future__ import annotations
import base64
import os
import sys
import tarfile
from pathlib import Path
import paramiko
ROOT = Path(__file__).resolve().parents[1]
APP_HOST = "192.168.10.162"
APP_USER = "morteza"
APP_PASS = "Moli@5404"
NGX_HOST = "192.168.10.156"
NGX_USER = "torbatyaruser"
NGX_PASS = "Moli@5404"
REMOTE_DIR = "/home/morteza/torbatyar"
EXCLUDE_DIRS = {
".git",
"node_modules",
".next",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".idea",
".vscode",
"agent-transcripts",
}
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"}
ENV_UPSERT = {
"NEXT_PUBLIC_HOSPITALITY_API_URL": "https://hospitality.torbatyar.ir",
"HOSPITALITY_SERVICE_URL": "http://hospitality-service:8009",
"HOSPITALITY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db",
"HOSPITALITY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db",
"HOSPITALITY_SERVICE_NAME": "hospitality-service",
"KEYCLOAK_SERVER_URL": "http://keycloak:8080",
"KEYCLOAK_REALM": "superapp",
}
def connect(host: str, user: str, password: str) -> paramiko.SSHClient:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
host,
username=user,
password=password,
timeout=30,
allow_agent=False,
look_for_keys=False,
)
return c
def run(
client: paramiko.SSHClient,
cmd: str,
*,
sudo: bool = False,
password: str = "",
timeout: int = 600,
) -> tuple[int, str]:
cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n"
b64 = base64.b64encode(cmd.encode()).decode()
if sudo:
full = f"echo {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'"
else:
full = f"bash -lc 'echo {b64} | base64 -d | bash'"
print(
f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:220].replace(chr(10), ' | ')}"
f"{'...' if len(cmd) > 220 else ''}"
)
_stdin, stdout, stderr = client.exec_command(full, timeout=timeout, get_pty=True)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
text = out + (("\n" + err) if err.strip() else "")
printable = text[-5000:] if len(text) > 5000 else text
try:
print(printable)
except UnicodeEncodeError:
print(printable.encode("ascii", "replace").decode("ascii"))
return code, text
def should_exclude(path: Path) -> bool:
parts = set(path.parts)
if parts & EXCLUDE_DIRS:
return True
if path.name in EXCLUDE_FILES:
return True
if path.suffix in {".pyc", ".log"}:
return True
return False
def make_tarball() -> Path:
out = ROOT / "scripts" / "_deploy_hospitality_bundle.tar.gz"
print(f"Creating tarball {out} ...")
with tarfile.open(out, "w:gz") as tar:
for dirpath, dirnames, filenames in os.walk(ROOT):
p = Path(dirpath)
dirnames[:] = [
d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".cursor")
]
rel_dir = p.relative_to(ROOT)
if should_exclude(rel_dir) and rel_dir != Path("."):
continue
for name in filenames:
fp = p / name
rel = fp.relative_to(ROOT)
if should_exclude(rel):
continue
if rel.as_posix().startswith("scripts/_deploy"):
continue
tar.add(fp, arcname=str(rel).replace("\\", "/"))
print(f"Tarball size: {out.stat().st_size / 1024 / 1024:.1f} MB")
return out
def upload_file(client: paramiko.SSHClient, local: Path, remote: str) -> None:
sftp = client.open_sftp()
print(f"Uploading {local} -> {remote}")
sftp.put(str(local), remote)
sftp.close()
def put_text_sudo(
client: paramiko.SSHClient, password: str, remote: str, content: str
) -> None:
b64 = base64.b64encode(content.encode()).decode()
cmd = (
f"mkdir -p $(dirname {remote!r}) && echo {b64} | base64 -d > {remote!r} "
f"&& chmod 644 {remote!r}"
)
code, _ = run(client, cmd, sudo=True, password=password, timeout=60)
if code != 0:
raise RuntimeError(f"failed writing {remote}")
def merge_env_remote(client: paramiko.SSHClient) -> None:
py = f"""
from pathlib import Path
p = Path({REMOTE_DIR!r}) / '.env'
text = p.read_text(encoding='utf-8') if p.exists() else ''
lines = text.splitlines()
keys = {ENV_UPSERT!r}
seen = set()
out = []
for line in lines:
if not line.strip() or line.lstrip().startswith('#') or '=' not in line:
out.append(line)
continue
k = line.split('=', 1)[0].strip()
if k in keys:
out.append(f"{{k}}={{keys[k]}}")
seen.add(k)
else:
out.append(line)
for k, v in keys.items():
if k not in seen:
out.append(f"{{k}}={{v}}")
p.write_text('\\n'.join(out).rstrip() + '\\n', encoding='utf-8')
print('ENV_MERGED', len(keys))
"""
b64 = base64.b64encode(py.encode()).decode()
code, _ = run(
client,
f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"",
)
if code != 0:
raise RuntimeError("env merge failed")
def compose_up(client: paramiko.SSHClient, services: str) -> None:
code, _ = run(
client,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c 'docker compose up -d --build {services}'
sg docker -c 'docker compose ps'
""",
timeout=1800,
)
if code != 0:
code, _ = run(
client,
f"""
set -e
cd {REMOTE_DIR}
docker compose up -d --build {services}
docker compose ps
""",
sudo=True,
password=APP_PASS,
timeout=1800,
)
if code != 0:
raise RuntimeError(f"compose up failed for {services}")
def main() -> int:
tarball = make_tarball()
app = connect(APP_HOST, APP_USER, APP_PASS)
try:
run(app, f"mkdir -p {REMOTE_DIR}")
upload_file(app, tarball, "/tmp/torbatyar_hospitality_bundle.tar.gz")
code, _ = run(
app,
f"""
set -e
cd {REMOTE_DIR}
if [ -f .env ]; then cp .env /tmp/torbatyar.env.bak; fi
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs {REMOTE_DIR}/docker-compose.yml {REMOTE_DIR}/infrastructure 2>/dev/null || true
chmod -R u+rwX {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs 2>/dev/null || true
tar --overwrite -xzf /tmp/torbatyar_hospitality_bundle.tar.gz -C {REMOTE_DIR}
if [ -f /tmp/torbatyar.env.bak ]; then cp /tmp/torbatyar.env.bak .env; fi
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs 2>/dev/null || true
rm -f /tmp/torbatyar_hospitality_bundle.tar.gz
ls -la {REMOTE_DIR}/backend/services/hospitality | head
ls -la {REMOTE_DIR}/frontend/app/hospitality | head
""",
sudo=True,
password=APP_PASS,
timeout=300,
)
if code != 0:
raise RuntimeError("extract failed")
merge_env_remote(app)
run(
app,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c "docker compose exec -T postgres psql -U superapp -d postgres -tc \\"SELECT 1 FROM pg_database WHERE datname='hospitality_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE hospitality_db;'"
""",
timeout=120,
)
compose_up(app, "hospitality-service frontend")
run(
app,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c 'docker compose exec -T frontend npm install' || docker compose exec -T frontend npm install
sg docker -c 'docker compose restart frontend' || docker compose restart frontend
sleep 15
sg docker -c 'docker compose logs --tail=80 hospitality-service frontend' || docker compose logs --tail=80 hospitality-service frontend
""",
sudo=True,
password=APP_PASS,
timeout=1800,
)
code, health = run(
app,
"""
set -e
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf http://127.0.0.1:8009/health; then
echo
echo HOSPITALITY_HEALTH_OK
exit 0
fi
sleep 3
done
curl -sv http://127.0.0.1:8009/health || true
exit 1
""",
timeout=120,
)
if code != 0:
raise RuntimeError(f"Hospitality health failed: {health}")
code, fe = run(
app,
"""
curl -s -o /dev/null -w 'hospitality_hub=%{http_code}\n' http://127.0.0.1:3000/hospitality/hub
curl -s -o /dev/null -w 'hospitality_root=%{http_code}\n' http://127.0.0.1:3000/hospitality
""",
timeout=60,
)
if code != 0:
raise RuntimeError(f"Frontend hospitality route failed: {fe}")
finally:
app.close()
ngx = connect(NGX_HOST, NGX_USER, NGX_PASS)
try:
conf = (ROOT / "infrastructure" / "nginx" / "torbatyar.ir.conf").read_text(
encoding="utf-8"
)
put_text_sudo(ngx, NGX_PASS, "/etc/nginx/conf.d/torbatyar.ir.conf", conf)
certbot = r"""
set -e
mkdir -p /var/www/html
certbot certonly --webroot -w /var/www/html \
-d torbatyar.ir -d www.torbatyar.ir \
-d api.torbatyar.ir -d identity.torbatyar.ir -d auth.torbatyar.ir \
-d accounting.torbatyar.ir -d crm.torbatyar.ir -d loyalty.torbatyar.ir \
-d healthcare.torbatyar.ir -d beauty.torbatyar.ir -d hospitality.torbatyar.ir \
--non-interactive --agree-tos --email support@torbatyar.ir --expand || true
nginx -t && systemctl reload nginx && echo NGINX_RELOADED
"""
code, _ = run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300)
if code != 0:
raise RuntimeError("nginx reload / certbot failed")
finally:
ngx.close()
print("\n=== HOSPITALITY DEPLOY FINISHED ===")
print("API: https://hospitality.torbatyar.ir/health")
print("Hub: https://torbatyar.ir/hospitality/hub")
print("Local API: http://192.168.10.162:8009/health")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"FATAL: {exc}", file=sys.stderr)
raise