diff --git a/backend/services/hospitality/alembic/versions/0002_phase_121_digital_menu_catalog.py b/backend/services/hospitality/alembic/versions/0002_phase_121_digital_menu_catalog.py new file mode 100644 index 0000000..85277dc --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0002_phase_121_digital_menu_catalog.py @@ -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) diff --git a/backend/services/hospitality/alembic/versions/0003_phase_122_qr_menu_ordering.py b/backend/services/hospitality/alembic/versions/0003_phase_122_qr_menu_ordering.py new file mode 100644 index 0000000..138a070 --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0003_phase_122_qr_menu_ordering.py @@ -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) diff --git a/backend/services/hospitality/alembic/versions/0004_phase_123_table_service_reservations.py b/backend/services/hospitality/alembic/versions/0004_phase_123_table_service_reservations.py new file mode 100644 index 0000000..3512dc2 --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0004_phase_123_table_service_reservations.py @@ -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) diff --git a/backend/services/hospitality/alembic/versions/0005_phase_124_pos_lite.py b/backend/services/hospitality/alembic/versions/0005_phase_124_pos_lite.py new file mode 100644 index 0000000..658d776 --- /dev/null +++ b/backend/services/hospitality/alembic/versions/0005_phase_124_pos_lite.py @@ -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) diff --git a/frontend/docs/hospitality-api-connection-report.md b/frontend/docs/hospitality-api-connection-report.md new file mode 100644 index 0000000..03845f4 --- /dev/null +++ b/frontend/docs/hospitality-api-connection-report.md @@ -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 diff --git a/frontend/docs/hospitality-crud-report.md b/frontend/docs/hospitality-crud-report.md new file mode 100644 index 0000000..6381aa7 --- /dev/null +++ b/frontend/docs/hospitality-crud-report.md @@ -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 | diff --git a/frontend/docs/hospitality-page-completion-report.md b/frontend/docs/hospitality-page-completion-report.md new file mode 100644 index 0000000..47f244c --- /dev/null +++ b/frontend/docs/hospitality-page-completion-report.md @@ -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 diff --git a/frontend/docs/hospitality-ui-validation.md b/frontend/docs/hospitality-ui-validation.md new file mode 100644 index 0000000..93a99de --- /dev/null +++ b/frontend/docs/hospitality-ui-validation.md @@ -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`. diff --git a/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx b/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx index 54cdda0..585ed4b 100644 --- a/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx +++ b/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx @@ -1,48 +1,84 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm, type FieldValues, type DefaultValues } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Button, Dialog, + Drawer, Input, EmptyState, FormField, ConfirmDialog, Select, + Textarea, + Badge, } from "@/components/ds"; -import { HospitalityTablePage } from "@/modules/hospitality/design-system"; -import { HospitalityPageLoader, HospitalityPageError, exportToCsv } from "@/modules/hospitality/pages/shared"; +import { + HospitalityTablePage, + HospitalityStatGrid, +} from "@/modules/hospitality/design-system"; +import { + HospitalityPageLoader, + HospitalityPageError, + exportToCsv, +} from "@/modules/hospitality/pages/shared"; import { useTenantId } from "@/hooks/useTenantId"; import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities"; +import { useHospitalityVenues } from "@/modules/hospitality/hooks/useHospitalityVenues"; import { HospitalityStatusChip } from "@/modules/hospitality/design-system"; import { HospitalityBundleGate } from "@/modules/hospitality/components/HospitalityBundleGate"; +import { countByStatus } from "@/modules/hospitality/constants/fieldHelpers"; export type HospitalityFieldConfig = { name: string; label: string; - type?: "text" | "number" | "select"; + type?: "text" | "number" | "email" | "tel" | "textarea" | "select" | "venue" | "datetime-local"; options?: { value: string; label: string }[]; required?: boolean; + createOnly?: boolean; + editOnly?: boolean; + placeholder?: string; }; export type HospitalityColumnConfig = { key: string; header: string; - type?: "status" | "datetime" | "text"; + type?: "status" | "datetime" | "text" | "money"; render?: (row: Record) => React.ReactNode; + searchable?: boolean; + sortable?: boolean; + defaultVisible?: boolean; +}; + +export type HospitalityRowAction = { + label: string; + onClick: (tenantId: string, row: Record) => Promise; + variant?: "default" | "outline" | "danger"; }; type ApiResource = { list: (tenantId: string, params?: { page?: number; page_size?: number }) => Promise[]>; + get?: (tenantId: string, id: string) => Promise>; create?: (tenantId: string, body: Record) => Promise; update?: (tenantId: string, id: string, body: Record) => Promise; remove?: (tenantId: string, id: string) => Promise; + upsert?: (tenantId: string, body: Record) => Promise; }; export type HospitalityListConfig = { @@ -57,14 +93,26 @@ export type HospitalityListConfig = { createFields?: HospitalityFieldConfig[]; editFields?: HospitalityFieldConfig[]; filterDeleted?: boolean; + listFilter?: (row: Record) => 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[]) { const shape: Record = {}; for (const f of fields) { - shape[f.name] = f.required - ? z.string().min(1, `${f.label} الزامی است`) - : z.string().optional(); + if (f.type === "number") { + shape[f.name] = f.required ? z.coerce.number() : z.coerce.number().optional(); + } else { + shape[f.name] = f.required + ? z.string().min(1, `${f.label} الزامی است`) + : z.string().optional(); + } } return z.object(shape); } @@ -80,18 +128,85 @@ function renderCell(col: HospitalityColumnConfig, row: Record) return String(val); } } + if (col.type === "money" && val != null) { + return `${Number(val).toLocaleString("fa-IR")} ${row.currency_code ?? "IRR"}`; + } return val != null ? String(val) : "—"; } +function renderField( + field: HospitalityFieldConfig, + register: ReturnType["register"], + mode: "create" | "edit", + venues: Record[] +) { + if (mode === "create" && field.editOnly) return null; + if (mode === "edit" && field.createOnly) return null; + + if (field.type === "textarea") { + return ( + +