From 87bed9f5cabcdb3a4b9622f6207cdf8c3f11ad95 Mon Sep 17 00:00:00 2001 From: laithanhhung Date: Wed, 22 Jul 2026 00:43:51 +0700 Subject: [PATCH 1/4] fix(deploy): stop nginx serving stale translations, pass config to the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed builds rendered raw i18n keys (`admin.categories.map.open`) beside strings that translated fine. The file on the server was correct — the browser was reusing an old copy of it. nginx set no Cache-Control at all, so browsers fell back to guessing a lifetime from Last-Modified. Angular fingerprints its JS/CSS, so those always refreshed; `i18n/vi.json` is fetched from a fixed path, so a stale copy could sit in the cache for hours. New code, previous deploy's strings. index.html had the same exposure, which pins the whole app to an older bundle. Caching is now stated rather than guessed: fingerprinted JS/CSS immutable for a year, index.html and /i18n/ revalidated on every request (the ETag makes that a 304 with no body), other unhashed assets a day. Also repairs the deploy path for the required API_BASE_URL introduced in the previous commit. `.dockerignore` did not exclude `.env`, so an image would either bake real config into a layer or — with no file in the CI context — fail the build. `.env` is now out of the build context and the values arrive as build args, which `generate-env.mjs` already prefers over any file. They are set only in the discarded builder stage, so they do not reach the final image's metadata. Verified against a real image: nginx config passes `nginx -t`; a build without API_BASE_URL fails with the fix in the message; a build with args bakes the values into the bundle, serves the intended Cache-Control per path, and leaks nothing into `docker history`. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 8 ++++++++ Dockerfile | 18 ++++++++++++++++++ nginx.conf | 35 ++++++++++++++++++++++++++++++++--- scripts/generate-env.mjs | 5 ++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index eb67077..8387ff3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,11 @@ dist .git .github *.md + +# Never copy real config into an image layer — values are passed as build args +# (see Dockerfile). The template is harmless but has no use in the build. +.env +.env.* +!.env.example +src/environments/env.generated.ts +src/environments/environment.local.ts diff --git a/Dockerfile b/Dockerfile index b743174..faa77e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,24 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . + +# Config for the browser bundle. `.env` is excluded from the build context (see +# .dockerignore) so secrets never end up in an image layer — the values are +# passed in instead, and `scripts/generate-env.mjs` prefers the environment over +# any file. API_BASE_URL has no default: a build without it fails here rather +# than shipping a bundle whose requests resolve against the page URL. +# +# docker build \ +# --build-arg API_BASE_URL=https://api.example.com \ +# --build-arg GOONG_MAPS_KEY=... \ +# --build-arg GOONG_PLACES_KEY=... . +ARG API_BASE_URL +ARG GOONG_MAPS_KEY +ARG GOONG_PLACES_KEY +ENV API_BASE_URL=$API_BASE_URL \ + GOONG_MAPS_KEY=$GOONG_MAPS_KEY \ + GOONG_PLACES_KEY=$GOONG_PLACES_KEY + RUN npm run build -- --configuration production # Stage 2: Serve diff --git a/nginx.conf b/nginx.conf index cd436e2..58e149b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,10 +3,39 @@ server { root /usr/share/nginx/html; index index.html; + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Angular fingerprints its JS/CSS, so a changed file always has a new name. + # Those can be cached forever; anything whose name is stable must not be, + # or a deploy ships new code against assets the browser kept. + location ~* \.(js|css)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Translations are fetched at runtime from a fixed path, so the filename + # gives the browser no way to notice a new build. Without an explicit + # Cache-Control the browser guesses a lifetime from Last-Modified and can + # serve a stale copy for hours — which renders the raw i18n keys of any + # string added since. `no-cache` means revalidate, not "don't store": the + # ETag turns the check into a 304 with no body. + location /i18n/ { + add_header Cache-Control "no-cache"; + } + + # index.html names the hashed bundles, so a stale copy pins the whole app to + # the previous deploy. Always revalidate. + location = /index.html { + add_header Cache-Control "no-cache"; + } + + # Remaining static files (icons, fonts, images) are unhashed too. A day is + # long enough to be worth caching and short enough that a fix propagates. + location ~* \.(svg|png|jpe?g|gif|ico|webp|woff2?|ttf|eot)$ { + add_header Cache-Control "public, max-age=86400, must-revalidate"; + } + location / { try_files $uri $uri/ /index.html; } - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; } diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 0182cd6..efe00cb 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -80,7 +80,10 @@ if (missingRequired.length) { `✖ Missing required config: ${missingRequired.join(', ')}\n` + (existsSync(envFile) ? ` Set it in ${relativeEnvFile}, or pass it in the environment.` - : ` No ${relativeEnvFile} found. Run: cp .env.example .env`) + : ` No ${relativeEnvFile} found.\n` + + ` Locally: cp .env.example .env\n` + + ` In CI/Docker: pass it in the environment ` + + `(docker build --build-arg API_BASE_URL=…).`) ); process.exit(1); } From 495488346d03a1382018d1736895be61adcb0d4c Mon Sep 17 00:00:00 2001 From: laithanhhung Date: Thu, 23 Jul 2026 19:51:39 +0700 Subject: [PATCH 2/4] feat(admin,restaurant): add missing admin APIs and restaurant onboarding Restaurant onboarding (M2, /profile), spec-driven under specs/001-restaurant-onboarding: - self-service business-profile page: name/address/contact/receiving-window form with cross-field window validation, approval-status gate, save toasts - lazy /profile route (restaurant-gated), storefront nav item, wired user menu Admin > Restaurants -- surface RestaurantCredit endpoints that had no UI: - credit statements list + monthly statement generation + PDF download - credit transactions ledger Admin > Categories: - status (active/inactive) filter and a shared "clear all filters" button on the config-driven CRUD screen Backend rejection surfacing: - shared apiErrorMessage now reads the reason from BOTH the typed ApiError subclasses (401/403/5xx) and the generated ResponseError (4xx); wired into the admin CRUD screen and the restaurant profile form so permission/ validation/conflict reasons show in the toast instead of a generic message Contract: - add PATCH /categories/{id}/activate to the generated client and use it for category reactivate (replacing the ignored PUT+isActive workaround); refresh the openapi.json snapshot - fix stale ignore paths (src/api -> src/contract) in .prettierignore and eslint.config.js so the generated client/spec are excluded as intended Verified: lint, prettier, 53 unit tests, production build all green. Co-Authored-By: Claude Opus 4.8 --- .prettierignore | 4 +- .specify/feature.json | 2 +- CLAUDE.md | 3 +- eslint.config.js | 2 +- public/i18n/en.json | 40 +- public/i18n/vi.json | 40 +- .../checklists/requirements.md | 39 + .../contracts/restaurant-profile-api.md | 64 + specs/001-restaurant-onboarding/data-model.md | 84 + specs/001-restaurant-onboarding/plan.md | 120 + specs/001-restaurant-onboarding/quickstart.md | 54 + specs/001-restaurant-onboarding/research.md | 94 + specs/001-restaurant-onboarding/spec.md | 193 + specs/001-restaurant-onboarding/tasks.md | 272 + src/app/app.routes.ts | 5 + src/app/core/api/envelope.spec.ts | 51 +- src/app/core/api/envelope.ts | 53 + src/app/core/navigation/navigation.data.ts | 9 + .../layout/common/user/user.component.html | 2 +- src/app/layout/common/user/user.component.ts | 3 +- src/app/modules/admin/admin.service.ts | 113 +- src/app/modules/admin/admin.types.ts | 37 + .../admin/catalog/catalog-admin.service.ts | 27 +- .../admin/catalog/categories.component.ts | 24 + .../restaurants-admin.component.html | 193 + .../restaurants-admin.component.ts | 99 +- .../admin/shared/resource-crud.component.html | 15 + .../admin/shared/resource-crud.component.ts | 46 +- .../business-profile-form.component.html | 95 + .../business-profile-form.component.ts | 152 + .../pickup-window.validator.spec.ts | 44 + .../pickup-window.validator.ts | 49 + .../modules/restaurant/profile.component.html | 28 + .../modules/restaurant/profile.component.scss | 3 + .../modules/restaurant/profile.component.ts | 22 + src/app/modules/restaurant/profile.routes.ts | 16 + .../restaurant-profile.service.spec.ts | 65 + .../restaurant/restaurant-profile.service.ts | 51 + .../restaurant/restaurant-profile.types.ts | 49 + src/contract/generated/apis/CategoriesApi.ts | 71 + src/contract/openapi.json | 18704 ++++++++-------- 41 files changed, 11848 insertions(+), 9189 deletions(-) create mode 100644 specs/001-restaurant-onboarding/checklists/requirements.md create mode 100644 specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md create mode 100644 specs/001-restaurant-onboarding/data-model.md create mode 100644 specs/001-restaurant-onboarding/plan.md create mode 100644 specs/001-restaurant-onboarding/quickstart.md create mode 100644 specs/001-restaurant-onboarding/research.md create mode 100644 specs/001-restaurant-onboarding/spec.md create mode 100644 specs/001-restaurant-onboarding/tasks.md create mode 100644 src/app/modules/restaurant/business-profile/business-profile-form.component.html create mode 100644 src/app/modules/restaurant/business-profile/business-profile-form.component.ts create mode 100644 src/app/modules/restaurant/business-profile/pickup-window.validator.spec.ts create mode 100644 src/app/modules/restaurant/business-profile/pickup-window.validator.ts create mode 100644 src/app/modules/restaurant/profile.component.html create mode 100644 src/app/modules/restaurant/profile.component.scss create mode 100644 src/app/modules/restaurant/profile.component.ts create mode 100644 src/app/modules/restaurant/profile.routes.ts create mode 100644 src/app/modules/restaurant/restaurant-profile.service.spec.ts create mode 100644 src/app/modules/restaurant/restaurant-profile.service.ts create mode 100644 src/app/modules/restaurant/restaurant-profile.types.ts diff --git a/.prettierignore b/.prettierignore index 8579599..dac684a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,5 @@ src/@fuse/styles/user-themes.scss # Generated API client + spec snapshot — never hand-format (npm run generate:api) -src/api/generated -src/api/openapi.json +src/contract/generated +src/contract/openapi.json diff --git a/.specify/feature.json b/.specify/feature.json index 3f8b600..81e15e3 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-photo-albums" + "feature_directory": "specs/001-restaurant-onboarding" } diff --git a/CLAUDE.md b/CLAUDE.md index f9e73ce..329f83e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ For additional context about technologies to be used, project structure, -shell commands, and other important information, read the current plan +shell commands, and other important information, read the current plan: +`specs/001-restaurant-onboarding/plan.md` # FreshFlow Web — Agent Guide diff --git a/eslint.config.js b/eslint.config.js index 2e12cf8..1327d0f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ const angular = require("angular-eslint"); module.exports = defineConfig([ { // Generated API client — never linted (re-run npm run generate:api). - ignores: ["src/api/generated/**"], + ignores: ["src/contract/generated/**"], }, { linterOptions: { diff --git a/public/i18n/en.json b/public/i18n/en.json index 4643b07..05d644a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -328,6 +328,25 @@ "admin.restaurants.settle.note": "Note", "admin.restaurants.settle.submit": "Confirm settlement", "admin.restaurants.settle.success": "Credit settlement recorded", + "admin.restaurants.statements.title": "Credit statements", + "admin.restaurants.statements.year": "Year", + "admin.restaurants.statements.month": "Month", + "admin.restaurants.statements.generateSubmit": "Generate statement", + "admin.restaurants.statements.generateSuccess": "Statement generated", + "admin.restaurants.statements.period": "Period", + "admin.restaurants.statements.opening": "Opening balance", + "admin.restaurants.statements.closing": "Closing balance", + "admin.restaurants.statements.charges": "Charges", + "admin.restaurants.statements.payments": "Payments", + "admin.restaurants.statements.pdf": "Download PDF", + "admin.restaurants.statements.empty": "No statements yet", + "admin.restaurants.transactions.title": "Credit transactions", + "admin.restaurants.transactions.date": "Date", + "admin.restaurants.transactions.type": "Type", + "admin.restaurants.transactions.amount": "Amount", + "admin.restaurants.transactions.balance": "Balance after", + "admin.restaurants.transactions.description": "Description", + "admin.restaurants.transactions.empty": "No transactions yet", "admin.crud.search": "Search", "admin.crud.edit": "Edit", "admin.crud.remove": "Remove", @@ -348,6 +367,7 @@ "admin.crud.errors.maxLength": "Must be at most {{max}} characters", "admin.crud.errors.min": "Must be at least {{min}}", "admin.crud.filterAll": "All", + "admin.crud.clearFilters": "Clear all filters", "admin.crud.image.upload": "Upload image", "admin.crud.image.hint": "Drag & drop or click to browse", "admin.crud.image.change": "Change image", @@ -516,5 +536,23 @@ "admin.crud.reactivate": "Reactivate", "admin.crud.reactivateSuccess": "Reactivated.", "admin.crud.reactivateIgnored": "The server accepted the request but the item is still inactive — the API may not support reactivating yet.", - "admin.crud.missingIdError": "Cannot save: this record has no id in the API response. Reload the page; if it persists, report it — saving would create a duplicate." + "admin.crud.missingIdError": "Cannot save: this record has no id in the API response. Reload the page; if it persists, report it — saving would create a duplicate.", + "restaurantProfile.title": "Restaurant profile", + "restaurantProfile.subtitle": "Keep your business details up to date so we can serve and deliver to you.", + "restaurantProfile.profile.sectionTitle": "Business details", + "restaurantProfile.profile.sectionSubtitle": "Name, address, contact, and the daily window when you can receive deliveries.", + "restaurantProfile.profile.name": "Business name", + "restaurantProfile.profile.namePlaceholder": "e.g. Green Garden Restaurant", + "restaurantProfile.profile.nameRequired": "Business name is required.", + "restaurantProfile.profile.address": "Address", + "restaurantProfile.profile.contactPerson": "Contact person", + "restaurantProfile.profile.pickupWindow": "Receiving window", + "restaurantProfile.profile.pickupStart": "From", + "restaurantProfile.profile.pickupEnd": "To", + "restaurantProfile.profile.pickupIncomplete": "Please provide both a start and an end time.", + "restaurantProfile.profile.pickupEndBeforeStart": "The end time must be after the start time.", + "restaurantProfile.profile.save": "Save changes", + "restaurantProfile.profile.saved": "Profile updated.", + "restaurantProfile.profile.saveError": "Could not save your profile. Please try again.", + "restaurantProfile.profile.loadError": "Could not load your profile. Please refresh to try again." } diff --git a/public/i18n/vi.json b/public/i18n/vi.json index ffc81cb..dc7f3c1 100644 --- a/public/i18n/vi.json +++ b/public/i18n/vi.json @@ -328,6 +328,25 @@ "admin.restaurants.settle.note": "Ghi chú", "admin.restaurants.settle.submit": "Xác nhận thanh toán", "admin.restaurants.settle.success": "Đã ghi nhận thanh toán công nợ", + "admin.restaurants.statements.title": "Sao kê công nợ", + "admin.restaurants.statements.year": "Năm", + "admin.restaurants.statements.month": "Tháng", + "admin.restaurants.statements.generateSubmit": "Tạo sao kê", + "admin.restaurants.statements.generateSuccess": "Đã tạo sao kê", + "admin.restaurants.statements.period": "Kỳ", + "admin.restaurants.statements.opening": "Số dư đầu kỳ", + "admin.restaurants.statements.closing": "Số dư cuối kỳ", + "admin.restaurants.statements.charges": "Phát sinh nợ", + "admin.restaurants.statements.payments": "Đã thanh toán", + "admin.restaurants.statements.pdf": "Tải PDF", + "admin.restaurants.statements.empty": "Chưa có sao kê", + "admin.restaurants.transactions.title": "Giao dịch công nợ", + "admin.restaurants.transactions.date": "Ngày", + "admin.restaurants.transactions.type": "Loại", + "admin.restaurants.transactions.amount": "Số tiền", + "admin.restaurants.transactions.balance": "Số dư sau", + "admin.restaurants.transactions.description": "Diễn giải", + "admin.restaurants.transactions.empty": "Chưa có giao dịch", "admin.crud.search": "Tìm kiếm", "admin.crud.edit": "Sửa", "admin.crud.remove": "Xóa", @@ -348,6 +367,7 @@ "admin.crud.errors.maxLength": "Tối đa {{max}} ký tự", "admin.crud.errors.min": "Phải lớn hơn hoặc bằng {{min}}", "admin.crud.filterAll": "Tất cả", + "admin.crud.clearFilters": "Xóa hết lọc", "admin.crud.image.upload": "Tải ảnh lên", "admin.crud.image.hint": "Kéo thả hoặc bấm để chọn ảnh", "admin.crud.image.change": "Đổi ảnh", @@ -516,5 +536,23 @@ "admin.crud.reactivate": "Kích hoạt lại", "admin.crud.reactivateSuccess": "Đã kích hoạt lại.", "admin.crud.reactivateIgnored": "Server nhận yêu cầu nhưng mục này vẫn đang ngừng hoạt động — có thể API chưa hỗ trợ kích hoạt lại.", - "admin.crud.missingIdError": "Không lưu được: bản ghi này không có id trong dữ liệu API trả về. Hãy tải lại trang; nếu vẫn vậy thì báo lại — lưu tiếp sẽ tạo bản ghi trùng." + "admin.crud.missingIdError": "Không lưu được: bản ghi này không có id trong dữ liệu API trả về. Hãy tải lại trang; nếu vẫn vậy thì báo lại — lưu tiếp sẽ tạo bản ghi trùng.", + "restaurantProfile.title": "Hồ sơ nhà hàng", + "restaurantProfile.subtitle": "Cập nhật thông tin nhà hàng để chúng tôi phục vụ và giao hàng cho bạn.", + "restaurantProfile.profile.sectionTitle": "Thông tin nhà hàng", + "restaurantProfile.profile.sectionSubtitle": "Tên, địa chỉ, người liên hệ và khung giờ nhận hàng hằng ngày.", + "restaurantProfile.profile.name": "Tên nhà hàng", + "restaurantProfile.profile.namePlaceholder": "VD: Nhà hàng Vườn Xanh", + "restaurantProfile.profile.nameRequired": "Vui lòng nhập tên nhà hàng.", + "restaurantProfile.profile.address": "Địa chỉ", + "restaurantProfile.profile.contactPerson": "Người liên hệ", + "restaurantProfile.profile.pickupWindow": "Khung giờ nhận hàng", + "restaurantProfile.profile.pickupStart": "Từ", + "restaurantProfile.profile.pickupEnd": "Đến", + "restaurantProfile.profile.pickupIncomplete": "Vui lòng nhập cả giờ bắt đầu và giờ kết thúc.", + "restaurantProfile.profile.pickupEndBeforeStart": "Giờ kết thúc phải sau giờ bắt đầu.", + "restaurantProfile.profile.save": "Lưu thay đổi", + "restaurantProfile.profile.saved": "Đã cập nhật hồ sơ.", + "restaurantProfile.profile.saveError": "Không thể lưu hồ sơ. Vui lòng thử lại.", + "restaurantProfile.profile.loadError": "Không thể tải hồ sơ. Vui lòng tải lại trang." } diff --git a/specs/001-restaurant-onboarding/checklists/requirements.md b/specs/001-restaurant-onboarding/checklists/requirements.md new file mode 100644 index 0000000..23145d0 --- /dev/null +++ b/specs/001-restaurant-onboarding/checklists/requirements.md @@ -0,0 +1,39 @@ +# Specification Quality Checklist: Restaurant Onboarding & Profile + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`. +- Spec keeps API operation names out of the requirement statements; the mapping to the + existing folder-04 endpoints and generated client is recorded in Assumptions/Dependencies + and belongs to the planning phase. +- No `[NEEDS CLARIFICATION]` markers were needed: the folder-04 flow, PRD §M2, BR-AUTH-1, + and SITEMAP `/profile` gave reasonable defaults for every open decision. diff --git a/specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md b/specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md new file mode 100644 index 0000000..ad92117 --- /dev/null +++ b/specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md @@ -0,0 +1,64 @@ +# Contract: UI ↔ RestaurantProfileApi + +The feature consumes only the existing generated `restaurantProfileApi` singleton +(`import { restaurantProfileApi } from 'contract'`). No new backend endpoints. All responses +use the `{ success, data }` envelope; unwrap before use. Bearer/base-URL/error handling come +from the shared `apiConfiguration`. + +## Operations used + +| UI action | Generated method | HTTP | Request model | Response (provisional) | +|-----------|------------------|------|---------------|------------------------| +| Load business profile | `apiV1RestaurantsMeProfileGet` / `...GetRaw` | GET `/api/v1/restaurants/me/profile` | — | `RestaurantProfileView` | +| Save business profile | `apiV1RestaurantsMeProfilePut` | PUT `/api/v1/restaurants/me/profile` | `UpdateRestaurantProfileRequest` | 200 | +| List delivery addresses | `apiV1RestaurantsMeDeliveryAddressesGet` / `...GetRaw` | GET `/api/v1/restaurants/me/delivery-addresses` | — | `DeliveryAddressView[]` | +| Add delivery address | `apiV1RestaurantsMeDeliveryAddressesPost` | POST `/api/v1/restaurants/me/delivery-addresses` | `DeliveryAddressRequest` | created; capture `id` | +| Edit delivery address | `apiV1RestaurantsMeDeliveryAddressesIdPut` | PUT `/api/v1/restaurants/me/delivery-addresses/{id}` | `DeliveryAddressRequest` | 200 | +| Delete delivery address | `apiV1RestaurantsMeDeliveryAddressesIdDelete` | DELETE `/api/v1/restaurants/me/delivery-addresses/{id}` | — | 200 | +| Mint license upload signature | `apiV1RestaurantsMeBusinessLicenseUploadSignaturePostRaw` | POST `/api/v1/restaurants/me/business-license/upload-signature` | — | Cloudinary signed params | +| Approval status | (via `AuthService`) `apiV1RestaurantsMeApprovalStatusGetRaw` | GET `/api/v1/restaurants/me/approval-status` | — | `{ status }` → normalized enum | + +Set-default is expressed through the create/edit request's `isDefault: true` (there is no +dedicated set-default endpoint); after any default-changing mutation the client re-lists. + +## Provisional response types (`restaurant-profile.types.ts`) + +```ts +export interface RestaurantProfileView { + name: string; + address?: string | null; + contactPerson?: string | null; + pickupStart?: string | null; // 'HH:mm:ss' + pickupEnd?: string | null; // 'HH:mm:ss' + businessLicenseUrl?: string | null; +} + +export interface DeliveryAddressView { + id: string; + addressLine: string; + recipientName?: string | null; + phone?: string | null; + latitude?: number | null; + longitude?: number | null; + isDefault?: boolean; +} + +export interface BusinessLicenseSignature { + cloudName: string; + apiKey: string; + timestamp: number; + signature: string; + folder: string; +} +``` + +These are provisional (the backend OpenAPI does not yet publish GET response schemas). When the +backend adds them and the client is regenerated, replace these with the generated models. + +## Error handling contract + +- `401` → handled globally by `apiConfiguration` (session refresh / sign-in). +- `403` → surface a permission message; do not crash (RBAC is server-authoritative, BR-AUTH-4). +- `4xx/5xx` on save → keep the user's entered form values and show a retryable error. +- Read failure → retryable empty/error state; no data loss. +- Upload failure → previously stored `businessLicenseUrl` left unchanged. diff --git a/specs/001-restaurant-onboarding/data-model.md b/specs/001-restaurant-onboarding/data-model.md new file mode 100644 index 0000000..c4f83b4 --- /dev/null +++ b/specs/001-restaurant-onboarding/data-model.md @@ -0,0 +1,84 @@ +# Phase 1 Data Model: Restaurant Onboarding & Profile + +Client-side view/DTO shapes. Request bodies reuse the generated models; GET responses are +declared locally in `restaurant-profile.types.ts` because the generated client types them as +`void` (backend OpenAPI omits response schemas). All persistence is server-side. + +## Entity: RestaurantProfile + +The restaurant's business identity and operating details. + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `name` | string | yes | Business name. Non-empty. | +| `address` | string \| null | no | Business address (free text). | +| `contactPerson` | string \| null | no | Named contact. | +| `pickupStart` | string \| null | no | `HH:mm:ss` time-of-day; receiving-window start. | +| `pickupEnd` | string \| null | no | `HH:mm:ss`; receiving-window end. | +| `businessLicenseUrl` | string \| null | no | Hosted image URL (Cloudinary `secure_url`). | + +- **Write model**: `UpdateRestaurantProfileRequest` (generated) — exact field parity. +- **Read**: `GET /api/v1/restaurants/me/profile` → provisional `RestaurantProfileView` (same + fields; unwrapped from `{ success, data }`). +- **Validation**: + - `name` required, trimmed non-empty. + - Receiving window: if either `pickupStart` or `pickupEnd` is set, both must be set and + `pickupEnd` strictly after `pickupStart` (`pickup-window.validator.ts`). + - `businessLicenseUrl` set only via a successful upload; never free-typed. + +## Entity: DeliveryAddress + +A place an order can be delivered to. + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `id` | string | (read) | Server-assigned; present on list/read, absent on create. | +| `addressLine` | string | yes | Street address. Non-empty. | +| `recipientName` | string \| null | no | Person receiving. | +| `phone` | string \| null | no | Contact phone. | +| `latitude` | number \| null | no | Geographic point (via LocationPicker). | +| `longitude` | number \| null | no | Paired with latitude. | +| `isDefault` | boolean | no | At most one default per restaurant. | + +- **Write model**: `DeliveryAddressRequest` (generated) for create (`POST`) and update (`PUT`). +- **Read**: `GET /api/v1/restaurants/me/delivery-addresses` → provisional + `DeliveryAddressView[]` (adds server `id`). +- **Validation**: + - `addressLine` required, trimmed non-empty. + - `recipientName`, `phone` — recommended; validated as non-empty when provided (phone format + kept lenient; VN numbers vary). + - `latitude`/`longitude` — optional but set together when the picker is used. +- **Invariant**: at most one `isDefault: true`. Server-owned; client re-lists after any + mutation that can change the default (set-default, add-with-default, delete-default). +- **State on delete-of-default**: after deleting the current default, the client re-lists and + renders whatever the server returns as authoritative (either no default or a reassigned one). + +## Entity: ApprovalStatus (read-only, external) + +Owned by the server; surfaced through `AuthService`/`UserService`, not re-fetched here. + +- **Type**: `'pending' | 'approved' | 'rejected'` (`user.types.ts`). +- **UI mapping**: + - `approved` → no gate; normal ordering allowed elsewhere. + - `pending` → inline explanation "awaiting admin approval"; ordering unavailable. + - `rejected` (or any non-approved/unknown) → inline explanation; ordering unavailable. +- **No write path** in this feature. + +## Entity: BusinessLicenseUpload (transient) + +Not persisted as an entity; a transient flow producing a URL. + +| Field | Type | Notes | +|-------|------|-------| +| `file` | File | Chosen image (client-only). | +| `secureUrl` | string | Cloudinary result → stored into `RestaurantProfile.businessLicenseUrl`. | + +- **Signature source**: `POST /api/v1/restaurants/me/business-license/upload-signature`. +- **Failure**: on any failure, keep the previously stored `businessLicenseUrl` unchanged. + +## Relationships + +- One `RestaurantProfile` per restaurant account (1:1, keyed by "me"/token). +- One restaurant → zero-or-more `DeliveryAddress` (1:N); ≤1 default. +- `ApprovalStatus` is a scalar attribute of the account, read-only here. +- `BusinessLicenseUpload` feeds `RestaurantProfile.businessLicenseUrl`. diff --git a/specs/001-restaurant-onboarding/plan.md b/specs/001-restaurant-onboarding/plan.md new file mode 100644 index 0000000..a94c405 --- /dev/null +++ b/specs/001-restaurant-onboarding/plan.md @@ -0,0 +1,120 @@ +# Implementation Plan: Restaurant Onboarding & Profile + +**Branch**: `001-restaurant-onboarding` | **Date**: 2026-07-22 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-restaurant-onboarding/spec.md` + +## Summary + +Deliver the restaurant-facing self-service profile area at route `/profile` (M2) inside the +enterprise/storefront chrome. It lets an authenticated restaurant view and edit its business +profile (name, address, contact person, receiving/pickup window, business-license image), +manage its delivery addresses (list / add / edit / delete / set-default with a single-default +invariant), and see its approval status with the BR-AUTH-1 ordering gate explained inline. + +The backend surface already exists as the generated `RestaurantProfileApi` (Postman folder 04), +approval status is already fetched into the user signal by `AuthService`, and an +`ApprovalBannerComponent` and `LocationPickerComponent` are already in the codebase. This +feature is therefore almost entirely a new lazy-loaded Angular feature module plus one route, +one nav entry, and Transloco strings — reusing existing services and the signed-upload pattern +rather than adding new infrastructure. + +## Technical Context + +**Language/Version**: TypeScript 5.x (strict), Angular 22 (standalone, signals, new control flow) + +**Primary Dependencies**: Angular Material 22 + Fuse template; Transloco (i18n); generated +`typescript-fetch` OpenAPI client (`contract` barrel → `restaurantProfileApi`); Reactive Forms; +Goong maps (`LocationPickerComponent`) for the address point; Cloudinary signed upload (existing +pattern) for the license image. + +**Storage**: None client-side beyond Angular signals; all persistence is via the backend +`/api/v1/restaurants/me/*` endpoints. + +**Testing**: Jasmine + Karma unit tests (validators, service envelope unwrap, default-address +invariant). `npm run precheck` (lint → Prettier → tests → prod build) is the merge gate. + +**Target Platform**: Modern evergreen browsers; responsive within the enterprise layout. + +**Project Type**: Web application (single Angular frontend; backend is external and fixed). + +**Performance Goals**: Feature stays lazy-loaded; per-component styles ≤ 90 KB; no measurable +regression to the ≤ 3 MB warning / ≤ 5 MB error initial-bundle budget. + +**Constraints**: Strict TS, no `any` in new code; all user-facing text bilingual vi/en; RBAC +stays server-authoritative (handle `403` gracefully); reuse the `{ success, data }` envelope +unwrap convention. + +**Scale/Scope**: One route, one feature module (~3 sub-views: profile form, address list/editor, +license upload), one service, one nav item, two Transloco scopes (vi/en). Single-restaurant data. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Angular-First, Signal-Driven | PASS | Standalone components, signals, lazy route. No NgModules. | +| II. Real-Time by Default | PASS (N/A) | Profile/addresses are user-edited config, not live-pushed data. Approval status is read on load and already refreshed by `AuthService`; no SignalR channel required. | +| III. Type Safety (NON-NEGOTIABLE) | PASS | Generated request models (`UpdateRestaurantProfileRequest`, `DeliveryAddressRequest`) typed; local response types declared for the `void`-typed GETs (same approach as `CatalogService`). No `any`. | +| IV. Test Before Merge | PASS | Unit tests for validators + default-address invariant + envelope unwrap; `precheck` gate. | +| V. Bilingual UX | PASS | All labels/errors via Transloco vi/en; no hardcoded strings. | +| VI. Performance Budget | PASS | Lazy-loaded feature; Material components; styles under budget. | + +**Domain facts honored**: Self-registration + approval gate (BR-AUTH-1) — profile is editable +while `pending`/`rejected`, but ordering stays disabled and explained. B2B credit is a separate +feature (no checkout here). No invented business rules, thresholds, or endpoints. + +**Result**: No violations. Complexity Tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-restaurant-onboarding/ +├── plan.md # This file +├── spec.md # Feature spec +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── restaurant-profile-api.md # UI ↔ RestaurantProfileApi operation map +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +src/app/modules/restaurant/ # NEW lazy feature module (route: /profile) +├── profile.routes.ts # lazy route config for the /profile area +├── restaurant-profile.service.ts # data access over restaurantProfileApi (+ envelope unwrap, upload) +├── restaurant-profile.types.ts # local response/view types (GETs are void in the generated client) +├── profile.component.ts|html|scss # shell: tabs/sections — Business profile · Delivery addresses +├── business-profile/ +│ ├── business-profile-form.component.ts|html # name/address/contact/pickup window + license upload +│ └── pickup-window.validator.ts # end-after-start reactive-form validator (+ spec) +└── delivery-addresses/ + ├── delivery-address-list.component.ts|html # list + set-default + delete + └── delivery-address-editor.component.ts|html # add/edit dialog/form (uses LocationPickerComponent) + +src/app/app.routes.ts # EDIT: add `/profile` child under the storefront (OptionalAuthGuard) area, restaurant-gated +src/app/core/navigation/navigation.data.ts # EDIT: add storefront nav item id 'profile', roles: ['restaurant'] +src/app/layout/common/user/user.component.html # EDIT: wire the existing "Profile" menu button to routerLink="/profile" + +src/assets/i18n/ (or existing Transloco location) # EDIT: vi/en keys under a `restaurantProfile` scope +``` + +**Structure Decision**: Single Angular frontend (Option 2, frontend only — the backend is a +fixed external service consumed through the generated client). The feature is a new +self-contained lazy module `src/app/modules/restaurant/` mirroring the existing module +conventions (`catalog`, `admin/catalog`): a thin signal-based service wrapping the generated +API with envelope unwrap, standalone components, and a lazy `*.routes.ts`. Reused, not rebuilt: +`restaurantProfileApi`, `AuthService.approvalStatus`, `ApprovalBannerComponent`, +`LocationPickerComponent`, and the Cloudinary signed-upload flow from `CatalogAdminService`. + +## Complexity Tracking + +No constitution violations — section intentionally empty. diff --git a/specs/001-restaurant-onboarding/quickstart.md b/specs/001-restaurant-onboarding/quickstart.md new file mode 100644 index 0000000..d726214 --- /dev/null +++ b/specs/001-restaurant-onboarding/quickstart.md @@ -0,0 +1,54 @@ +# Quickstart: Restaurant Onboarding & Profile + +How to build, wire, and verify the `/profile` feature. Assumes the standard dev setup +(`npm install`, a reachable backend via `.env` `API_BASE_URL`, Node 24). + +## Build order (matches tasks.md once generated) + +1. **Types** — `src/app/modules/restaurant/restaurant-profile.types.ts`: `RestaurantProfileView`, + `DeliveryAddressView`, `BusinessLicenseSignature` (see contracts/). +2. **Service** — `restaurant-profile.service.ts`: signal-backed wrapper over + `restaurantProfileApi` with `unwrap()`; methods `loadProfile`, `saveProfile`, + `listAddresses`, `addAddress`, `updateAddress`, `deleteAddress`, `setDefaultAddress` + (re-lists after default-changing mutations), `uploadLicense(file)` (Cloudinary signed flow). +3. **Validator** — `business-profile/pickup-window.validator.ts` + spec: end strictly after + start; both-or-neither. +4. **Components** (standalone, signals, OnPush): + - `business-profile/business-profile-form.component` — profile fields + license upload + + `ApprovalBannerComponent` for non-approved accounts. + - `delivery-addresses/delivery-address-list.component` — list, set-default, delete. + - `delivery-addresses/delivery-address-editor.component` — add/edit form using + `LocationPickerComponent` for lat/lng. + - `profile.component` — shell hosting the two sections. +5. **Route** — `profile.routes.ts`; register lazily at `/profile` under the storefront area in + `app.routes.ts`. +6. **Nav + menu** — add nav item `id: 'profile'`, `roles: ['restaurant']` in + `navigation.data.ts`; set `routerLink="/profile"` on the Profile button in + `user.component.html`. +7. **i18n** — vi + en keys under a `restaurantProfile` Transloco scope for every label, hint, + validation message, and toast. + +## Manual verification (happy path — mirrors Postman folder 04) + +1. Sign in as a restaurant (`restaurant+*@freshflow.local`). +2. Open the user menu → **Profile** (or navigate to `/profile`). +3. Confirm the approval banner shows for a `pending` account and ordering stays gated. +4. Fill business name, address, contact, pickup window (e.g. 08:00–18:00); save; reload; values + persist. +5. Try an invalid window (end ≤ start) → inline error, save blocked. +6. Upload a business-license image (needs Cloudinary config) → thumbnail shows; save; reload. +7. Add a delivery address (address line, recipient, phone, pick a point) → appears in list. +8. Mark it default; add a second; switch default → only one default remains. +9. Edit then delete an address → list stays consistent; deleting the default leaves a + consistent state. + +## Automated checks + +- `npm test` — unit specs (pickup-window validator, envelope unwrap, default-address + reconciliation). +- `npm run precheck` — lint → Prettier → tests → prod build (the merge gate). Must be green. + +## Definition of done + +- All FR-001..FR-012 satisfied; SC-001..SC-005 demonstrable via the steps above. +- No `any`; no hardcoded user-facing strings; `/profile` lazy-loaded; `precheck` green. diff --git a/specs/001-restaurant-onboarding/research.md b/specs/001-restaurant-onboarding/research.md new file mode 100644 index 0000000..574512b --- /dev/null +++ b/specs/001-restaurant-onboarding/research.md @@ -0,0 +1,94 @@ +# Phase 0 Research: Restaurant Onboarding & Profile + +All open questions were resolvable from the existing codebase and specs; none remain as +NEEDS CLARIFICATION. Findings below drive the data model, contracts, and tasks. + +## R1 — Approval-status vocabulary (spec said PENDING_APPROVAL/APPROVED/SUSPENDED) + +- **Decision**: Use the vocabulary already in the client — `ApprovalStatus = 'pending' | + 'approved' | 'rejected'` (`src/app/core/user/user.types.ts`). The profile screen reads the + status from `AuthService`/`UserService` (already populated on load), not from a fresh call. +- **Rationale**: `AuthService._fetchApprovalStatus()` already calls + `restaurantProfileApi.apiV1RestaurantsMeApprovalStatusGetRaw()`, lowercases `data.status`, + and maps unknown → `pending`. The Postman collection's `PENDING_APPROVAL/APPROVED/SUSPENDED` + are variable labels, not the response contract; the live client normalizes to the three + lowercase states. Duplicating the fetch would risk drift. +- **Handling SUSPENDED**: The suspend/reactivate endpoints exist (admin, folder 90), but the + restaurant-facing status feed currently normalizes anything non-standard to `pending`. The + UI will render an explanation per state and treat any not-`approved` state as "ordering + unavailable", so a future `suspended`/`rejected` value degrades safely without a code change. +- **Alternatives considered**: Re-fetch approval status inside the feature service — rejected + (redundant with `AuthService`, causes two sources of truth). Introduce a `suspended` enum + member now — deferred (no restaurant-facing endpoint emits it yet; adding an unused state + would be speculative). +- **Spec impact**: `spec.md` wording (PENDING_APPROVAL/APPROVED/SUSPENDED) is treated as the + business intent; the implementation maps it to `pending/approved/rejected` and shows a + generic "account not active" explanation for any non-approved state. + +## R2 — Data access & response typing + +- **Decision**: Add `RestaurantProfileService` wrapping `restaurantProfileApi`, following + `CatalogService`/`CatalogAdminService`: call the generated typed methods for requests, use + the `*Raw` methods + a local `unwrap()` for the GET responses (which the generator types + as `void` because the backend OpenAPI omits response schemas), and declare provisional + response interfaces in `restaurant-profile.types.ts`. +- **Rationale**: This is the established, constitution-compliant pattern in the repo; keeps + strict typing with no `any` while the backend response schemas are unpublished. +- **Alternatives considered**: Hand-rolled `HttpClient` calls — rejected (bypasses the shared + base URL, bearer, and 401/403/5xx handling in `apiConfiguration`). + +## R3 — Business-license image upload + +- **Decision**: Reuse the Cloudinary signed-upload flow. Mint a signature via + `restaurantProfileApi.apiV1RestaurantsMeBusinessLicenseUploadSignaturePostRaw()`, POST the + file to Cloudinary, and store the returned `secure_url` in `businessLicenseUrl` on the next + profile save — identical in shape to `CatalogAdminService.uploadProductImage()`. +- **Rationale**: Same signed-upload contract; a single tested pattern already exists to copy. +- **Alternatives considered**: Direct multipart to the backend — rejected (no such endpoint; + the backend only issues a signature). +- **Dependency note**: Requires configured Cloudinary credentials (a documented manual + prerequisite, folder 90.14). The UI must handle a missing signature / failed upload + gracefully and leave any existing license unchanged. + +## R4 — Delivery-address geographic point + +- **Decision**: Capture `latitude`/`longitude` with the existing `LocationPickerComponent` + (`src/app/core/maps/location-picker.component.ts`), binding its required `latControl`/ + `lngControl` FormControls into the address editor form. +- **Rationale**: Component already exists and is the project's standard place/point picker + (Goong). No new mapping code. +- **Alternatives considered**: Free-text lat/lng inputs — rejected (poor UX, error-prone); + the model allows null coordinates, so the picker is used but coordinates are optional. + +## R5 — Single-default-address invariant + +- **Decision**: Enforce "at most one default" primarily server-side (the write endpoints own + it); the client optimistically reflects the new default and then reconciles with a re-list + after any set-default / add-as-default / delete-default action. +- **Rationale**: The server is authoritative (BR-AUTH-4); a re-list after mutation keeps the + list consistent without the client trying to out-guess server rules. +- **Alternatives considered**: Purely client-side toggling without re-list — rejected (drifts + from server truth if the backend reassigns defaults on delete). + +## R6 — Route, guard, and navigation placement + +- **Decision**: Add `/profile` as a child of the existing storefront area + (`OptionalAuthGuard`, `layout: 'enterprise'`) in `app.routes.ts`, lazy-loading + `modules/restaurant/profile.routes.ts`. Gate visibility/access to the restaurant role + (nav item `roles: ['restaurant']`; the component redirects/blocks non-restaurants). Wire the + already-present "Profile" button in `user.component.html` to `routerLink="/profile"`. +- **Rationale**: SITEMAP places `/profile` (M2) in the restaurant enterprise area; the nav + system gates by role via the existing `roles` field; the user menu already has an unwired + Profile entry. +- **Alternatives considered**: A dedicated `roleGuard(['restaurant'])` route branch like + `/admin` — deferred; the storefront area already restores the session and drives the + enterprise chrome, and role visibility is handled by the nav `roles` field, so a separate + guarded branch is unnecessary for a single self-service page. + +## R7 — Approval gate on ordering + +- **Decision**: Reuse `ApprovalBannerComponent` on the profile screen for non-approved + accounts (as `CatalogComponent` already does) and rely on the existing approval gating that + hides/disables ordering actions elsewhere; this feature adds no new ordering surface. +- **Rationale**: Gate already implemented and reused across the storefront; consistency. +- **Alternatives considered**: A bespoke banner — rejected (duplicates existing component). diff --git a/specs/001-restaurant-onboarding/spec.md b/specs/001-restaurant-onboarding/spec.md new file mode 100644 index 0000000..2626585 --- /dev/null +++ b/specs/001-restaurant-onboarding/spec.md @@ -0,0 +1,193 @@ +# Feature Specification: Restaurant Onboarding & Profile + +**Feature Branch**: `001-restaurant-onboarding` + +**Created**: 2026-07-22 + +**Status**: Draft + +**Input**: User description: "Restaurant onboarding & profile (M2, route /profile) for the restaurant role — view/edit business profile, manage delivery addresses, view approval status, upload business license. Restaurant-facing self-service only; admin approve/credit-limit out of scope. Follows Postman collection folder 04." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Complete and maintain the restaurant business profile (Priority: P1) + +A restaurant owner who has just self-registered signs in and lands on their profile +area. It shows their current approval status prominently and lets them fill in the +business details the platform needs before it can serve them: business name, address, +a contact person, and the daily receiving/pickup window during which deliveries can be +accepted. They edit these details, save, and see confirmation that the profile was +updated. + +**Why this priority**: This is the gate. A restaurant cannot be approved, and therefore +cannot order, until its business profile is complete. It is the minimum viable slice — +delivering just this screen already moves a restaurant from "registered" to "ready for +admin approval". + +**Independent Test**: Sign in as a restaurant, open the profile area, edit every business +field, save, reload the page, and confirm the saved values persist and the approval-status +indicator reflects the account's real state. + +**Acceptance Scenarios**: + +1. **Given** a signed-in restaurant with an incomplete profile, **When** they open the + profile area, **Then** they see their current business details (empty where unset) and + a clearly labelled approval-status indicator. +2. **Given** the profile form, **When** the restaurant edits the name, address, contact + person and receiving window and saves, **Then** the changes persist and a success + confirmation is shown. +3. **Given** a receiving window whose end time is not after its start time, **When** the + restaurant tries to save, **Then** the save is blocked with an inline, human-readable + explanation and no change is sent. +4. **Given** the account is `PENDING_APPROVAL`, **When** the restaurant views the profile, + **Then** an explanation states that ordering is unavailable until an administrator + approves the account. + +--- + +### User Story 2 - Manage delivery addresses (Priority: P2) + +The restaurant maintains the set of locations that orders can be delivered to. They can +view all saved addresses, add a new one (street address, recipient name, phone, and a map +point), edit an existing one, remove one that is no longer used, and mark exactly one as +the default delivery address. + +**Why this priority**: Orders must be delivered somewhere. At least one delivery address is +required for the restaurant to receive goods, but it is separable from the core business +profile and can ship immediately after P1. + +**Independent Test**: Sign in as a restaurant, add a delivery address, mark it default, +edit it, add a second and switch the default, then delete one — confirming the list and the +default selection stay consistent after each action. + +**Acceptance Scenarios**: + +1. **Given** the delivery-addresses view, **When** the restaurant adds an address with all + required fields, **Then** it appears in the list. +2. **Given** an existing address, **When** the restaurant marks it as default, **Then** it + becomes the sole default and any previously default address is no longer default. +3. **Given** an existing address, **When** the restaurant edits or deletes it, **Then** the + list reflects the change immediately. +4. **Given** a required field is missing or invalid (e.g. empty recipient name or phone), + **When** the restaurant tries to save, **Then** the save is blocked with an inline + explanation. + +--- + +### User Story 3 - Attach a business-license document (Priority: P3) + +The restaurant uploads an image of its business license so an administrator can verify the +business during approval. After a successful upload the license image is shown on the +profile and stored with the business profile. + +**Why this priority**: It strengthens the approval evidence and is expected by the approval +workflow, but a restaurant can still be created and reviewed without it, so it follows the +two core slices. + +**Independent Test**: Sign in as a restaurant, upload a license image from the profile +area, save, reload, and confirm the image is associated with the profile. + +**Acceptance Scenarios**: + +1. **Given** the profile area, **When** the restaurant selects a valid image to upload, + **Then** the image is stored and shown as the current business license. +2. **Given** an upload that fails (network or rejected file), **When** it happens, **Then** + the restaurant sees a clear error and the previously stored license, if any, is + unchanged. + +--- + +### Edge Cases + +- **Approval gate enforcement**: a `PENDING_APPROVAL` or `SUSPENDED` restaurant may open and + edit its profile and addresses, but ordering actions remain unavailable with an inline + explanation (per BR-AUTH-1; the server remains authoritative and any `403` is handled + gracefully). +- **Deleting the default address**: removing the current default must leave the address set + in a consistent state (either no default, or a clearly indicated next default) without a + broken UI. +- **Suspended account**: the approval-status indicator distinguishes `SUSPENDED` from + `PENDING_APPROVAL` so the restaurant understands why ordering is blocked. +- **Concurrent/stale data**: if the saved profile or address list changed elsewhere, the + view can be refreshed to the authoritative server state. +- **Offline / API unreachable**: read failures show a retryable empty/error state; write + failures preserve the user's entered values so they can retry. +- **Receiving window validation**: end time must be after start time; both are required + together. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST let a signed-in restaurant view its current business profile: + business name, address, contact person, and receiving/pickup window (start and end time). +- **FR-002**: The system MUST let a restaurant update its business profile and confirm the + result of the save (success or a human-readable failure). +- **FR-003**: The system MUST validate the receiving/pickup window before saving so that the + end time is after the start time and both are provided together. +- **FR-004**: The system MUST display the restaurant's approval status + (`PENDING_APPROVAL`, `APPROVED`, `SUSPENDED`) with an inline explanation of what it means + for the restaurant, refreshed from the authoritative source. +- **FR-005**: While the account is not `APPROVED`, the system MUST make ordering actions + unavailable in the UI and MUST still handle a server rejection of a disallowed action + gracefully. +- **FR-006**: The system MUST let a restaurant view all of its saved delivery addresses. +- **FR-007**: The system MUST let a restaurant add a delivery address with street address, + recipient name, phone, and a geographic point, and MUST validate required fields before + saving. +- **FR-008**: The system MUST let a restaurant edit and delete existing delivery addresses, + reflecting each change in the list. +- **FR-009**: The system MUST let a restaurant mark exactly one delivery address as the + default, ensuring no more than one default exists at a time. +- **FR-010**: The system MUST let a restaurant upload a business-license image, show it as + the current license after a successful upload, and leave the previous value unchanged on + failure. +- **FR-011**: All restaurant-facing text in this feature MUST be available in both Vietnamese + and English. +- **FR-012**: The feature MUST be reachable from the restaurant navigation at the profile + destination and MUST render within the restaurant (enterprise) chrome. + +### Key Entities *(include if feature involves data)* + +- **Restaurant Business Profile**: the restaurant's identity and operating details — business + name, address, contact person, receiving/pickup window, and an optional business-license + image reference. One per restaurant account. +- **Delivery Address**: a place an order can be delivered to — street address, recipient + name, phone, geographic point, and a default flag. A restaurant has zero or more; at most + one is the default. +- **Approval Status**: the account's standing in the approval workflow — one of + `PENDING_APPROVAL`, `APPROVED`, `SUSPENDED` — determined and owned by the server. +- **Business License**: an uploaded image used as verification evidence, associated with the + business profile. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A restaurant can complete every required business-profile field and save a + valid profile in a single visit, in under 3 minutes. +- **SC-002**: A restaurant can add, edit, set-default, and delete a delivery address without + leaving the profile area, and the list stays consistent after each action 100% of the time. +- **SC-003**: On every visit, the restaurant sees an approval-status indicator that matches + the account's authoritative status, and — when not approved — an explanation of why + ordering is unavailable. +- **SC-004**: Invalid input (bad receiving window, missing required address field) is caught + before submission and never results in a saved invalid record. +- **SC-005**: The full profile area is presented correctly in both Vietnamese and English + with no untranslated text. + +## Assumptions + +- The restaurant account already exists and can sign in; self-registration and + authentication are provided by the existing Auth feature (M1) and are out of scope here. +- The approval decision, credit limit, and any admin-side actions are performed by + administrators in a separate feature; this feature is restaurant-facing self-service only. +- The restaurant-facing profile, delivery-address, approval-status, and business-license + operations are already available from the existing backend (Postman collection folder 04) + and its generated typed client; no new backend endpoints are required. +- Image uploads reuse the platform's existing signed-upload pattern (as already used for + product images), including its storage provider configuration. +- The profile area lives at the restaurant profile destination (`/profile`, M2) within the + enterprise layout, consistent with the sitemap. +- A geographic point for a delivery address is captured with the platform's existing map/ + place tooling; precise map interaction detail is an implementation concern for planning. diff --git a/specs/001-restaurant-onboarding/tasks.md b/specs/001-restaurant-onboarding/tasks.md new file mode 100644 index 0000000..b0a0dd1 --- /dev/null +++ b/specs/001-restaurant-onboarding/tasks.md @@ -0,0 +1,272 @@ +--- + +description: "Task list for Restaurant Onboarding & Profile" +--- + +# Tasks: Restaurant Onboarding & Profile + +**Input**: Design documents from `specs/001-restaurant-onboarding/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/restaurant-profile-api.md, quickstart.md + +**Tests**: Included — the constitution mandates "Test Before Merge" (Principle IV) and the plan +names specific unit specs (pickup-window validator, service envelope unwrap, default-address +reconciliation). Only these targeted specs are written; no full contract-test suite. + +**Organization**: Tasks are grouped by user story (US1 P1, US2 P2, US3 P3) so each story is an +independently testable, deployable increment. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependency on an incomplete task) +- **[Story]**: US1 / US2 / US3 (setup, foundational, polish carry no story label) +- All paths are repository-relative. + +## Path map (from plan.md) + +- Feature module: `src/app/modules/restaurant/` +- i18n: `public/i18n/en.json`, `public/i18n/vi.json` (namespace `restaurantProfile`) +- Route registration: `src/app/app.routes.ts` +- Navigation: `src/app/core/navigation/navigation.data.ts` +- User menu: `src/app/layout/common/user/user.component.html` +- Reused: `contract` (`restaurantProfileApi`), `app/core/auth` (`AuthService`, + `ApprovalBannerComponent`), `app/core/maps/location-picker.component` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Scaffolding for the feature module and i18n namespace. + +- [X] T001 [P] Create the feature folder structure `src/app/modules/restaurant/` with empty + subfolders `business-profile/` and `delivery-addresses/` (add a `.gitkeep` if needed). +- [X] T002 [P] Add an empty `"restaurantProfile": {}` namespace to both `public/i18n/en.json` + and `public/i18n/vi.json` as the home for this feature's keys. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The data layer, route, shell, and navigation that every user story renders inside. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T003 [P] Create `src/app/modules/restaurant/restaurant-profile.types.ts` with + `RestaurantProfileView`, `DeliveryAddressView`, and `BusinessLicenseSignature` exactly as + specified in `contracts/restaurant-profile-api.md` (strict types, no `any`). +- [X] T004 Create `src/app/modules/restaurant/restaurant-profile.service.ts` skeleton: + `@Injectable({ providedIn: 'root' })`, signals for `profile` and `addresses`, a private + `unwrap()` envelope helper (mirroring `CatalogService`), and typed method stubs + `loadProfile`, `saveProfile`, `listAddresses`, `addAddress`, `updateAddress`, + `deleteAddress`, `setDefaultAddress`, `uploadLicense`. Depends on T003. +- [X] T005 [P] Create the shell `src/app/modules/restaurant/profile.component.ts` + + `profile.component.html` + `profile.component.scss` (standalone, OnPush, signals, + `TranslocoModule`) with two clearly separated sections — "Business profile" and "Delivery + addresses" — as empty host slots for now. +- [X] T006 Create `src/app/modules/restaurant/profile.routes.ts` exporting a default lazy route + that loads `ProfileComponent`. Depends on T005. +- [X] T007 Register `/profile` as a child of the storefront area (the `OptionalAuthGuard` / + `layout: 'enterprise'` block) in `src/app/app.routes.ts`, lazy-loading + `app/modules/restaurant/profile.routes`. Depends on T006. +- [X] T008 [P] Add a storefront navigation item `{ id: 'profile', roles: ['restaurant'], + area: 'storefront', type: 'basic', link: '/profile' }` (title key/text vi/en) to + `src/app/core/navigation/navigation.data.ts`. +- [X] T009 [P] Wire the existing "Profile" menu button to `routerLink="/profile"` in + `src/app/layout/common/user/user.component.html`. + +**Checkpoint**: Navigating to `/profile` renders the empty two-section shell for a restaurant. + +--- + +## Phase 3: User Story 1 - Business profile + approval gate (Priority: P1) 🎯 MVP + +**Goal**: A restaurant can view and edit its business profile (name, address, contact, receiving +window) with validation, see its approval status inline, and have ordering gated while not +approved. + +**Independent Test**: Sign in as a restaurant, open `/profile`, edit every business field, save, +reload → values persist; an invalid receiving window is blocked; a `pending` account shows the +approval banner. + +### Tests for User Story 1 + +- [X] T010 [P] [US1] Unit spec `src/app/modules/restaurant/business-profile/pickup-window.validator.spec.ts`: + both-or-neither, end-after-start, valid pass-through. +- [X] T011 [P] [US1] Unit spec `src/app/modules/restaurant/restaurant-profile.service.spec.ts` + (profile portion): `loadProfile` unwraps `{ success, data }`; `saveProfile` sends an + `UpdateRestaurantProfileRequest`; read failure surfaces a retryable state. + +### Implementation for User Story 1 + +- [X] T012 [P] [US1] Implement + `src/app/modules/restaurant/business-profile/pickup-window.validator.ts` (reactive-form + cross-field validator: `pickupEnd` strictly after `pickupStart`; both required together). +- [X] T013 [US1] Implement `loadProfile()` and `saveProfile()` in `restaurant-profile.service.ts` + over `restaurantProfileApi.apiV1RestaurantsMeProfileGet(Raw)` / `...ProfilePut`, unwrapping the + envelope into `RestaurantProfileView`. Depends on T004. +- [X] T014 [US1] Implement + `src/app/modules/restaurant/business-profile/business-profile-form.component.ts` + + `.html`: reactive form (name required; address; contactPerson; pickupStart; pickupEnd) using + the pickup-window validator, save with success/error toast (MatSnackBar), and embed + `ApprovalBannerComponent` shown when `AuthService`/`UserService` approval status ≠ `approved`. + Depends on T012, T013. +- [X] T015 [US1] Mount `` in the "Business profile" section of + `profile.component.html`. Depends on T014. +- [X] T016 [US1] Add US1 i18n keys (field labels, hints, validation messages, save toasts, + approval-state explanations) under `restaurantProfile` in both `public/i18n/en.json` and + `public/i18n/vi.json`. + +**Checkpoint**: US1 fully functional — the business profile is viewable, editable, validated, +and approval-gated. This is the MVP. + +--- + +## Phase 4: User Story 2 - Manage delivery addresses (Priority: P2) + +**Goal**: A restaurant can list, add, edit, delete delivery addresses and set exactly one +default, capturing a map point per address. + +**Independent Test**: Add an address, mark default, edit it, add a second and switch default, +delete one → the list and single-default stay consistent after each action. + +### Tests for User Story 2 + +- [ ] T017 [P] [US2] Extend `restaurant-profile.service.spec.ts` with the addresses portion: + `listAddresses` unwrap; `addAddress`/`updateAddress` send `DeliveryAddressRequest`; + `setDefaultAddress`/`deleteAddress` trigger a re-list so the client reflects server-owned + default reconciliation. + +### Implementation for User Story 2 + +- [ ] T018 [US2] Implement `listAddresses`, `addAddress`, `updateAddress`, `deleteAddress`, + `setDefaultAddress` in `restaurant-profile.service.ts` over the generated + `...DeliveryAddresses*` methods; re-list after any default-changing mutation. Depends on T004. +- [ ] T019 [P] [US2] Implement + `src/app/modules/restaurant/delivery-addresses/delivery-address-editor.component.ts` + `.html`: + add/edit reactive form (addressLine required; recipientName; phone; isDefault) using + `LocationPickerComponent` bound to `latControl`/`lngControl`. +- [ ] T020 [US2] Implement + `src/app/modules/restaurant/delivery-addresses/delivery-address-list.component.ts` + `.html`: + render the address list, set-default, delete, and open the editor for add/edit. Depends on + T018, T019. +- [ ] T021 [US2] Mount `` in the "Delivery addresses" section of + `profile.component.html`. Depends on T020. +- [ ] T022 [US2] Add US2 i18n keys (address fields, default badge, add/edit/delete actions, + confirmation + toasts) under `restaurantProfile` in `public/i18n/en.json` and + `public/i18n/vi.json`. + +**Checkpoint**: US1 and US2 both work independently; addresses stay consistent with the server. + +--- + +## Phase 5: User Story 3 - Business-license upload (Priority: P3) + +**Goal**: A restaurant can upload a business-license image; it shows on the profile and is stored +with the business profile on save; failures leave the previous license unchanged. + +**Independent Test**: Upload a license image, save, reload → the image is associated with the +profile; a failed upload leaves any existing license intact. + +### Tests for User Story 3 + +- [ ] T023 [P] [US3] Extend `restaurant-profile.service.spec.ts` with `uploadLicense`: a missing + signature or failed Cloudinary POST throws and does not alter the current + `businessLicenseUrl`. + +### Implementation for User Story 3 + +- [ ] T024 [US3] Implement `uploadLicense(file)` in `restaurant-profile.service.ts`: mint a + signature via `restaurantProfileApi.apiV1RestaurantsMeBusinessLicenseUploadSignaturePostRaw()`, + POST the file to Cloudinary, return `secure_url` (mirror + `CatalogAdminService.uploadProductImage()`). Depends on T004. +- [ ] T025 [US3] Add a license upload control to `business-profile-form.component` (file input + + thumbnail preview); on success set `businessLicenseUrl` in the form (persisted on profile + save); on failure keep the existing value and show an error toast. Depends on T014, T024. +- [ ] T026 [US3] Add US3 i18n keys (upload label, choose/replace, uploading, success/error) under + `restaurantProfile` in `public/i18n/en.json` and `public/i18n/vi.json`. + +**Checkpoint**: All three stories independently functional. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [ ] T027 [P] Verify strict typing (no `any`), no hardcoded user-facing strings, and OnPush + + signals throughout the `src/app/modules/restaurant/` module. +- [ ] T028 [P] Confirm bilingual completeness: every `restaurantProfile` key exists in both + `en.json` and `vi.json` with no missing/placeholder values. +- [ ] T029 Run the `quickstart.md` manual verification (folder-04 happy path) against a live + backend. +- [ ] T030 Run `npm run precheck` (lint → Prettier → tests → production build) and ensure it is + green. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Setup (Phase 1)**: no dependencies. +- **Foundational (Phase 2)**: after Setup — BLOCKS all user stories. (T004→T003; T006→T005; + T007→T006.) +- **User Stories (Phase 3–5)**: all require Phase 2. US1 is the MVP; US2 and US3 each depend only + on the foundational service/shell, not on each other. +- **Polish (Phase 6)**: after the desired stories are complete. + +### User story dependencies + +- **US1 (P1)**: after Phase 2. No dependency on US2/US3. +- **US2 (P2)**: after Phase 2. Independent of US1 (renders in a separate shell section). +- **US3 (P3)**: after Phase 2; its UI attaches to the US1 business-profile form (T025 needs + T014), so US3 is best done after US1. The service method (T024) is independent. + +### Within each story + +- Tests before implementation where listed; service methods before the components that call them; + components before they are mounted in the shell. + +### Parallel opportunities + +- Setup: T001, T002 in parallel. +- Foundational: T003, T005, T008, T009 in parallel; T004/T006/T007 follow their deps. +- US1: T010, T011 (tests) parallel; T012 parallel with them. +- US2: T017 (test) and T019 (editor) parallel with T018 (service). +- Polish: T027, T028 parallel. + +--- + +## Parallel Example: User Story 1 + +```text +# Tests + independent validator together: +Task: T010 pickup-window.validator.spec.ts +Task: T011 restaurant-profile.service.spec.ts (profile portion) +Task: T012 pickup-window.validator.ts +``` + +--- + +## Implementation Strategy + +### MVP first (US1 only) + +1. Phase 1 Setup → 2. Phase 2 Foundational → 3. Phase 3 US1 → **STOP & validate** the business + profile + approval gate against a live restaurant account → demo. + +### Incremental delivery + +1. Setup + Foundational → shell reachable at `/profile`. +2. US1 → business profile (MVP) → demo. +3. US2 → delivery addresses → demo. +4. US3 → license upload → demo. +5. Polish → precheck green → merge. + +--- + +## Notes + +- [P] = different files, no incomplete-task dependency. +- Keep RBAC server-authoritative: hide/disable ordering while not approved, but still handle a + server `403` gracefully. +- Commit after each task or logical group; stop at any checkpoint to validate a story. +- Total: 30 tasks — Setup 2, Foundational 7, US1 7, US2 6, US3 4, Polish 4. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 33dab97..a2d9103 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -119,6 +119,11 @@ export const appRoutes: Route[] = [ path: 'shop', loadChildren: () => import('app/modules/shop/shop.routes'), }, + { + path: 'profile', + loadChildren: () => + import('app/modules/restaurant/profile.routes'), + }, { path: 'wishlist', loadChildren: () => diff --git a/src/app/core/api/envelope.spec.ts b/src/app/core/api/envelope.spec.ts index df3a9ee..1865b99 100644 --- a/src/app/core/api/envelope.spec.ts +++ b/src/app/core/api/envelope.spec.ts @@ -1,4 +1,5 @@ -import { withId } from './envelope'; +import { PermissionError, ResponseError } from 'contract'; +import { apiErrorMessage, withId } from './envelope'; /** * Every screen feeds `row.id` into a path parameter, so a row that arrives @@ -56,3 +57,51 @@ describe('withId', () => { expect(input[0]).toEqual({ hubId: 'h1' }); }); }); + +/** + * A backend rejection must reach the user with its reason. The reason lives in + * the response body of either the generated `ResponseError` (4xx) or the typed + * `ApiError` subclasses the client throws for 401/403/5xx — both must be read. + */ +describe('apiErrorMessage', () => { + function response(body: unknown, status = 400): Response { + return new Response(JSON.stringify(body), { status }); + } + + it('reads the ProblemDetails "detail" from a ResponseError', async () => { + const err = new ResponseError( + response({ detail: 'Order already batched.' }, 409), + 'failed' + ); + expect(await apiErrorMessage(err)).toBe('Order already batched.'); + }); + + it('reads the reason from a typed PermissionError (403)', async () => { + const err = new PermissionError( + response({ detail: 'Restaurant not approved yet.' }, 403) + ); + expect(await apiErrorMessage(err)).toBe('Restaurant not approved yet.'); + }); + + it('joins a validation "errors" map', async () => { + const err = new ResponseError( + response({ errors: { name: ['Name is required.'] } }, 422), + 'failed' + ); + expect(await apiErrorMessage(err)).toBe('Name is required.'); + }); + + it('falls back through title/message/error', async () => { + const err = new ResponseError(response({ title: 'Conflict' }), 'x'); + expect(await apiErrorMessage(err)).toBe('Conflict'); + }); + + it('returns undefined for a non-HTTP error', async () => { + expect(await apiErrorMessage(new Error('boom'))).toBeUndefined(); + }); + + it('returns undefined when the body carries no usable message', async () => { + const err = new ResponseError(response({ foo: 'bar' }), 'x'); + expect(await apiErrorMessage(err)).toBeUndefined(); + }); +}); diff --git a/src/app/core/api/envelope.ts b/src/app/core/api/envelope.ts index 99bc1ae..5136a46 100644 --- a/src/app/core/api/envelope.ts +++ b/src/app/core/api/envelope.ts @@ -8,6 +8,7 @@ * an `@for` never receives a non-iterable. Mirrors the parsing in * `catalog.service.ts` / `admin.service.ts`, centralised for reuse. */ +import { ApiError, ResponseError } from 'contract'; /** * Largest `pageSize` the backend accepts on list endpoints. @@ -43,6 +44,58 @@ export function withId>( }); } +/** + * Extracts a human-readable reason from a failed API call, so a backend + * rejection can be shown to the user instead of a generic "something went + * wrong". + * + * Two error shapes carry a response: + * - the generated {@link ResponseError} (thrown for 4xx the middleware lets + * through — 400/404/409/422), and + * - the typed {@link ApiError} subclasses the client middleware throws for + * 401/403/5xx ({@link PermissionError} etc.). These are the "the backend did + * not allow it" cases whose reason was previously dropped. + * + * The body is read as RFC 7807 `ProblemDetails` (`detail`/`title`, or an + * `errors` validation map) with `message`/`error` as fallbacks. Returns + * `undefined` for non-HTTP failures or a bodiless response, so callers fall + * back to their own translated message. + */ +export async function apiErrorMessage( + err: unknown +): Promise { + const response = + err instanceof ResponseError + ? err.response + : err instanceof ApiError + ? err.response + : undefined; + if (!response) { + return undefined; + } + const body = await parseJson>(response.clone()); + if (!body) { + return undefined; + } + if (body['errors'] && typeof body['errors'] === 'object') { + const messages = Object.values( + body['errors'] as Record + ) + .flatMap((v) => (Array.isArray(v) ? v : [v])) + .filter((v): v is string => typeof v === 'string'); + if (messages.length) { + return messages.join(' '); + } + } + for (const key of ['detail', 'title', 'message', 'error']) { + const value = body[key]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return undefined; +} + /** Parses a JSON body, tolerating an empty (`void`) response. */ export async function parseJson(response: Response): Promise { const text = await response.text(); diff --git a/src/app/core/navigation/navigation.data.ts b/src/app/core/navigation/navigation.data.ts index 06e08b5..d5d5a55 100644 --- a/src/app/core/navigation/navigation.data.ts +++ b/src/app/core/navigation/navigation.data.ts @@ -35,6 +35,15 @@ const NAVIGATION: AreaNavItem[] = [ link: '/catalog', area: 'storefront', }, + { + id: 'profile', + title: 'Hồ sơ nhà hàng', + type: 'basic', + icon: 'heroicons_outline:building-storefront', + link: '/profile', + area: 'storefront', + roles: ['restaurant'], + }, { id: 'learn', title: 'Tìm hiểu FreshFlow', diff --git a/src/app/layout/common/user/user.component.html b/src/app/layout/common/user/user.component.html index 1416836..ddf7581 100644 --- a/src/app/layout/common/user/user.component.html +++ b/src/app/layout/common/user/user.component.html @@ -38,7 +38,7 @@ - diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 787e498..1bf746a 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -14,7 +14,7 @@ import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { TranslocoModule } from '@jsverse/transloco'; import { UserService } from 'app/core/user/user.service'; import { User } from 'app/core/user/user.types'; @@ -35,6 +35,7 @@ import { Subject, takeUntil } from 'rxjs'; NgClass, MatDividerModule, TranslocoModule, + RouterLink, ], }) export class UserComponent implements OnInit, OnDestroy { diff --git a/src/app/modules/admin/admin.service.ts b/src/app/modules/admin/admin.service.ts index 8baffa2..da025b9 100644 --- a/src/app/modules/admin/admin.service.ts +++ b/src/app/modules/admin/admin.service.ts @@ -7,18 +7,16 @@ import { unwrapData, withId, } from 'app/core/api/envelope'; -import { - adminApi, - marketsApi, - ResponseError, - restaurantCreditApi, -} from 'contract'; +import { adminApi, marketsApi, restaurantCreditApi } from 'contract'; import { AdminAuditLogFilters, AdminAuditLogRow, AdminAuditLogsResult, AdminAutoBatchPayload, AdminCreateUserPayload, + AdminCreditStatement, + AdminCreditTransaction, + AdminGenerateStatementPayload, AdminMarketAssignmentEntry, AdminMarketOption, AdminOperationalSettings, @@ -186,6 +184,70 @@ export class AdminService { } } + /** Monthly credit statements, newest first (best-effort; empty on failure). */ + async getCreditStatements( + restaurantId: string + ): Promise { + try { + const res = + await restaurantCreditApi.apiV1RestaurantsRestaurantIdCreditStatementsGetRaw( + { restaurantId, pageSize: MAX_PAGE_SIZE } + ); + return withId( + extractList(await parseJson(res.raw)), + 'statementId' + ); + } catch { + return []; + } + } + + /** Generates (or regenerates) the statement for a given year/month. */ + async generateCreditStatement( + restaurantId: string, + payload: AdminGenerateStatementPayload + ): Promise { + await restaurantCreditApi.apiV1RestaurantsRestaurantIdCreditStatementsGeneratePostRaw( + { + restaurantId, + generateStatementRequest: { + year: payload.year, + month: payload.month, + }, + } + ); + } + + /** Fetches a statement PDF as a Blob for download / preview. */ + async getStatementPdf( + restaurantId: string, + statementId: string + ): Promise { + const res = + await restaurantCreditApi.apiV1RestaurantsRestaurantIdCreditStatementsStatementIdPdfGetRaw( + { restaurantId, statementId } + ); + return res.raw.blob(); + } + + /** Credit ledger entries, newest first (best-effort; empty on failure). */ + async getCreditTransactions( + restaurantId: string + ): Promise { + try { + const res = + await restaurantCreditApi.apiV1RestaurantsRestaurantIdCreditTransactionsGetRaw( + { restaurantId, pageSize: MAX_PAGE_SIZE } + ); + return withId( + extractList(await parseJson(res.raw)), + 'transactionId' + ); + } catch { + return []; + } + } + // ------------------------------------------------------------------- // Platform settings // ------------------------------------------------------------------- @@ -331,37 +393,10 @@ export class AdminService { } /** - * Extracts a human-readable message from a failed API call. - * - * Backend errors surface as a {@link ResponseError} whose `response` carries an - * RFC 7807 `ProblemDetails` body (`detail`/`title`, or a `errors` validation - * map). Returns `undefined` for non-HTTP failures so callers can fall back to a - * generic translated message. + * Re-exported from the shared api layer, where it now also reads the typed + * {@link ApiError} subclasses (401/403/5xx) — not just {@link ResponseError} — + * so RBAC/permission rejections surface their backend reason too. Kept exported + * here so existing `import { apiErrorMessage } from '../admin.service'` callers + * are unaffected. */ -export async function apiErrorMessage( - err: unknown -): Promise { - if (!(err instanceof ResponseError)) { - return undefined; - } - const body = await parseJson>(err.response.clone()); - if (!body) { - return undefined; - } - if (body['errors'] && typeof body['errors'] === 'object') { - const messages = Object.values( - body['errors'] as Record - ) - .flatMap((v) => (Array.isArray(v) ? v : [v])) - .filter((v): v is string => typeof v === 'string'); - if (messages.length) { - return messages.join(' '); - } - } - for (const key of ['detail', 'title', 'message']) { - if (typeof body[key] === 'string' && body[key]) { - return body[key] as string; - } - } - return undefined; -} +export { apiErrorMessage } from 'app/core/api/envelope'; diff --git a/src/app/modules/admin/admin.types.ts b/src/app/modules/admin/admin.types.ts index 194c72a..bbd83b2 100644 --- a/src/app/modules/admin/admin.types.ts +++ b/src/app/modules/admin/admin.types.ts @@ -160,3 +160,40 @@ export interface AdminRestaurantCredit { availableCredit?: number; [key: string]: unknown; } + +/** + * A monthly credit statement row + * (`GET /restaurants/{id}/credit/statements`, untyped envelope). + */ +export interface AdminCreditStatement { + id: string; + year?: number; + month?: number; + openingBalance?: number; + closingBalance?: number; + totalCharges?: number; + totalPayments?: number; + generatedAt?: string; + [key: string]: unknown; +} + +/** + * A single credit ledger entry + * (`GET /restaurants/{id}/credit/transactions`, untyped envelope). + */ +export interface AdminCreditTransaction { + id: string; + createdAt?: string; + type?: string; + amount?: number; + balanceAfter?: number; + description?: string; + reference?: string; + [key: string]: unknown; +} + +/** Period selector for generating a statement (`POST .../credit/statements/generate`). */ +export interface AdminGenerateStatementPayload { + year: number; + month: number; +} diff --git a/src/app/modules/admin/catalog/catalog-admin.service.ts b/src/app/modules/admin/catalog/catalog-admin.service.ts index 82a5625..175c02e 100644 --- a/src/app/modules/admin/catalog/catalog-admin.service.ts +++ b/src/app/modules/admin/catalog/catalog-admin.service.ts @@ -6,13 +6,7 @@ import { unwrapData, withId, } from 'app/core/api/envelope'; -import { - categoriesApi, - marketsApi, - productsApi, - rawApi, - unitsApi, -} from 'contract'; +import { categoriesApi, marketsApi, productsApi, unitsApi } from 'contract'; import { CrudFormValue, CrudOption, @@ -110,22 +104,13 @@ export class CatalogAdminService { } /** - * Reactivates a category through `PUT /categories/{id}`. - * - * There is no activate endpoint (`PATCH /categories/{id}/activate` answers - * 404), so the only route back is the update call with `isActive: true`. - * `UpdateCategoryRequest` does not declare `isActive`, and the generated - * serialiser would strip it, so this goes out via {@link rawApi}. - * - * The update replaces the whole record, so the current name and parent are - * resent unchanged — omitting them would blank them out. + * Reactivates a category through the dedicated + * `PATCH /categories/{id}/activate` endpoint (added to the backend API). + * Replaces the earlier `PUT` + `isActive: true` workaround, which the server + * ignored (the update body has no `isActive`), leaving the row inactive. */ async activateCategory(row: CrudRow): Promise { - await rawApi.send(`/api/v1/categories/${row.id}`, 'PUT', { - name: str(row['name']), - parentId: optStr(row['parentId']), - isActive: true, - }); + await categoriesApi.apiV1CategoriesIdActivatePatch({ id: row.id }); } // ---- Units ------------------------------------------------------------ diff --git a/src/app/modules/admin/catalog/categories.component.ts b/src/app/modules/admin/catalog/categories.component.ts index 3bd5739..52b2041 100644 --- a/src/app/modules/admin/catalog/categories.component.ts +++ b/src/app/modules/admin/catalog/categories.component.ts @@ -84,6 +84,30 @@ export class CategoriesComponent { ? !row['parentId'] : String(row['parentId'] ?? '') === value, }, + { + name: 'status', + label: 'admin.users.filters.status', + // Fixed active/inactive choices — the same test the table's + // status pill uses, so a row missing the field counts as active. + options: async () => [ + { + value: 'active', + label: this._transloco.translate( + 'admin.users.filters.active' + ), + }, + { + value: 'inactive', + label: this._transloco.translate( + 'admin.users.filters.inactive' + ), + }, + ], + match: (row, value) => + value === 'inactive' + ? row.isActive === false + : row.isActive !== false, + }, ], columns: [ { diff --git a/src/app/modules/admin/restaurants/restaurants-admin.component.html b/src/app/modules/admin/restaurants/restaurants-admin.component.html index 9d14f33..09ff001 100644 --- a/src/app/modules/admin/restaurants/restaurants-admin.component.html +++ b/src/app/modules/admin/restaurants/restaurants-admin.component.html @@ -195,4 +195,197 @@

+ + @if (lookedUp()) { + +
+
+

+ {{ t('admin.restaurants.statements.title') }} +

+
+ + {{ + t('admin.restaurants.statements.year') + }} + + + + {{ + t('admin.restaurants.statements.month') + }} + + + +
+
+ +
+ + + + + + + + + + + + + @for (s of statements(); track trackById($index, s)) { + + + + + + + + + } @empty { + + + + } + +
+ {{ t('admin.restaurants.statements.period') }} + + {{ t('admin.restaurants.statements.opening') }} + + {{ t('admin.restaurants.statements.closing') }} + + {{ t('admin.restaurants.statements.charges') }} + + {{ t('admin.restaurants.statements.payments') }} + + {{ t('admin.users.table.actions') }} +
+ {{ s.month ?? '—' }}/{{ s.year ?? '—' }} + + {{ s.openingBalance ?? '—' }} + + {{ s.closingBalance ?? '—' }} + + {{ s.totalCharges ?? '—' }} + + {{ s.totalPayments ?? '—' }} + + +
+ {{ + t('admin.restaurants.statements.empty') + }} +
+
+
+ + +
+

+ {{ t('admin.restaurants.transactions.title') }} +

+
+ + + + + + + + + + + + @for (x of transactions(); track trackById($index, x)) { + + + + + + + + } @empty { + + + + } + +
+ {{ t('admin.restaurants.transactions.date') }} + + {{ t('admin.restaurants.transactions.type') }} + + {{ t('admin.restaurants.transactions.amount') }} + + {{ + t('admin.restaurants.transactions.balance') + }} + + {{ + t( + 'admin.restaurants.transactions.description' + ) + }} +
+ {{ x.createdAt ?? '—' }} + {{ x.type ?? '—' }}{{ x.amount ?? '—' }} + {{ x.balanceAfter ?? '—' }} + + {{ x.description ?? '—' }} +
+ {{ + t( + 'admin.restaurants.transactions.empty' + ) + }} +
+
+
+ } diff --git a/src/app/modules/admin/restaurants/restaurants-admin.component.ts b/src/app/modules/admin/restaurants/restaurants-admin.component.ts index a025c34..de58bbf 100644 --- a/src/app/modules/admin/restaurants/restaurants-admin.component.ts +++ b/src/app/modules/admin/restaurants/restaurants-admin.component.ts @@ -13,9 +13,14 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; import { AdminService, apiErrorMessage } from '../admin.service'; -import { AdminRestaurantCredit } from '../admin.types'; +import { + AdminCreditStatement, + AdminCreditTransaction, + AdminRestaurantCredit, +} from '../admin.types'; /** UUID v4-ish check — good enough to short-circuit obviously malformed input. */ const UUID_PATTERN = @@ -41,6 +46,7 @@ const UUID_PATTERN = MatInputModule, MatProgressBarModule, MatSnackBarModule, + MatTooltipModule, ReactiveFormsModule, TranslocoModule, ], @@ -59,6 +65,15 @@ export class RestaurantsAdminComponent { readonly settingLimit = signal(false); readonly settling = signal(false); + /** True once a valid restaurant id has been looked up (reveals the ledger). */ + readonly lookedUp = signal(false); + readonly statements = signal([]); + readonly transactions = signal([]); + readonly loadingLedger = signal(false); + readonly generating = signal(false); + /** Statement id whose PDF is currently being fetched, or null. */ + readonly pdfBusyId = signal(null); + readonly lookupForm = this._formBuilder.nonNullable.group({ restaurantId: [ '', @@ -78,6 +93,17 @@ export class RestaurantsAdminComponent { note: [''], }); + readonly generateForm = this._formBuilder.nonNullable.group({ + year: [ + new Date().getFullYear(), + [Validators.required, Validators.min(2000)], + ], + month: [ + new Date().getMonth() + 1, + [Validators.required, Validators.min(1), Validators.max(12)], + ], + }); + get restaurantId(): string { return this.lookupForm.getRawValue().restaurantId; } @@ -92,11 +118,82 @@ export class RestaurantsAdminComponent { if (!restaurantId) { return; } + this.lookedUp.set(true); this.loadingCredit.set(true); this._admin .getRestaurantCredit(restaurantId) .then((credit) => this.credit.set(credit)) .finally(() => this.loadingCredit.set(false)); + this._loadLedger(restaurantId); + } + + /** Loads the statements + transactions for the looked-up restaurant. */ + private _loadLedger(restaurantId: string): void { + this.loadingLedger.set(true); + Promise.all([ + this._admin.getCreditStatements(restaurantId), + this._admin.getCreditTransactions(restaurantId), + ]) + .then(([statements, transactions]) => { + this.statements.set(statements); + this.transactions.set(transactions); + }) + .finally(() => this.loadingLedger.set(false)); + } + + generateStatement(): void { + const restaurantId = this._validRestaurantId; + if (!restaurantId || this.generateForm.invalid) { + this.generateForm.markAllAsTouched(); + return; + } + const { year, month } = this.generateForm.getRawValue(); + this.generating.set(true); + this._admin + .generateCreditStatement(restaurantId, { year, month }) + .then(() => { + this._notify('admin.restaurants.statements.generateSuccess'); + this._loadLedger(restaurantId); + }) + .catch(async (err) => + this._notifyText( + (await apiErrorMessage(err)) ?? + this._transloco.translate( + 'admin.restaurants.actionError' + ) + ) + ) + .finally(() => this.generating.set(false)); + } + + /** Fetches a statement's PDF and opens it in a new tab. */ + downloadStatementPdf(statementId: string): void { + const restaurantId = this._validRestaurantId; + if (!restaurantId) { + return; + } + this.pdfBusyId.set(statementId); + this._admin + .getStatementPdf(restaurantId, statementId) + .then((blob) => { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + // Revoke later so the opened tab has time to render the PDF. + setTimeout(() => URL.revokeObjectURL(url), 60000); + }) + .catch(async (err) => + this._notifyText( + (await apiErrorMessage(err)) ?? + this._transloco.translate( + 'admin.restaurants.actionError' + ) + ) + ) + .finally(() => this.pdfBusyId.set(null)); + } + + trackById(_: number, row: { id: string }): string { + return row.id; } approve(): void { diff --git a/src/app/modules/admin/shared/resource-crud.component.html b/src/app/modules/admin/shared/resource-crud.component.html index a8201cc..416e310 100644 --- a/src/app/modules/admin/shared/resource-crud.component.html +++ b/src/app/modules/admin/shared/resource-crud.component.html @@ -77,6 +77,21 @@

} + + @if (hasActiveFilters()) { + + } } diff --git a/src/app/modules/admin/shared/resource-crud.component.ts b/src/app/modules/admin/shared/resource-crud.component.ts index 91b30d1..40f36aa 100644 --- a/src/app/modules/admin/shared/resource-crud.component.ts +++ b/src/app/modules/admin/shared/resource-crud.component.ts @@ -33,6 +33,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { FuseConfirmationService } from '@fuse/services/confirmation'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; +import { apiErrorMessage } from 'app/core/api/envelope'; import { LocationPickerComponent } from 'app/core/maps/location-picker.component'; import { CoalescedTask } from './coalesced-task'; import { @@ -137,6 +138,13 @@ export class ResourceCrudComponent implements OnInit { }); }); + /** True when a search term or any page-level filter is currently applied. */ + readonly hasActiveFilters = computed( + () => + this.search().trim() !== '' || + Object.values(this.filterValues()).some((v) => v) + ); + /** Column sort state, applied after filtering and before pagination. */ readonly sort = new TableSort(); @@ -226,9 +234,9 @@ export class ResourceCrudComponent implements OnInit { this.loading.set(true); try { await this._fetchRows(); - } catch { + } catch (err) { this.rows.set([]); - this._notify('admin.crud.loadError'); + await this._notifyError(err, 'admin.crud.loadError'); } finally { this.loading.set(false); } @@ -268,8 +276,8 @@ export class ResourceCrudComponent implements OnInit { ? 'admin.crud.reactivateIgnored' : 'admin.crud.reactivateSuccess' ); - } catch { - this._notify('admin.crud.saveError'); + } catch (err) { + await this._notifyError(err, 'admin.crud.saveError'); } finally { this.saving.set(false); } @@ -288,6 +296,13 @@ export class ResourceCrudComponent implements OnInit { this.pageIndex.set(0); } + /** Resets the search box and every page-level filter to "all". */ + clearFilters(): void { + this.search.set(''); + this.filterValues.set({}); + this.pageIndex.set(0); + } + onPageChange(event: PageEvent): void { this.pageIndex.set(event.pageIndex); this.pageSize.set(event.pageSize); @@ -375,7 +390,7 @@ export class ResourceCrudComponent implements OnInit { this.closeDialog(); this.load(); }) - .catch(() => this._notify('admin.crud.saveError')) + .catch((err) => this._notifyError(err, 'admin.crud.saveError')) .finally(() => this.saving.set(false)); } @@ -453,7 +468,7 @@ export class ResourceCrudComponent implements OnInit { this._notify('admin.crud.removeSuccess'); this.load(); }) - .catch(() => this._notify('admin.crud.saveError')); + .catch((err) => this._notifyError(err, 'admin.crud.saveError')); }); } @@ -570,7 +585,9 @@ export class ResourceCrudComponent implements OnInit { field .upload(file) .then((url) => this.controlOf(field.name)?.setValue(url)) - .catch(() => this._notify('admin.crud.image.uploadError')) + .catch((err) => + this._notifyError(err, 'admin.crud.image.uploadError') + ) .finally(() => this._setUploading(field.name, false)); } @@ -709,4 +726,19 @@ export class ResourceCrudComponent implements OnInit { duration: 3000, }); } + + /** + * Shows the backend's rejection reason when it sent one (permission denied, + * validation, conflict…), falling back to a translated generic message when + * it didn't. Errors linger longer than successes so the reason can be read. + */ + private async _notifyError( + err: unknown, + fallbackKey: string + ): Promise { + const message = + (await apiErrorMessage(err)) ?? + this._transloco.translate(fallbackKey); + this._snackBar.open(message, undefined, { duration: 6000 }); + } } diff --git a/src/app/modules/restaurant/business-profile/business-profile-form.component.html b/src/app/modules/restaurant/business-profile/business-profile-form.component.html new file mode 100644 index 0000000..6252f16 --- /dev/null +++ b/src/app/modules/restaurant/business-profile/business-profile-form.component.html @@ -0,0 +1,95 @@ + +
+ + + @if (loading()) { + + } + + @if (loadError()) { +
+ + {{ t('restaurantProfile.profile.loadError') }} +
+ } + + + {{ t('restaurantProfile.profile.name') }} + + @if (form.controls.name.hasError('required')) { + {{ + t('restaurantProfile.profile.nameRequired') + }} + } + + + + {{ t('restaurantProfile.profile.address') }} + + + + + {{ + t('restaurantProfile.profile.contactPerson') + }} + + + +
+
+ {{ t('restaurantProfile.profile.pickupWindow') }} +
+
+ + {{ + t('restaurantProfile.profile.pickupStart') + }} + + + + {{ + t('restaurantProfile.profile.pickupEnd') + }} + + +
+ @if (form.hasError('pickupWindow') && form.touched) { +
+ @if (form.getError('pickupWindow') === 'incomplete') { + {{ t('restaurantProfile.profile.pickupIncomplete') }} + } @else { + {{ + t('restaurantProfile.profile.pickupEndBeforeStart') + }} + } +
+ } +
+ +
+ +
+
+
diff --git a/src/app/modules/restaurant/business-profile/business-profile-form.component.ts b/src/app/modules/restaurant/business-profile/business-profile-form.component.ts new file mode 100644 index 0000000..eda8e77 --- /dev/null +++ b/src/app/modules/restaurant/business-profile/business-profile-form.component.ts @@ -0,0 +1,152 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, + inject, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; +import { apiErrorMessage } from 'app/core/api/envelope'; +import { ApprovalBannerComponent } from 'app/core/auth/components/approval-banner.component'; +import { UpdateRestaurantProfileRequest } from 'contract'; +import { RestaurantProfileService } from '../restaurant-profile.service'; +import { pickupWindowValidator } from './pickup-window.validator'; + +/** + * Restaurant business-profile editor (spec US1): name, address, contact person, + * and receiving/pickup window, with the cross-field window validation and an + * inline approval-status notice for accounts that are not yet approved. + */ +@Component({ + selector: 'business-profile-form', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './business-profile-form.component.html', + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressBarModule, + MatSnackBarModule, + TranslocoModule, + ApprovalBannerComponent, + ], +}) +export class BusinessProfileFormComponent implements OnInit { + private readonly _fb = inject(FormBuilder); + private readonly _service = inject(RestaurantProfileService); + private readonly _snackBar = inject(MatSnackBar); + private readonly _transloco = inject(TranslocoService); + + readonly loading = signal(false); + readonly saving = signal(false); + readonly loadError = signal(false); + + readonly form = this._fb.group( + { + name: this._fb.control('', { + validators: [Validators.required], + nonNullable: true, + }), + address: this._fb.control(null), + contactPerson: this._fb.control(null), + pickupStart: this._fb.control(null), + pickupEnd: this._fb.control(null), + businessLicenseUrl: this._fb.control(null), + }, + { validators: pickupWindowValidator() } + ); + + async ngOnInit(): Promise { + this.loading.set(true); + this.loadError.set(false); + try { + const profile = await this._service.loadProfile(); + if (profile) { + this.form.patchValue({ + name: profile.name ?? '', + address: profile.address ?? null, + contactPerson: profile.contactPerson ?? null, + pickupStart: toInputTime(profile.pickupStart), + pickupEnd: toInputTime(profile.pickupEnd), + businessLicenseUrl: profile.businessLicenseUrl ?? null, + }); + } + } catch { + this.loadError.set(true); + } finally { + this.loading.set(false); + } + } + + async save(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const v = this.form.getRawValue(); + const payload: UpdateRestaurantProfileRequest = { + name: v.name.trim(), + address: emptyToNull(v.address), + contactPerson: emptyToNull(v.contactPerson), + pickupStart: toApiTime(v.pickupStart), + pickupEnd: toApiTime(v.pickupEnd), + businessLicenseUrl: emptyToNull(v.businessLicenseUrl), + }; + + this.saving.set(true); + try { + await this._service.saveProfile(payload); + this._toast('restaurantProfile.profile.saved'); + } catch (err) { + // Show the backend's rejection reason (permission, validation…) when + // it sent one, else a translated generic message. + const message = + (await apiErrorMessage(err)) ?? + this._transloco.translate( + 'restaurantProfile.profile.saveError' + ); + this._snackBar.open(message, undefined, { duration: 6000 }); + } finally { + this.saving.set(false); + } + } + + private _toast(key: string): void { + this._snackBar.open(this._transloco.translate(key), undefined, { + duration: 3000, + }); + } +} + +/** `HH:mm:ss` (or null) → `HH:mm` for the native time input. */ +function toInputTime(value: string | null | undefined): string | null { + if (!value) { + return null; + } + return value.slice(0, 5); +} + +/** `HH:mm` from the time input → `HH:mm:ss` for the API; empty → null. */ +function toApiTime(value: string | null): string | null { + const trimmed = (value ?? '').trim(); + if (!trimmed) { + return null; + } + return trimmed.length === 5 ? `${trimmed}:00` : trimmed; +} + +/** Blank strings become null so unset fields are cleared, not stored empty. */ +function emptyToNull(value: string | null): string | null { + const trimmed = (value ?? '').trim(); + return trimmed === '' ? null : trimmed; +} diff --git a/src/app/modules/restaurant/business-profile/pickup-window.validator.spec.ts b/src/app/modules/restaurant/business-profile/pickup-window.validator.spec.ts new file mode 100644 index 0000000..708332a --- /dev/null +++ b/src/app/modules/restaurant/business-profile/pickup-window.validator.spec.ts @@ -0,0 +1,44 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { pickupWindowValidator } from './pickup-window.validator'; + +describe('pickupWindowValidator', () => { + function group(start: string, end: string): FormGroup { + return new FormGroup( + { + pickupStart: new FormControl(start), + pickupEnd: new FormControl(end), + }, + { validators: pickupWindowValidator() } + ); + } + + it('passes when both times are empty', () => { + expect(group('', '').errors).toBeNull(); + }); + + it('passes when end is after start', () => { + expect(group('08:00', '18:00').errors).toBeNull(); + }); + + it('accepts HH:mm:ss values', () => { + expect(group('08:00:00', '18:00:00').errors).toBeNull(); + }); + + it('flags an incomplete window (only one time set)', () => { + expect(group('08:00', '').errors).toEqual({ + pickupWindow: 'incomplete', + }); + expect(group('', '18:00').errors).toEqual({ + pickupWindow: 'incomplete', + }); + }); + + it('flags end equal to or before start', () => { + expect(group('18:00', '08:00').errors).toEqual({ + pickupWindow: 'endBeforeStart', + }); + expect(group('08:00', '08:00').errors).toEqual({ + pickupWindow: 'endBeforeStart', + }); + }); +}); diff --git a/src/app/modules/restaurant/business-profile/pickup-window.validator.ts b/src/app/modules/restaurant/business-profile/pickup-window.validator.ts new file mode 100644 index 0000000..c7e46bd --- /dev/null +++ b/src/app/modules/restaurant/business-profile/pickup-window.validator.ts @@ -0,0 +1,49 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Cross-field validator for the restaurant receiving/pickup window. + * + * Rules (spec FR-003): + * - both `pickupStart` and `pickupEnd` are provided together, or neither; + * - when both are set, `pickupEnd` must be strictly after `pickupStart`. + * + * Times are the `HH:mm` / `HH:mm:ss` strings the time inputs produce; they are + * compared lexically, which is order-preserving for zero-padded 24-hour times. + * Returns `{ pickupWindow: 'incomplete' | 'endBeforeStart' }` on the group, or + * `null` when valid. + * + * @param startKey control name holding the start time (default `pickupStart`) + * @param endKey control name holding the end time (default `pickupEnd`) + */ +export function pickupWindowValidator( + startKey = 'pickupStart', + endKey = 'pickupEnd' +): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const start = (group.get(startKey)?.value ?? '') as string; + const end = (group.get(endKey)?.value ?? '') as string; + + const hasStart = start.trim() !== ''; + const hasEnd = end.trim() !== ''; + + if (!hasStart && !hasEnd) { + return null; + } + if (hasStart !== hasEnd) { + return { pickupWindow: 'incomplete' }; + } + if (normalize(end) <= normalize(start)) { + return { pickupWindow: 'endBeforeStart' }; + } + return null; + }; +} + +/** Pad an `HH:mm` value to `HH:mm:ss` so lexical comparison is well-defined. */ +function normalize(time: string): string { + const parts = time.split(':'); + while (parts.length < 3) { + parts.push('00'); + } + return parts.map((p) => p.padStart(2, '0')).join(':'); +} diff --git a/src/app/modules/restaurant/profile.component.html b/src/app/modules/restaurant/profile.component.html new file mode 100644 index 0000000..b83a25b --- /dev/null +++ b/src/app/modules/restaurant/profile.component.html @@ -0,0 +1,28 @@ +
+ +
+
+

+ {{ t('restaurantProfile.title') }} +

+

+ {{ t('restaurantProfile.subtitle') }} +

+
+
+ + +
+
+
+

+ {{ t('restaurantProfile.profile.sectionTitle') }} +

+

+ {{ t('restaurantProfile.profile.sectionSubtitle') }} +

+ +
+
+
+
diff --git a/src/app/modules/restaurant/profile.component.scss b/src/app/modules/restaurant/profile.component.scss new file mode 100644 index 0000000..6450a35 --- /dev/null +++ b/src/app/modules/restaurant/profile.component.scss @@ -0,0 +1,3 @@ +.restaurant-profile { + min-height: 100%; +} diff --git a/src/app/modules/restaurant/profile.component.ts b/src/app/modules/restaurant/profile.component.ts new file mode 100644 index 0000000..f4c7488 --- /dev/null +++ b/src/app/modules/restaurant/profile.component.ts @@ -0,0 +1,22 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from '@angular/core'; +import { TranslocoModule } from '@jsverse/transloco'; +import { BusinessProfileFormComponent } from './business-profile/business-profile-form.component'; + +/** + * Restaurant self-service profile area (route `/profile`, M2). Hosts the + * business-profile editor; the delivery-addresses section is added in US2. + */ +@Component({ + selector: 'restaurant-profile', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + templateUrl: './profile.component.html', + styleUrls: ['./profile.component.scss'], + imports: [TranslocoModule, BusinessProfileFormComponent], +}) +export class ProfileComponent {} diff --git a/src/app/modules/restaurant/profile.routes.ts b/src/app/modules/restaurant/profile.routes.ts new file mode 100644 index 0000000..9412874 --- /dev/null +++ b/src/app/modules/restaurant/profile.routes.ts @@ -0,0 +1,16 @@ +import { Routes } from '@angular/router'; +import { roleGuard } from 'app/core/auth/guards/role.guard'; +import { ProfileComponent } from './profile.component'; + +/** + * Restaurant self-service profile area (`/profile`, M2). Restaurant-only: + * `roleGuard` redirects guests to sign-in and other roles to `/home`, keeping + * RBAC server-authoritative (BR-AUTH-4) while blocking unauthorized deep-links. + */ +export default [ + { + path: '', + canActivate: [roleGuard(['restaurant'])], + component: ProfileComponent, + }, +] as Routes; diff --git a/src/app/modules/restaurant/restaurant-profile.service.spec.ts b/src/app/modules/restaurant/restaurant-profile.service.spec.ts new file mode 100644 index 0000000..045d2ff --- /dev/null +++ b/src/app/modules/restaurant/restaurant-profile.service.spec.ts @@ -0,0 +1,65 @@ +import { restaurantProfileApi } from 'contract'; +import { RestaurantProfileService } from './restaurant-profile.service'; + +/** Minimal `ApiResponse`-like stub whose `.raw.json()` yields `body`. */ +function rawResponse(body: unknown): any { + return { raw: { json: () => Promise.resolve(body) } }; +} + +describe('RestaurantProfileService', () => { + let service: RestaurantProfileService; + + beforeEach(() => { + service = new RestaurantProfileService(); + }); + + describe('loadProfile', () => { + it('unwraps the { success, data } envelope into the profile signal', async () => { + spyOn( + restaurantProfileApi, + 'apiV1RestaurantsMeProfileGetRaw' + ).and.resolveTo( + rawResponse({ success: true, data: { name: 'Green Garden' } }) + ); + + const profile = await service.loadProfile(); + + expect(profile?.name).toBe('Green Garden'); + expect(service.profile()?.name).toBe('Green Garden'); + }); + + it('tolerates a bare (un-enveloped) body', async () => { + spyOn( + restaurantProfileApi, + 'apiV1RestaurantsMeProfileGetRaw' + ).and.resolveTo(rawResponse({ name: 'Bare' })); + + const profile = await service.loadProfile(); + + expect(profile?.name).toBe('Bare'); + }); + }); + + describe('saveProfile', () => { + it('sends an UpdateRestaurantProfileRequest and reflects it in the signal', async () => { + const put = spyOn( + restaurantProfileApi, + 'apiV1RestaurantsMeProfilePut' + ).and.resolveTo(undefined as any); + + await service.saveProfile({ + name: 'Updated', + address: '1 Main St', + }); + + expect(put).toHaveBeenCalledWith({ + updateRestaurantProfileRequest: { + name: 'Updated', + address: '1 Main St', + }, + }); + expect(service.profile()?.name).toBe('Updated'); + expect(service.profile()?.address).toBe('1 Main St'); + }); + }); +}); diff --git a/src/app/modules/restaurant/restaurant-profile.service.ts b/src/app/modules/restaurant/restaurant-profile.service.ts new file mode 100644 index 0000000..8627b40 --- /dev/null +++ b/src/app/modules/restaurant/restaurant-profile.service.ts @@ -0,0 +1,51 @@ +import { Injectable, signal } from '@angular/core'; +import { restaurantProfileApi, UpdateRestaurantProfileRequest } from 'contract'; +import { RestaurantProfileView } from './restaurant-profile.types'; + +/** Unwraps the `{ success, data }` envelope, tolerating a bare body too. */ +function unwrap(body: unknown): T | undefined { + if (body && typeof body === 'object' && 'data' in body) { + return (body as { data?: T }).data; + } + return body as T; +} + +/** + * Data access for the restaurant self-service onboarding area, backed by the + * generated `restaurantProfileApi` singleton (base URL + bearer + 401/403/5xx + * handling come from the shared `apiConfiguration`). + * + * Reads use the generated `*Raw` methods + {@link unwrap} because the backend + * OpenAPI does not yet publish response schemas (the typed reads are `void`); + * writes use the generated request models. See + * `specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md`. + */ +@Injectable({ providedIn: 'root' }) +export class RestaurantProfileService { + private readonly _profile = signal(null); + + /** Latest loaded business profile, or `null` before the first load. */ + readonly profile = this._profile.asReadonly(); + + /** Load the restaurant business profile into the `profile` signal. */ + async loadProfile(): Promise { + const res = + await restaurantProfileApi.apiV1RestaurantsMeProfileGetRaw(); + const profile = + unwrap(await res.raw.json()) ?? null; + this._profile.set(profile); + return profile; + } + + /** Persist the restaurant business profile. */ + async saveProfile(value: UpdateRestaurantProfileRequest): Promise { + await restaurantProfileApi.apiV1RestaurantsMeProfilePut({ + updateRestaurantProfileRequest: value, + }); + // Reflect the saved values optimistically; a reload can re-sync later. + this._profile.set({ + ...(this._profile() ?? { name: value.name }), + ...value, + }); + } +} diff --git a/src/app/modules/restaurant/restaurant-profile.types.ts b/src/app/modules/restaurant/restaurant-profile.types.ts new file mode 100644 index 0000000..15c3226 --- /dev/null +++ b/src/app/modules/restaurant/restaurant-profile.types.ts @@ -0,0 +1,49 @@ +/** + * Provisional response/view types for the restaurant onboarding feature. + * + * The generated OpenAPI client types the `GET /api/v1/restaurants/me/*` reads as + * `void` (the backend spec documents them only as "200 OK"), so — as with + * `CatalogService` — we call the generated `*Raw` methods and parse the body + * against these local contracts, unwrapped from the `{ success, data }` + * envelope. Request bodies still use the generated models + * (`UpdateRestaurantProfileRequest`, `DeliveryAddressRequest`). Replace these + * with generated models once the backend publishes the response schemas. + * + * See `specs/001-restaurant-onboarding/contracts/restaurant-profile-api.md`. + */ + +/** Restaurant business profile (`GET/PUT /api/v1/restaurants/me/profile`). */ +export interface RestaurantProfileView { + name: string; + address?: string | null; + contactPerson?: string | null; + /** Receiving-window start, `HH:mm:ss`. */ + pickupStart?: string | null; + /** Receiving-window end, `HH:mm:ss`. */ + pickupEnd?: string | null; + businessLicenseUrl?: string | null; +} + +/** A saved delivery address (`GET /api/v1/restaurants/me/delivery-addresses`). */ +export interface DeliveryAddressView { + id: string; + addressLine: string; + recipientName?: string | null; + phone?: string | null; + latitude?: number | null; + longitude?: number | null; + isDefault?: boolean; +} + +/** + * Signed Cloudinary upload params minted by + * `POST /api/v1/restaurants/me/business-license/upload-signature`, mirroring the + * product-image signature shape already consumed by `CatalogAdminService`. + */ +export interface BusinessLicenseSignature { + cloudName: string; + apiKey: string; + timestamp: number; + signature: string; + folder: string; +} diff --git a/src/contract/generated/apis/CategoriesApi.ts b/src/contract/generated/apis/CategoriesApi.ts index eed7210..2e0afaf 100644 --- a/src/contract/generated/apis/CategoriesApi.ts +++ b/src/contract/generated/apis/CategoriesApi.ts @@ -26,6 +26,10 @@ export interface ApiV1CategoriesGetRequest { activeOnly?: boolean; } +export interface ApiV1CategoriesIdActivatePatchRequest { + id: string; +} + export interface ApiV1CategoriesIdDeactivatePatchRequest { id: string; } @@ -102,6 +106,73 @@ export class CategoriesApi extends runtime.BaseAPI { await this.apiV1CategoriesGetRaw(requestParameters, initOverrides); } + /** + * Creates request options for apiV1CategoriesIdActivatePatch without sending the request + */ + async apiV1CategoriesIdActivatePatchRequestOpts( + requestParameters: ApiV1CategoriesIdActivatePatchRequest + ): Promise { + if (requestParameters['id'] == null) { + throw new runtime.RequiredError( + 'id', + 'Required parameter "id" was null or undefined when calling apiV1CategoriesIdActivatePatch().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token('Bearer', []); + + if (tokenString) { + headerParameters['Authorization'] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/api/v1/categories/{id}/activate`; + urlPath = urlPath.replace( + '{id}', + encodeURIComponent(String(requestParameters['id'])) + ); + + return { + path: urlPath, + method: 'PATCH', + headers: headerParameters, + query: queryParameters, + }; + } + + /** + */ + async apiV1CategoriesIdActivatePatchRaw( + requestParameters: ApiV1CategoriesIdActivatePatchRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction + ): Promise> { + const requestOptions = + await this.apiV1CategoriesIdActivatePatchRequestOpts( + requestParameters + ); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + */ + async apiV1CategoriesIdActivatePatch( + requestParameters: ApiV1CategoriesIdActivatePatchRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction + ): Promise { + await this.apiV1CategoriesIdActivatePatchRaw( + requestParameters, + initOverrides + ); + } + /** * Creates request options for apiV1CategoriesIdDeactivatePatch without sending the request */ diff --git a/src/contract/openapi.json b/src/contract/openapi.json index d235c7d..4ffcb9a 100644 --- a/src/contract/openapi.json +++ b/src/contract/openapi.json @@ -1,9121 +1,9603 @@ { - "openapi": "3.0.4", - "info": { - "title": "FreshFlow API", - "description": "Wholesale market food procurement & logistics platform", - "version": "v1" - }, - "paths": { - "/api/v1/admin/users": { - "post": { - "tags": ["Admin"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserCommand" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserCommand" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateUserCommand" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["Admin"], - "parameters": [ - { - "name": "role", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "isActive", - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "search", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - }, - { - "name": "restaurantStatus", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/users/{userId}/activate": { - "patch": { - "tags": ["Admin"], - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActivateRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ActivateRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ActivateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/users/{userId}/unlock": { - "post": { - "tags": ["Admin"], - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/roles": { - "get": { - "tags": ["Admin"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/users/{userId}/role": { - "patch": { - "tags": ["Admin"], - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignRoleRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AssignRoleRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AssignRoleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/restaurants/{restaurantId}/approve": { - "patch": { - "tags": ["Admin"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/restaurants/{restaurantId}/suspend": { - "patch": { - "tags": ["Admin"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/restaurants/{restaurantId}/reactivate": { - "patch": { - "tags": ["Admin"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/restaurants/{restaurantId}/credit/settle": { - "post": { - "tags": ["Admin"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettleCreditRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/SettleCreditRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/SettleCreditRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/admin/restaurants/{restaurantId}/credit/limit": { - "put": { - "tags": ["Admin"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetCreditLimitRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/SetCreditLimitRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/SetCreditLimitRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/operational-settings": { - "get": { - "tags": ["Admin"], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Admin"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups": { - "get": { - "tags": ["Admin"], - "parameters": [ - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups/progress": { - "get": { - "tags": ["Admin"], - "parameters": [ - { - "name": "date", - "in": "query", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups/auto-batch": { - "post": { - "tags": ["Admin"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RunAutoBatchRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RunAutoBatchRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RunAutoBatchRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups/{batchId}/manifest": { - "post": { - "tags": ["Admin"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups/{batchId}/agent": { - "post": { - "tags": ["Admin"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignAgentRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AssignAgentRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AssignAgentRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/order-groups/{batchId}/cancel": { - "post": { - "tags": ["Admin"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderGroupRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderGroupRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderGroupRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/pricing-settings": { - "get": { - "tags": ["Admin"], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Admin"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePricingSettingsRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePricingSettingsRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdatePricingSettingsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/audit-logs": { - "get": { - "tags": ["Admin"], - "parameters": [ - { - "name": "actorId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "action", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "entityType", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/admin/users/{userId}/market-assignments": { - "get": { - "tags": ["Admin"], - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Admin"], - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/overview": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "date", - "in": "query", - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/price-trends": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "marketProductId", - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "interval", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/order-metrics": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "restaurantId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "groupBy", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/procurement-metrics": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "marketId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/hub-throughput": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "hubId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/delivery-performance": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/demand-heatmap": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/demand-heatmap/time-distribution": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/recent-activities": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "entityType", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "action", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/analytics/export": { - "get": { - "tags": ["Analytics"], - "parameters": [ - { - "name": "dataset", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "to", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "marketProductId", - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "format", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/assistant/chat": { - "post": { - "tags": ["Assistant"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssistantChatRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AssistantChatRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AssistantChatRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "Too Many Requests", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/auth/register": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRestaurantRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRestaurantRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RegisterRestaurantRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/login": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/refresh": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogoutRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/LogoutRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/LogoutRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/forgot-password": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/reset-password": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/verify/request": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestVerificationRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RequestVerificationRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RequestVerificationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/verify": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/VerifyRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/VerifyRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/auth/change-password": { - "post": { - "tags": ["Auth"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/categories": { - "get": { - "tags": ["Categories"], - "parameters": [ - { - "name": "activeOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Categories"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCategoryRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateCategoryRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateCategoryRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/categories/{id}": { - "get": { - "tags": ["Categories"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Categories"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCategoryRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCategoryRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateCategoryRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/categories/{id}/deactivate": { - "patch": { - "tags": ["Categories"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/delivery-zones": { - "post": { - "tags": ["DeliveryZones"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDeliveryZoneRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateDeliveryZoneRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateDeliveryZoneRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["DeliveryZones"], - "parameters": [ - { - "name": "active_only", - "in": "query", - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/delivery-zones/{id}": { - "get": { - "tags": ["DeliveryZones"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["DeliveryZones"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "delete": { - "tags": ["DeliveryZones"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/routes/today": { - "get": { - "tags": ["Driver"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/routes/{routeId}/start": { - "post": { - "tags": ["Driver"], - "parameters": [ - { - "name": "routeId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/routes/{routeId}/confirm-pickup": { - "post": { - "tags": ["Driver"], - "parameters": [ - { - "name": "routeId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPickupRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPickupRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPickupRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/deliveries/{deliveryId}/proof-of-delivery/upload-signature": { - "post": { - "tags": ["Driver"], - "parameters": [ - { - "name": "deliveryId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/deliveries/{deliveryId}/proof-of-delivery": { - "put": { - "tags": ["Driver"], - "parameters": [ - { - "name": "deliveryId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/deliveries/{deliveryId}/status": { - "patch": { - "tags": ["Driver"], - "parameters": [ - { - "name": "deliveryId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/driver/deliveries/{deliveryId}/issues": { - "post": { - "tags": ["Driver"], - "parameters": [ - { - "name": "deliveryId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportDeliveryIssueRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReportDeliveryIssueRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReportDeliveryIssueRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/handover": { - "post": { - "tags": ["HubHandover"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateHandoverRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateHandoverRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateHandoverRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/handover/{id}/checkout": { - "post": { - "tags": ["HubHandover"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/handovers": { - "get": { - "tags": ["HubHandover"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/inbound": { - "post": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordInboundRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RecordInboundRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RecordInboundRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "date", - "in": "query", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/scan": { - "post": { - "tags": ["HubInbound"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanInboundRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ScanInboundRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ScanInboundRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/pending-inbound": { - "get": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/inbound/{inboundId}/discrepancy": { - "post": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "inboundId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordDiscrepancyRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RecordDiscrepancyRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RecordDiscrepancyRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/discrepancies": { - "get": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/discrepancies/{discrepancyId}/acknowledge": { - "post": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "discrepancyId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/cross-dock": { - "post": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCrossDockRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateCrossDockRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateCrossDockRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/outbound": { - "post": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordOutboundRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RecordOutboundRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RecordOutboundRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["HubInbound"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "date", - "in": "query", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs": { - "post": { - "tags": ["Hubs"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateHubRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateHubRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateHubRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["Hubs"], - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "is_active", - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{id}": { - "get": { - "tags": ["Hubs"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "patch": { - "tags": ["Hubs"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateHubRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateHubRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateHubRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "delete": { - "tags": ["Hubs"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/{hubId}/staff-assignments": { - "get": { - "tags": ["HubStaffAssignments"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["HubStaffAssignments"], - "parameters": [ - { - "name": "hubId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/hubs/assigned": { - "get": { - "tags": ["HubStaffAssignments"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/markets": { - "get": { - "tags": ["Markets"], - "parameters": [ - { - "name": "activeOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Markets"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/markets/{id}": { - "get": { - "tags": ["Markets"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Markets"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMarketRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMarketRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateMarketRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "delete": { - "tags": ["Markets"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/markets/{id}/deactivate": { - "patch": { - "tags": ["Markets"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/markets/{marketId}/products": { - "get": { - "tags": ["Markets"], - "parameters": [ - { - "name": "marketId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "category", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "post": { - "tags": ["Markets"], - "parameters": [ - { - "name": "marketId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketProductRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketProductRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateMarketProductRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/markets/{marketId}/products/{productId}/price-history": { - "get": { - "tags": ["Markets"], - "parameters": [ - { - "name": "marketId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "productId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/markets/{marketId}/products/{productId}/price": { - "patch": { - "tags": ["Markets"], - "parameters": [ - { - "name": "marketId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "productId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductPriceRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductPriceRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductPriceRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/markets/{marketId}/products/{productId}/quantity": { - "patch": { - "tags": ["Markets"], - "parameters": [ - { - "name": "marketId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "productId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/notifications": { - "get": { - "tags": ["Notification"], - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "is_read", - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/notifications/{id}/read": { - "patch": { - "tags": ["Notification"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/notifications/devices": { - "post": { - "tags": ["NotificationDevice"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "delete": { - "tags": ["NotificationDevice"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "restaurantId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "sort", - "in": "query", - "schema": { - "type": "string", - "default": "createdAt:desc" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "post": { - "tags": ["Orders"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDraftOrderRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateDraftOrderRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateDraftOrderRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/history": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "restaurantId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "sort", - "in": "query", - "schema": { - "type": "string", - "default": "createdAt:desc" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/scheduled": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "restaurantId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "includeCancelled", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "post": { - "tags": ["Orders"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduledOrderRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduledOrderRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduledOrderRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/scheduled/{scheduledOrderId}": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "scheduledOrderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "patch": { - "tags": ["Orders"], - "parameters": [ - { - "name": "scheduledOrderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduledOrderRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduledOrderRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduledOrderRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/scheduled/{scheduledOrderId}/instances": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "scheduledOrderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/items": { - "post": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddOrderItemRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AddOrderItemRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AddOrderItemRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/items/{itemId}": { - "put": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "itemId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrderItemRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrderItemRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrderItemRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "delete": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "itemId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/confirm": { - "post": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/confirm-preview": { - "get": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/cancel": { - "patch": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CancelOrderRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/items/{itemId}/actual-quantity": { - "patch": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "itemId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordActualQuantityRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RecordActualQuantityRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RecordActualQuantityRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/advance-status": { - "post": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdvanceOrderStatusRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AdvanceOrderStatusRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AdvanceOrderStatusRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/receipt": { - "patch": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/issues": { - "post": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportOrderIssueRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReportOrderIssueRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReportOrderIssueRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/{orderId}/reorder": { - "post": { - "tags": ["Orders"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReorderFromHistoryRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReorderFromHistoryRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReorderFromHistoryRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/orders/scheduled/{scheduledOrderId}/cancel": { - "patch": { - "tags": ["Orders"], - "parameters": [ - { - "name": "scheduledOrderId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/pricing/assigned-markets": { - "get": { - "tags": ["Pricing"], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/procurement/tasks": { - "get": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/procurement/tasks/{batchId}": { - "get": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/procurement/tasks/{batchId}/purchase": { - "patch": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPurchaseRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPurchaseRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ConfirmPurchaseRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/procurement/tasks/{batchId}/handover": { - "patch": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HandoverRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/HandoverRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/HandoverRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/procurement/tasks/{batchId}/exceptions": { - "post": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportProcurementExceptionRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReportProcurementExceptionRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReportProcurementExceptionRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/procurement/tasks/{batchId}/exceptions/upload-signature": { - "post": { - "tags": ["Procurement"], - "parameters": [ - { - "name": "batchId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/products": { - "post": { - "tags": ["Products"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProductRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateProductRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateProductRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["Products"], - "parameters": [ - { - "name": "Search", - "in": "query", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "Category", - "in": "query", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "IncludeInactive", - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "Page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "nullable": true - } - }, - { - "name": "PageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/products/image/upload-signature": { - "post": { - "tags": ["Products"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/products/{id}": { - "get": { - "tags": ["Products"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Products"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateProductRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/products/{id}/deactivate": { - "patch": { - "tags": ["Products"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/profile/me": { - "get": { - "tags": ["Profile"], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Profile"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMyProfileRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMyProfileRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateMyProfileRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/profile/me/avatar/upload-signature": { - "post": { - "tags": ["Profile"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit": { - "get": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit/transactions": { - "get": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit/statements/generate": { - "post": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenerateStatementRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/GenerateStatementRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/GenerateStatementRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit/statements/{statementId}": { - "get": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "statementId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit/statements/{statementId}/pdf": { - "get": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "statementId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/{restaurantId}/credit/statements": { - "get": { - "tags": ["RestaurantCredit"], - "parameters": [ - { - "name": "restaurantId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "403": { - "description": "Forbidden", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/restaurants/me/approval-status": { - "get": { - "tags": ["RestaurantProfile"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/restaurants/me/profile": { - "get": { - "tags": ["RestaurantProfile"], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["RestaurantProfile"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/restaurants/me/business-license/upload-signature": { - "post": { - "tags": ["RestaurantProfile"], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/restaurants/me/delivery-addresses": { - "get": { - "tags": ["RestaurantProfile"], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["RestaurantProfile"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/restaurants/me/delivery-addresses/{id}": { - "put": { - "tags": ["RestaurantProfile"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/DeliveryAddressRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "delete": { - "tags": ["RestaurantProfile"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/calculate": { - "post": { - "tags": ["Routes"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CalculateRouteRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CalculateRouteRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CalculateRouteRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{id}/select": { - "post": { - "tags": ["Routes"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{id}/optimize": { - "post": { - "tags": ["Routes"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OptimizeRouteRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/OptimizeRouteRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/OptimizeRouteRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{id}/review": { - "post": { - "tags": ["Routes"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReviewRouteRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ReviewRouteRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ReviewRouteRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{id}/assign-vehicle": { - "post": { - "tags": ["Routes"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignVehicleRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/AssignVehicleRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/AssignVehicleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes": { - "get": { - "tags": ["Routes"], - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "service_date", - "in": "query", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{routeId}/eligibility": { - "get": { - "tags": ["Routes"], - "parameters": [ - { - "name": "routeId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "vehicleId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "driver_user_id", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/routes/{id}": { - "get": { - "tags": ["Routes"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/units": { - "get": { - "tags": ["Units"], - "parameters": [ - { - "name": "activeOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Units"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUnitRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateUnitRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateUnitRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/units/{id}": { - "get": { - "tags": ["Units"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Units"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUnitRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateUnitRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateUnitRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/units/{id}/deactivate": { - "patch": { - "tags": ["Units"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/vehicles": { - "post": { - "tags": ["Vehicles"], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterVehicleRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RegisterVehicleRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RegisterVehicleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "get": { - "tags": ["Vehicles"], - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "is_active", - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v1/logistics/vehicles/{id}": { - "get": { - "tags": ["Vehicles"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "put": { - "tags": ["Vehicles"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateVehicleRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateVehicleRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateVehicleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - }, - "delete": { - "tags": ["Vehicles"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - } - }, - "components": { - "schemas": { - "ActivateRequest": { - "type": "object", - "properties": { - "isActive": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "AddOrderItemRequest": { - "required": ["marketProductId"], - "type": "object", - "properties": { - "marketProductId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "quantity": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "AdvanceOrderStatusRequest": { - "type": "object", - "properties": { - "status": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "AssignAgentRequest": { - "required": ["agentUserId"], - "type": "object", - "properties": { - "agentUserId": { - "minLength": 1, - "type": "string", - "format": "uuid" - } - }, - "additionalProperties": false - }, - "AssignRoleRequest": { - "required": ["roleName"], - "type": "object", - "properties": { - "roleName": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "AssignVehicleRequest": { - "required": ["vehicleId"], - "type": "object", - "properties": { - "vehicleId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "driverUserId": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "AssistantChatRequest": { - "required": ["message", "sessionId"], - "type": "object", - "properties": { - "sessionId": { - "maxLength": 128, - "minLength": 1, - "type": "string" - }, - "message": { - "maxLength": 4000, - "minLength": 1, - "type": "string" - }, - "marketId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "confirmOrderId": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "AttachProofOfDeliveryRequest": { - "required": ["proofUrl"], - "type": "object", - "properties": { - "proofUrl": { - "maxLength": 512, - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "CalculateRouteRequest": { - "required": ["destinationRestaurantIds", "sourceMarketIds"], - "type": "object", - "properties": { - "sourceMarketIds": { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - }, - "hubIds": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "nullable": true - }, - "destinationRestaurantIds": { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - }, - "optimizationCriteria": { - "type": "string", - "nullable": true - }, - "serviceDate": { - "type": "string", - "format": "date" - }, - "compareWithHub": { - "type": "boolean", - "nullable": true - } - }, - "additionalProperties": false - }, - "CancelOrderGroupRequest": { - "type": "object", - "properties": { - "reason": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CancelOrderRequest": { - "type": "object", - "properties": { - "reason": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ChangePasswordRequest": { - "required": ["currentPassword", "newPassword"], - "type": "object", - "properties": { - "currentPassword": { - "minLength": 1, - "type": "string" - }, - "newPassword": { - "minLength": 8, - "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", - "type": "string" - } - }, - "additionalProperties": false - }, - "ConfirmPickupRequest": { - "required": ["orderIds"], - "type": "object", - "properties": { - "orderIds": { - "minItems": 1, - "type": "array", - "items": { - "minLength": 1, - "type": "string", - "format": "uuid" - } - } - }, - "additionalProperties": false - }, - "ConfirmPurchaseRequest": { - "required": ["lines"], - "type": "object", - "properties": { - "lines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PurchaseLineDto" - } - } - }, - "additionalProperties": false - }, - "CreateCategoryRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "parentId": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateCrossDockRequest": { - "required": ["inboundEventId", "outboundRouteId"], - "type": "object", - "properties": { - "inboundEventId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "outboundRouteId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "notes": { - "maxLength": 1000, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateDeliveryZoneRequest": { - "required": ["code", "name"], - "type": "object", - "properties": { - "code": { - "maxLength": 50, - "minLength": 1, - "pattern": "^[A-Za-z0-9_]+$", - "type": "string" - }, - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "description": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateDraftOrderRequest": { - "required": ["items"], - "type": "object", - "properties": { - "items": { - "minItems": 1, - "type": "array", - "items": { - "$ref": "#/components/schemas/DraftOrderItemRequest" - } - }, - "scheduledFor": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "notes": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateHandoverRequest": { - "required": ["deliveryRouteId", "driverUserId"], - "type": "object", - "properties": { - "deliveryRouteId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "driverUserId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "outboundEventId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "notes": { - "maxLength": 1000, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateHubRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "address": { - "maxLength": 500, - "type": "string", - "nullable": true - }, - "latitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "longitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "capacityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "managedBy": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateMarketProductRequest": { - "required": ["productId"], - "type": "object", - "properties": { - "productId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "initialPrice": { - "maximum": 50000000, - "type": "number", - "format": "double" - }, - "initialQuantity": { - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "CreateMarketRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "location": { - "type": "string", - "nullable": true - }, - "address": { - "type": "string", - "nullable": true - }, - "latitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "longitude": { - "type": "number", - "format": "double", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateProductRequest": { - "required": ["name", "unitId"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "unitId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "categoryId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateScheduledOrderRequest": { - "required": ["firstRunAt", "recurrenceType"], - "type": "object", - "properties": { - "recurrenceType": { - "minLength": 1, - "type": "string" - }, - "firstRunAt": { - "minLength": 1, - "type": "string", - "format": "date-time" - }, - "notes": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateUnitRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "abbreviation": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "CreateUserCommand": { - "required": ["email", "password", "role"], - "type": "object", - "properties": { - "email": { - "maxLength": 255, - "minLength": 1, - "type": "string", - "format": "email" - }, - "password": { - "minLength": 8, - "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", - "type": "string" - }, - "role": { - "minLength": 1, - "type": "string" - }, - "marketId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "restaurantName": { - "type": "string", - "nullable": true - }, - "phone": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "DeliveryAddressRequest": { - "required": ["addressLine"], - "type": "object", - "properties": { - "addressLine": { - "maxLength": 500, - "minLength": 1, - "type": "string" - }, - "recipientName": { - "type": "string", - "nullable": true - }, - "phone": { - "type": "string", - "nullable": true - }, - "latitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "longitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "isDefault": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "DraftOrderItemRequest": { - "required": ["marketProductId"], - "type": "object", - "properties": { - "marketProductId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "quantity": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "ForgotPasswordRequest": { - "required": ["identifier"], - "type": "object", - "properties": { - "identifier": { - "minLength": 1, - "type": "string", - "format": "email" - } - }, - "additionalProperties": false - }, - "GenerateStatementRequest": { - "type": "object", - "properties": { - "year": { - "type": "integer", - "format": "int32" - }, - "month": { - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "HandoverRequest": { - "type": "object", - "properties": { - "hubId": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "HubInboundItemCommand": { - "required": ["marketProductId"], - "type": "object", - "properties": { - "marketProductId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "productId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "quantityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "HubOutboundItemCommand": { - "required": ["marketProductId"], - "type": "object", - "properties": { - "marketProductId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "productId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "quantityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "LoginRequest": { - "required": ["identifier", "password"], - "type": "object", - "properties": { - "identifier": { - "maxLength": 255, - "minLength": 1, - "type": "string" - }, - "password": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "LogoutRequest": { - "required": ["refreshToken"], - "type": "object", - "properties": { - "refreshToken": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "OptimizeRouteRequest": { - "required": ["optimizationCriteria"], - "type": "object", - "properties": { - "optimizationCriteria": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "ProblemDetails": { - "type": "object", - "properties": { - "type": { - "type": "string", - "nullable": true - }, - "title": { - "type": "string", - "nullable": true - }, - "status": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "detail": { - "type": "string", - "nullable": true - }, - "instance": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": {} - }, - "PurchaseLineDto": { - "required": ["marketProductId"], - "type": "object", - "properties": { - "marketProductId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "actualQuantity": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "integer", - "format": "int32" - }, - "actualUnitPrice": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "RecordActualQuantityRequest": { - "type": "object", - "properties": { - "actualQuantity": { - "minimum": 0, - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "RecordDiscrepancyRequest": { - "required": ["orderItemId"], - "type": "object", - "properties": { - "orderItemId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "affectedQuantity": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "conditionStatus": { - "type": "string", - "nullable": true - }, - "notes": { - "maxLength": 1000, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "RecordInboundItemRequest": { - "type": "object", - "properties": { - "marketProductId": { - "type": "string", - "format": "uuid" - }, - "productId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "quantityKg": { - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "RecordInboundRequest": { - "required": ["arrivedAt", "items"], - "type": "object", - "properties": { - "sourceMarketId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "deliveryScheduleId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "items": { - "minItems": 1, - "type": "array", - "items": { - "$ref": "#/components/schemas/RecordInboundItemRequest" - } - }, - "arrivedAt": { - "minLength": 1, - "type": "string", - "format": "date-time" - } - }, - "additionalProperties": false - }, - "RecordOutboundItemRequest": { - "type": "object", - "properties": { - "marketProductId": { - "type": "string", - "format": "uuid" - }, - "productId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "quantityKg": { - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "RecordOutboundRequest": { - "required": ["destinationRouteId", "dispatchedAt", "items"], - "type": "object", - "properties": { - "destinationRouteId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "items": { - "minItems": 1, - "type": "array", - "items": { - "$ref": "#/components/schemas/RecordOutboundItemRequest" - } - }, - "dispatchedAt": { - "minLength": 1, - "type": "string", - "format": "date-time" - } - }, - "additionalProperties": false - }, - "RefreshRequest": { - "required": ["refreshToken"], - "type": "object", - "properties": { - "refreshToken": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "RegisterNotificationDeviceRequest": { - "type": "object", - "properties": { - "token": { - "type": "string", - "nullable": true - }, - "platform": { - "type": "string", - "nullable": true - }, - "deviceId": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "RegisterRestaurantRequest": { - "required": ["email", "password", "restaurantName"], - "type": "object", - "properties": { - "email": { - "maxLength": 255, - "minLength": 1, - "type": "string", - "format": "email" - }, - "password": { - "minLength": 8, - "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", - "type": "string" - }, - "restaurantName": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "phone": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "RegisterVehicleRequest": { - "required": ["plateNumber"], - "type": "object", - "properties": { - "plateNumber": { - "maxLength": 20, - "minLength": 1, - "type": "string" - }, - "capacityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "vehicleType": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ReorderFromHistoryRequest": { - "type": "object", - "properties": { - "scheduledFor": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "notes": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ReplaceHubStaffAssignmentsRequest": { - "required": ["staffUserIds"], - "type": "object", - "properties": { - "staffUserIds": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - "additionalProperties": false - }, - "ReplaceMarketAssignmentsRequest": { - "required": ["marketIds"], - "type": "object", - "properties": { - "marketIds": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - "additionalProperties": false - }, - "ReportDeliveryIssueRequest": { - "type": "object", - "properties": { - "issueType": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ReportOrderIssueRequest": { - "type": "object", - "properties": { - "orderItemId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "issueType": { - "type": "string", - "nullable": true - }, - "affectedQuantity": { - "type": "number", - "format": "double" - }, - "description": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ReportProcurementExceptionRequest": { - "type": "object", - "properties": { - "marketProductId": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string", - "nullable": true - }, - "reportedQuantity": { - "type": "integer", - "format": "int32" - }, - "note": { - "type": "string", - "nullable": true - }, - "proofImageUrl": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "RequestVerificationRequest": { - "required": ["channel", "identifier"], - "type": "object", - "properties": { - "identifier": { - "minLength": 1, - "type": "string", - "format": "email" - }, - "channel": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "ResetPasswordRequest": { - "required": ["newPassword", "token"], - "type": "object", - "properties": { - "token": { - "minLength": 1, - "type": "string" - }, - "newPassword": { - "minLength": 8, - "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", - "type": "string" - } - }, - "additionalProperties": false - }, - "ReviewRouteRequest": { - "type": "object", - "properties": { - "stopOrder": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "nullable": true - } - }, - "additionalProperties": false - }, - "RunAutoBatchRequest": { - "type": "object", - "properties": { - "targetDate": { - "type": "string", - "format": "date", - "nullable": true - }, - "dryRun": { - "type": "boolean", - "nullable": true - }, - "force": { - "type": "boolean", - "nullable": true - } - }, - "additionalProperties": false - }, - "ScanInboundRequest": { - "required": ["code"], - "type": "object", - "properties": { - "code": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - }, - "SetCreditLimitRequest": { - "type": "object", - "properties": { - "creditLimit": { - "minimum": 0, - "type": "number", - "format": "double" - }, - "note": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "SettleCreditRequest": { - "type": "object", - "properties": { - "amount": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "paymentMethod": { - "type": "string", - "nullable": true - }, - "reference": { - "maxLength": 200, - "type": "string", - "nullable": true - }, - "note": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UnregisterNotificationDeviceRequest": { - "type": "object", - "properties": { - "token": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateAvailableQuantityRequest": { - "type": "object", - "properties": { - "quantity": { - "type": "integer", - "format": "int32" - }, - "expectedVersion": { - "type": "string", - "format": "date-time", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateCategoryRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "parentId": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateDeliveryStatusRequest": { - "required": ["status"], - "type": "object", - "properties": { - "status": { - "minLength": 1, - "type": "string" - }, - "failureReason": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateDeliveryZoneRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "description": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateHubRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "address": { - "maxLength": 500, - "type": "string", - "nullable": true - }, - "latitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "longitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "capacityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "managedBy": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateMarketRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "location": { - "type": "string", - "nullable": true - }, - "address": { - "type": "string", - "nullable": true - }, - "latitude": { - "type": "number", - "format": "double", - "nullable": true - }, - "longitude": { - "type": "number", - "format": "double", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateMyProfileRequest": { - "type": "object", - "properties": { - "fullName": { - "type": "string", - "nullable": true - }, - "phone": { - "type": "string", - "nullable": true - }, - "avatarUrl": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateOperationalSettingsRequest": { - "type": "object", - "properties": { - "dailyCutoffTime": { - "type": "string", - "format": "time" - }, - "batchingEnabled": { - "type": "boolean" - }, - "defaultRouteType": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateOrderItemRequest": { - "type": "object", - "properties": { - "quantity": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "UpdatePricingSettingsRequest": { - "type": "object", - "properties": { - "priceAlertThresholdPercent": { - "maximum": 100, - "minimum": 0.01, - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "UpdateProductPriceRequest": { - "type": "object", - "properties": { - "price": { - "type": "number", - "format": "double", - "nullable": true - }, - "quantity": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "expectedVersion": { - "type": "string", - "format": "date-time", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateProductRequest": { - "required": ["name", "unitId"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "unitId": { - "minLength": 1, - "type": "string", - "format": "uuid" - }, - "categoryId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "imageUrl": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateRestaurantProfileRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "address": { - "type": "string", - "nullable": true - }, - "contactPerson": { - "type": "string", - "nullable": true - }, - "pickupStart": { - "type": "string", - "format": "time", - "nullable": true - }, - "pickupEnd": { - "type": "string", - "format": "time", - "nullable": true - }, - "businessLicenseUrl": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateScheduledOrderRequest": { - "type": "object", - "properties": { - "recurrenceType": { - "type": "string", - "nullable": true - }, - "firstRunAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "notes": { - "maxLength": 500, - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateUnitRequest": { - "required": ["name"], - "type": "object", - "properties": { - "name": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "abbreviation": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "UpdateVehicleRequest": { - "required": ["plateNumber"], - "type": "object", - "properties": { - "plateNumber": { - "maxLength": 20, - "minLength": 1, - "type": "string" - }, - "capacityKg": { - "minimum": 0, - "exclusiveMinimum": true, - "type": "number", - "format": "double" - }, - "vehicleType": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "VerifyRequest": { - "required": ["channel", "code", "identifier"], - "type": "object", - "properties": { - "identifier": { - "minLength": 1, - "type": "string", - "format": "email" - }, - "channel": { - "minLength": 1, - "type": "string" - }, - "code": { - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false - } - }, - "securitySchemes": { - "Bearer": { - "type": "http", - "description": "Paste the access token returned by POST /api/v1/auth/login", - "scheme": "bearer", - "bearerFormat": "JWT" - } - } - }, - "security": [ - { - "Bearer": [] - } - ], - "tags": [ - { - "name": "Admin" - }, - { - "name": "Analytics" - }, - { - "name": "Assistant" - }, - { - "name": "Auth" - }, - { - "name": "Categories" - }, - { - "name": "DeliveryZones" - }, - { - "name": "Driver" - }, - { - "name": "HubHandover" - }, - { - "name": "HubInbound" - }, - { - "name": "Hubs" - }, - { - "name": "HubStaffAssignments" - }, - { - "name": "Markets" - }, - { - "name": "Notification" - }, - { - "name": "NotificationDevice" - }, - { - "name": "Orders" - }, - { - "name": "Pricing" + "openapi": "3.0.4", + "info": { + "title": "FreshFlow API", + "description": "Wholesale market food procurement & logistics platform", + "version": "v1" + }, + "paths": { + "/api/v1/admin/users": { + "post": { + "tags": [ + "Admin" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserCommand" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserCommand" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateUserCommand" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "role", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isActive", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + }, + { + "name": "restaurantStatus", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/users/{userId}/activate": { + "patch": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/users/{userId}/unlock": { + "post": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/roles": { + "get": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/users/{userId}/role": { + "patch": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignRoleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AssignRoleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AssignRoleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/restaurants/{restaurantId}/approve": { + "patch": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/restaurants/{restaurantId}/suspend": { + "patch": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/restaurants/{restaurantId}/reactivate": { + "patch": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/restaurants/{restaurantId}/credit/settle": { + "post": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettleCreditRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SettleCreditRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SettleCreditRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/admin/restaurants/{restaurantId}/credit/limit": { + "put": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetCreditLimitRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SetCreditLimitRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SetCreditLimitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/operational-settings": { + "get": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Admin" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateOperationalSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups": { + "get": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups/progress": { + "get": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups/auto-batch": { + "post": { + "tags": [ + "Admin" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunAutoBatchRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RunAutoBatchRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RunAutoBatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups/{batchId}/manifest": { + "post": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups/{batchId}/agent": { + "post": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignAgentRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AssignAgentRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AssignAgentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/order-groups/{batchId}/cancel": { + "post": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderGroupRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/pricing-settings": { + "get": { + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Admin" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePricingSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePricingSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePricingSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/audit-logs": { + "get": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "actorId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "entityType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/admin/users/{userId}/market-assignments": { + "get": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceMarketAssignmentsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/overview": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/price-trends": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "marketProductId", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "interval", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/order-metrics": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "restaurantId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "groupBy", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/procurement-metrics": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "marketId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/hub-throughput": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "hubId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/delivery-performance": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/demand-heatmap": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/demand-heatmap/time-distribution": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/recent-activities": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "entityType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/analytics/export": { + "get": { + "tags": [ + "Analytics" + ], + "parameters": [ + { + "name": "dataset", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "marketProductId", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "format", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/assistant/chat": { + "post": { + "tags": [ + "Assistant" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantChatRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AssistantChatRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AssistantChatRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRestaurantRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRestaurantRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RegisterRestaurantRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LogoutRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/LogoutRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/verify/request": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestVerificationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RequestVerificationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RequestVerificationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/verify": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/change-password": { + "post": { + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/categories": { + "get": { + "tags": [ + "Categories" + ], + "parameters": [ + { + "name": "activeOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "Categories" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/categories/{id}": { + "get": { + "tags": [ + "Categories" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Categories" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCategoryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCategoryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateCategoryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/categories/{id}/deactivate": { + "patch": { + "tags": [ + "Categories" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/categories/{id}/activate": { + "patch": { + "tags": [ + "Categories" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/delivery-zones": { + "post": { + "tags": [ + "DeliveryZones" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDeliveryZoneRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDeliveryZoneRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDeliveryZoneRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "DeliveryZones" + ], + "parameters": [ + { + "name": "active_only", + "in": "query", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/delivery-zones/{id}": { + "get": { + "tags": [ + "DeliveryZones" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "DeliveryZones" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryZoneRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "DeliveryZones" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/routes/today": { + "get": { + "tags": [ + "Driver" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/routes/{routeId}/start": { + "post": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "routeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/routes/{routeId}/confirm-pickup": { + "post": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "routeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPickupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPickupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPickupRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/deliveries/{deliveryId}/proof-of-delivery/upload-signature": { + "post": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/deliveries/{deliveryId}/proof-of-delivery": { + "put": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AttachProofOfDeliveryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/deliveries/{deliveryId}/status": { + "patch": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryStatusRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/driver/deliveries/{deliveryId}/issues": { + "post": { + "tags": [ + "Driver" + ], + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDeliveryIssueRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReportDeliveryIssueRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReportDeliveryIssueRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/handover": { + "post": { + "tags": [ + "HubHandover" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHandoverRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateHandoverRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateHandoverRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/handover/{id}/checkout": { + "post": { + "tags": [ + "HubHandover" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/handovers": { + "get": { + "tags": [ + "HubHandover" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/inbound": { + "post": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordInboundRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RecordInboundRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RecordInboundRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/scan": { + "post": { + "tags": [ + "HubInbound" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanInboundRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScanInboundRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ScanInboundRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/pending-inbound": { + "get": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/inbound/{inboundId}/discrepancy": { + "post": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "inboundId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordDiscrepancyRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RecordDiscrepancyRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RecordDiscrepancyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/discrepancies": { + "get": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/discrepancies/{discrepancyId}/acknowledge": { + "post": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "discrepancyId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/cross-dock": { + "post": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCrossDockRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateCrossDockRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateCrossDockRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/outbound": { + "post": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordOutboundRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RecordOutboundRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RecordOutboundRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "HubInbound" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs": { + "post": { + "tags": [ + "Hubs" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHubRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateHubRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateHubRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Hubs" + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "is_active", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{id}": { + "get": { + "tags": [ + "Hubs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "patch": { + "tags": [ + "Hubs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateHubRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateHubRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateHubRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "Hubs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/{hubId}/staff-assignments": { + "get": { + "tags": [ + "HubStaffAssignments" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "HubStaffAssignments" + ], + "parameters": [ + { + "name": "hubId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceHubStaffAssignmentsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/hubs/assigned": { + "get": { + "tags": [ + "HubStaffAssignments" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/markets": { + "get": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "activeOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "Markets" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/markets/{id}": { + "get": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMarketRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMarketRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateMarketRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/markets/{id}/deactivate": { + "patch": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/markets/{marketId}/products": { + "get": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "marketId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "marketId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketProductRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketProductRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateMarketProductRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/markets/{marketId}/products/{productId}/price-history": { + "get": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "marketId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/markets/{marketId}/products/{productId}/price": { + "patch": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "marketId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductPriceRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductPriceRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductPriceRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/markets/{marketId}/products/{productId}/quantity": { + "patch": { + "tags": [ + "Markets" + ], + "parameters": [ + { + "name": "marketId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateAvailableQuantityRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications": { + "get": { + "tags": [ + "Notification" + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "is_read", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/{id}/read": { + "patch": { + "tags": [ + "Notification" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/devices": { + "post": { + "tags": [ + "NotificationDevice" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RegisterNotificationDeviceRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "NotificationDevice" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UnregisterNotificationDeviceRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "default": "createdAt:desc" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Orders" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDraftOrderRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDraftOrderRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDraftOrderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/history": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "default": "createdAt:desc" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/scheduled": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "includeCancelled", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Orders" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduledOrderRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduledOrderRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduledOrderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/scheduled/{scheduledOrderId}": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "scheduledOrderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "scheduledOrderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduledOrderRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduledOrderRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduledOrderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/scheduled/{scheduledOrderId}/instances": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "scheduledOrderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/items": { + "post": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddOrderItemRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AddOrderItemRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AddOrderItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/items/{itemId}": { + "put": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrderItemRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrderItemRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrderItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/confirm": { + "post": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/confirm-preview": { + "get": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/cancel": { + "patch": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/items/{itemId}/actual-quantity": { + "patch": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordActualQuantityRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RecordActualQuantityRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RecordActualQuantityRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/advance-status": { + "post": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdvanceOrderStatusRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AdvanceOrderStatusRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AdvanceOrderStatusRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/receipt": { + "patch": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/issues": { + "post": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportOrderIssueRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReportOrderIssueRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReportOrderIssueRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/{orderId}/reorder": { + "post": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderFromHistoryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReorderFromHistoryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReorderFromHistoryRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/orders/scheduled/{scheduledOrderId}/cancel": { + "patch": { + "tags": [ + "Orders" + ], + "parameters": [ + { + "name": "scheduledOrderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/pricing/assigned-markets": { + "get": { + "tags": [ + "Pricing" + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/procurement/tasks": { + "get": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/procurement/tasks/{batchId}": { + "get": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/procurement/tasks/{batchId}/purchase": { + "patch": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPurchaseRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPurchaseRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPurchaseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/procurement/tasks/{batchId}/handover": { + "patch": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HandoverRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/HandoverRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/HandoverRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/procurement/tasks/{batchId}/exceptions": { + "post": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportProcurementExceptionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReportProcurementExceptionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReportProcurementExceptionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/procurement/tasks/{batchId}/exceptions/upload-signature": { + "post": { + "tags": [ + "Procurement" + ], + "parameters": [ + { + "name": "batchId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/products": { + "post": { + "tags": [ + "Products" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProductRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateProductRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateProductRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Products" + ], + "parameters": [ + { + "name": "Search", + "in": "query", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "Category", + "in": "query", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "IncludeInactive", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/products/image/upload-signature": { + "post": { + "tags": [ + "Products" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/products/{id}": { + "get": { + "tags": [ + "Products" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Products" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/products/{id}/deactivate": { + "patch": { + "tags": [ + "Products" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/profile/me": { + "get": { + "tags": [ + "Profile" + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMyProfileRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMyProfileRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateMyProfileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/profile/me/avatar/upload-signature": { + "post": { + "tags": [ + "Profile" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit": { + "get": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit/transactions": { + "get": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit/statements/generate": { + "post": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateStatementRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/GenerateStatementRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/GenerateStatementRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit/statements/{statementId}": { + "get": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "statementId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit/statements/{statementId}/pdf": { + "get": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "statementId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/{restaurantId}/credit/statements": { + "get": { + "tags": [ + "RestaurantCredit" + ], + "parameters": [ + { + "name": "restaurantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/restaurants/me/approval-status": { + "get": { + "tags": [ + "RestaurantProfile" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/restaurants/me/profile": { + "get": { + "tags": [ + "RestaurantProfile" + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "RestaurantProfile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateRestaurantProfileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/restaurants/me/business-license/upload-signature": { + "post": { + "tags": [ + "RestaurantProfile" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/restaurants/me/delivery-addresses": { + "get": { + "tags": [ + "RestaurantProfile" + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "RestaurantProfile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/restaurants/me/delivery-addresses/{id}": { + "put": { + "tags": [ + "RestaurantProfile" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DeliveryAddressRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "RestaurantProfile" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/calculate": { + "post": { + "tags": [ + "Routes" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalculateRouteRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CalculateRouteRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CalculateRouteRequest" + } + } + } }, - { - "name": "Procurement" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{id}/select": { + "post": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{id}/optimize": { + "post": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OptimizeRouteRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/OptimizeRouteRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/OptimizeRouteRequest" + } + } + } }, - { - "name": "Products" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{id}/review": { + "post": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewRouteRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReviewRouteRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReviewRouteRequest" + } + } + } }, - { - "name": "Profile" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{id}/assign-vehicle": { + "post": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignVehicleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AssignVehicleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AssignVehicleRequest" + } + } + } }, - { - "name": "RestaurantCredit" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes": { + "get": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "service_date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{routeId}/eligibility": { + "get": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "routeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "vehicleId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "driver_user_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/routes/{id}": { + "get": { + "tags": [ + "Routes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/units": { + "get": { + "tags": [ + "Units" + ], + "parameters": [ + { + "name": "activeOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "Units" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUnitRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateUnitRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateUnitRequest" + } + } + } }, - { - "name": "RestaurantProfile" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/units/{id}": { + "get": { + "tags": [ + "Units" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Units" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUnitRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUnitRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUnitRequest" + } + } + } }, - { - "name": "Routes" + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/units/{id}/deactivate": { + "patch": { + "tags": [ + "Units" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/vehicles": { + "post": { + "tags": [ + "Vehicles" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterVehicleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RegisterVehicleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/RegisterVehicleRequest" + } + } + } }, - { - "name": "Units" + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Vehicles" + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + } + }, + { + "name": "is_active", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/logistics/vehicles/{id}": { + "get": { + "tags": [ + "Vehicles" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Vehicles" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateVehicleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateVehicleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateVehicleRequest" + } + } + } }, - { - "name": "Vehicles" + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "Vehicles" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } } - ] -} + } + } + }, + "components": { + "schemas": { + "ActivateRequest": { + "type": "object", + "properties": { + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "AddOrderItemRequest": { + "required": [ + "marketProductId" + ], + "type": "object", + "properties": { + "marketProductId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "quantity": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "AdvanceOrderStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AssignAgentRequest": { + "required": [ + "agentUserId" + ], + "type": "object", + "properties": { + "agentUserId": { + "minLength": 1, + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "AssignRoleRequest": { + "required": [ + "roleName" + ], + "type": "object", + "properties": { + "roleName": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "AssignVehicleRequest": { + "required": [ + "vehicleId" + ], + "type": "object", + "properties": { + "vehicleId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "driverUserId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "AssistantChatRequest": { + "required": [ + "message", + "sessionId" + ], + "type": "object", + "properties": { + "sessionId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "message": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "marketId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "confirmOrderId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "AttachProofOfDeliveryRequest": { + "required": [ + "proofUrl" + ], + "type": "object", + "properties": { + "proofUrl": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "CalculateRouteRequest": { + "required": [ + "destinationRestaurantIds", + "sourceMarketIds" + ], + "type": "object", + "properties": { + "sourceMarketIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "hubIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + }, + "destinationRestaurantIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "optimizationCriteria": { + "type": "string", + "nullable": true + }, + "serviceDate": { + "type": "string", + "format": "date" + }, + "compareWithHub": { + "type": "boolean", + "nullable": true + } + }, + "additionalProperties": false + }, + "CancelOrderGroupRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CancelOrderRequest": { + "type": "object", + "properties": { + "reason": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ChangePasswordRequest": { + "required": [ + "currentPassword", + "newPassword" + ], + "type": "object", + "properties": { + "currentPassword": { + "minLength": 1, + "type": "string" + }, + "newPassword": { + "minLength": 8, + "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", + "type": "string" + } + }, + "additionalProperties": false + }, + "ConfirmPickupRequest": { + "required": [ + "orderIds" + ], + "type": "object", + "properties": { + "orderIds": { + "minItems": 1, + "type": "array", + "items": { + "minLength": 1, + "type": "string", + "format": "uuid" + } + } + }, + "additionalProperties": false + }, + "ConfirmPurchaseRequest": { + "required": [ + "lines" + ], + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PurchaseLineDto" + } + } + }, + "additionalProperties": false + }, + "CreateCategoryRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "parentId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateCrossDockRequest": { + "required": [ + "inboundEventId", + "outboundRouteId" + ], + "type": "object", + "properties": { + "inboundEventId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "outboundRouteId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "notes": { + "maxLength": 1000, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateDeliveryZoneRequest": { + "required": [ + "code", + "name" + ], + "type": "object", + "properties": { + "code": { + "maxLength": 50, + "minLength": 1, + "pattern": "^[A-Za-z0-9_]+$", + "type": "string" + }, + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "description": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateDraftOrderRequest": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftOrderItemRequest" + } + }, + "scheduledFor": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "notes": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateHandoverRequest": { + "required": [ + "deliveryRouteId", + "driverUserId" + ], + "type": "object", + "properties": { + "deliveryRouteId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "driverUserId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "outboundEventId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "notes": { + "maxLength": 1000, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateHubRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "address": { + "maxLength": 500, + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "capacityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "managedBy": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateMarketProductRequest": { + "required": [ + "productId" + ], + "type": "object", + "properties": { + "productId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "initialPrice": { + "maximum": 50000000, + "type": "number", + "format": "double" + }, + "initialQuantity": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "CreateMarketRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "location": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateProductRequest": { + "required": [ + "name", + "unitId" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "unitId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "categoryId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateScheduledOrderRequest": { + "required": [ + "firstRunAt", + "recurrenceType" + ], + "type": "object", + "properties": { + "recurrenceType": { + "minLength": 1, + "type": "string" + }, + "firstRunAt": { + "minLength": 1, + "type": "string", + "format": "date-time" + }, + "notes": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateUnitRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "abbreviation": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateUserCommand": { + "required": [ + "email", + "password", + "role" + ], + "type": "object", + "properties": { + "email": { + "maxLength": 255, + "minLength": 1, + "type": "string", + "format": "email" + }, + "password": { + "minLength": 8, + "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", + "type": "string" + }, + "role": { + "minLength": 1, + "type": "string" + }, + "marketId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "restaurantName": { + "type": "string", + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "DeliveryAddressRequest": { + "required": [ + "addressLine" + ], + "type": "object", + "properties": { + "addressLine": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "isDefault": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DraftOrderItemRequest": { + "required": [ + "marketProductId" + ], + "type": "object", + "properties": { + "marketProductId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "quantity": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "ForgotPasswordRequest": { + "required": [ + "identifier" + ], + "type": "object", + "properties": { + "identifier": { + "minLength": 1, + "type": "string", + "format": "email" + } + }, + "additionalProperties": false + }, + "GenerateStatementRequest": { + "type": "object", + "properties": { + "year": { + "type": "integer", + "format": "int32" + }, + "month": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "HandoverRequest": { + "type": "object", + "properties": { + "hubId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "HubInboundItemCommand": { + "required": [ + "marketProductId" + ], + "type": "object", + "properties": { + "marketProductId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "productId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "quantityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "HubOutboundItemCommand": { + "required": [ + "marketProductId" + ], + "type": "object", + "properties": { + "marketProductId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "productId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "quantityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "LoginRequest": { + "required": [ + "identifier", + "password" + ], + "type": "object", + "properties": { + "identifier": { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + "password": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "LogoutRequest": { + "required": [ + "refreshToken" + ], + "type": "object", + "properties": { + "refreshToken": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "OptimizeRouteRequest": { + "required": [ + "optimizationCriteria" + ], + "type": "object", + "properties": { + "optimizationCriteria": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": { } + }, + "PurchaseLineDto": { + "required": [ + "marketProductId" + ], + "type": "object", + "properties": { + "marketProductId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "actualQuantity": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + }, + "actualUnitPrice": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "RecordActualQuantityRequest": { + "type": "object", + "properties": { + "actualQuantity": { + "minimum": 0, + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "RecordDiscrepancyRequest": { + "required": [ + "orderItemId" + ], + "type": "object", + "properties": { + "orderItemId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "affectedQuantity": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "conditionStatus": { + "type": "string", + "nullable": true + }, + "notes": { + "maxLength": 1000, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "RecordInboundItemRequest": { + "type": "object", + "properties": { + "marketProductId": { + "type": "string", + "format": "uuid" + }, + "productId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "quantityKg": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "RecordInboundRequest": { + "required": [ + "arrivedAt", + "items" + ], + "type": "object", + "properties": { + "sourceMarketId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "deliveryScheduleId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "items": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/components/schemas/RecordInboundItemRequest" + } + }, + "arrivedAt": { + "minLength": 1, + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "RecordOutboundItemRequest": { + "type": "object", + "properties": { + "marketProductId": { + "type": "string", + "format": "uuid" + }, + "productId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "quantityKg": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "RecordOutboundRequest": { + "required": [ + "destinationRouteId", + "dispatchedAt", + "items" + ], + "type": "object", + "properties": { + "destinationRouteId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "items": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/components/schemas/RecordOutboundItemRequest" + } + }, + "dispatchedAt": { + "minLength": 1, + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "RefreshRequest": { + "required": [ + "refreshToken" + ], + "type": "object", + "properties": { + "refreshToken": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "RegisterNotificationDeviceRequest": { + "type": "object", + "properties": { + "token": { + "type": "string", + "nullable": true + }, + "platform": { + "type": "string", + "nullable": true + }, + "deviceId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "RegisterRestaurantRequest": { + "required": [ + "email", + "password", + "restaurantName" + ], + "type": "object", + "properties": { + "email": { + "maxLength": 255, + "minLength": 1, + "type": "string", + "format": "email" + }, + "password": { + "minLength": 8, + "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", + "type": "string" + }, + "restaurantName": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "phone": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "RegisterVehicleRequest": { + "required": [ + "plateNumber" + ], + "type": "object", + "properties": { + "plateNumber": { + "maxLength": 20, + "minLength": 1, + "type": "string" + }, + "capacityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "vehicleType": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReorderFromHistoryRequest": { + "type": "object", + "properties": { + "scheduledFor": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "notes": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReplaceHubStaffAssignmentsRequest": { + "required": [ + "staffUserIds" + ], + "type": "object", + "properties": { + "staffUserIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "additionalProperties": false + }, + "ReplaceMarketAssignmentsRequest": { + "required": [ + "marketIds" + ], + "type": "object", + "properties": { + "marketIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "additionalProperties": false + }, + "ReportDeliveryIssueRequest": { + "type": "object", + "properties": { + "issueType": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReportOrderIssueRequest": { + "type": "object", + "properties": { + "orderItemId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "issueType": { + "type": "string", + "nullable": true + }, + "affectedQuantity": { + "type": "number", + "format": "double" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReportProcurementExceptionRequest": { + "type": "object", + "properties": { + "marketProductId": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "nullable": true + }, + "reportedQuantity": { + "type": "integer", + "format": "int32" + }, + "note": { + "type": "string", + "nullable": true + }, + "proofImageUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "RequestVerificationRequest": { + "required": [ + "channel", + "identifier" + ], + "type": "object", + "properties": { + "identifier": { + "minLength": 1, + "type": "string", + "format": "email" + }, + "channel": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "ResetPasswordRequest": { + "required": [ + "newPassword", + "token" + ], + "type": "object", + "properties": { + "token": { + "minLength": 1, + "type": "string" + }, + "newPassword": { + "minLength": 8, + "pattern": "(?=[\\s\\S]*(?:[A-Z]))(?=[\\s\\S]*(?:[0-9]))(?=[\\s\\S]*(?:[^a-zA-Z0-9]))", + "type": "string" + } + }, + "additionalProperties": false + }, + "ReviewRouteRequest": { + "type": "object", + "properties": { + "stopOrder": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "RunAutoBatchRequest": { + "type": "object", + "properties": { + "targetDate": { + "type": "string", + "format": "date", + "nullable": true + }, + "dryRun": { + "type": "boolean", + "nullable": true + }, + "force": { + "type": "boolean", + "nullable": true + } + }, + "additionalProperties": false + }, + "ScanInboundRequest": { + "required": [ + "code" + ], + "type": "object", + "properties": { + "code": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "SetCreditLimitRequest": { + "type": "object", + "properties": { + "creditLimit": { + "minimum": 0, + "type": "number", + "format": "double" + }, + "note": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "SettleCreditRequest": { + "type": "object", + "properties": { + "amount": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "paymentMethod": { + "type": "string", + "nullable": true + }, + "reference": { + "maxLength": 200, + "type": "string", + "nullable": true + }, + "note": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UnregisterNotificationDeviceRequest": { + "type": "object", + "properties": { + "token": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateAvailableQuantityRequest": { + "type": "object", + "properties": { + "quantity": { + "type": "integer", + "format": "int32" + }, + "expectedVersion": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateCategoryRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "parentId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateDeliveryStatusRequest": { + "required": [ + "status" + ], + "type": "object", + "properties": { + "status": { + "minLength": 1, + "type": "string" + }, + "failureReason": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateDeliveryZoneRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "description": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateHubRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "address": { + "maxLength": 500, + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "capacityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "managedBy": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateMarketRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "location": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "double", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateMyProfileRequest": { + "type": "object", + "properties": { + "fullName": { + "type": "string", + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateOperationalSettingsRequest": { + "type": "object", + "properties": { + "dailyCutoffTime": { + "type": "string", + "format": "time" + }, + "batchingEnabled": { + "type": "boolean" + }, + "defaultRouteType": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateOrderItemRequest": { + "type": "object", + "properties": { + "quantity": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "UpdatePricingSettingsRequest": { + "type": "object", + "properties": { + "priceAlertThresholdPercent": { + "maximum": 100, + "minimum": 0.01, + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "UpdateProductPriceRequest": { + "type": "object", + "properties": { + "price": { + "type": "number", + "format": "double", + "nullable": true + }, + "quantity": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "expectedVersion": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateProductRequest": { + "required": [ + "name", + "unitId" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "unitId": { + "minLength": 1, + "type": "string", + "format": "uuid" + }, + "categoryId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "imageUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateRestaurantProfileRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "address": { + "type": "string", + "nullable": true + }, + "contactPerson": { + "type": "string", + "nullable": true + }, + "pickupStart": { + "type": "string", + "format": "time", + "nullable": true + }, + "pickupEnd": { + "type": "string", + "format": "time", + "nullable": true + }, + "businessLicenseUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateScheduledOrderRequest": { + "type": "object", + "properties": { + "recurrenceType": { + "type": "string", + "nullable": true + }, + "firstRunAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "notes": { + "maxLength": 500, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateUnitRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "abbreviation": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateVehicleRequest": { + "required": [ + "plateNumber" + ], + "type": "object", + "properties": { + "plateNumber": { + "maxLength": 20, + "minLength": 1, + "type": "string" + }, + "capacityKg": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "number", + "format": "double" + }, + "vehicleType": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "VerifyRequest": { + "required": [ + "channel", + "code", + "identifier" + ], + "type": "object", + "properties": { + "identifier": { + "minLength": 1, + "type": "string", + "format": "email" + }, + "channel": { + "minLength": 1, + "type": "string" + }, + "code": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "Bearer": { + "type": "http", + "description": "Paste the access token returned by POST /api/v1/auth/login", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ], + "tags": [ + { + "name": "Admin" + }, + { + "name": "Analytics" + }, + { + "name": "Assistant" + }, + { + "name": "Auth" + }, + { + "name": "Categories" + }, + { + "name": "DeliveryZones" + }, + { + "name": "Driver" + }, + { + "name": "HubHandover" + }, + { + "name": "HubInbound" + }, + { + "name": "Hubs" + }, + { + "name": "HubStaffAssignments" + }, + { + "name": "Markets" + }, + { + "name": "Notification" + }, + { + "name": "NotificationDevice" + }, + { + "name": "Orders" + }, + { + "name": "Pricing" + }, + { + "name": "Procurement" + }, + { + "name": "Products" + }, + { + "name": "Profile" + }, + { + "name": "RestaurantCredit" + }, + { + "name": "RestaurantProfile" + }, + { + "name": "Routes" + }, + { + "name": "Units" + }, + { + "name": "Vehicles" + } + ] +} \ No newline at end of file From f24ad3a2b144641935704b5e40fc6e83f6b64261 Mon Sep 17 00:00:00 2001 From: laithanhhung Date: Fri, 24 Jul 2026 07:02:50 +0700 Subject: [PATCH 3/4] feat(admin): inventory-style users, markets, and restaurants consoles Align admin lists with the Fuse inventory pattern, move market-agent assignment onto markets, and wire restaurant approve/suspend/credit actions into the restaurants detail flow. Ignore the local Fuse demo folder. Co-authored-by: Cursor --- .gitignore | 3 + public/i18n/en.json | 117 +- public/i18n/vi.json | 117 +- src/app/core/api/envelope.spec.ts | 73 +- src/app/core/api/envelope.ts | 140 +- src/app/core/api/error-codes.spec.ts | 123 ++ src/app/core/api/error-codes.ts | 222 +++ src/app/core/api/validators.spec.ts | 57 + src/app/core/api/validators.ts | 69 + src/app/core/navigation/navigation.data.ts | 6 - src/app/core/util/text-search.spec.ts | 29 + src/app/core/util/text-search.ts | 28 + .../vertical/classic/classic.component.html | 20 +- .../vertical/classic/classic.component.ts | 48 +- .../vertical/dense/dense.component.html | 48 +- .../layouts/vertical/dense/dense.component.ts | 72 +- src/app/modules/admin/admin.routes.ts | 5 - src/app/modules/admin/admin.service.ts | 73 + .../analytics-dashboard.component.html | 601 +++++--- .../analytics-dashboard.component.ts | 285 +++- .../admin/catalog/categories.component.ts | 1 + .../admin/catalog/category-map.component.ts | 9 +- .../catalog/market-products.component.html | 1 - .../admin/catalog/markets.component.html | 562 ++++++++ .../admin/catalog/markets.component.ts | 526 +++++-- .../admin/catalog/products.component.html | 681 ++++++++++ .../admin/catalog/products.component.ts | 571 ++++++-- .../modules/admin/catalog/units.component.ts | 1 + .../admin/logistics/hub-staff.component.ts | 9 +- .../order-groups/order-groups.component.html | 1 - .../order-groups/order-groups.component.ts | 11 +- .../restaurants-admin.component.html | 1100 ++++++++++----- .../restaurants-admin.component.ts | 581 +++++--- .../admin/settings/settings.component.html | 1 - .../admin/settings/settings.component.ts | 11 +- .../admin/shared/resource-crud.component.html | 1203 +++++++++-------- .../admin/shared/resource-crud.component.ts | 234 +++- .../admin/shared/resource-crud.types.ts | 6 + .../admin/users/user-detail.component.html | 176 --- .../admin/users/user-detail.component.ts | 220 --- .../admin/users/users-list.component.html | 814 +++++++---- .../admin/users/users-list.component.ts | 242 +++- src/styles/styles.scss | 22 + 43 files changed, 6762 insertions(+), 2357 deletions(-) create mode 100644 src/app/core/api/error-codes.spec.ts create mode 100644 src/app/core/api/error-codes.ts create mode 100644 src/app/core/api/validators.spec.ts create mode 100644 src/app/core/api/validators.ts create mode 100644 src/app/core/util/text-search.spec.ts create mode 100644 src/app/core/util/text-search.ts create mode 100644 src/app/modules/admin/catalog/markets.component.html create mode 100644 src/app/modules/admin/catalog/products.component.html delete mode 100644 src/app/modules/admin/users/user-detail.component.html delete mode 100644 src/app/modules/admin/users/user-detail.component.ts diff --git a/.gitignore b/.gitignore index d6297cf..a868a50 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ testem.log .DS_Store Thumbs.db +# Local Fuse theme reference — never push to the repo +/fuse-demo-v20.0.0/ + # Local environment overrides (real backend origin / keys — never commit) /src/environments/environment.local.ts diff --git a/public/i18n/en.json b/public/i18n/en.json index 05d644a..f656ddd 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -288,6 +288,84 @@ "admin.users.create.submit": "Create user", "admin.users.create.success": "User created", "admin.users.create.error": "Could not create the user", + "admin.users.create.errors.emailRequired": "Email is required", + "admin.users.create.errors.emailInvalid": "Enter a valid email address", + "admin.users.create.errors.emailMax": "Must not exceed 255 characters", + "admin.users.create.errors.passwordRequired": "Password is required", + "admin.users.create.errors.roleRequired": "Please choose a role", + "admin.users.create.errors.marketRequired": "Market agents must be assigned a market", + "admin.users.create.errors.restaurantRequired": "Restaurant name is required", + "admin.users.create.errors.restaurantMax": "Must not exceed 200 characters", + "admin.users.create.errors.phoneInvalid": "Enter 7–15 digits, optional leading +", + "admin.users.create.errors.phoneMax": "Must not exceed 20 characters", + "admin.users.create.password.hint": "Password must contain:", + "admin.users.create.password.minLength": "At least 8 characters", + "admin.users.create.password.uppercase": "An uppercase letter (A–Z)", + "admin.users.create.password.digit": "A number (0–9)", + "admin.users.create.password.special": "A special character (e.g. @ # ! $)", + "errors.api.generic": "Something went wrong, please try again", + "errors.api.validation": "Please check the highlighted fields and try again", + "errors.api.businessRule": "This action isn't allowed right now", + "errors.api.invalidCredentials": "Incorrect email/phone or password", + "errors.api.invalidCurrentPassword": "Your current password is incorrect", + "errors.api.accountInactive": "This account is inactive", + "errors.api.accountLocked": "This account is temporarily locked", + "errors.api.emailAlreadyExists": "An account with this email already exists", + "errors.api.phoneAlreadyExists": "An account with this phone number already exists", + "errors.api.weakPassword": "The password does not meet the strength requirements", + "errors.api.sessionExpired": "Your session has expired, please sign in again", + "errors.api.resetTokenInvalid": "This reset link is invalid or has expired", + "errors.api.otpInvalid": "The verification code is incorrect or has expired", + "errors.api.channelNotSupported": "This channel is not supported yet", + "errors.api.userNotFound": "This user no longer exists", + "errors.api.invalidRole": "The selected role is not valid", + "errors.api.invalidMarket": "The selected market is not valid", + "errors.api.cannotDisableSelf": "You cannot deactivate your own account", + "errors.api.cannotChangeOwnRole": "You cannot change your own role", + "errors.api.restaurantNotFound": "No restaurant was found for this id", + "errors.api.alreadyApproved": "This restaurant has already been approved", + "errors.api.marketNotFound": "This market no longer exists", + "errors.api.productNotFound": "This product no longer exists", + "errors.api.marketAccessDenied": "You are not assigned to this market", + "errors.api.concurrencyConflict": "Someone else just updated this. Please refresh and try again", + "errors.api.invalidPrice": "The price is not valid", + "errors.api.invalidQuantity": "The quantity is not valid", + "errors.api.restaurantNotApproved": "Your restaurant is pending admin approval", + "errors.api.emptyOrder": "The order has no items", + "errors.api.invalidProduct": "One or more items are no longer available", + "errors.api.insufficientStock": "One or more items exceed the available stock", + "errors.api.scheduledTooSoon": "The scheduled time must be at least 2 hours from now", + "errors.api.forbidden": "You do not have permission to perform this action", + "errors.api.rateLimited": "Too many requests, please wait a moment and try again", + "errors.api.notFound": "The requested item was not found or no longer exists", + "errors.api.conflict": "This conflicts with the current state — please refresh and try again", + "errors.api.serverError": "A server error occurred, please try again in a moment", + "errors.api.network": "Could not reach the server. Check your connection and try again", + "errors.field.emailInvalid": "Must be a valid email address", + "errors.field.max255": "Must not exceed 255 characters", + "errors.field.passwordMinLength": "Must be at least 8 characters", + "errors.field.passwordUppercase": "Must contain at least one uppercase letter", + "errors.field.passwordDigit": "Must contain at least one number", + "errors.field.passwordSpecial": "Must contain at least one special character", + "errors.field.passwordDifferent": "New password must be different from the current password", + "errors.field.identifierInvalid": "Must be a valid email address or phone number", + "errors.field.phoneInvalid": "Must be a valid phone number (7–15 digits, optional leading +)", + "errors.field.max200": "Must not exceed 200 characters", + "errors.field.roleInvalid": "Role must be one of the accepted values", + "errors.field.pricePositive": "Price must be greater than 0", + "errors.field.priceDecimals": "Price must have at most 2 decimal places", + "errors.field.quantityInteger": "Quantity must be a whole number", + "errors.field.quantityNonNegative": "Quantity must be 0 or greater", + "errors.field.quantityPositive": "Quantity must be greater than 0", + "errors.field.ordersRequired": "At least one order must be provided", + "errors.field.ordersMax20": "Cannot process more than 20 orders at once", + "errors.field.orderNotCancellable": "This order cannot be cancelled in its current status", + "errors.field.ordersMustBeConfirmed": "All orders must be confirmed before grouping", + "errors.field.ordersAlreadyGrouped": "One or more orders are already in an active group", + "errors.field.autoBatchRunning": "Auto-batch is already running for this date", + "errors.field.hubStockExceeded": "Requested quantity exceeds available hub stock", + "errors.field.endDateBeforeStart": "The end date must be on or after the start date", + "errors.field.capacityPositive": "Capacity must be greater than 0", "admin.userDetail.title": "User detail", "admin.userDetail.actionError": "The action failed, please try again", "admin.userDetail.profile.title": "Profile", @@ -305,7 +383,25 @@ "admin.userDetail.assignments.empty": "No markets available to assign", "admin.userDetail.assignments.agentOnly": "Market assignments apply only to market-agent users. Set this user's role to Market Agent and save it first.", "admin.restaurants.title": "Restaurants", - "admin.restaurants.subtitle": "Approve restaurants and manage credit by restaurant id (UUID).", + "admin.restaurants.subtitle": "Manage restaurant accounts, approval and credit.", + "admin.restaurants.empty": "No restaurants found", + "admin.restaurants.loadError": "Could not load the restaurant list", + "admin.restaurants.noRestaurantId": "This account has no restaurant id yet — approval and credit are unavailable.", + "admin.restaurants.filters.searchPlaceholder": "Email, restaurant name...", + "admin.restaurants.filters.status": "Status", + "admin.restaurants.filters.allStatuses": "All statuses", + "admin.restaurants.table.restaurant": "Restaurant", + "admin.restaurants.table.email": "Email", + "admin.restaurants.table.phone": "Phone", + "admin.restaurants.table.status": "Status", + "admin.restaurants.table.actions": "Actions", + "admin.restaurants.unnamed": "Unnamed restaurant", + "admin.restaurants.create.trigger": "Add restaurant", + "admin.restaurants.create.title": "Create restaurant account", + "admin.restaurants.create.submit": "Create restaurant", + "admin.restaurants.create.success": "Restaurant account created", + "admin.restaurants.create.error": "Could not create the restaurant account", + "admin.restaurants.lifecycle.title": "Approval", "admin.restaurants.actionError": "The action failed, please try again", "admin.restaurants.lookup.label": "Restaurant id (UUID)", "admin.restaurants.lookup.placeholder": "e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6", @@ -316,7 +412,10 @@ "admin.restaurants.credit.limit": "Credit limit", "admin.restaurants.credit.balance": "Current balance", "admin.restaurants.credit.available": "Available credit", + "admin.restaurants.credit.snapshot": "Credit", + "admin.restaurants.credit.empty": "No credit data yet", "admin.restaurants.creditLimit.title": "Set credit limit", + "admin.restaurants.creditLimit.activate": "Activate credit limit", "admin.restaurants.creditLimit.amount": "Credit limit", "admin.restaurants.creditLimit.note": "Note", "admin.restaurants.creditLimit.submit": "Update limit", @@ -368,6 +467,7 @@ "admin.crud.errors.min": "Must be at least {{min}}", "admin.crud.filterAll": "All", "admin.crud.clearFilters": "Clear all filters", + "admin.crud.details": "Details", "admin.crud.image.upload": "Upload image", "admin.crud.image.hint": "Drag & drop or click to browse", "admin.crud.image.change": "Change image", @@ -403,6 +503,8 @@ "admin.products.description": "Description", "admin.products.image": "Image", "admin.products.imageUrl": "Product image", + "admin.products.details": "Details", + "admin.products.noThumb": "No thumb", "admin.markets.title": "Markets", "admin.markets.subtitle": "Manage source markets", "admin.markets.create": "Add market", @@ -410,9 +512,20 @@ "admin.markets.name": "Market name", "admin.markets.location": "Location", "admin.markets.address": "Address", + "admin.markets.coordinates": "Coordinates", + "admin.markets.details": "Details", "admin.markets.latitude": "Latitude", "admin.markets.longitude": "Longitude", "admin.markets.pricing": "Manage pricing", + "admin.markets.agent": "Market agent", + "admin.markets.agentNone": "Unassigned", + "admin.markets.agentDialog.title": "Assign market agent", + "admin.markets.agentDialog.current": "Current agent", + "admin.markets.agentDialog.select": "Market agent", + "admin.markets.agentDialog.clear": "Clear assignment", + "admin.markets.agentDialog.save": "Save", + "admin.markets.agentDialog.success": "Market agent updated", + "admin.markets.agentDialog.error": "Could not update the market agent", "admin.markets.pricingTitle": "Per-market price & quantity", "admin.markets.pricingSubtitle": "Update product price and available quantity at this market", "admin.markets.addProduct": "Add product", @@ -499,6 +612,8 @@ "admin.analytics.to": "To", "admin.analytics.apply": "Apply", "admin.analytics.noData": "No data for this period.", + "admin.analytics.kpiLabel": "Overview", + "admin.analytics.welcomeStatus": "{{count}} recent activities on the platform", "admin.analytics.recentActivity": "Recent activity", "admin.analytics.export": "Export", "admin.analytics.exportError": "The export could not be downloaded.", diff --git a/public/i18n/vi.json b/public/i18n/vi.json index dc7f3c1..fd7ea67 100644 --- a/public/i18n/vi.json +++ b/public/i18n/vi.json @@ -288,6 +288,84 @@ "admin.users.create.submit": "Tạo người dùng", "admin.users.create.success": "Đã tạo người dùng", "admin.users.create.error": "Không thể tạo người dùng", + "admin.users.create.errors.emailRequired": "Vui lòng nhập email", + "admin.users.create.errors.emailInvalid": "Email không hợp lệ", + "admin.users.create.errors.emailMax": "Không được vượt quá 255 ký tự", + "admin.users.create.errors.passwordRequired": "Vui lòng nhập mật khẩu", + "admin.users.create.errors.roleRequired": "Vui lòng chọn vai trò", + "admin.users.create.errors.marketRequired": "Nhân viên chợ phải được gán một chợ", + "admin.users.create.errors.restaurantRequired": "Vui lòng nhập tên nhà hàng", + "admin.users.create.errors.restaurantMax": "Không được vượt quá 200 ký tự", + "admin.users.create.errors.phoneInvalid": "Nhập 7–15 chữ số, có thể có dấu + ở đầu", + "admin.users.create.errors.phoneMax": "Không được vượt quá 20 ký tự", + "admin.users.create.password.hint": "Mật khẩu phải bao gồm:", + "admin.users.create.password.minLength": "Ít nhất 8 ký tự", + "admin.users.create.password.uppercase": "Một chữ hoa (A–Z)", + "admin.users.create.password.digit": "Một chữ số (0–9)", + "admin.users.create.password.special": "Một ký tự đặc biệt (vd: @ # ! $)", + "errors.api.generic": "Đã xảy ra lỗi, vui lòng thử lại", + "errors.api.validation": "Vui lòng kiểm tra lại các trường được đánh dấu và thử lại", + "errors.api.businessRule": "Thao tác này hiện không được phép", + "errors.api.invalidCredentials": "Email/số điện thoại hoặc mật khẩu không đúng", + "errors.api.invalidCurrentPassword": "Mật khẩu hiện tại không đúng", + "errors.api.accountInactive": "Tài khoản này đang bị vô hiệu hóa", + "errors.api.accountLocked": "Tài khoản này đang tạm khóa", + "errors.api.emailAlreadyExists": "Email này đã được đăng ký", + "errors.api.phoneAlreadyExists": "Số điện thoại này đã được đăng ký", + "errors.api.weakPassword": "Mật khẩu chưa đáp ứng yêu cầu độ mạnh", + "errors.api.sessionExpired": "Phiên đăng nhập đã hết hạn, vui lòng đăng nhập lại", + "errors.api.resetTokenInvalid": "Liên kết đặt lại mật khẩu không hợp lệ hoặc đã hết hạn", + "errors.api.otpInvalid": "Mã xác thực không đúng hoặc đã hết hạn", + "errors.api.channelNotSupported": "Kênh này hiện chưa được hỗ trợ", + "errors.api.userNotFound": "Người dùng này không còn tồn tại", + "errors.api.invalidRole": "Vai trò đã chọn không hợp lệ", + "errors.api.invalidMarket": "Chợ đã chọn không hợp lệ", + "errors.api.cannotDisableSelf": "Bạn không thể vô hiệu hóa tài khoản của chính mình", + "errors.api.cannotChangeOwnRole": "Bạn không thể thay đổi vai trò của chính mình", + "errors.api.restaurantNotFound": "Không tìm thấy nhà hàng với mã này", + "errors.api.alreadyApproved": "Nhà hàng này đã được duyệt", + "errors.api.marketNotFound": "Chợ này không còn tồn tại", + "errors.api.productNotFound": "Sản phẩm này không còn tồn tại", + "errors.api.marketAccessDenied": "Bạn không được phân công cho chợ này", + "errors.api.concurrencyConflict": "Người khác vừa cập nhật mục này. Vui lòng tải lại và thử lại", + "errors.api.invalidPrice": "Giá không hợp lệ", + "errors.api.invalidQuantity": "Số lượng không hợp lệ", + "errors.api.restaurantNotApproved": "Nhà hàng của bạn đang chờ quản trị viên duyệt", + "errors.api.emptyOrder": "Đơn hàng chưa có sản phẩm nào", + "errors.api.invalidProduct": "Một hoặc nhiều sản phẩm không còn khả dụng", + "errors.api.insufficientStock": "Một hoặc nhiều sản phẩm vượt quá số lượng còn lại", + "errors.api.scheduledTooSoon": "Thời gian đặt lịch phải cách hiện tại ít nhất 2 giờ", + "errors.api.forbidden": "Bạn không có quyền thực hiện thao tác này", + "errors.api.rateLimited": "Quá nhiều yêu cầu, vui lòng đợi một lát và thử lại", + "errors.api.notFound": "Không tìm thấy mục yêu cầu hoặc mục này không còn tồn tại", + "errors.api.conflict": "Thao tác xung đột với trạng thái hiện tại — vui lòng tải lại và thử lại", + "errors.api.serverError": "Máy chủ gặp lỗi, vui lòng thử lại sau giây lát", + "errors.api.network": "Không thể kết nối máy chủ. Vui lòng kiểm tra kết nối và thử lại", + "errors.field.emailInvalid": "Email không hợp lệ", + "errors.field.max255": "Không được vượt quá 255 ký tự", + "errors.field.passwordMinLength": "Phải có ít nhất 8 ký tự", + "errors.field.passwordUppercase": "Phải có ít nhất một chữ hoa", + "errors.field.passwordDigit": "Phải có ít nhất một chữ số", + "errors.field.passwordSpecial": "Phải có ít nhất một ký tự đặc biệt", + "errors.field.passwordDifferent": "Mật khẩu mới phải khác mật khẩu hiện tại", + "errors.field.identifierInvalid": "Phải là email hoặc số điện thoại hợp lệ", + "errors.field.phoneInvalid": "Số điện thoại không hợp lệ (7–15 chữ số, có thể có dấu + ở đầu)", + "errors.field.max200": "Không được vượt quá 200 ký tự", + "errors.field.roleInvalid": "Vai trò phải là một trong các giá trị được chấp nhận", + "errors.field.pricePositive": "Giá phải lớn hơn 0", + "errors.field.priceDecimals": "Giá chỉ được có tối đa 2 chữ số thập phân", + "errors.field.quantityInteger": "Số lượng phải là số nguyên", + "errors.field.quantityNonNegative": "Số lượng phải bằng 0 hoặc lớn hơn", + "errors.field.quantityPositive": "Số lượng phải lớn hơn 0", + "errors.field.ordersRequired": "Phải có ít nhất một đơn hàng", + "errors.field.ordersMax20": "Không thể xử lý quá 20 đơn hàng cùng lúc", + "errors.field.orderNotCancellable": "Không thể hủy đơn hàng này ở trạng thái hiện tại", + "errors.field.ordersMustBeConfirmed": "Tất cả đơn hàng phải được xác nhận trước khi gộp", + "errors.field.ordersAlreadyGrouped": "Một hoặc nhiều đơn hàng đã thuộc một nhóm đang hoạt động", + "errors.field.autoBatchRunning": "Tiến trình gộp tự động đang chạy cho ngày này", + "errors.field.hubStockExceeded": "Số lượng yêu cầu vượt quá tồn kho tại hub", + "errors.field.endDateBeforeStart": "Ngày kết thúc phải bằng hoặc sau ngày bắt đầu", + "errors.field.capacityPositive": "Sức chứa phải lớn hơn 0", "admin.userDetail.title": "Chi tiết người dùng", "admin.userDetail.actionError": "Thao tác không thành công, vui lòng thử lại", "admin.userDetail.profile.title": "Hồ sơ", @@ -305,7 +383,25 @@ "admin.userDetail.assignments.empty": "Chưa có chợ nào để gán", "admin.userDetail.assignments.agentOnly": "Chỉ có thể gán chợ cho người dùng là nhân viên chợ (market agent). Hãy đặt vai trò của người dùng này thành Market Agent và lưu lại trước.", "admin.restaurants.title": "Nhà hàng", - "admin.restaurants.subtitle": "Duyệt nhà hàng và quản lý công nợ theo mã nhà hàng (UUID).", + "admin.restaurants.subtitle": "Quản lý tài khoản nhà hàng, duyệt và công nợ.", + "admin.restaurants.empty": "Không tìm thấy nhà hàng nào", + "admin.restaurants.loadError": "Không tải được danh sách nhà hàng", + "admin.restaurants.noRestaurantId": "Tài khoản này chưa có mã nhà hàng — không thể duyệt hoặc quản lý công nợ.", + "admin.restaurants.filters.searchPlaceholder": "Email, tên nhà hàng...", + "admin.restaurants.filters.status": "Trạng thái", + "admin.restaurants.filters.allStatuses": "Tất cả trạng thái", + "admin.restaurants.table.restaurant": "Nhà hàng", + "admin.restaurants.table.email": "Email", + "admin.restaurants.table.phone": "Số điện thoại", + "admin.restaurants.table.status": "Trạng thái", + "admin.restaurants.table.actions": "Hành động", + "admin.restaurants.unnamed": "Chưa đặt tên", + "admin.restaurants.create.trigger": "Thêm nhà hàng", + "admin.restaurants.create.title": "Tạo tài khoản nhà hàng", + "admin.restaurants.create.submit": "Tạo nhà hàng", + "admin.restaurants.create.success": "Đã tạo tài khoản nhà hàng", + "admin.restaurants.create.error": "Không tạo được tài khoản nhà hàng", + "admin.restaurants.lifecycle.title": "Duyệt", "admin.restaurants.actionError": "Thao tác không thành công, vui lòng thử lại", "admin.restaurants.lookup.label": "Mã nhà hàng (UUID)", "admin.restaurants.lookup.placeholder": "vd: 3fa85f64-5717-4562-b3fc-2c963f66afa6", @@ -316,7 +412,10 @@ "admin.restaurants.credit.limit": "Hạn mức công nợ", "admin.restaurants.credit.balance": "Dư nợ hiện tại", "admin.restaurants.credit.available": "Hạn mức còn lại", + "admin.restaurants.credit.snapshot": "Công nợ", + "admin.restaurants.credit.empty": "Chưa có dữ liệu công nợ", "admin.restaurants.creditLimit.title": "Đặt hạn mức công nợ", + "admin.restaurants.creditLimit.activate": "Kích hoạt hạn mức", "admin.restaurants.creditLimit.amount": "Hạn mức", "admin.restaurants.creditLimit.note": "Ghi chú", "admin.restaurants.creditLimit.submit": "Cập nhật hạn mức", @@ -368,6 +467,7 @@ "admin.crud.errors.min": "Phải lớn hơn hoặc bằng {{min}}", "admin.crud.filterAll": "Tất cả", "admin.crud.clearFilters": "Xóa hết lọc", + "admin.crud.details": "Chi tiết", "admin.crud.image.upload": "Tải ảnh lên", "admin.crud.image.hint": "Kéo thả hoặc bấm để chọn ảnh", "admin.crud.image.change": "Đổi ảnh", @@ -403,6 +503,8 @@ "admin.products.description": "Mô tả", "admin.products.image": "Ảnh", "admin.products.imageUrl": "Ảnh sản phẩm", + "admin.products.details": "Chi tiết", + "admin.products.noThumb": "Không ảnh", "admin.markets.title": "Chợ đầu mối", "admin.markets.subtitle": "Quản lý chợ đầu mối", "admin.markets.create": "Thêm chợ", @@ -410,9 +512,20 @@ "admin.markets.name": "Tên chợ", "admin.markets.location": "Khu vực", "admin.markets.address": "Địa chỉ", + "admin.markets.coordinates": "Toạ độ", + "admin.markets.details": "Chi tiết", "admin.markets.latitude": "Vĩ độ", "admin.markets.longitude": "Kinh độ", "admin.markets.pricing": "Quản lý giá", + "admin.markets.agent": "Nhân viên chợ", + "admin.markets.agentNone": "Chưa gán", + "admin.markets.agentDialog.title": "Gán nhân viên chợ", + "admin.markets.agentDialog.current": "Nhân viên hiện tại", + "admin.markets.agentDialog.select": "Nhân viên chợ", + "admin.markets.agentDialog.clear": "Bỏ gán", + "admin.markets.agentDialog.save": "Lưu", + "admin.markets.agentDialog.success": "Đã cập nhật nhân viên chợ", + "admin.markets.agentDialog.error": "Không cập nhật được nhân viên chợ", "admin.markets.pricingTitle": "Giá & sản lượng theo chợ", "admin.markets.pricingSubtitle": "Cập nhật giá và sản lượng sản phẩm tại chợ này", "admin.markets.addProduct": "Thêm sản phẩm", @@ -499,6 +612,8 @@ "admin.analytics.to": "Đến ngày", "admin.analytics.apply": "Áp dụng", "admin.analytics.noData": "Không có dữ liệu trong kỳ này.", + "admin.analytics.kpiLabel": "Tổng quan", + "admin.analytics.welcomeStatus": "{{count}} hoạt động gần đây trên nền tảng", "admin.analytics.recentActivity": "Hoạt động gần đây", "admin.analytics.export": "Xuất dữ liệu", "admin.analytics.exportError": "Không thể tải tệp xuất.", diff --git a/src/app/core/api/envelope.spec.ts b/src/app/core/api/envelope.spec.ts index 1865b99..5098216 100644 --- a/src/app/core/api/envelope.spec.ts +++ b/src/app/core/api/envelope.spec.ts @@ -1,5 +1,5 @@ import { PermissionError, ResponseError } from 'contract'; -import { apiErrorMessage, withId } from './envelope'; +import { apiErrorMessage, readApiError, withId } from './envelope'; /** * Every screen feeds `row.id` into a path parameter, so a row that arrives @@ -104,4 +104,75 @@ describe('apiErrorMessage', () => { const err = new ResponseError(response({ foo: 'bar' }), 'x'); expect(await apiErrorMessage(err)).toBeUndefined(); }); + + it('reads the FreshFlow envelope message', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'ALREADY_APPROVED', + message: 'The restaurant is already active.', + }, + }, + 409 + ), + 'failed' + ); + expect(await apiErrorMessage(err)).toBe( + 'The restaurant is already active.' + ); + }); + + it('joins FreshFlow envelope field details', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'One or more fields failed validation.', + details: [{ field: 'price', message: 'Must be > 0' }], + }, + }, + 400 + ), + 'failed' + ); + // Field-level detail takes precedence over the summary message. + expect(await apiErrorMessage(err)).toBe('Must be > 0'); + }); +}); + +/** + * The backend's machine-readable `code` drives the localized message a screen + * shows, so it must survive both body shapes. + */ +describe('readApiError', () => { + function response(body: unknown, status = 400): Response { + return new Response(JSON.stringify(body), { status }); + } + + it('reads code + message from the FreshFlow envelope', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'EMAIL_ALREADY_EXISTS', + message: 'Email already registered.', + }, + }, + 409 + ), + 'failed' + ); + const info = await readApiError(err); + expect(info?.code).toBe('EMAIL_ALREADY_EXISTS'); + expect(info?.message).toBe('Email already registered.'); + }); + + it('returns undefined for a non-HTTP error', async () => { + expect(await readApiError(new Error('boom'))).toBeUndefined(); + }); }); diff --git a/src/app/core/api/envelope.ts b/src/app/core/api/envelope.ts index 5136a46..43ffa9f 100644 --- a/src/app/core/api/envelope.ts +++ b/src/app/core/api/envelope.ts @@ -45,25 +45,42 @@ export function withId>( } /** - * Extracts a human-readable reason from a failed API call, so a backend - * rejection can be shown to the user instead of a generic "something went - * wrong". + * Structured view of a failed API call, normalised across the two body shapes + * the backend can send (see {@link readApiError}). + */ +export interface ApiErrorInfo { + /** HTTP status of the response, so callers can localize by category. */ + status?: number; + /** Machine-readable code, e.g. `EMAIL_ALREADY_EXISTS` (when present). */ + code?: string; + /** Human-readable message from the server (may be untranslated). */ + message?: string; + /** Per-field validation messages, keyed by field name. */ + fieldErrors?: Record; +} + +/** + * Reads the response body from a failed API call into a normalised + * {@link ApiErrorInfo}, so callers can react to the backend's own `code` + * (mapping it to a localized message) and surface field-level detail. * * Two error shapes carry a response: - * - the generated {@link ResponseError} (thrown for 4xx the middleware lets - * through — 400/404/409/422), and - * - the typed {@link ApiError} subclasses the client middleware throws for - * 401/403/5xx ({@link PermissionError} etc.). These are the "the backend did - * not allow it" cases whose reason was previously dropped. + * - the generated {@link ResponseError} (4xx the middleware lets through — + * 400/404/409/422), and + * - the typed {@link ApiError} subclasses thrown for 401/403/5xx + * ({@link PermissionError} etc.). + * + * Two body shapes are understood: + * - the FreshFlow envelope `{ error: { code, message, details:[{field,message}] } }` + * (see `docs/04-api-design.md` §1.3), and + * - RFC 7807 `ProblemDetails` (`detail`/`title`, an `errors` validation map, + * with `message`/`error` as fallbacks). * - * The body is read as RFC 7807 `ProblemDetails` (`detail`/`title`, or an - * `errors` validation map) with `message`/`error` as fallbacks. Returns - * `undefined` for non-HTTP failures or a bodiless response, so callers fall - * back to their own translated message. + * Returns `undefined` for a non-HTTP failure or a bodiless response. */ -export async function apiErrorMessage( +export async function readApiError( err: unknown -): Promise { +): Promise { const response = err instanceof ResponseError ? err.response @@ -73,27 +90,104 @@ export async function apiErrorMessage( if (!response) { return undefined; } + const status = response.status || undefined; const body = await parseJson>(response.clone()); if (!body) { - return undefined; + // A bodiless rejection (common for 401/403/404/5xx) still tells us the + // category via its status, so the caller can localize by that. + return { status }; + } + + // FreshFlow envelope: { success:false, error:{ code, message, details:[…] } }. + const envelope = body['error']; + if (envelope && typeof envelope === 'object') { + const e = envelope as Record; + const info: ApiErrorInfo = { status }; + if (typeof e['code'] === 'string') { + info.code = e['code']; + } + if (typeof e['message'] === 'string' && e['message'].trim()) { + info.message = e['message']; + } + const fieldErrors = detailsToFieldErrors(e['details']); + if (fieldErrors) { + info.fieldErrors = fieldErrors; + } + return info; + } + + // RFC 7807 ProblemDetails. + const info: ApiErrorInfo = { status }; + if (typeof body['code'] === 'string') { + info.code = body['code']; } if (body['errors'] && typeof body['errors'] === 'object') { - const messages = Object.values( + const map: Record = {}; + for (const [field, value] of Object.entries( body['errors'] as Record - ) - .flatMap((v) => (Array.isArray(v) ? v : [v])) - .filter((v): v is string => typeof v === 'string'); - if (messages.length) { - return messages.join(' '); + )) { + const msg = (Array.isArray(value) ? value : [value]) + .filter((v): v is string => typeof v === 'string') + .join(' '); + if (msg.trim()) { + map[field] = msg; + } + } + if (Object.keys(map).length) { + info.fieldErrors = map; } } for (const key of ['detail', 'title', 'message', 'error']) { const value = body[key]; if (typeof value === 'string' && value.trim()) { - return value; + info.message = value; + break; } } - return undefined; + return info; +} + +/** Reads a `details: [{ field, message }]` array into a `{ field: message }` map. */ +function detailsToFieldErrors( + details: unknown +): Record | undefined { + if (!Array.isArray(details)) { + return undefined; + } + const map: Record = {}; + for (const entry of details) { + if (entry && typeof entry === 'object') { + const field = (entry as Record)['field']; + const message = (entry as Record)['message']; + if (typeof field === 'string' && typeof message === 'string') { + map[field] = message; + } + } + } + return Object.keys(map).length ? map : undefined; +} + +/** + * Extracts a human-readable reason from a failed API call, so a backend + * rejection can be shown to the user instead of a generic "something went + * wrong". Field-level validation detail takes precedence over the summary + * message. Returns `undefined` when nothing usable is present, so callers can + * fall back to their own translated message. + */ +export async function apiErrorMessage( + err: unknown +): Promise { + const info = await readApiError(err); + if (!info) { + return undefined; + } + if (info.fieldErrors) { + const joined = Object.values(info.fieldErrors).join(' '); + if (joined.trim()) { + return joined; + } + } + return info.message?.trim() ? info.message : undefined; } /** Parses a JSON body, tolerating an empty (`void`) response. */ diff --git a/src/app/core/api/error-codes.spec.ts b/src/app/core/api/error-codes.spec.ts new file mode 100644 index 0000000..36775e7 --- /dev/null +++ b/src/app/core/api/error-codes.spec.ts @@ -0,0 +1,123 @@ +import { ResponseError } from 'contract'; +import { describeApiError } from './error-codes'; + +/** + * The user must see the backend's *specific* reason in their own language, so + * `describeApiError` localizes field-level detail and known codes, and only + * falls back to a generic key when the response says nothing usable. + */ +describe('describeApiError', () => { + // A fake translator: echoes the key so tests can assert which key was used. + const translate = (key: string): string => `t:${key}`; + + function response(body: unknown, status = 400): Response { + return new Response(JSON.stringify(body), { status }); + } + + it('localizes each field-level validation detail', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'One or more fields failed validation.', + details: [ + { + field: 'email', + message: 'Must be a valid email address', + }, + { + field: 'password', + message: 'Must be at least 8 characters', + }, + ], + }, + }, + 400 + ), + 'failed' + ); + const message = await describeApiError(err, translate, 'fallback'); + expect(message).toBe( + 't:errors.field.emailInvalid t:errors.field.passwordMinLength' + ); + }); + + it('localizes a known error code when there is no field detail', async () => { + const err = new ResponseError( + response( + { success: false, error: { code: 'EMAIL_ALREADY_EXISTS' } }, + 409 + ), + 'failed' + ); + expect(await describeApiError(err, translate, 'fallback')).toBe( + 't:errors.api.emailAlreadyExists' + ); + }); + + it('localizes a known top-level business-rule message', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'BUSINESS_RULE_ERROR', + message: + 'Order cannot be cancelled in its current status', + }, + }, + 409 + ), + 'failed' + ); + expect(await describeApiError(err, translate, 'fallback')).toBe( + 't:errors.field.orderNotCancellable' + ); + }); + + it('never shows the raw server string — localizes by status instead', async () => { + const err = new ResponseError( + response( + { + success: false, + error: { + code: 'SOME_UNMAPPED_CODE', + message: 'An undocumented English sentence.', + }, + }, + 403 + ), + 'failed' + ); + // Unmapped code + unmapped message, but the 403 still says *why*. + expect(await describeApiError(err, translate, 'my.fallback')).toBe( + 't:errors.api.forbidden' + ); + }); + + it('localizes a bodiless rejection by its HTTP status', async () => { + const err = new ResponseError(new Response(null, { status: 409 }), 'x'); + expect(await describeApiError(err, translate, 'my.fallback')).toBe( + 't:errors.api.conflict' + ); + }); + + it('reports a network failure for a request that never landed', async () => { + // fetch throws a TypeError when the server can't be reached. + expect( + await describeApiError( + new TypeError('Failed to fetch'), + translate, + 'my.fallback' + ) + ).toBe('t:errors.api.network'); + }); + + it('uses the caller key only for a truly unknown error', async () => { + expect( + await describeApiError(new Error('boom'), translate, 'my.fallback') + ).toBe('t:my.fallback'); + }); +}); diff --git a/src/app/core/api/error-codes.ts b/src/app/core/api/error-codes.ts new file mode 100644 index 0000000..92f013a --- /dev/null +++ b/src/app/core/api/error-codes.ts @@ -0,0 +1,222 @@ +/** + * Turns a backend error response into a **detailed, localized** message. + * + * The API design doc (`docs/04-api-design.md`) enumerates every failure: each + * has a machine-readable `code` (Auth §3, Domains §4, Admin §4.6) and, for + * validation failures, an exact per-field `message` (Validation §6). We map + * both to i18n keys so the user sees the specific reason in their own language + * — never the backend's English-only text, and never a vague catch-all when the + * doc actually specifies the detail. + */ +import { readApiError } from './envelope'; + +/** + * Error `code` → i18n key. Used when the response carries no localizable + * message/field detail (e.g. a bare 404/409 with just a code). + */ +export const API_ERROR_MESSAGE_KEYS: Record = { + // Account / credentials / auth tokens + INVALID_CREDENTIALS: 'errors.api.invalidCredentials', + INVALID_CURRENT_PASSWORD: 'errors.api.invalidCurrentPassword', + ACCOUNT_INACTIVE: 'errors.api.accountInactive', + ACCOUNT_LOCKED: 'errors.api.accountLocked', + EMAIL_ALREADY_EXISTS: 'errors.api.emailAlreadyExists', + PHONE_ALREADY_EXISTS: 'errors.api.phoneAlreadyExists', + WEAK_PASSWORD: 'errors.api.weakPassword', + REFRESH_TOKEN_INVALID: 'errors.api.sessionExpired', + REFRESH_TOKEN_EXPIRED: 'errors.api.sessionExpired', + REFRESH_TOKEN_REUSE: 'errors.api.sessionExpired', + RESET_TOKEN_INVALID: 'errors.api.resetTokenInvalid', + RESET_TOKEN_EXPIRED: 'errors.api.resetTokenInvalid', + OTP_INVALID: 'errors.api.otpInvalid', + CHANNEL_NOT_SUPPORTED: 'errors.api.channelNotSupported', + + // Admin — users / roles / markets + USER_NOT_FOUND: 'errors.api.userNotFound', + INVALID_ROLE: 'errors.api.invalidRole', + INVALID_MARKET: 'errors.api.invalidMarket', + CANNOT_DISABLE_SELF: 'errors.api.cannotDisableSelf', + CANNOT_CHANGE_OWN_ROLE: 'errors.api.cannotChangeOwnRole', + + // Admin — restaurants + RESTAURANT_NOT_FOUND: 'errors.api.restaurantNotFound', + ALREADY_APPROVED: 'errors.api.alreadyApproved', + + // Catalog / pricing + MARKET_NOT_FOUND: 'errors.api.marketNotFound', + PRODUCT_NOT_FOUND: 'errors.api.productNotFound', + MARKET_ACCESS_DENIED: 'errors.api.marketAccessDenied', + OPTIMISTIC_CONCURRENCY_CONFLICT: 'errors.api.concurrencyConflict', + INVALID_PRICE: 'errors.api.invalidPrice', + INVALID_QUANTITY: 'errors.api.invalidQuantity', + + // Orders + RESTAURANT_NOT_APPROVED: 'errors.api.restaurantNotApproved', + EMPTY_ORDER: 'errors.api.emptyOrder', + INVALID_PRODUCT: 'errors.api.invalidProduct', + INSUFFICIENT_STOCK: 'errors.api.insufficientStock', + SCHEDULED_FOR_TOO_SOON: 'errors.api.scheduledTooSoon', + + // Generic buckets — only reached when there is no specific message/detail + VALIDATION_ERROR: 'errors.api.validation', + BUSINESS_RULE_ERROR: 'errors.api.businessRule', + AUTHORIZATION_ERROR: 'errors.api.forbidden', + + // Cross-cutting + UNAUTHORIZED: 'errors.api.sessionExpired', + TOKEN_EXPIRED: 'errors.api.sessionExpired', + FORBIDDEN: 'errors.api.forbidden', + RATE_LIMIT_EXCEEDED: 'errors.api.rateLimited', + RATE_LIMITED: 'errors.api.rateLimited', +}; + +/** + * Exact backend message text → i18n key. These are the strings the doc §6 + * ("Validation Rules") and the field-level `details` arrays send verbatim; + * mapping them lets us re-issue the *same* detail in the user's language rather + * than showing the English original. Anything not listed here degrades to the + * code- or caller-level message (see {@link describeApiError}). + */ +export const API_MESSAGE_TEXT_KEYS: Record = { + 'Must be a valid email address': 'errors.field.emailInvalid', + 'Must not exceed 255 characters': 'errors.field.max255', + 'Must be at least 8 characters': 'errors.field.passwordMinLength', + 'Must contain at least one uppercase letter': + 'errors.field.passwordUppercase', + 'Must contain at least one number': 'errors.field.passwordDigit', + 'Must contain at least one special character': + 'errors.field.passwordSpecial', + 'New password must be different from current password': + 'errors.field.passwordDifferent', + 'Identifier must be a valid email address or phone number': + 'errors.field.identifierInvalid', + 'Must be a valid phone number (7–15 digits, optional leading +)': + 'errors.field.phoneInvalid', + 'Must not exceed 200 characters': 'errors.field.max200', + 'Role must be one of the accepted values': 'errors.field.roleInvalid', + 'Must be greater than 0': 'errors.field.pricePositive', + 'Price must be greater than 0': 'errors.field.pricePositive', + 'Price must have at most 2 decimal places': 'errors.field.priceDecimals', + 'Quantity must be a whole number': 'errors.field.quantityInteger', + 'Quantity must be 0 or greater': 'errors.field.quantityNonNegative', + 'Quantity must be greater than 0': 'errors.field.quantityPositive', + 'Scheduled time must be at least 2 hours from now': + 'errors.api.scheduledTooSoon', + 'At least one order must be provided': 'errors.field.ordersRequired', + 'Cannot calculate a route for more than 20 orders at once': + 'errors.field.ordersMax20', + 'Order cannot be cancelled in its current status': + 'errors.field.orderNotCancellable', + 'You are not authorized to update prices at this market': + 'errors.api.marketAccessDenied', + 'Your restaurant account is pending Admin approval': + 'errors.api.restaurantNotApproved', + 'All orders must be confirmed before grouping': + 'errors.field.ordersMustBeConfirmed', + 'One or more orders are already in an active order group': + 'errors.field.ordersAlreadyGrouped', + 'Auto-batch is already running': 'errors.field.autoBatchRunning', + 'Requested quantity exceeds available hub stock': + 'errors.field.hubStockExceeded', + 'End date must be on or after start date': + 'errors.field.endDateBeforeStart', + 'Capacity must be greater than 0': 'errors.field.capacityPositive', + 'Authentication is required': 'errors.api.sessionExpired', + 'You do not have permission to perform this action': 'errors.api.forbidden', +}; + +/** + * HTTP status → i18n key. The safety net so that even a rejection with no + * recognised code or message still explains *why* by category (permission, + * not found, conflict, server error…) rather than a bare "action failed". + */ +export const API_STATUS_MESSAGE_KEYS: Record = { + 400: 'errors.api.validation', + 401: 'errors.api.sessionExpired', + 403: 'errors.api.forbidden', + 404: 'errors.api.notFound', + 409: 'errors.api.conflict', + 422: 'errors.api.businessRule', + 429: 'errors.api.rateLimited', + 500: 'errors.api.serverError', + 502: 'errors.api.serverError', + 503: 'errors.api.serverError', + 504: 'errors.api.serverError', +}; + +/** Localizes an exact backend string, or `undefined` if it isn't a known one. */ +function localizeKnownText( + text: string | undefined, + translate: (key: string) => string +): string | undefined { + if (!text) { + return undefined; + } + const key = API_MESSAGE_TEXT_KEYS[text.trim()]; + return key ? translate(key) : undefined; +} + +/** + * Resolves a failed API call to a **detailed, localized** user-facing message, + * in order of specificity: + * 1. the per-field validation detail (`details[]`), each re-issued in the + * user's language — this is the "detailed" case the doc §6 specifies; + * 2. a specific documented top-level message, localized; + * 3. a message for the backend `code`; + * 4. a message for the HTTP status category (permission / not found / …); + * 5. a network message for a request that never reached the server; + * 6. the caller's own localized `fallbackKey` (last resort). + * + * The backend's raw (English) text is never shown as-is: an unmapped string + * falls through to the next, localized, level. Because of steps 4–5, a plain + * "the action failed" is effectively never shown — the user always gets a + * reason. `translate` is the caller's Transloco `translate` fn, passed in to + * keep this helper DI-free. + */ +export async function describeApiError( + err: unknown, + translate: (key: string) => string, + fallbackKey: string +): Promise { + const info = await readApiError(err); + + if (info) { + // 1) Field-level detail — the most specific reason(s). + if (info.fieldErrors) { + const messages = [...new Set(Object.values(info.fieldErrors))] + .map((text) => localizeKnownText(text, translate)) + .filter((text): text is string => !!text); + if (messages.length) { + return messages.join(' '); + } + } + + // 2) A specific documented message (e.g. a business-rule violation). + const localizedMessage = localizeKnownText(info.message, translate); + if (localizedMessage) { + return localizedMessage; + } + + // 3) The error code. + const codeKey = info.code + ? API_ERROR_MESSAGE_KEYS[info.code] + : undefined; + if (codeKey) { + return translate(codeKey); + } + + // 4) The HTTP status category — always says *why* at some level. + const statusKey = info.status + ? API_STATUS_MESSAGE_KEYS[info.status] + : undefined; + if (statusKey) { + return translate(statusKey); + } + } else if (err instanceof TypeError) { + // 5) No response at all — a fetch network failure throws TypeError. + return translate('errors.api.network'); + } + + // 6) The caller's localized fallback. + return translate(fallbackKey); +} diff --git a/src/app/core/api/validators.spec.ts b/src/app/core/api/validators.spec.ts new file mode 100644 index 0000000..52f1719 --- /dev/null +++ b/src/app/core/api/validators.spec.ts @@ -0,0 +1,57 @@ +import { FormControl } from '@angular/forms'; +import { passwordStrengthValidator, phoneNumberValidator } from './validators'; + +describe('passwordStrengthValidator', () => { + it('passes a compliant password', () => { + expect( + passwordStrengthValidator(new FormControl('MySecureP@ss1')) + ).toBeNull(); + }); + + it('leaves an empty value to the required validator', () => { + expect(passwordStrengthValidator(new FormControl(''))).toBeNull(); + }); + + it('reports each failing rule granularly', () => { + // "short" — too short, no uppercase, no digit, no special. + const errors = passwordStrengthValidator(new FormControl('short')); + expect(errors?.['passwordStrength']).toEqual({ + minLength: true, + uppercase: true, + digit: true, + special: true, + }); + }); + + it('flags only the missing special character', () => { + const errors = passwordStrengthValidator(new FormControl('Password1')); + expect(errors?.['passwordStrength']).toEqual({ + minLength: false, + uppercase: false, + digit: false, + special: true, + }); + }); +}); + +describe('phoneNumberValidator', () => { + it('accepts an empty (optional) value', () => { + expect(phoneNumberValidator(new FormControl(''))).toBeNull(); + }); + + it('accepts 7–15 digits with an optional leading +', () => { + expect( + phoneNumberValidator(new FormControl('+84901234567')) + ).toBeNull(); + expect(phoneNumberValidator(new FormControl('0901234'))).toBeNull(); + }); + + it('rejects malformed numbers', () => { + expect(phoneNumberValidator(new FormControl('12-34'))).toEqual({ + phoneNumber: true, + }); + expect(phoneNumberValidator(new FormControl('123456'))).toEqual({ + phoneNumber: true, + }); + }); +}); diff --git a/src/app/core/api/validators.ts b/src/app/core/api/validators.ts new file mode 100644 index 0000000..befe180 --- /dev/null +++ b/src/app/core/api/validators.ts @@ -0,0 +1,69 @@ +/** + * Reactive-form validators that mirror the backend's server-side rules so the + * UI can block invalid input before a request is ever sent. + * + * These reproduce the FluentValidation rules documented in + * `docs/04-api-design.md` §6 ("mirror them client-side so the front end can + * block invalid input before submitting"). Keeping them in one place means + * every password/phone field enforces the same policy the API does. + */ +import { AbstractControl, ValidationErrors } from '@angular/forms'; + +/** + * Per-rule breakdown of the password strength policy. Emitted under the + * `passwordStrength` key so the template can render a live requirement + * checklist (each `true` is a rule that is still failing). + */ +export interface PasswordStrengthErrors { + minLength: boolean; + uppercase: boolean; + digit: boolean; + special: boolean; +} + +/** Longest email the backend accepts (`email` max 255, §6). */ +export const EMAIL_MAX_LENGTH = 255; + +/** Phone: 7–15 digits, optional leading `+` (§6). */ +const PHONE_PATTERN = /^\+?[0-9]{7,15}$/; + +/** + * Password strength — min 8 chars, ≥1 uppercase, ≥1 digit, ≥1 special char + * (anything that is not a letter or digit). Mirrors the API regex + * `^(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$`. + * + * Returns a granular `{ passwordStrength: { … } }` map rather than a bare flag + * so the field can show which requirements are not yet met. Empty values are + * left to a separate `Validators.required`. + */ +export function passwordStrengthValidator( + control: AbstractControl +): ValidationErrors | null { + const value = typeof control.value === 'string' ? control.value : ''; + if (!value) { + return null; + } + const failing: PasswordStrengthErrors = { + minLength: value.length < 8, + uppercase: !/[A-Z]/.test(value), + digit: !/\d/.test(value), + special: !/[^A-Za-z0-9]/.test(value), + }; + return Object.values(failing).some(Boolean) + ? { passwordStrength: failing } + : null; +} + +/** + * Phone number — optional, but when present must be 7–15 digits with an + * optional leading `+`. Emits `{ phoneNumber: true }` on a malformed value. + */ +export function phoneNumberValidator( + control: AbstractControl +): ValidationErrors | null { + const value = typeof control.value === 'string' ? control.value.trim() : ''; + if (!value) { + return null; + } + return PHONE_PATTERN.test(value) ? null : { phoneNumber: true }; +} diff --git a/src/app/core/navigation/navigation.data.ts b/src/app/core/navigation/navigation.data.ts index d5d5a55..10c4fbf 100644 --- a/src/app/core/navigation/navigation.data.ts +++ b/src/app/core/navigation/navigation.data.ts @@ -87,7 +87,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.dashboards', title: 'Tổng quan', - subtitle: 'Bảng điều khiển quản trị', type: 'group', area: 'admin', children: [ @@ -105,7 +104,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.management', title: 'Quản lý', - subtitle: 'Tài khoản & nhà hàng', type: 'group', area: 'admin', children: [ @@ -128,7 +126,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.operations', title: 'Vận hành', - subtitle: 'Gom đơn & cấu hình', type: 'group', area: 'admin', children: [ @@ -151,7 +148,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.catalog', title: 'Danh mục', - subtitle: 'Sản phẩm & chợ đầu mối', type: 'group', area: 'admin', children: [ @@ -188,7 +184,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.logistics', title: 'Giao vận', - subtitle: 'Hub, phương tiện & vùng giao', type: 'group', area: 'admin', children: [ @@ -218,7 +213,6 @@ const NAVIGATION: AreaNavItem[] = [ { id: 'admin.links', title: 'Liên kết', - subtitle: 'Đi tới khu vực khác', type: 'group', area: 'admin', children: [ diff --git a/src/app/core/util/text-search.spec.ts b/src/app/core/util/text-search.spec.ts new file mode 100644 index 0000000..c7dded6 --- /dev/null +++ b/src/app/core/util/text-search.spec.ts @@ -0,0 +1,29 @@ +import { foldSearchText, includesFolded } from './text-search'; + +describe('foldSearchText', () => { + it('strips Vietnamese diacritics and lowercases', () => { + expect(foldSearchText('Hải sản')).toBe('hai san'); + expect(foldSearchText('Củ - Quả')).toBe('cu - qua'); + expect(foldSearchText('Đường')).toBe('duong'); + }); +}); + +describe('includesFolded', () => { + it('matches accented text with an unaccented query', () => { + expect(includesFolded('Hải sản', 'hai san')).toBe(true); + expect(includesFolded('Củ - Quả', 'cu')).toBe(true); + expect(includesFolded('Đường phố', 'duong')).toBe(true); + }); + + it('still matches when both sides have accents', () => { + expect(includesFolded('Hải sản', 'Hải')).toBe(true); + }); + + it('treats a blank needle as a match', () => { + expect(includesFolded('anything', ' ')).toBe(true); + }); + + it('rejects non-matching terms', () => { + expect(includesFolded('Rau củ', 'thit')).toBe(false); + }); +}); diff --git a/src/app/core/util/text-search.ts b/src/app/core/util/text-search.ts new file mode 100644 index 0000000..5447345 --- /dev/null +++ b/src/app/core/util/text-search.ts @@ -0,0 +1,28 @@ +/** + * Accent-insensitive text helpers for client-side search. + * + * Vietnamese (and other Latin-script) queries typed without diacritics still + * match accented values — e.g. "hai san" → "Hải sản", "cu qua" → "Củ - Quả". + * `đ`/`Đ` are folded to `d` because they are not NFD combining marks. + */ + +/** Lowercases and strips diacritics so "Hải" and "hai" compare equal. */ +export function foldSearchText(value: string): string { + return value + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/đ/gi, 'd') + .toLowerCase(); +} + +/** + * True when `haystack` contains `needle` after both are folded. + * An empty/whitespace needle matches everything. + */ +export function includesFolded(haystack: string, needle: string): boolean { + const term = foldSearchText(needle).trim(); + if (!term) { + return true; + } + return foldSearchText(haystack).includes(term); +} diff --git a/src/app/layout/layouts/vertical/classic/classic.component.html b/src/app/layout/layouts/vertical/classic/classic.component.html index da5a5a2..2c98bb3 100644 --- a/src/app/layout/layouts/vertical/classic/classic.component.html +++ b/src/app/layout/layouts/vertical/classic/classic.component.html @@ -7,7 +7,8 @@ [mode]="isScreenSmall ? 'over' : 'side'" [name]="'mainNavigation'" [navigation]="navigation.default" - [opened]="!isScreenSmall" + [opened]="navigationOpened" + (openedChanged)="navigationOpened = $event" > @@ -34,21 +35,7 @@
- - - - - -
@@ -71,6 +58,3 @@ > - - - diff --git a/src/app/layout/layouts/vertical/classic/classic.component.ts b/src/app/layout/layouts/vertical/classic/classic.component.ts index 53c3724..23e0dbf 100644 --- a/src/app/layout/layouts/vertical/classic/classic.component.ts +++ b/src/app/layout/layouts/vertical/classic/classic.component.ts @@ -7,8 +7,7 @@ import { } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; -import { FuseFullscreenComponent } from '@fuse/components/fullscreen'; +import { RouterOutlet } from '@angular/router'; import { FuseLoadingBarComponent } from '@fuse/components/loading-bar'; import { FuseNavigationService, @@ -17,12 +16,7 @@ import { import { FuseMediaWatcherService } from '@fuse/services/media-watcher'; import { NavigationService } from 'app/core/navigation/navigation.service'; import { Navigation } from 'app/core/navigation/navigation.types'; -import { LanguagesComponent } from 'app/layout/common/languages/languages.component'; -import { MessagesComponent } from 'app/layout/common/messages/messages.component'; import { NotificationsComponent } from 'app/layout/common/notifications/notifications.component'; -import { QuickChatComponent } from 'app/layout/common/quick-chat/quick-chat.component'; -import { SearchComponent } from 'app/layout/common/search/search.component'; -import { ShortcutsComponent } from 'app/layout/common/shortcuts/shortcuts.component'; import { UserComponent } from 'app/layout/common/user/user.component'; import { Subject, takeUntil } from 'rxjs'; @@ -37,19 +31,15 @@ import { Subject, takeUntil } from 'rxjs'; FuseVerticalNavigationComponent, MatButtonModule, MatIconModule, - LanguagesComponent, - FuseFullscreenComponent, - SearchComponent, - ShortcutsComponent, - MessagesComponent, NotificationsComponent, UserComponent, RouterOutlet, - QuickChatComponent, ], }) export class ClassicLayoutComponent implements OnInit, OnDestroy { - isScreenSmall: boolean; + isScreenSmall = false; + /** Sidebar open state — independent of theme/scheme changes. */ + navigationOpened = true; navigation: Navigation; private _unsubscribeAll: Subject = new Subject(); @@ -57,17 +47,11 @@ export class ClassicLayoutComponent implements OnInit, OnDestroy { * Constructor */ constructor( - private _activatedRoute: ActivatedRoute, - private _router: Router, private _navigationService: NavigationService, private _fuseMediaWatcherService: FuseMediaWatcherService, private _fuseNavigationService: FuseNavigationService ) {} - // ----------------------------------------------------------------------------------------------------- - // @ Accessors - // ----------------------------------------------------------------------------------------------------- - /** * Getter for current year */ @@ -75,10 +59,6 @@ export class ClassicLayoutComponent implements OnInit, OnDestroy { return new Date().getFullYear(); } - // ----------------------------------------------------------------------------------------------------- - // @ Lifecycle hooks - // ----------------------------------------------------------------------------------------------------- - /** * On init */ @@ -90,12 +70,17 @@ export class ClassicLayoutComponent implements OnInit, OnDestroy { this.navigation = navigation; }); - // Subscribe to media changes + // Sync sidebar only when the breakpoint actually changes — not when + // light/dark scheme flips (that would reset a user-collapsed sidebar). this._fuseMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({ matchingAliases }) => { - // Check if the screen is small - this.isScreenSmall = !matchingAliases.includes('md'); + const isSmall = !matchingAliases.includes('md'); + if (isSmall === this.isScreenSmall) { + return; + } + this.isScreenSmall = isSmall; + this.navigationOpened = !isSmall; }); } @@ -103,29 +88,20 @@ export class ClassicLayoutComponent implements OnInit, OnDestroy { * On destroy */ ngOnDestroy(): void { - // Unsubscribe from all subscriptions this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } - // ----------------------------------------------------------------------------------------------------- - // @ Public methods - // ----------------------------------------------------------------------------------------------------- - /** * Toggle navigation - * - * @param name */ toggleNavigation(name: string): void { - // Get the navigation const navigation = this._fuseNavigationService.getComponent( name ); if (navigation) { - // Toggle the opened status navigation.toggle(); } } diff --git a/src/app/layout/layouts/vertical/dense/dense.component.html b/src/app/layout/layouts/vertical/dense/dense.component.html index cc2878d..601c9fd 100644 --- a/src/app/layout/layouts/vertical/dense/dense.component.html +++ b/src/app/layout/layouts/vertical/dense/dense.component.html @@ -8,13 +8,39 @@ [mode]="isScreenSmall ? 'over' : 'side'" [name]="'mainNavigation'" [navigation]="navigation.default" - [opened]="!isScreenSmall" + [opened]="navigationOpened" + (openedChanged)="navigationOpened = $event" > - -
- Logo image +
+ @if (navigationAppearance === 'dense') { + + FreshFlow + + } @else { + + FreshFlow + }
@@ -69,18 +95,7 @@ - - -
@@ -104,6 +119,3 @@ - - - diff --git a/src/app/layout/layouts/vertical/dense/dense.component.ts b/src/app/layout/layouts/vertical/dense/dense.component.ts index c9f1d8a..7daf6de 100644 --- a/src/app/layout/layouts/vertical/dense/dense.component.ts +++ b/src/app/layout/layouts/vertical/dense/dense.component.ts @@ -8,7 +8,7 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; +import { RouterOutlet } from '@angular/router'; import { FuseFullscreenComponent } from '@fuse/components/fullscreen'; import { FuseLoadingBarComponent } from '@fuse/components/loading-bar'; import { @@ -22,11 +22,8 @@ import { FuseRouteAnimationDirective } from 'app/core/animations/route-animation import { NavigationService } from 'app/core/navigation/navigation.service'; import { Navigation } from 'app/core/navigation/navigation.types'; import { LanguagesComponent } from 'app/layout/common/languages/languages.component'; -import { MessagesComponent } from 'app/layout/common/messages/messages.component'; import { NotificationsComponent } from 'app/layout/common/notifications/notifications.component'; -import { QuickChatComponent } from 'app/layout/common/quick-chat/quick-chat.component'; import { SearchComponent } from 'app/layout/common/search/search.component'; -import { ShortcutsComponent } from 'app/layout/common/shortcuts/shortcuts.component'; import { UserComponent } from 'app/layout/common/user/user.component'; import { Subject, takeUntil } from 'rxjs'; @@ -46,17 +43,40 @@ import { Subject, takeUntil } from 'rxjs'; LanguagesComponent, FuseFullscreenComponent, SearchComponent, - ShortcutsComponent, - MessagesComponent, NotificationsComponent, UserComponent, RouterOutlet, FuseRouteAnimationDirective, - QuickChatComponent, + ], + styles: [ + ` + /* + Dense nav stays appearance="dense" on hover — Fuse only adds + .fuse-vertical-navigation-hover while expanding. Swap the mark + for the primary wordmark to match the pinned-open state. + */ + fuse-vertical-navigation.fuse-vertical-navigation-appearance-dense.fuse-vertical-navigation-hover { + .admin-dense-logo-header { + justify-content: flex-start; + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .admin-dense-logo-mark { + display: none !important; + } + + .admin-dense-logo-primary { + display: block !important; + } + } + `, ], }) export class DenseLayoutComponent implements OnInit, OnDestroy { - isScreenSmall: boolean; + isScreenSmall = false; + /** Sidebar open state — independent of theme/scheme toggles. */ + navigationOpened = true; navigation: Navigation; navigationAppearance: 'default' | 'dense' = 'dense'; scheme: 'auto' | 'dark' | 'light'; @@ -66,18 +86,12 @@ export class DenseLayoutComponent implements OnInit, OnDestroy { * Constructor */ constructor( - private _activatedRoute: ActivatedRoute, - private _router: Router, private _navigationService: NavigationService, private _fuseConfigService: FuseConfigService, private _fuseMediaWatcherService: FuseMediaWatcherService, private _fuseNavigationService: FuseNavigationService ) {} - // ----------------------------------------------------------------------------------------------------- - // @ Accessors - // ----------------------------------------------------------------------------------------------------- - /** * Getter for current year */ @@ -85,10 +99,6 @@ export class DenseLayoutComponent implements OnInit, OnDestroy { return new Date().getFullYear(); } - // ----------------------------------------------------------------------------------------------------- - // @ Lifecycle hooks - // ----------------------------------------------------------------------------------------------------- - /** * On init */ @@ -107,17 +117,18 @@ export class DenseLayoutComponent implements OnInit, OnDestroy { this.navigation = navigation; }); - // Subscribe to media changes + // Sync sidebar / appearance only when the breakpoint actually changes — + // not when light/dark scheme flips (that would reset a collapsed sidebar). this._fuseMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({ matchingAliases }) => { - // Check if the screen is small - this.isScreenSmall = !matchingAliases.includes('md'); - - // Change the navigation appearance - this.navigationAppearance = this.isScreenSmall - ? 'default' - : 'dense'; + const isSmall = !matchingAliases.includes('md'); + if (isSmall === this.isScreenSmall) { + return; + } + this.isScreenSmall = isSmall; + this.navigationOpened = !isSmall; + this.navigationAppearance = isSmall ? 'default' : 'dense'; }); } @@ -125,29 +136,20 @@ export class DenseLayoutComponent implements OnInit, OnDestroy { * On destroy */ ngOnDestroy(): void { - // Unsubscribe from all subscriptions this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } - // ----------------------------------------------------------------------------------------------------- - // @ Public methods - // ----------------------------------------------------------------------------------------------------- - /** * Toggle navigation - * - * @param name */ toggleNavigation(name: string): void { - // Get the navigation const navigation = this._fuseNavigationService.getComponent( name ); if (navigation) { - // Toggle the opened status navigation.toggle(); } } diff --git a/src/app/modules/admin/admin.routes.ts b/src/app/modules/admin/admin.routes.ts index e72c609..f08131e 100644 --- a/src/app/modules/admin/admin.routes.ts +++ b/src/app/modules/admin/admin.routes.ts @@ -12,7 +12,6 @@ import { VehiclesComponent } from './logistics/vehicles.component'; import { OrderGroupsComponent } from './order-groups/order-groups.component'; import { RestaurantsAdminComponent } from './restaurants/restaurants-admin.component'; import { AdminSettingsComponent } from './settings/settings.component'; -import { UserDetailComponent } from './users/user-detail.component'; import { UsersListComponent } from './users/users-list.component'; export default [ @@ -25,10 +24,6 @@ export default [ path: 'users', component: UsersListComponent, }, - { - path: 'users/:userId', - component: UserDetailComponent, - }, { path: 'restaurants', component: RestaurantsAdminComponent, diff --git a/src/app/modules/admin/admin.service.ts b/src/app/modules/admin/admin.service.ts index da025b9..6cdc4ab 100644 --- a/src/app/modules/admin/admin.service.ts +++ b/src/app/modules/admin/admin.service.ts @@ -112,6 +112,79 @@ export class AdminService { }); } + /** + * Loads every `market_agent` user and their market-assignments, then + * builds marketId → agent for the markets table. + */ + async getMarketAgentsWithAssignments(): Promise<{ + agents: AdminUserRow[]; + agentsByMarket: Map; + }> { + const { users } = await this.getUsers({ + role: MARKET_AGENT_ROLE, + pageSize: MAX_PAGE_SIZE, + }); + const agents = users.filter((u) => !!u.id); + const pairs = await Promise.all( + agents.map(async (agent) => ({ + agent, + markets: await this.getMarketAssignments(agent.id), + })) + ); + const agentsByMarket = new Map(); + for (const { agent, markets } of pairs) { + for (const marketId of markets) { + agentsByMarket.set(marketId, agent); + } + } + return { agents, agentsByMarket }; + } + + /** + * Resolves which market-agent (if any) currently holds each market, by + * reading every agent's assignment list. There is no market→agent GET. + */ + async getAgentsByMarketId(): Promise> { + const { agentsByMarket } = await this.getMarketAgentsWithAssignments(); + return agentsByMarket; + } + + /** + * Makes `agentUserId` the sole agent for `marketId` (or clears the + * assignment when `agentUserId` is null) via market-assignments PUT. + * Other agents that held this market lose it; the chosen agent keeps + * their other markets. + */ + async setMarketAgent( + marketId: string, + agentUserId: string | null + ): Promise { + const { agentsByMarket } = await this.getMarketAgentsWithAssignments(); + const previous = agentsByMarket.get(marketId); + + if (previous?.id === agentUserId) { + return; + } + + if (previous) { + const markets = await this.getMarketAssignments(previous.id); + await this.replaceMarketAssignments( + previous.id, + markets.filter((id) => id !== marketId) + ); + } + + if (agentUserId) { + const markets = await this.getMarketAssignments(agentUserId); + if (!markets.includes(marketId)) { + await this.replaceMarketAssignments(agentUserId, [ + ...markets, + marketId, + ]); + } + } + } + // ------------------------------------------------------------------- // Roles // ------------------------------------------------------------------- diff --git a/src/app/modules/admin/analytics/analytics-dashboard.component.html b/src/app/modules/admin/analytics/analytics-dashboard.component.html index 5fb0d30..22e7c38 100644 --- a/src/app/modules/admin/analytics/analytics-dashboard.component.html +++ b/src/app/modules/admin/analytics/analytics-dashboard.component.html @@ -1,225 +1,408 @@ -
-
-
-

- {{ t('admin.analytics.title') }} -

-

- {{ t('admin.analytics.subtitle') }} -

-
- -
- - {{ t('admin.analytics.from') }} - - - - {{ t('admin.analytics.to') }} - - - -
-
- - @if (loading()) { - - } +
+
+ @if (user()?.avatar || user()?.avatarUrl; as avatar) { + + } @else { + {{ displayName().charAt(0) || 'A' }} + } +
+
+
+ {{ t('welcome-back') }} + @if (displayName()) { + , {{ displayName() }}! + } +
+
+ +
+ {{ + t('admin.analytics.welcomeStatus', { + count: activities().length, + }) + }} +
+
+
+
- - @if (tiles().length) { -
- @for (tile of tiles(); track tile.key) { -
-

- {{ tile.key }} -

-

- {{ tile.value }} -

+
+
+ + {{ + t('admin.analytics.from') + }} + + + + {{ t('admin.analytics.to') }} + + + +
+ + + {{ + t('admin.dashboard.settings.title') + }} +
- } +
- } - - -
- - - - - -
- -
-

- {{ t('admin.analytics.recentActivity') }} -

-
    - @for (activity of activities(); track $index) { -
  • - {{ activityLabel(activity) }} - {{ - activityTime(activity) - }} -
  • - } @empty { -
  • - {{ t('admin.analytics.noData') }} -
  • + +
    +
    + + @if (tiles().length) { +
    + @for (tile of tiles(); track tile.key) { +
    +
    + {{ tile.key }} +
    +
    +
    + {{ tile.value }} +
    +
    + {{ t('admin.analytics.kpiLabel') }} +
    +
    +
    + } +
    } -
-
- -
-

- {{ t('admin.analytics.export') }} -

- @for (dataset of exportDatasets; track dataset) { - - } -
+ +
+
+ {{ t('admin.analytics.orderMetrics') }} +
+ @if (orderMetrics().length) { +
+ +
+ } @else { +

+ {{ t('admin.analytics.noData') }} +

+ } +
- -
- @for (card of cards; track card.link) { - - - - {{ t(card.titleKey) }} - {{ - t(card.descriptionKey) - }} - - - } -
-
+
+
+ {{ t('admin.analytics.procurementMetrics') }} +
+ @if (procurementMetrics().length) { +
+ +
+ } @else { +

+ {{ t('admin.analytics.noData') }} +

+ } +
- - -
-

{{ title }}

- @if (bars.length) { -
    - @for (bar of bars; track bar.label) { -
  • -
    - {{ bar.label || '—' }} - {{ - bar.value - }} + +
    +
    + {{ t('admin.analytics.recentActivity') }} +
    +
    + @for (activity of activities(); track $index) { +
    +
    + {{ activityLabel(activity) }} +
    +
    + {{ activityTime(activity) }} + @if (activity.actorEmail) { + · {{ activity.actorEmail }} + } +
    +
    + } @empty { +

    + {{ t('admin.analytics.noData') }} +

    + } +
    +
    + +
    +
    + {{ t('admin.analytics.hubThroughput') }} +
    + @if (hubThroughput().length) { +
    + +
    + } @else { +

    + {{ t('admin.analytics.noData') }} +

    + } +
    + +
    +
    + {{ t('admin.analytics.deliveryPerformance') }} +
    + @if (deliveryPerformance().length) { +
    + +
    + } @else { +

    + {{ t('admin.analytics.noData') }} +

    + } +
    + +
    +
    + {{ t('admin.analytics.priceTrends') }} +
    + @if (priceTrends().length) { +
    + +
    + } @else { +

    + {{ t('admin.analytics.noData') }} +

    + } +
    + +
    +
    + {{ t('admin.analytics.demandDistribution') }} +
    + @if (demandDistribution().length) { +
    +
    + } @else { +

    + {{ t('admin.analytics.noData') }} +

    + } +
    +
    + + +
    +
    + {{ t('admin.analytics.export') }} +
    + @for (dataset of exportDatasets; track dataset) { + + } +
    + + +
    + @for (card of cards; track card.link) { + -
  • + + {{ + t(card.titleKey) + }} + {{ + t(card.descriptionKey) + }} + + } -
- } @else { -

{{ empty }}

- } -
-
+ + + + diff --git a/src/app/modules/admin/analytics/analytics-dashboard.component.ts b/src/app/modules/admin/analytics/analytics-dashboard.component.ts index 0eeff3a..1950590 100644 --- a/src/app/modules/admin/analytics/analytics-dashboard.component.ts +++ b/src/app/modules/admin/analytics/analytics-dashboard.component.ts @@ -1,9 +1,10 @@ -import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, + OnDestroy, OnInit, ViewEncapsulation, + computed, inject, signal, } from '@angular/core'; @@ -16,7 +17,11 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { RouterLink } from '@angular/router'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { apiErrorMessage } from '../admin.service'; +import { describeApiError } from 'app/core/api/error-codes'; +import { UserService } from 'app/core/user/user.service'; +import { User } from 'app/core/user/user.types'; +import { ApexOptions, NgApexchartsModule } from 'ng-apexcharts'; +import { Subject, takeUntil } from 'rxjs'; import { AnalyticsActivity, AnalyticsPoint, @@ -24,18 +29,15 @@ import { isoDate, } from './analytics.service'; -/** A KPI tile derived from the untyped `/analytics/overview` body. */ +/** Overview KPI tile with Fuse-style accent classes. */ interface OverviewTile { key: string; value: string; + valueClass: string; + labelClass: string; } -/** One horizontal bar: a point plus its width as a share of the series max. */ -interface BarDatum extends AnalyticsPoint { - percent: number; -} - -/** Quick-link cards kept from the previous admin landing page. */ +/** Quick-link cards under the analytics panels. */ interface DashboardCard { icon: string; link: string; @@ -43,25 +45,28 @@ interface DashboardCard { descriptionKey: string; } -/** Datasets `GET /analytics/export` accepts, offered in the export menu. */ const EXPORT_DATASETS = ['orders', 'procurement', 'deliveries'] as const; -/** - * Turns a series into bars sized against the series maximum. An all-zero (or - * empty) series yields no bars rather than a row of full-width blocks. - */ -function toBars(points: AnalyticsPoint[]): BarDatum[] { - const max = Math.max(0, ...points.map((p) => p.value)); - if (max <= 0) { - return []; - } - return points.map((point) => ({ - ...point, - percent: Math.round((point.value / max) * 100), - })); -} +const KPI_ACCENTS = [ + { + valueClass: 'text-blue-500', + labelClass: 'text-blue-600 dark:text-blue-500', + }, + { + valueClass: 'text-red-500', + labelClass: 'text-red-600 dark:text-red-500', + }, + { + valueClass: 'text-amber-500', + labelClass: 'text-amber-600 dark:text-amber-500', + }, + { + valueClass: 'text-green-500', + labelClass: 'text-green-600 dark:text-green-500', + }, +] as const; -/** `overviewTotalOrders` → `Overview total orders` for an untyped KPI key. */ +/** `overviewTotalOrders` → `Overview total orders`. */ function humanize(key: string): string { const spaced = key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') @@ -72,13 +77,8 @@ function humanize(key: string): string { } /** - * Admin ▸ Dashboard — the analytics landing page (`/api/v1/analytics/*`). - * - * The spec declares no response schemas for analytics, so the overview KPIs are - * rendered generically from whatever scalar fields the backend returns (keys - * humanized for display) and each series is normalised to `{ label, value }` - * before charting. Every panel loads independently: one failing endpoint leaves - * the rest of the dashboard usable. + * Admin ▸ Dashboard (`/admin`) — Fuse project-dashboard shell with live + * `/analytics/*` data, ApexCharts series, and quick links. */ @Component({ selector: 'admin-analytics-dashboard', @@ -86,7 +86,6 @@ function humanize(key: string): string { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - // Full-width flex host so the page fills the screen (see ResourceCrudComponent). host: { class: 'flex flex-auto flex-col' }, imports: [ MatButtonModule, @@ -95,29 +94,39 @@ function humanize(key: string): string { MatInputModule, MatProgressBarModule, MatSnackBarModule, - NgTemplateOutlet, + NgApexchartsModule, ReactiveFormsModule, RouterLink, TranslocoModule, ], }) -export class AdminDashboardComponent implements OnInit { +export class AdminDashboardComponent implements OnInit, OnDestroy { private readonly _analytics = inject(AnalyticsService); private readonly _snackBar = inject(MatSnackBar); private readonly _transloco = inject(TranslocoService); private readonly _formBuilder = inject(FormBuilder); + private readonly _userService = inject(UserService); + private readonly _unsubscribeAll = new Subject(); readonly loading = signal(false); readonly exporting = signal(false); + readonly user = signal(null); readonly tiles = signal([]); - readonly orderMetrics = signal([]); - readonly procurementMetrics = signal([]); - readonly hubThroughput = signal([]); - readonly deliveryPerformance = signal([]); - readonly priceTrends = signal([]); - readonly demandDistribution = signal([]); + readonly orderMetrics = signal([]); + readonly procurementMetrics = signal([]); + readonly hubThroughput = signal([]); + readonly deliveryPerformance = signal([]); + readonly priceTrends = signal([]); + readonly demandDistribution = signal([]); readonly activities = signal([]); + readonly chartOrders = signal({}); + readonly chartProcurement = signal({}); + readonly chartHubs = signal({}); + readonly chartDelivery = signal({}); + readonly chartPrices = signal({}); + readonly chartDemand = signal({}); + readonly exportDatasets = [...EXPORT_DATASETS]; readonly rangeForm = this._formBuilder.nonNullable.group({ @@ -125,6 +134,11 @@ export class AdminDashboardComponent implements OnInit { to: [isoDate(new Date())], }); + readonly displayName = computed(() => { + const u = this.user(); + return u?.fullName || u?.name || u?.email || ''; + }); + readonly cards: DashboardCard[] = [ { icon: 'heroicons_outline:users', @@ -153,9 +167,32 @@ export class AdminDashboardComponent implements OnInit { ]; ngOnInit(): void { + // ApexCharts + ``: keep fill urls rooted (Fuse project pattern). + (window as Window & { Apex?: unknown }).Apex = { + chart: { + events: { + mounted: (chart: { el: Element }): void => { + this._fixSvgFill(chart.el); + }, + updated: (chart: { el: Element }): void => { + this._fixSvgFill(chart.el); + }, + }, + }, + }; + + this._userService.user$ + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((user) => this.user.set(user)); + this.reload(); } + ngOnDestroy(): void { + this._unsubscribeAll.next(); + this._unsubscribeAll.complete(); + } + reload(): void { const { from, to } = this.rangeForm.getRawValue(); if (!from || !to) { @@ -163,7 +200,6 @@ export class AdminDashboardComponent implements OnInit { } this.loading.set(true); - // Each panel resolves independently so one 500 doesn't blank the page. Promise.all([ this._analytics.getOverview().catch(() => ({})), this._analytics.getOrderMetrics(from, to).catch(() => []), @@ -192,18 +228,36 @@ export class AdminDashboardComponent implements OnInit { typeof value === 'number' || typeof value === 'string' ) - .map(([key, value]) => ({ - key: humanize(key), - value: String(value), - })) + .slice(0, 4) + .map(([key, value], index) => { + const accent = + KPI_ACCENTS[index % KPI_ACCENTS.length]; + return { + key: humanize(key), + value: String(value), + valueClass: accent.valueClass, + labelClass: accent.labelClass, + }; + }) ); - this.orderMetrics.set(toBars(orders)); - this.procurementMetrics.set(toBars(procurement)); - this.hubThroughput.set(toBars(hubs)); - this.deliveryPerformance.set(toBars(deliveries)); - this.priceTrends.set(toBars(prices)); - this.demandDistribution.set(toBars(demand)); + this.orderMetrics.set(orders); + this.procurementMetrics.set(procurement); + this.hubThroughput.set(hubs); + this.deliveryPerformance.set(deliveries); + this.priceTrends.set(prices); + this.demandDistribution.set(demand); this.activities.set(activities); + + this.chartOrders.set(this._areaChart(orders, '#34D399')); + this.chartProcurement.set( + this._columnChart(procurement, '#60A5FA') + ); + this.chartHubs.set(this._columnChart(hubs, '#A78BFA')); + this.chartDelivery.set( + this._areaChart(deliveries, '#FBBF24') + ); + this.chartPrices.set(this._areaChart(prices, '#F87171')); + this.chartDemand.set(this._columnChart(demand, '#2DD4BF')); } ) .finally(() => this.loading.set(false)); @@ -228,7 +282,6 @@ export class AdminDashboardComponent implements OnInit { return Number.isNaN(date.getTime()) ? raw : date.toLocaleString(); } - /** Downloads a dataset export and hands it to the browser as a file. */ exportDataset(dataset: string): void { const { from, to } = this.rangeForm.getRawValue(); this.exporting.set(true); @@ -244,14 +297,132 @@ export class AdminDashboardComponent implements OnInit { }) .catch(async (err) => this._snackBar.open( - (await apiErrorMessage(err)) ?? - this._transloco.translate( - 'admin.analytics.exportError' - ), + await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.analytics.exportError' + ), undefined, { duration: 5000 } ) ) .finally(() => this.exporting.set(false)); } + + private _areaChart(points: AnalyticsPoint[], color: string): ApexOptions { + return { + chart: { + animations: { enabled: true }, + fontFamily: 'inherit', + foreColor: 'inherit', + height: '100%', + type: 'area', + toolbar: { show: false }, + zoom: { enabled: false }, + }, + colors: [color], + dataLabels: { enabled: false }, + fill: { + colors: [color], + opacity: 0.2, + }, + grid: { + borderColor: 'var(--fuse-border)', + }, + series: [ + { + name: 'Series', + data: points.map((p) => p.value), + }, + ], + stroke: { + curve: 'smooth', + width: 2, + }, + tooltip: { + followCursor: true, + theme: 'dark', + }, + xaxis: { + categories: points.map((p) => p.label || '—'), + axisBorder: { show: false }, + axisTicks: { color: 'var(--fuse-border)' }, + labels: { + style: { colors: 'var(--fuse-text-secondary)' }, + }, + }, + yaxis: { + labels: { + offsetX: -16, + style: { colors: 'var(--fuse-text-secondary)' }, + }, + }, + }; + } + + private _columnChart(points: AnalyticsPoint[], color: string): ApexOptions { + return { + chart: { + animations: { enabled: true }, + fontFamily: 'inherit', + foreColor: 'inherit', + height: '100%', + type: 'bar', + toolbar: { show: false }, + zoom: { enabled: false }, + }, + colors: [color], + dataLabels: { enabled: false }, + grid: { + borderColor: 'var(--fuse-border)', + }, + plotOptions: { + bar: { + columnWidth: '50%', + borderRadius: 4, + }, + }, + series: [ + { + name: 'Series', + data: points.map((p) => p.value), + }, + ], + tooltip: { + followCursor: true, + theme: 'dark', + }, + xaxis: { + categories: points.map((p) => p.label || '—'), + axisBorder: { show: false }, + axisTicks: { color: 'var(--fuse-border)' }, + labels: { + style: { colors: 'var(--fuse-text-secondary)' }, + }, + }, + yaxis: { + labels: { + offsetX: -16, + style: { colors: 'var(--fuse-text-secondary)' }, + }, + }, + }; + } + + /** Rewrites absolute fill urls so gradients work under ``. */ + private _fixSvgFill(element: Element): void { + const currentURL = window.location.href; + Array.from(element.querySelectorAll('*[fill]')) + .filter((el) => el.getAttribute('fill')?.indexOf('url(') !== -1) + .forEach((el) => { + const attr = el.getAttribute('fill'); + if (!attr) { + return; + } + el.setAttribute( + 'fill', + `url(${currentURL}${attr.slice(attr.indexOf('#'))}` + ); + }); + } } diff --git a/src/app/modules/admin/catalog/categories.component.ts b/src/app/modules/admin/catalog/categories.component.ts index 52b2041..816b2b5 100644 --- a/src/app/modules/admin/catalog/categories.component.ts +++ b/src/app/modules/admin/catalog/categories.component.ts @@ -61,6 +61,7 @@ export class CategoriesComponent { title: 'admin.categories.title', subtitle: 'admin.categories.subtitle', createLabel: 'admin.categories.create', + inlineDetail: false, searchKeys: ['name', 'parentName'], searchPlaceholder: 'admin.categories.searchPlaceholder', filters: [ diff --git a/src/app/modules/admin/catalog/category-map.component.ts b/src/app/modules/admin/catalog/category-map.component.ts index f0ba12c..c673171 100644 --- a/src/app/modules/admin/catalog/category-map.component.ts +++ b/src/app/modules/admin/catalog/category-map.component.ts @@ -15,7 +15,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { apiErrorMessage } from '../admin.service'; +import { describeApiError } from 'app/core/api/error-codes'; import { CrudRow } from '../shared/resource-crud.types'; import { CatalogAdminService } from './catalog-admin.service'; @@ -504,8 +504,11 @@ export class CategoryMapComponent { ); } catch (err) { this._notify( - (await apiErrorMessage(err)) ?? - this._transloco.translate('admin.categories.map.moveError') + await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.categories.map.moveError' + ) ); } finally { this.saving.set(false); diff --git a/src/app/modules/admin/catalog/market-products.component.html b/src/app/modules/admin/catalog/market-products.component.html index 5417fe8..dae7691 100644 --- a/src/app/modules/admin/catalog/market-products.component.html +++ b/src/app/modules/admin/catalog/market-products.component.html @@ -13,7 +13,6 @@

{{ t('admin.markets.pricingTitle') }}

-

{{ t('admin.markets.pricingSubtitle') }}

+ +
+ @if (loading()) { +
+ +
+ } +
+
+ {{ t('admin.markets.title') }} +
+
+
+ + + + + +
+
+ + +
+
+ @if (filteredRows().length > 0) { +
+ +
+
+ +
+ + +
+ +
+
{{ t('admin.markets.pricing') }}
+
+ {{ t('admin.markets.details') }} +
+
+ + + @for (row of pagedRows(); track row.id) { +
+
+ {{ row['name'] || '—' }} + @if (isInactive(row)) { + + {{ t('admin.users.filters.inactive') }} + + } +
+ + +
+ +
+
+ +
+
+ +
+
+ + @if (selectedId() === row.id) { +
+ +
+
+ + {{ + t('admin.markets.name') + }} + + @if ( + selectedForm.controls.name + .touched && + selectedForm.controls.name.hasError( + 'required' + ) + ) { + {{ + t( + 'admin.crud.errors.required' + ) + }} + } + + + {{ + t('admin.markets.location') + }} + + + + {{ + t('admin.markets.address') + }} + + +
+
+ + {{ + t( + 'admin.markets.coordinates' + ) + }} + + +
+
+ +
+ +
+ @if (flashMessage(); as flash) { +
+ @if (flash === 'success') { + + {{ + t( + 'admin.crud.updateSuccess' + ) + }} + } + @if (flash === 'error') { + + {{ + t( + 'admin.crud.saveError' + ) + }} + } +
+ } + +
+
+ +
+ } + } +
+ + @if (filteredRows().length > pageSize()) { + + } + } @else if (!loading()) { +
+ {{ t('admin.crud.empty') }} +
+ } +
+
+ + + + +
+
+

+ {{ t('admin.markets.create') }} +

+ +
+ +
+ + {{ t('admin.markets.name') }} + + @if ( + createForm.controls.name.touched && + createForm.controls.name.hasError('required') + ) { + {{ t('admin.crud.errors.required') }} + } + + + {{ t('admin.markets.location') }} + + + + {{ t('admin.markets.address') }} + + + + +
+ + +
+
+
+
+ + + +
+
+
+ {{ t('admin.markets.agentDialog.title') }} +
+ +
+ +
+ @if (agentDialogMarket(); as market) { +
+ {{ market['name'] || '—' }} +
+
+ {{ market['location'] || market['address'] || '' }} +
+ +
+
+ {{ t('admin.markets.agentDialog.current') }} +
+ @if (agentFor(market); as agent) { +
+ {{ + t('admin.userDetail.profile.email') + }} + {{ + agent.email || '—' + }} + {{ + t('admin.userDetail.profile.phone') + }} + {{ agent.phone || '—' }} +
+ } @else { +
+ {{ t('admin.markets.agentNone') }} +
+ } +
+ } + + + {{ + t('admin.markets.agentDialog.select') + }} + + {{ + t('admin.markets.agentNone') + }} + @for (agent of agentOptions(); track agent.id) { + {{ + agent.email || agent.id + }} + } + + + +
+ +
+ + +
+
+
+
+
diff --git a/src/app/modules/admin/catalog/markets.component.ts b/src/app/modules/admin/catalog/markets.component.ts index f1cf3bf..30df361 100644 --- a/src/app/modules/admin/catalog/markets.component.ts +++ b/src/app/modules/admin/catalog/markets.component.ts @@ -1,98 +1,468 @@ import { ChangeDetectionStrategy, Component, + OnInit, + TemplateRef, ViewEncapsulation, + computed, inject, + signal, } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; -import { ResourceCrudComponent } from '../shared/resource-crud.component'; -import { CrudResource } from '../shared/resource-crud.types'; +import { collapseOnLeave, expandOnEnter } from '@fuse/animations'; +import { FuseConfirmationService } from '@fuse/services/confirmation'; +import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; +import { describeApiError } from 'app/core/api/error-codes'; +import { LocationPickerComponent } from 'app/core/maps/location-picker.component'; +import { includesFolded } from 'app/core/util/text-search'; +import { AdminService } from '../admin.service'; +import { AdminUserRow } from '../admin.types'; +import { CrudRow } from '../shared/resource-crud.types'; +import { TableSort } from '../shared/table-sort'; import { CatalogAdminService } from './catalog-admin.service'; -/** Admin ▸ Catalog ▸ Markets — market master data + drill-in to pricing (M3, admin = Full). */ +/** + * Admin ▸ Catalog ▸ Markets — inventory-style list with inline detail editor + * (Fuse ecommerce inventory pattern). Row actions column is omitted; edit / + * deactivate / pricing live in the expanded detail panel. + */ @Component({ selector: 'admin-markets', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, - imports: [ResourceCrudComponent], - template: ``, + host: { class: 'flex flex-auto flex-col' }, + imports: [ + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressBarModule, + MatSelectModule, + MatSnackBarModule, + MatTooltipModule, + ReactiveFormsModule, + TranslocoModule, + LocationPickerComponent, + ], + templateUrl: './markets.component.html', + styles: [ + ` + .markets-grid { + /* name | agent | pricing | details */ + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto; + + @screen sm { + /* name | location | agent | pricing | details */ + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax( + 0, + 1fr + ) auto auto; + } + + @screen md { + /* name | location | address | agent | pricing | details */ + grid-template-columns: + minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1.25fr) + minmax(0, 1fr) auto auto; + } + } + `, + ], }) -export class MarketsComponent { +export class MarketsComponent implements OnInit { + protected readonly expandOnEnter = expandOnEnter; + protected readonly collapseOnLeave = collapseOnLeave; + private readonly _catalog = inject(CatalogAdminService); + private readonly _admin = inject(AdminService); private readonly _router = inject(Router); + private readonly _dialog = inject(MatDialog); + private readonly _confirmation = inject(FuseConfirmationService); + private readonly _snackBar = inject(MatSnackBar); + private readonly _transloco = inject(TranslocoService); - readonly resource: CrudResource = { - title: 'admin.markets.title', - subtitle: 'admin.markets.subtitle', - createLabel: 'admin.markets.create', - searchKeys: ['name', 'location', 'address'], - searchPlaceholder: 'admin.markets.searchPlaceholder', - columns: [ - { - label: 'admin.markets.name', - sortable: true, - cell: (row) => String(row['name'] ?? ''), - }, - { - label: 'admin.markets.location', - sortable: true, - cell: (row) => String(row['location'] ?? ''), - }, - { - label: 'admin.markets.address', - sortable: true, - cell: (row) => String(row['address'] ?? ''), - }, - ], - fields: [ - { - name: 'name', - label: 'admin.markets.name', - type: 'text', - required: true, - }, - { - name: 'location', - label: 'admin.markets.location', - type: 'text', - }, - { - name: 'address', - label: 'admin.markets.address', - type: 'text', - }, - { - name: 'latitude', - label: 'admin.markets.latitude', - type: 'number', - }, - { - name: 'longitude', - label: 'admin.markets.longitude', - type: 'number', - }, - ], - rowActions: [ - { - icon: 'currency-dollar', - tooltip: 'admin.markets.pricing', - run: (row) => - void this._router.navigate([ - '/admin/markets', - row.id, - 'products', - ]), + private _createDialogRef: MatDialogRef | null = null; + private _agentDialogRef: MatDialogRef | null = null; + + readonly rows = signal([]); + /** marketId → assigned market_agent user */ + readonly agentsByMarket = signal>(new Map()); + readonly agentOptions = signal([]); + readonly agentDialogMarket = signal(null); + readonly agentDialogSaving = signal(false); + readonly loading = signal(false); + readonly saving = signal(false); + readonly search = signal(''); + readonly pageIndex = signal(0); + readonly pageSize = signal(10); + readonly selectedId = signal(null); + readonly flashMessage = signal<'success' | 'error' | null>(null); + readonly sort = new TableSort(); + + readonly agentForm = new FormGroup({ + agentUserId: new FormControl('', { nonNullable: true }), + }); + + readonly selectedForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + location: new FormControl('', { nonNullable: true }), + address: new FormControl('', { nonNullable: true }), + latitude: new FormControl(null), + longitude: new FormControl(null), + }); + + readonly createForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + location: new FormControl('', { nonNullable: true }), + address: new FormControl('', { nonNullable: true }), + latitude: new FormControl(null), + longitude: new FormControl(null), + }); + + readonly filteredRows = computed(() => { + const term = this.search().trim(); + const list = this.rows(); + const agents = this.agentsByMarket(); + if (!term) { + return list; + } + return list.filter((row) => { + const agent = agents.get(row.id); + const agentText = agent + ? `${agent.email ?? ''} ${agent.name ?? ''}` + : ''; + return ( + ['name', 'location', 'address'].some((key) => + includesFolded(String(row[key] ?? ''), term) + ) || includesFolded(agentText, term) + ); + }); + }); + + readonly sortedRows = computed(() => + this.sort.apply(this.filteredRows(), (row, key) => { + if (key === 'agent') { + const agent = this.agentsByMarket().get(row.id); + return agent?.email ?? String(agent?.['name'] ?? ''); + } + return String(row[key] ?? ''); + }) + ); + + readonly pagedRows = computed(() => { + const start = this.pageIndex() * this.pageSize(); + return this.sortedRows().slice(start, start + this.pageSize()); + }); + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + Promise.all([ + this._catalog.listMarkets(), + this._admin.getMarketAgentsWithAssignments().catch(() => ({ + agents: [] as AdminUserRow[], + agentsByMarket: new Map(), + })), + ]) + .then(([rows, { agents, agentsByMarket }]) => { + this.rows.set(rows); + this.agentOptions.set(agents); + this.agentsByMarket.set(agentsByMarket); + const id = this.selectedId(); + if (id && !rows.some((r) => r.id === id)) { + this.closeDetails(); + } else if (id) { + const row = rows.find((r) => r.id === id); + if (row) { + this._patchSelected(row); + } + } + }) + .catch((err) => void this._notifyError(err, 'admin.crud.loadError')) + .finally(() => this.loading.set(false)); + } + + agentFor(row: CrudRow): AdminUserRow | undefined { + return this.agentsByMarket().get(row.id); + } + + agentLabel(row: CrudRow): string { + const agent = this.agentFor(row); + if (!agent) { + return ''; + } + return agent.email || String(agent['name'] ?? ''); + } + + openAgentDialog(row: CrudRow, template: TemplateRef): void { + this.agentDialogMarket.set(row); + const current = this.agentFor(row); + this.agentForm.reset({ agentUserId: current?.id ?? '' }); + this.agentDialogSaving.set(false); + + this._agentDialogRef = this._dialog.open(template, { + autoFocus: 'first-tabbable', + maxWidth: '95vw', + }); + this._agentDialogRef.afterClosed().subscribe(() => { + this._agentDialogRef = null; + this.agentDialogMarket.set(null); + }); + } + + closeAgentDialog(): void { + this._agentDialogRef?.close(); + } + + clearAgentAssignment(): void { + this.agentForm.reset({ agentUserId: '' }); + this.saveAgentAssignment(); + } + + saveAgentAssignment(): void { + const market = this.agentDialogMarket(); + if (!market) { + return; + } + const agentUserId = this.agentForm.getRawValue().agentUserId || null; + + this.agentDialogSaving.set(true); + this._admin + .setMarketAgent(market.id, agentUserId) + .then(() => { + this._notify('admin.markets.agentDialog.success'); + this.closeAgentDialog(); + // Same fetch as initial page load: markets + market agents. + this.load(); + }) + .catch( + (err) => + void this._notifyError( + err, + 'admin.markets.agentDialog.error' + ) + ) + .finally(() => this.agentDialogSaving.set(false)); + } + + onSearch(value: string): void { + this.search.set(value); + this.pageIndex.set(0); + this.closeDetails(); + } + + onSort(key: string): void { + this.sort.toggle(key); + this.pageIndex.set(0); + this.closeDetails(); + } + + onPageChange(event: PageEvent): void { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + this.closeDetails(); + } + + toggleDetails(row: CrudRow): void { + if (this.selectedId() === row.id) { + this.closeDetails(); + return; + } + this.selectedId.set(row.id); + this.flashMessage.set(null); + this._patchSelected(row); + } + + closeDetails(): void { + this.selectedId.set(null); + this.flashMessage.set(null); + this.selectedForm.reset({ + name: '', + location: '', + address: '', + latitude: null, + longitude: null, + }); + } + + openCreate(template: TemplateRef): void { + this.createForm.reset({ + name: '', + location: '', + address: '', + latitude: null, + longitude: null, + }); + this._createDialogRef = this._dialog.open(template, { + width: '560px', + maxWidth: '95vw', + autoFocus: 'first-tabbable', + }); + } + + closeCreate(): void { + this._createDialogRef?.close(); + this._createDialogRef = null; + } + + saveCreate(): void { + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); + return; + } + this.saving.set(true); + this._catalog + .createMarket(this.createForm.getRawValue()) + .then(() => { + this._notify('admin.crud.createSuccess'); + this.closeCreate(); + this.load(); + }) + .catch((err) => void this._notifyError(err, 'admin.crud.saveError')) + .finally(() => this.saving.set(false)); + } + + updateSelected(): void { + const id = this.selectedId(); + if (!id || this.selectedForm.invalid) { + this.selectedForm.markAllAsTouched(); + return; + } + this.saving.set(true); + this._catalog + .updateMarket(id, this.selectedForm.getRawValue()) + .then(() => { + this.showFlashMessage('success'); + this.load(); + }) + .catch((err) => { + this.showFlashMessage('error'); + void this._notifyError(err, 'admin.crud.saveError'); + }) + .finally(() => this.saving.set(false)); + } + + deactivateSelected(): void { + const id = this.selectedId(); + if (!id) { + return; + } + const row = this.rows().find((r) => r.id === id); + if (row?.isActive === false) { + return; + } + + const confirmation = this._confirmation.open({ + title: this._transloco.translate('admin.crud.confirmRemove.title'), + message: this._transloco.translate( + 'admin.crud.confirmRemove.message' + ), + actions: { + confirm: { + label: this._transloco.translate('admin.crud.deactivate'), + }, }, - ], - list: () => this._catalog.listMarkets(), - create: (value) => this._catalog.createMarket(value), - update: (id, value) => this._catalog.updateMarket(id, value), - remove: (row) => this._catalog.deactivateMarket(row.id), - removeLabel: 'admin.crud.deactivate', - removeIsDeactivate: true, - removeIcon: 'archive-box-x-mark', - }; + }); + + confirmation.afterClosed().subscribe((result) => { + if (result !== 'confirmed') { + return; + } + this.saving.set(true); + this._catalog + .deactivateMarket(id) + .then(() => { + this._notify('admin.crud.removeSuccess'); + this.closeDetails(); + this.load(); + }) + .catch( + (err) => void this._notifyError(err, 'admin.crud.saveError') + ) + .finally(() => this.saving.set(false)); + }); + } + + openPricing(row: CrudRow): void { + void this._router.navigate(['/admin/markets', row.id, 'products']); + } + + isInactive(row: CrudRow): boolean { + return row.isActive === false; + } + + showFlashMessage(type: 'success' | 'error'): void { + this.flashMessage.set(type); + window.setTimeout(() => { + if (this.flashMessage() === type) { + this.flashMessage.set(null); + } + }, 3000); + } + + private _patchSelected(row: CrudRow): void { + this.selectedForm.reset({ + name: String(row['name'] ?? ''), + location: String(row['location'] ?? ''), + address: String(row['address'] ?? ''), + latitude: + row['latitude'] == null || row['latitude'] === '' + ? null + : Number(row['latitude']), + longitude: + row['longitude'] == null || row['longitude'] === '' + ? null + : Number(row['longitude']), + }); + } + + private _notify(key: string): void { + this._snackBar.open(this._transloco.translate(key), undefined, { + duration: 3000, + }); + } + + private async _notifyError( + err: unknown, + fallbackKey: string + ): Promise { + const message = await describeApiError( + err, + (key) => this._transloco.translate(key), + fallbackKey + ); + this._snackBar.open(message, undefined, { duration: 5000 }); + } } diff --git a/src/app/modules/admin/catalog/products.component.html b/src/app/modules/admin/catalog/products.component.html new file mode 100644 index 0000000..ac6b14b --- /dev/null +++ b/src/app/modules/admin/catalog/products.component.html @@ -0,0 +1,681 @@ +
+ +
+ @if (loading()) { +
+ +
+ } +
+
+
+ {{ t('admin.products.title') }} +
+
+
+ + + + + +
+
+ + +
+ + {{ t('admin.products.filterCategory') }} + + + {{ + t('admin.crud.filterAll') + }} + @for (opt of filterCategoryOptions(); track opt.value) { + {{ + opt.label + }} + } + + + + {{ t('admin.products.filterStatus') }} + + + {{ + t('admin.crud.filterAll') + }} + {{ + t('admin.users.filters.active') + }} + {{ + t('admin.users.filters.inactive') + }} + + + @if (hasActiveFilters()) { + + } +
+
+ + +
+
+ @if (filteredRows().length > 0) { +
+ +
+
{{ t('admin.products.image') }}
+
+ +
+ + +
+ {{ t('admin.products.details') }} +
+
+ + + @for (row of pagedRows(); track row.id) { +
+
+ @if (row['imageUrl']) { + + } @else { +
+ {{ t('admin.products.noThumb') }} +
+ } +
+
+ {{ row['name'] || '—' }} + @if (isInactive(row)) { + + {{ t('admin.users.filters.inactive') }} + + } +
+ + +
+ +
+
+ + @if (selectedId() === row.id) { +
+
+
+ +
+ + {{ + t('admin.products.imageUrl') + }} + +
+ @if ( + selectedForm.controls + .imageUrl.value + ) { + +
+ + +
+ } @else { + + } + @if (uploading()) { +
+ + {{ + t( + 'admin.crud.image.uploading' + ) + }} +
+ } +
+ +
+ + +
+ + {{ + t('admin.products.name') + }} + + @if ( + selectedForm.controls.name + .touched && + selectedForm.controls.name.hasError( + 'required' + ) + ) { + {{ + t( + 'admin.crud.errors.required' + ) + }} + } + +
+ + {{ + t('admin.products.unit') + }} + + @for ( + opt of unitOptions(); + track opt.value + ) { + {{ + opt.label + }} + } + + @if ( + selectedForm.controls + .unitId.touched && + selectedForm.controls.unitId.hasError( + 'required' + ) + ) { + {{ + t( + 'admin.crud.errors.required' + ) + }} + } + + + {{ + t( + 'admin.products.category' + ) + }} + + + @for ( + opt of categoryOptions(); + track opt.value + ) { + {{ + opt.label + }} + } + + +
+ + {{ + t( + 'admin.products.description' + ) + }} + + +
+
+ +
+ +
+ @if (flashMessage(); as flash) { +
+ @if (flash === 'success') { + + {{ + t( + 'admin.crud.updateSuccess' + ) + }} + } + @if (flash === 'error') { + + {{ + t( + 'admin.crud.saveError' + ) + }} + } +
+ } + +
+
+
+
+ } + } +
+ + @if (filteredRows().length > pageSize()) { + + } + } @else if (!loading()) { +
+ {{ t('admin.crud.empty') }} +
+ } +
+
+
+ + + +
+
+

+ {{ t('admin.products.create') }} +

+ +
+ +
+ + {{ t('admin.products.name') }} + + @if ( + createForm.controls.name.touched && + createForm.controls.name.hasError('required') + ) { + {{ t('admin.crud.errors.required') }} + } + + + {{ t('admin.products.unit') }} + + @for (opt of unitOptions(); track opt.value) { + {{ + opt.label + }} + } + + @if ( + createForm.controls.unitId.touched && + createForm.controls.unitId.hasError('required') + ) { + {{ t('admin.crud.errors.required') }} + } + + + {{ t('admin.products.category') }} + + + @for (opt of categoryOptions(); track opt.value) { + {{ + opt.label + }} + } + + + + {{ t('admin.products.description') }} + + + +
+ + +
+
+
+
diff --git a/src/app/modules/admin/catalog/products.component.ts b/src/app/modules/admin/catalog/products.component.ts index 697daa4..c73010a 100644 --- a/src/app/modules/admin/catalog/products.component.ts +++ b/src/app/modules/admin/catalog/products.component.ts @@ -1,131 +1,478 @@ import { ChangeDetectionStrategy, Component, + OnInit, + TemplateRef, ViewEncapsulation, + computed, inject, + signal, } from '@angular/core'; -import { TranslocoService } from '@jsverse/transloco'; -import { ResourceCrudComponent } from '../shared/resource-crud.component'; -import { CrudResource } from '../shared/resource-crud.types'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { collapseOnLeave, expandOnEnter } from '@fuse/animations'; +import { FuseConfirmationService } from '@fuse/services/confirmation'; +import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; +import { describeApiError } from 'app/core/api/error-codes'; +import { includesFolded } from 'app/core/util/text-search'; +import { CrudOption, CrudRow } from '../shared/resource-crud.types'; +import { TableSort } from '../shared/table-sort'; import { CatalogAdminService } from './catalog-admin.service'; -/** Admin ▸ Catalog ▸ Products — product master data (M3, admin = Full). */ +/** + * Admin ▸ Catalog ▸ Products — inventory-style list with inline detail editor + * (same pattern as markets / Fuse ecommerce inventory). + */ @Component({ selector: 'admin-products', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, - imports: [ResourceCrudComponent], - template: ``, + host: { class: 'flex flex-auto flex-col' }, + imports: [ + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressBarModule, + MatProgressSpinnerModule, + MatSelectModule, + MatSnackBarModule, + MatTooltipModule, + ReactiveFormsModule, + TranslocoModule, + ], + templateUrl: './products.component.html', + styles: [ + ` + .products-grid { + /* image | name | details — matches cells visible below sm */ + grid-template-columns: 3rem minmax(0, 1fr) auto; + + @screen sm { + /* image | name | category | details */ + grid-template-columns: 3rem minmax(0, 1.5fr) minmax(0, 1fr) auto; + } + + @screen md { + /* image | name | category | unit | details */ + grid-template-columns: + 3rem minmax(0, 1.5fr) minmax(0, 1fr) minmax(5rem, 0.7fr) + auto; + } + } + `, + ], }) -export class ProductsComponent { +export class ProductsComponent implements OnInit { + protected readonly expandOnEnter = expandOnEnter; + protected readonly collapseOnLeave = collapseOnLeave; + private readonly _catalog = inject(CatalogAdminService); + private readonly _dialog = inject(MatDialog); + private readonly _confirmation = inject(FuseConfirmationService); + private readonly _snackBar = inject(MatSnackBar); private readonly _transloco = inject(TranslocoService); - readonly resource: CrudResource = { - title: 'admin.products.title', - subtitle: 'admin.products.subtitle', - createLabel: 'admin.products.create', - searchKeys: ['name'], - searchPlaceholder: 'admin.products.searchPlaceholder', - filters: [ - { - name: 'categoryId', - label: 'admin.products.filterCategory', - // Only offer categories that are still active. - options: () => this._catalog.categoryOptions(true), - match: (row, value) => String(row['categoryId']) === value, - }, - { - name: 'status', - label: 'admin.products.filterStatus', - options: () => - Promise.resolve([ - { - value: 'active', - label: this._transloco.translate( - 'admin.users.filters.active' - ), - }, - { - value: 'inactive', - label: this._transloco.translate( - 'admin.users.filters.inactive' - ), - }, - ]), - match: (row, value) => - value === 'active' - ? row.isActive !== false - : row.isActive === false, - }, - ], - columns: [ - { - label: 'admin.products.image', - image: true, - cell: (row) => String(row['imageUrl'] ?? ''), - }, - { - label: 'admin.products.name', - sortable: true, - cell: (row) => String(row['name'] ?? ''), - }, - { - label: 'admin.products.category', - sortable: true, - cell: (row) => String(row['categoryName'] ?? ''), - }, - { - label: 'admin.products.unit', - sortable: true, - cell: (row) => - String(row['unitName'] ?? row['unitAbbreviation'] ?? ''), - }, - ], - fields: [ - { - name: 'name', - label: 'admin.products.name', - type: 'text', - required: true, - }, - { - name: 'unitId', - label: 'admin.products.unit', - type: 'select', - required: true, - options: () => this._catalog.unitOptions(), - }, - { - name: 'categoryId', - label: 'admin.products.category', - type: 'select', - searchable: true, - options: () => this._catalog.categoryOptions(), - }, - { - name: 'description', - label: 'admin.products.description', - type: 'textarea', - }, - { - name: 'imageUrl', - label: 'admin.products.imageUrl', - type: 'image', - // The create endpoint ignores imageUrl; only editing persists it. - editOnly: true, - upload: (file) => this._catalog.uploadProductImage(file), + private _createDialogRef: MatDialogRef | null = null; + + readonly rows = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly uploading = signal(false); + readonly dragOver = signal(false); + readonly search = signal(''); + readonly categoryFilter = signal(''); + readonly statusFilter = signal(''); + readonly pageIndex = signal(0); + readonly pageSize = signal(10); + readonly selectedId = signal(null); + readonly flashMessage = signal<'success' | 'error' | null>(null); + readonly sort = new TableSort(); + readonly categoryOptions = signal([]); + readonly unitOptions = signal([]); + /** Active categories only — for the page filter dropdown. */ + readonly filterCategoryOptions = signal([]); + + readonly selectedForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + unitId: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + categoryId: new FormControl('', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + imageUrl: new FormControl('', { nonNullable: true }), + }); + + readonly createForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + unitId: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + categoryId: new FormControl('', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + }); + + readonly filteredRows = computed(() => { + const term = this.search().trim(); + const categoryId = this.categoryFilter(); + const status = this.statusFilter(); + + return this.rows().filter((row) => { + const matchesSearch = + !term || includesFolded(String(row['name'] ?? ''), term); + const matchesCategory = + !categoryId || String(row['categoryId'] ?? '') === categoryId; + const matchesStatus = + !status || + (status === 'active' + ? row.isActive !== false + : row.isActive === false); + return matchesSearch && matchesCategory && matchesStatus; + }); + }); + + readonly sortedRows = computed(() => + this.sort.apply(this.filteredRows(), (row, key) => { + if (key === 'category') { + return String(row['categoryName'] ?? ''); + } + if (key === 'unit') { + return String(row['unitName'] ?? row['unitAbbreviation'] ?? ''); + } + return String(row[key] ?? ''); + }) + ); + + readonly pagedRows = computed(() => { + const start = this.pageIndex() * this.pageSize(); + return this.sortedRows().slice(start, start + this.pageSize()); + }); + + readonly hasActiveFilters = computed( + () => + this.search().trim() !== '' || + this.categoryFilter() !== '' || + this.statusFilter() !== '' + ); + + ngOnInit(): void { + this.load(); + void this._loadOptions(); + } + + load(): void { + this.loading.set(true); + this._catalog + .listProducts() + .then((rows) => { + this.rows.set(rows); + const id = this.selectedId(); + if (id && !rows.some((r) => r.id === id)) { + this.closeDetails(); + } else if (id) { + const row = rows.find((r) => r.id === id); + if (row) { + this._patchSelected(row); + } + } + }) + .catch((err) => void this._notifyError(err, 'admin.crud.loadError')) + .finally(() => this.loading.set(false)); + } + + onSearch(value: string): void { + this.search.set(value); + this.pageIndex.set(0); + this.closeDetails(); + } + + onCategoryFilter(value: string): void { + this.categoryFilter.set(value); + this.pageIndex.set(0); + this.closeDetails(); + } + + onStatusFilter(value: string): void { + this.statusFilter.set(value); + this.pageIndex.set(0); + this.closeDetails(); + } + + clearFilters(): void { + this.search.set(''); + this.categoryFilter.set(''); + this.statusFilter.set(''); + this.pageIndex.set(0); + this.closeDetails(); + } + + onSort(key: string): void { + this.sort.toggle(key); + this.pageIndex.set(0); + this.closeDetails(); + } + + onPageChange(event: PageEvent): void { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + this.closeDetails(); + } + + toggleDetails(row: CrudRow): void { + if (this.selectedId() === row.id) { + this.closeDetails(); + return; + } + this.selectedId.set(row.id); + this.flashMessage.set(null); + this._patchSelected(row); + } + + closeDetails(): void { + this.selectedId.set(null); + this.flashMessage.set(null); + this.selectedForm.reset({ + name: '', + unitId: '', + categoryId: '', + description: '', + imageUrl: '', + }); + } + + openCreate(template: TemplateRef): void { + this.createForm.reset({ + name: '', + unitId: '', + categoryId: '', + description: '', + }); + this._createDialogRef = this._dialog.open(template, { + width: '560px', + maxWidth: '95vw', + autoFocus: 'first-tabbable', + }); + } + + closeCreate(): void { + this._createDialogRef?.close(); + this._createDialogRef = null; + } + + saveCreate(): void { + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); + return; + } + this.saving.set(true); + this._catalog + .createProduct(this.createForm.getRawValue()) + .then(() => { + this._notify('admin.crud.createSuccess'); + this.closeCreate(); + this.load(); + }) + .catch((err) => void this._notifyError(err, 'admin.crud.saveError')) + .finally(() => this.saving.set(false)); + } + + updateSelected(): void { + const id = this.selectedId(); + if (!id || this.selectedForm.invalid) { + this.selectedForm.markAllAsTouched(); + return; + } + this.saving.set(true); + this._catalog + .updateProduct(id, this.selectedForm.getRawValue()) + .then(() => { + this.showFlashMessage('success'); + this.load(); + }) + .catch((err) => { + this.showFlashMessage('error'); + void this._notifyError(err, 'admin.crud.saveError'); + }) + .finally(() => this.saving.set(false)); + } + + deactivateSelected(): void { + const id = this.selectedId(); + if (!id) { + return; + } + const row = this.rows().find((r) => r.id === id); + if (row?.isActive === false) { + return; + } + + const confirmation = this._confirmation.open({ + title: this._transloco.translate('admin.crud.confirmRemove.title'), + message: this._transloco.translate( + 'admin.crud.confirmRemove.message' + ), + actions: { + confirm: { + label: this._transloco.translate('admin.crud.deactivate'), + }, }, - ], - list: () => this._catalog.listProducts(), - create: (value) => this._catalog.createProduct(value), - update: (id, value) => this._catalog.updateProduct(id, value), - remove: (row) => this._catalog.deactivateProduct(row.id), - removeLabel: 'admin.crud.deactivate', - removeIsDeactivate: true, - removeIcon: 'archive-box-x-mark', - }; + }); + + confirmation.afterClosed().subscribe((result) => { + if (result !== 'confirmed') { + return; + } + this.saving.set(true); + this._catalog + .deactivateProduct(id) + .then(() => { + this._notify('admin.crud.removeSuccess'); + this.closeDetails(); + this.load(); + }) + .catch( + (err) => void this._notifyError(err, 'admin.crud.saveError') + ) + .finally(() => this.saving.set(false)); + }); + } + + isInactive(row: CrudRow): boolean { + return row.isActive === false; + } + + unitLabel(row: CrudRow): string { + return String(row['unitName'] ?? row['unitAbbreviation'] ?? ''); + } + + showFlashMessage(type: 'success' | 'error'): void { + this.flashMessage.set(type); + window.setTimeout(() => { + if (this.flashMessage() === type) { + this.flashMessage.set(null); + } + }, 3000); + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + this.dragOver.set(true); + } + + onDragLeave(): void { + this.dragOver.set(false); + } + + onImageDropped(event: DragEvent): void { + event.preventDefault(); + this.dragOver.set(false); + const file = event.dataTransfer?.files?.[0]; + if (file) { + void this._uploadImage(file); + } + } + + onImagePicked(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (file) { + void this._uploadImage(file); + } + } + + clearImage(): void { + this.selectedForm.controls.imageUrl.setValue(''); + } + + private async _uploadImage(file: File): Promise { + if (!file.type.startsWith('image/')) { + this._notify('admin.crud.image.invalidType'); + return; + } + this.uploading.set(true); + try { + const url = await this._catalog.uploadProductImage(file); + this.selectedForm.controls.imageUrl.setValue(url); + } catch (err) { + await this._notifyError(err, 'admin.crud.image.uploadError'); + } finally { + this.uploading.set(false); + } + } + + private async _loadOptions(): Promise { + try { + const [units, categories, filterCategories] = await Promise.all([ + this._catalog.unitOptions(), + this._catalog.categoryOptions(), + this._catalog.categoryOptions(true), + ]); + this.unitOptions.set(units); + this.categoryOptions.set(categories); + this.filterCategoryOptions.set(filterCategories); + } catch { + this.unitOptions.set([]); + this.categoryOptions.set([]); + this.filterCategoryOptions.set([]); + } + } + + private _patchSelected(row: CrudRow): void { + this.selectedForm.reset({ + name: String(row['name'] ?? ''), + unitId: String(row['unitId'] ?? ''), + categoryId: String(row['categoryId'] ?? ''), + description: String(row['description'] ?? ''), + imageUrl: String(row['imageUrl'] ?? ''), + }); + } + + private _notify(key: string): void { + this._snackBar.open(this._transloco.translate(key), undefined, { + duration: 3000, + }); + } + + private async _notifyError( + err: unknown, + fallbackKey: string + ): Promise { + const message = await describeApiError( + err, + (key) => this._transloco.translate(key), + fallbackKey + ); + this._snackBar.open(message, undefined, { duration: 5000 }); + } } diff --git a/src/app/modules/admin/catalog/units.component.ts b/src/app/modules/admin/catalog/units.component.ts index 8a23b16..a36c51d 100644 --- a/src/app/modules/admin/catalog/units.component.ts +++ b/src/app/modules/admin/catalog/units.component.ts @@ -26,6 +26,7 @@ export class UnitsComponent { title: 'admin.units.title', subtitle: 'admin.units.subtitle', createLabel: 'admin.units.create', + inlineDetail: false, searchKeys: ['name', 'abbreviation'], searchPlaceholder: 'admin.units.searchPlaceholder', columns: [ diff --git a/src/app/modules/admin/logistics/hub-staff.component.ts b/src/app/modules/admin/logistics/hub-staff.component.ts index 390b275..b8c973a 100644 --- a/src/app/modules/admin/logistics/hub-staff.component.ts +++ b/src/app/modules/admin/logistics/hub-staff.component.ts @@ -14,7 +14,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { apiErrorMessage } from '../admin.service'; +import { describeApiError } from 'app/core/api/error-codes'; import { CrudOption } from '../shared/resource-crud.types'; import { LogisticsAdminService } from './logistics-admin.service'; @@ -123,8 +123,11 @@ export class HubStaffComponent implements OnInit { }) .catch(async (err) => this._notify( - (await apiErrorMessage(err)) ?? - this._transloco.translate('admin.hubStaff.saveError') + await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.hubStaff.saveError' + ) ) ) .finally(() => this.saving.set(false)); diff --git a/src/app/modules/admin/order-groups/order-groups.component.html b/src/app/modules/admin/order-groups/order-groups.component.html index c2b2927..457b652 100644 --- a/src/app/modules/admin/order-groups/order-groups.component.html +++ b/src/app/modules/admin/order-groups/order-groups.component.html @@ -2,7 +2,6 @@

{{ t('admin.orderGroups.title') }}

-

{{ t('admin.orderGroups.subtitle') }}

{ - const message = - (await apiErrorMessage(err)) ?? - this._transloco.translate('admin.orderGroups.actionError'); + const message = await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.orderGroups.actionError' + ); this._snackBar.open(message, undefined, { duration: 5000 }); } } diff --git a/src/app/modules/admin/restaurants/restaurants-admin.component.html b/src/app/modules/admin/restaurants/restaurants-admin.component.html index 09ff001..522ee94 100644 --- a/src/app/modules/admin/restaurants/restaurants-admin.component.html +++ b/src/app/modules/admin/restaurants/restaurants-admin.component.html @@ -1,162 +1,618 @@ -
-

- {{ t('admin.restaurants.title') }} -

-

- {{ t('admin.restaurants.subtitle') }} -

- - - + +
- - {{ t('admin.restaurants.lookup.label') }} - - @if ( - lookupForm.get('restaurantId')?.invalid && - lookupForm.get('restaurantId')?.touched - ) { - {{ - t('admin.restaurants.lookup.invalid') - }} - } - - - - - - - - @if (loadingCredit()) { - - } - - @if (credit()) { -
-
- {{ - t('admin.restaurants.credit.limit') - }} - {{ - credit()?.creditLimit ?? '—' - }} + @if (loading()) { +
+
-
- {{ - t('admin.restaurants.credit.balance') - }} - {{ - credit()?.currentBalance ?? '—' - }} -
-
- {{ - t('admin.restaurants.credit.available') - }} - {{ - credit()?.availableCredit ?? '—' - }} + } +
+
+ {{ t('admin.restaurants.title') }}
- } - -
-
-

- {{ t('admin.restaurants.creditLimit.title') }} -

- - {{ - t('admin.restaurants.creditLimit.amount') - }} + + - + + {{ - t('admin.restaurants.creditLimit.note') + t('admin.restaurants.filters.status') }} - + + {{ + t('admin.restaurants.filters.allStatuses') + }} + {{ + t('admin.users.filters.active') + }} + {{ + t('admin.users.filters.inactive') + }} + + + @if (hasActiveFilters()) { + + } + +
+ + +
+
+ @if (sortedUsers().length > 0) { +
+
+ @for ( + col of [ + { + key: 'restaurantName', + label: 'admin.restaurants.table.restaurant', + }, + { + key: 'email', + label: 'admin.restaurants.table.email', + }, + { + key: 'phone', + label: 'admin.restaurants.table.phone', + }, + { + key: 'status', + label: 'admin.restaurants.table.status', + }, + ]; + track col.key + ) { +
+ +
+ } +
+ {{ t('admin.restaurants.table.actions') }} +
+
+ {{ t('admin.crud.details') }} +
+
+ + @for ( + user of sortedUsers(); + track trackById($index, user) + ) { +
+
+ {{ + user.restaurantName || + t('admin.restaurants.unnamed') + }} +
+
+ {{ user.email || user.id }} +
+
{{ user.phone || '—' }}
+
+ @if (user.isActive) { + + {{ t('admin.users.filters.active') }} + + } @else { + + {{ t('admin.users.filters.inactive') }} + + } +
+ + +
+ + +
+ +
+ +
+
+ + @if (selectedId() === user.id) { +
+
+ +
+
+
+ {{ + t( + 'admin.userDetail.profile.title' + ) + }} +
+
+ {{ + user.restaurantName || + t( + 'admin.restaurants.unnamed' + ) + }} +
+
+
+ + {{ + user.email || '—' + }} +
+
+ + {{ + user.phone || '—' + }} +
+
+
+ @if (user.isActive) { + + {{ + t( + 'admin.users.filters.active' + ) + }} + + } @else { + + {{ + t( + 'admin.users.filters.inactive' + ) + }} + + } + @if (user.restaurantId) { + + {{ user.restaurantId }} + + } @else { + + {{ + t( + 'admin.restaurants.noRestaurantId' + ) + }} + + } +
+
+ +
+
+ {{ + t( + 'admin.restaurants.credit.snapshot' + ) + }} +
+ @if (loadingCredit()) { + + } @else { +
+
+
+ {{ + t( + 'admin.restaurants.credit.limit' + ) + }} +
+ @if (hasCreditLimit()) { +
+ {{ + credit()! + .creditLimit + | number + : '1.0-0' + }}₫ +
+ } @else if ( + editingCreditLimit() + ) { + + + + + } @else { + + } +
+
+
+ {{ + t( + 'admin.restaurants.credit.balance' + ) + }} +
+
+ @if ( + credit() + ?.currentBalance !== + null && + credit() + ?.currentBalance !== + undefined + ) { + {{ + credit()! + .currentBalance + | number + : '1.0-0' + }}₫ + } @else { + — + } +
+
+
+
+ {{ + t( + 'admin.restaurants.credit.available' + ) + }} +
+
+ @if ( + credit() + ?.availableCredit !== + null && + credit() + ?.availableCredit !== + undefined + ) { + {{ + credit()! + .availableCredit + | number + : '1.0-0' + }}₫ + } @else { + — + } +
+
+
+ } +
+
+ +
+ @if (user.isActive) { + + } @else { + + } + +
+
+
+ } + } +
- + + } @else if (!loading()) { +
+ {{ t('admin.restaurants.empty') }} +
+ } +
+
+
+ + + +
+
+
+ {{ t('admin.restaurants.settle.title') }} +
+ +
-

- {{ t('admin.restaurants.settle.title') }} -

- + @if (actionUser(); as target) { +
+ {{ + target.restaurantName || + target.email || + t('admin.restaurants.unnamed') + }} +
+ } + {{ t('admin.restaurants.settle.amount') }} @@ -168,224 +624,206 @@

formControlName="amount" /> - + {{ t('admin.restaurants.settle.paymentMethod') }} - + {{ t('admin.restaurants.settle.reference') }} - + {{ t('admin.restaurants.settle.note') }} - +
+ + +

+
- @if (lookedUp()) { - -
-
-

- {{ t('admin.restaurants.statements.title') }} -

-
- - {{ - t('admin.restaurants.statements.year') - }} - - - - {{ - t('admin.restaurants.statements.month') - }} - - - -
+ + +
+
+
+ {{ t('admin.restaurants.create.title') }}
+ +
-
- - - - - - - - - - - - - @for (s of statements(); track trackById($index, s)) { - - - - - - - - - } @empty { - - - - } - -
- {{ t('admin.restaurants.statements.period') }} - - {{ t('admin.restaurants.statements.opening') }} - - {{ t('admin.restaurants.statements.closing') }} - - {{ t('admin.restaurants.statements.charges') }} - - {{ t('admin.restaurants.statements.payments') }} - - {{ t('admin.users.table.actions') }} -
- {{ s.month ?? '—' }}/{{ s.year ?? '—' }} - - {{ s.openingBalance ?? '—' }} - - {{ s.closingBalance ?? '—' }} - - {{ s.totalCharges ?? '—' }} - - {{ s.totalPayments ?? '—' }} - - -
- {{ - t('admin.restaurants.statements.empty') - }} -
-
-
+
+
+ + {{ t('admin.users.create.email') }} + + @if (createForm.controls.email.hasError('required')) { + {{ + t('admin.users.create.errors.emailRequired') + }} + } @else if (createForm.controls.email.hasError('email')) { + {{ + t('admin.users.create.errors.emailInvalid') + }} + } @else if ( + createForm.controls.email.hasError('maxlength') + ) { + {{ + t('admin.users.create.errors.emailMax') + }} + } + - -
-

- {{ t('admin.restaurants.transactions.title') }} -

-
- - + {{ + t('admin.users.create.password') + }} + + @if (createForm.controls.password.hasError('required')) { + {{ + t('admin.users.create.errors.passwordRequired') + }} + } + + + @if ( + createForm.controls.password.value || + createForm.controls.password.touched + ) { +
    -
- - - - - - - - - @for (x of transactions(); track trackById($index, x)) { - - - - - - - - } @empty { - - - + } - -
- {{ t('admin.restaurants.transactions.date') }} - - {{ t('admin.restaurants.transactions.type') }} - - {{ t('admin.restaurants.transactions.amount') }} - - {{ - t('admin.restaurants.transactions.balance') - }} - + @for ( + rule of [ + { key: 'minLength', label: 'minLength' }, + { key: 'uppercase', label: 'uppercase' }, + { key: 'digit', label: 'digit' }, + { key: 'special', label: 'special' }, + ]; + track rule.key + ) { +
  • + {{ t( - 'admin.restaurants.transactions.description' + 'admin.users.create.password.' + + rule.label ) }} -
  • - {{ x.createdAt ?? '—' }} - {{ x.type ?? '—' }}{{ x.amount ?? '—' }} - {{ x.balanceAfter ?? '—' }} - - {{ x.description ?? '—' }} -
    - {{ - t( - 'admin.restaurants.transactions.empty' - ) - }} -
    + + } + + + {{ + t('admin.users.create.restaurantName') + }} + + @if ( + createForm.controls.restaurantName.hasError('required') + ) { + {{ + t('admin.users.create.errors.restaurantRequired') + }} + } + + + + {{ t('admin.users.create.phone') }} + + @if (createForm.controls.phone.hasError('phoneNumber')) { + {{ + t('admin.users.create.errors.phoneInvalid') + }} + } +
    -
    - } -
    + +
    + + +
    +
    +
    + diff --git a/src/app/modules/admin/restaurants/restaurants-admin.component.ts b/src/app/modules/admin/restaurants/restaurants-admin.component.ts index de58bbf..84e0b88 100644 --- a/src/app/modules/admin/restaurants/restaurants-admin.component.ts +++ b/src/app/modules/admin/restaurants/restaurants-admin.component.ts @@ -1,35 +1,67 @@ +import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, + DestroyRef, + OnInit, + TemplateRef, + ViewChild, ViewEncapsulation, - WritableSignal, + computed, inject, signal, } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { + FormBuilder, + FormGroupDirective, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { collapseOnLeave, expandOnEnter } from '@fuse/animations'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { AdminService, apiErrorMessage } from '../admin.service'; +import { describeApiError } from 'app/core/api/error-codes'; import { - AdminCreditStatement, - AdminCreditTransaction, - AdminRestaurantCredit, -} from '../admin.types'; + EMAIL_MAX_LENGTH, + passwordStrengthValidator, + phoneNumberValidator, +} from 'app/core/api/validators'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; +import { AdminService } from '../admin.service'; +import { AdminRestaurantCredit, AdminUserRow } from '../admin.types'; +import { CoalescedTask } from '../shared/coalesced-task'; +import { TableSort } from '../shared/table-sort'; -/** UUID v4-ish check — good enough to short-circuit obviously malformed input. */ -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DEFAULT_PAGE_SIZE = 20; +const RESTAURANT_ROLE = 'restaurant'; +const RESTAURANT_NAME_MAX_LENGTH = 200; +const PHONE_MAX_LENGTH = 20; + +type RestaurantAction = + | 'approve' + | 'suspend' + | 'reactivate' + | 'creditLimit' + | 'settle'; /** - * Admin ▸ Restaurants. There is no `GET /admin/restaurants` list endpoint, - * so the admin looks up a restaurant by pasting its id and drives approval - * and credit operations directly against it. + * Admin ▸ Restaurants — inventory list of `restaurant` users. + * Row actions map 1:1 to admin restaurant APIs (approve / suspend / + * reactivate / credit limit / settle). Detail panel is profile + credit snapshot. */ @Component({ selector: 'admin-restaurants-admin', @@ -37,47 +69,133 @@ const UUID_PATTERN = encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - // Full-width flex host so the page fills the screen (see ResourceCrudComponent). host: { class: 'flex flex-auto flex-col' }, imports: [ + DecimalPipe, MatButtonModule, + MatDialogModule, MatFormFieldModule, MatIconModule, MatInputModule, + MatPaginatorModule, MatProgressBarModule, + MatSelectModule, MatSnackBarModule, MatTooltipModule, ReactiveFormsModule, TranslocoModule, ], + styles: [ + ` + .restaurants-grid { + grid-template-columns: + minmax(0, 1.25fr) minmax(0, 1.5fr) minmax(0, 1fr) + minmax(0, 0.9fr) auto auto; + } + `, + ], }) -export class RestaurantsAdminComponent { +export class RestaurantsAdminComponent implements OnInit { + protected readonly expandOnEnter = expandOnEnter; + protected readonly collapseOnLeave = collapseOnLeave; + + @ViewChild('createRestaurantPanel') + private _createPanel!: TemplateRef; + private readonly _admin = inject(AdminService); + private readonly _dialog = inject(MatDialog); private readonly _snackBar = inject(MatSnackBar); private readonly _transloco = inject(TranslocoService); private readonly _formBuilder = inject(FormBuilder); + private readonly _destroyRef = inject(DestroyRef); + + private _dialogRef: MatDialogRef | null = null; + + readonly users = signal([]); + readonly sort = new TableSort(); + readonly sortedUsers = computed(() => + this.sort.apply(this.users(), (user, key) => + key === 'status' ? user.isActive !== false : (user[key] as string) + ) + ); + readonly totalCount = signal(0); + readonly loading = signal(false); + readonly pageIndex = signal(0); + readonly pageSize = signal(DEFAULT_PAGE_SIZE); + readonly selectedId = signal(null); readonly credit = signal(null); readonly loadingCredit = signal(false); - readonly approving = signal(false); - readonly suspending = signal(false); - readonly reactivating = signal(false); - readonly settingLimit = signal(false); - readonly settling = signal(false); - - /** True once a valid restaurant id has been looked up (reveals the ledger). */ - readonly lookedUp = signal(false); - readonly statements = signal([]); - readonly transactions = signal([]); - readonly loadingLedger = signal(false); - readonly generating = signal(false); - /** Statement id whose PDF is currently being fetched, or null. */ - readonly pdfBusyId = signal(null); - - readonly lookupForm = this._formBuilder.nonNullable.group({ - restaurantId: [ + + /** Which row + API action is currently in flight. */ + readonly busyAction = signal<{ + userId: string; + kind: RestaurantAction; + } | null>(null); + + /** User targeted by credit limit / settle dialogs. */ + readonly actionUser = signal(null); + + readonly selectedUser = computed(() => { + const id = this.selectedId(); + return id ? this.users().find((u) => u.id === id) ?? null : null; + }); + + /** True when credit snapshot already has a limit value. */ + readonly hasCreditLimit = computed(() => { + const limit = this.credit()?.creditLimit; + return limit != null && !Number.isNaN(Number(limit)); + }); + + /** Reveals the credit-limit input after "activate credit limit". */ + readonly editingCreditLimit = signal(false); + + /** Save is only for setting an initial credit limit from the detail panel. */ + needsCreditLimitSave(): boolean { + return !this.hasCreditLimit() && this.editingCreditLimit(); + } + + startEditingCreditLimit(): void { + this.editingCreditLimit.set(true); + if (!this.creditLimitForm.controls.creditLimit.value) { + this.creditLimitForm.patchValue({ creditLimit: 0 }); + } + } + + readonly filterForm = this._formBuilder.nonNullable.group({ + search: [''], + isActive: [''], + }); + + private readonly _filterValues = toSignal(this.filterForm.valueChanges, { + initialValue: this.filterForm.getRawValue(), + }); + + readonly hasActiveFilters = computed(() => { + const v = this._filterValues(); + return (v.search ?? '').trim() !== '' || !!(v.isActive ?? ''); + }); + + readonly createForm = this._formBuilder.nonNullable.group({ + email: [ + '', + [ + Validators.required, + Validators.email, + Validators.maxLength(EMAIL_MAX_LENGTH), + ], + ], + password: ['', [Validators.required, passwordStrengthValidator]], + restaurantName: [ + '', + [ + Validators.required, + Validators.maxLength(RESTAURANT_NAME_MAX_LENGTH), + ], + ], + phone: [ '', - [Validators.required, Validators.pattern(UUID_PATTERN)], + [phoneNumberValidator, Validators.maxLength(PHONE_MAX_LENGTH)], ], }); @@ -93,175 +211,187 @@ export class RestaurantsAdminComponent { note: [''], }); - readonly generateForm = this._formBuilder.nonNullable.group({ - year: [ - new Date().getFullYear(), - [Validators.required, Validators.min(2000)], - ], - month: [ - new Date().getMonth() + 1, - [Validators.required, Validators.min(1), Validators.max(12)], - ], - }); + ngOnInit(): void { + this._load(); + + this.filterForm.valueChanges + .pipe( + debounceTime(300), + distinctUntilChanged( + (a, b) => JSON.stringify(a) === JSON.stringify(b) + ), + takeUntilDestroyed(this._destroyRef) + ) + .subscribe(() => { + this.pageIndex.set(0); + this.closeDetails(); + this._load(); + }); + } - get restaurantId(): string { - return this.lookupForm.getRawValue().restaurantId; + onPageChange(event: PageEvent): void { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + this.closeDetails(); + this._load(); } - private get _validRestaurantId(): string | null { - this.lookupForm.markAllAsTouched(); - return this.lookupForm.valid ? this.restaurantId : null; + clearFilters(): void { + this.filterForm.reset({ search: '', isActive: '' }); } - lookupCredit(): void { - const restaurantId = this._validRestaurantId; - if (!restaurantId) { + isActionBusy(user: AdminUserRow, kind: RestaurantAction): boolean { + const busy = this.busyAction(); + return !!busy && busy.userId === user.id && busy.kind === kind; + } + + anyActionBusy(user: AdminUserRow): boolean { + const busy = this.busyAction(); + return !!busy && busy.userId === user.id; + } + + toggleDetails(user: AdminUserRow): void { + if (this.selectedId() === user.id) { + this.closeDetails(); return; } - this.lookedUp.set(true); - this.loadingCredit.set(true); - this._admin - .getRestaurantCredit(restaurantId) - .then((credit) => this.credit.set(credit)) - .finally(() => this.loadingCredit.set(false)); - this._loadLedger(restaurantId); + this.selectedId.set(user.id); + this._loadCreditSnapshot(user.restaurantId ?? null); } - /** Loads the statements + transactions for the looked-up restaurant. */ - private _loadLedger(restaurantId: string): void { - this.loadingLedger.set(true); - Promise.all([ - this._admin.getCreditStatements(restaurantId), - this._admin.getCreditTransactions(restaurantId), - ]) - .then(([statements, transactions]) => { - this.statements.set(statements); - this.transactions.set(transactions); - }) - .finally(() => this.loadingLedger.set(false)); + closeDetails(): void { + this.selectedId.set(null); + this.credit.set(null); + this.editingCreditLimit.set(false); } - generateStatement(): void { - const restaurantId = this._validRestaurantId; - if (!restaurantId || this.generateForm.invalid) { - this.generateForm.markAllAsTouched(); + openCreatePanel(): void { + if (!this._createPanel || this._dialogRef) { return; } - const { year, month } = this.generateForm.getRawValue(); - this.generating.set(true); - this._admin - .generateCreditStatement(restaurantId, { year, month }) - .then(() => { - this._notify('admin.restaurants.statements.generateSuccess'); - this._loadLedger(restaurantId); - }) - .catch(async (err) => - this._notifyText( - (await apiErrorMessage(err)) ?? - this._transloco.translate( - 'admin.restaurants.actionError' - ) - ) - ) - .finally(() => this.generating.set(false)); + this.closeDetails(); + this.createForm.reset({ + email: '', + password: '', + restaurantName: '', + phone: '', + }); + this._dialogRef = this._dialog.open(this._createPanel, { + autoFocus: false, + maxWidth: '100vw', + }); + this._dialogRef.afterClosed().subscribe(() => { + this._dialogRef = null; + this.createForm.enable(); + }); } - /** Fetches a statement's PDF and opens it in a new tab. */ - downloadStatementPdf(statementId: string): void { - const restaurantId = this._validRestaurantId; - if (!restaurantId) { + closeCreatePanel(): void { + this._dialogRef?.close(); + } + + createRestaurant(ngForm: FormGroupDirective): void { + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); return; } - this.pdfBusyId.set(statementId); + const value = this.createForm.getRawValue(); + this.createForm.disable(); this._admin - .getStatementPdf(restaurantId, statementId) - .then((blob) => { - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); - // Revoke later so the opened tab has time to render the PDF. - setTimeout(() => URL.revokeObjectURL(url), 60000); + .createUser({ + email: value.email.trim(), + password: value.password, + role: RESTAURANT_ROLE, + marketId: null, + restaurantName: value.restaurantName.trim() || null, + phone: value.phone.trim() || null, }) - .catch(async (err) => + .then(() => { + this._notify('admin.restaurants.create.success'); + this.closeCreatePanel(); + this.pageIndex.set(0); + this._load(); + }) + .catch(async (err) => { + this.createForm.enable(); this._notifyText( - (await apiErrorMessage(err)) ?? - this._transloco.translate( - 'admin.restaurants.actionError' - ) - ) - ) - .finally(() => this.pdfBusyId.set(null)); - } - - trackById(_: number, row: { id: string }): string { - return row.id; + await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.restaurants.create.error' + ) + ); + }) + .finally(() => ngForm.form.markAsPristine()); } - approve(): void { - this._runLifecycleAction( - this.approving, + approve(user: AdminUserRow): void { + this._runRestaurantAction( + user, + 'approve', (id) => this._admin.approveRestaurant(id), 'admin.restaurants.approve.success' ); } - suspend(): void { - this._runLifecycleAction( - this.suspending, + suspend(user: AdminUserRow): void { + this._runRestaurantAction( + user, + 'suspend', (id) => this._admin.suspendRestaurant(id), 'admin.restaurants.suspend.success' ); } - reactivate(): void { - this._runLifecycleAction( - this.reactivating, + reactivate(user: AdminUserRow): void { + this._runRestaurantAction( + user, + 'reactivate', (id) => this._admin.reactivateRestaurant(id), 'admin.restaurants.reactivate.success' ); } - /** - * Shared runner for the approve/suspend/reactivate buttons: validates the - * looked-up id, toggles the button's own busy flag, and reports the outcome. - */ - private _runLifecycleAction( - busy: WritableSignal, - action: (restaurantId: string) => Promise, - successKey: string - ): void { - const restaurantId = this._validRestaurantId; - if (!restaurantId) { + openSettleDialog(user: AdminUserRow, template: TemplateRef): void { + if (!user.restaurantId) { + this._notify('admin.restaurants.noRestaurantId'); return; } - busy.set(true); - action(restaurantId) - .then(() => { - this._notify(successKey); - // Approval/suspension can change the credit record this page is - // showing, so refresh it instead of leaving a pre-action value. - if (this.credit()) { - this.lookupCredit(); - } - }) - .catch(async (err) => - this._notifyText( - (await apiErrorMessage(err)) ?? - this._transloco.translate( - 'admin.restaurants.actionError' - ) - ) - ) - .finally(() => busy.set(false)); + if (this._dialogRef) { + return; + } + this.actionUser.set(user); + this.settleForm.reset({ + amount: 0, + paymentMethod: '', + reference: '', + note: '', + }); + this._dialogRef = this._dialog.open(template, { + autoFocus: 'first-tabbable', + maxWidth: '95vw', + }); + this._dialogRef.afterClosed().subscribe(() => { + this._dialogRef = null; + this.actionUser.set(null); + }); + } + + closeActionDialog(): void { + this._dialogRef?.close(); } - setCreditLimit(): void { - const restaurantId = this._validRestaurantId; + saveCreditLimit(user: AdminUserRow): void { + const restaurantId = user.restaurantId; if (!restaurantId || this.creditLimitForm.invalid) { this.creditLimitForm.markAllAsTouched(); return; } + if (this.hasCreditLimit()) { + return; + } const { creditLimit, note } = this.creditLimitForm.getRawValue(); - this.settingLimit.set(true); + this.busyAction.set({ userId: user.id, kind: 'creditLimit' }); this._admin .setCreditLimit(restaurantId, { creditLimit, @@ -269,21 +399,22 @@ export class RestaurantsAdminComponent { }) .then(() => { this._notify('admin.restaurants.creditLimit.success'); - this.lookupCredit(); + this._loadCreditSnapshot(restaurantId); }) - .catch(() => this._notify('admin.restaurants.actionError')) - .finally(() => this.settingLimit.set(false)); + .catch((err) => void this._notifyError(err)) + .finally(() => this.busyAction.set(null)); } settleCredit(): void { - const restaurantId = this._validRestaurantId; - if (!restaurantId || this.settleForm.invalid) { + const user = this.actionUser(); + const restaurantId = user?.restaurantId; + if (!user || !restaurantId || this.settleForm.invalid) { this.settleForm.markAllAsTouched(); return; } const { amount, paymentMethod, reference, note } = this.settleForm.getRawValue(); - this.settling.set(true); + this.busyAction.set({ userId: user.id, kind: 'settle' }); this._admin .settleCredit(restaurantId, { amount, @@ -293,23 +424,133 @@ export class RestaurantsAdminComponent { }) .then(() => { this._notify('admin.restaurants.settle.success'); - this.settleForm.reset({ - amount: 0, - paymentMethod: '', - reference: '', + this.closeActionDialog(); + if (this.selectedId() === user.id) { + this._loadCreditSnapshot(restaurantId); + } + }) + .catch((err) => void this._notifyError(err)) + .finally(() => this.busyAction.set(null)); + } + + passwordRuleFailing(rule: string): boolean { + const control = this.createForm.controls.password; + if (!control.value) { + return true; + } + const strength = control.errors?.['passwordStrength'] as + | Record + | undefined; + return strength ? !!strength[rule] : false; + } + + trackById(_: number, row: { id: string }): string { + return row.id; + } + + private _runRestaurantAction( + user: AdminUserRow, + kind: RestaurantAction, + action: (restaurantId: string) => Promise, + successKey: string + ): void { + const restaurantId = user.restaurantId; + if (!restaurantId) { + this._notify('admin.restaurants.noRestaurantId'); + return; + } + this.busyAction.set({ userId: user.id, kind }); + action(restaurantId) + .then(() => { + this._notify(successKey); + if (kind === 'suspend') { + this._patchUser(user.id, { isActive: false }); + } else if (kind === 'reactivate') { + this._patchUser(user.id, { isActive: true }); + } + if (this.selectedId() === user.id) { + this._loadCreditSnapshot(restaurantId); + } + }) + .catch((err) => void this._notifyError(err)) + .finally(() => this.busyAction.set(null)); + } + + private _patchUser(id: string, patch: Partial): void { + this.users.update((list) => + list.map((u) => (u.id === id ? { ...u, ...patch } : u)) + ); + } + + private _loadCreditSnapshot(restaurantId: string | null): void { + this.credit.set(null); + this.editingCreditLimit.set(false); + this.creditLimitForm.reset({ creditLimit: 0, note: '' }); + if (!restaurantId) { + return; + } + this.loadingCredit.set(true); + this._admin + .getRestaurantCredit(restaurantId) + .then((credit) => { + this.credit.set(credit); + this.creditLimitForm.reset({ + creditLimit: + credit?.creditLimit != null + ? Number(credit.creditLimit) || 0 + : 0, note: '', }); - this.lookupCredit(); }) - .catch(() => this._notify('admin.restaurants.actionError')) - .finally(() => this.settling.set(false)); + .finally(() => this.loadingCredit.set(false)); + } + + private _load(): void { + this._loadTask.trigger(); } + private readonly _loadTask = new CoalescedTask(async () => { + this.loading.set(true); + const raw = this.filterForm.getRawValue(); + try { + const result = await this._admin.getUsers({ + search: raw.search || undefined, + role: RESTAURANT_ROLE, + isActive: + raw.isActive === '' ? undefined : raw.isActive === 'true', + page: this.pageIndex() + 1, + pageSize: this.pageSize(), + }); + this.users.set(result.users); + this.totalCount.set(result.totalCount); + const id = this.selectedId(); + if (id && !result.users.some((u) => u.id === id)) { + this.closeDetails(); + } + } catch { + this.users.set([]); + this.totalCount.set(0); + this._notify('admin.restaurants.loadError'); + } finally { + this.loading.set(false); + } + }); + private _notify(key: string): void { this._notifyText(this._transloco.translate(key)); } private _notifyText(message: string): void { - this._snackBar.open(message, undefined, { duration: 3000 }); + this._snackBar.open(message, undefined, { duration: 5000 }); + } + + private async _notifyError(err: unknown): Promise { + this._notifyText( + await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.restaurants.actionError' + ) + ); } } diff --git a/src/app/modules/admin/settings/settings.component.html b/src/app/modules/admin/settings/settings.component.html index fbb90f5..53324fe 100644 --- a/src/app/modules/admin/settings/settings.component.html +++ b/src/app/modules/admin/settings/settings.component.html @@ -2,7 +2,6 @@

    {{ t('admin.settings.title') }}

    -

    {{ t('admin.settings.subtitle') }}

    @if (loading()) { diff --git a/src/app/modules/admin/settings/settings.component.ts b/src/app/modules/admin/settings/settings.component.ts index 85df8cf..6656689 100644 --- a/src/app/modules/admin/settings/settings.component.ts +++ b/src/app/modules/admin/settings/settings.component.ts @@ -16,7 +16,8 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { AdminService, apiErrorMessage } from '../admin.service'; +import { describeApiError } from 'app/core/api/error-codes'; +import { AdminService } from '../admin.service'; import { AdminOperationalSettings, AdminPricingSettings } from '../admin.types'; /** @@ -162,9 +163,11 @@ export class AdminSettingsComponent implements OnInit { } private async _notifyError(err: unknown): Promise { - const message = - (await apiErrorMessage(err)) ?? - this._transloco.translate('admin.settings.error'); + const message = await describeApiError( + err, + (key) => this._transloco.translate(key), + 'admin.settings.error' + ); this._snackBar.open(message, undefined, { duration: 5000 }); } } diff --git a/src/app/modules/admin/shared/resource-crud.component.html b/src/app/modules/admin/shared/resource-crud.component.html index 416e310..3ae82ee 100644 --- a/src/app/modules/admin/shared/resource-crud.component.html +++ b/src/app/modules/admin/shared/resource-crud.component.html @@ -1,590 +1,506 @@ -
    -
    -
    -

    - {{ t(resource.title) }} -

    -

    {{ t(resource.subtitle) }}

    -
    -
    - @for (action of resource.headerActions; track action.label) { - - } - -
    -
    - - @if (resource.searchKeys?.length || resource.filters?.length) { -
    - @if (resource.searchKeys?.length) { - - {{ t('admin.crud.search') }} - - - - } - - @for (filter of resource.filters; track filter.name) { - - {{ t(filter.label) }} - - + +
    + @if (loading()) { +
    + +
    + } +
    +
    +
    + {{ t(resource.title) }} +
    +
    +
    + @if (resource.searchKeys?.length) { + - {{ - t('admin.crud.filterAll') - }} - @for ( - opt of filterOptions()[filter.name]; - track opt.value - ) { - {{ - opt.label - }} - } - - - } - - @if (hasActiveFilters()) { + + + + } + @for (action of resource.headerActions; track action.label) { + + } - } +
    - } - @if (loading()) { - - } - -
    - - - - @for (col of resource.columns; track col.label) { - - } - - - - - - @for (row of pagedRows(); track row.id) { - @for (col of resource.columns; track col.label) { - - } - - - - } @empty { - - - - } - -
    + @for (filter of resource.filters; track filter.name) { + + {{ t(filter.label) }} + + - @if (col.sortable) { - - } @else { - {{ t(col.label) }} + {{ + t('admin.crud.filterAll') + }} + @for ( + opt of filterOptions()[filter.name]; + track opt.value + ) { + {{ + opt.label + }} } - + + } + + @if (hasActiveFilters()) { + + } + + } + + + +
    +
    + @if (filteredRows().length > 0) { +
    + +
    - -
    - {{ t('admin.users.table.actions') }} -
    - @if (col.image) { - @if (col.cell(row)) { - - } @else { -
    - -
    - } - } @else { - {{ col.cell(row) || '—' }} - } -
    - @if (row.isActive === false) { - - {{ t('admin.users.filters.inactive') }} - - } @else { - - {{ t('admin.users.filters.active') }} - - } - -
    - @for ( - action of resource.rowActions; - track action.icon - ) { +
    + @if (col.sortable) { - } - - @if (resource.remove) { - - + } @else { + {{ t(col.label) }} }
    -
    - {{ t('admin.crud.empty') }} -
    -
    - - @if (filteredRows().length > pageSize()) { - - } -
    - - - -
    -
    -

    - {{ - editing() - ? t('admin.crud.editTitle') - : t(resource.createLabel) - }} -

    - -
    - - @if (form) { -
    - @for (field of resource.fields; track field.name) { - @if (isFieldVisible(field)) { - @if (field.type === 'location') { - - } @else if (field.type === 'image') { -
    + +
    + @if (usesInlineDetail && resource.rowActions?.length) { +
    + } + @if (usesInlineDetail) { +
    + {{ t('admin.crud.details') }} +
    + } @else { +
    + {{ t('admin.users.table.actions') }} +
    + } +
    + + + @for (row of pagedRows(); track row.id) { +
    + @for ( + col of resource.columns; + track col.label; + let i = $index + ) { + @if (col.image) { +
    + @if (col.cell(row)) { + + } @else { +
    - +
    + } +
    + } @else { +
    + {{ col.cell(row) || '—' }} +
    + } + } + +
    + @if (isInactive(row)) { + + {{ t('admin.users.filters.inactive') }} + + } @else { + + {{ t('admin.users.filters.active') }} + + } +
    + + @if (usesInlineDetail) { + @if (resource.rowActions?.length) { +
    + @for ( + action of resource.rowActions; + track action.icon + ) { -
    - } @else { - + } +
    + } + +
    + +
    + } @else { +
    + @for ( + action of resource.rowActions; + track action.icon + ) { } - - - @if (uploading()[field.name]) { -
    + + + @if (resource.remove) { +
    + + }
    + } +
    - -
    - } @else { - - {{ t(field.label) }} + +
    + +
    - @switch (field.type) { - @case ('select') { - - @if (field.searchable) { - +
    + @if (resource.remove) { + + } @else { + + } +
    + @if (flashMessage(); as flash) {
    - + {{ t( - 'admin.crud.optionSearch' + 'admin.crud.updateSuccess' ) - " - [value]=" - optionSearch()[ - field.name - ] ?? '' - " - (input)=" - onOptionSearch( - field, - $any( - $event.target - ).value + }} + } + @if (flash === 'error') { + + {{ + t( + 'admin.crud.saveError' ) - " - /> + }} + }
    } - @if (!field.required) { - - } - @for ( - opt of visibleOptions(field); - track opt.value - ) { - {{ opt.label }} - } @empty { -

    - {{ - t( - 'admin.crud.optionSearchEmpty' - ) - }} -

    - } - - } - @case ('textarea') { - - } - @case ('number') { - - } - @default { - - } - } - - @if ( - controlOf(field.name)?.hasError('required') - ) { - {{ - t('admin.crud.errors.required') - }} - } - @if ( - controlOf(field.name)?.hasError('maxlength') - ) { - {{ - t('admin.crud.errors.maxLength', { - max: field.maxLength, - }) - }} - } - @if (controlOf(field.name)?.hasError('min')) { - {{ - t('admin.crud.errors.min', { - min: field.min, - }) - }} - } - + +
    +
    + +
    } } - } +
    + @if (filteredRows().length > pageSize()) { + + } + } @else if (!loading()) {
    + {{ t('admin.crud.empty') }} +
    + } +
    + + + + + +
    + +
    +
    + {{ + editing() + ? t('admin.crud.editTitle') + : t(resource.createLabel) + }} +
    + +
    + + @if (form) { +
    +
    + +
    + +
    + + + + + @for (field of resource.fields; track field.name) { + @if (isFieldVisible(field)) { + @if (field.type === 'location') { +
    + + {{ t(field.label) }} + + +
    + } @else if (field.type === 'image') { +
    + + {{ t(field.label) }} + + +
    + @if (controlOf(field.name)?.value) { + +
    + + +
    + } @else { + + } + + @if (uploading()[field.name]) { +
    + + + {{ t('admin.crud.image.uploading') }} + +
    + } +
    + + +
    + } @else { + + {{ t(field.label) }} + + @switch (field.type) { + @case ('select') { + + @if (field.searchable) { +
    + +
    + } + @if (!field.required) { + + } + @for ( + opt of visibleOptions(field); + track opt.value + ) { + {{ + opt.label + }} + } @empty { +

    + {{ + t( + 'admin.crud.optionSearchEmpty' + ) + }} +

    + } +
    + } + @case ('textarea') { + + } + @case ('number') { + + } + @default { + + } + } + + @if (controlOf(field.name)?.hasError('required')) { + {{ + t('admin.crud.errors.required') + }} + } + @if (controlOf(field.name)?.hasError('maxlength')) { + {{ + t('admin.crud.errors.maxLength', { + max: field.maxLength, + }) + }} + } + @if (controlOf(field.name)?.hasError('min')) { + {{ + t('admin.crud.errors.min', { + min: field.min, + }) + }} + } +
    + } + } + } +
    +
    diff --git a/src/app/modules/admin/shared/resource-crud.component.ts b/src/app/modules/admin/shared/resource-crud.component.ts index 40f36aa..68fd81a 100644 --- a/src/app/modules/admin/shared/resource-crud.component.ts +++ b/src/app/modules/admin/shared/resource-crud.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -31,10 +32,12 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { collapseOnLeave, expandOnEnter } from '@fuse/animations'; import { FuseConfirmationService } from '@fuse/services/confirmation'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { apiErrorMessage } from 'app/core/api/envelope'; +import { describeApiError } from 'app/core/api/error-codes'; import { LocationPickerComponent } from 'app/core/maps/location-picker.component'; +import { includesFolded } from 'app/core/util/text-search'; import { CoalescedTask } from './coalesced-task'; import { CrudField, @@ -47,10 +50,10 @@ import { import { TableSort } from './table-sort'; /** - * Config-driven admin master-data screen: renders the list, a client-side - * search, a create/edit dialog built from `resource.fields`, and remove/row - * actions. One component backs every simple CRUD resource (categories, units, - * products, hubs, vehicles, delivery zones) so they stay consistent. + * Config-driven admin master-data screen in the markets inventory pattern: + * searchable list, optional filters, expandable inline detail editor, and a + * create dialog. One component backs categories, units, hubs, vehicles, and + * delivery zones so they stay consistent with Quản lý chợ. */ @Component({ selector: 'admin-resource-crud', @@ -63,6 +66,7 @@ import { TableSort } from './table-sort'; // whole screen instead of shrinking to the default inline host width. host: { class: 'flex flex-auto flex-col' }, imports: [ + NgTemplateOutlet, MatButtonModule, MatDialogModule, MatFormFieldModule, @@ -80,6 +84,10 @@ import { TableSort } from './table-sort'; ], }) export class ResourceCrudComponent implements OnInit { + /** Inventory-style row detail expand/collapse (Angular animate.enter/leave). */ + protected readonly expandOnEnter = expandOnEnter; + protected readonly collapseOnLeave = collapseOnLeave; + @Input({ required: true }) resource!: CrudResource; @ViewChild('formDialog') private _formDialog!: TemplateRef; @@ -100,9 +108,13 @@ export class ResourceCrudComponent implements OnInit { readonly filterValues = signal>({}); /** Loaded options per page-level filter name. */ readonly filterOptions = signal>({}); + /** Expanded row id for the inline detail editor (null = closed). */ + readonly selectedId = signal(null); readonly editingId = signal(null); - /** True while the dialog is editing an existing row (see {@link save}). */ + /** True while editing an existing row (see {@link save}). */ readonly editing = signal(false); + /** Inline save flash in the detail footer (markets pattern). */ + readonly flashMessage = signal<'success' | 'error' | null>(null); /** Loaded options per select field name. */ readonly selectOptions = signal>({}); /** In-dropdown filter term per `searchable` select field name. */ @@ -116,7 +128,7 @@ export class ResourceCrudComponent implements OnInit { readonly filteredRows = computed(() => { const keys = this.resource.searchKeys; - const term = this.search().trim().toLowerCase(); + const term = this.search().trim(); const values = this.filterValues(); const activeFilters = (this.resource.filters ?? []).filter( (f) => values[f.name] @@ -127,9 +139,7 @@ export class ResourceCrudComponent implements OnInit { !keys?.length || !term || keys.some((key) => - String(row[key] ?? '') - .toLowerCase() - .includes(term) + includesFolded(String(row[key] ?? ''), term) ); const matchesFilters = activeFilters.every((f) => f.match(row, values[f.name]) @@ -180,6 +190,36 @@ export class ResourceCrudComponent implements OnInit { return rows.slice(start, start + size); }); + /** + * `grid-template-columns` for the inventory-style list. Data columns (incl. + * status) share width evenly; the trailing actions/details column stays + * compact on the right edge. + */ + get gridTemplateColumns(): string { + const tracks: string[] = this.resource.columns.map((col) => + col.image ? '3rem' : 'minmax(0, 1fr)' + ); + tracks.push('minmax(0, 1fr)'); // status + if (this.usesInlineDetail && this.resource.rowActions?.length) { + tracks.push('auto'); + } + tracks.push('auto'); // details chevron or action buttons + return tracks.join(' '); + } + + /** Index of the first non-image column (emphasized as the primary label). */ + get nameColumnIndex(): number { + return this.resource.columns.findIndex((col) => !col.image); + } + + /** + * Expandable inline editor (markets pattern). Disabled for compact + * resources that use dialog edit + row action icons instead. + */ + get usesInlineDetail(): boolean { + return this.resource.inlineDetail !== false; + } + /** * Resources with several fields (hubs, markets, products) render the dialog * as a wider two-column grid; simple ones (categories, units) stay a narrow @@ -189,11 +229,29 @@ export class ResourceCrudComponent implements OnInit { return this.resource.fields.length >= 4; } - /** Container class for the dialog form (grid when wide, else a column). */ - get formLayoutClass(): string { + /** Field layout without dialog top margin — used by the inline detail panel. */ + get fieldsLayoutClass(): string { return this.wideDialog - ? 'mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2' - : 'mt-4 flex flex-col gap-3'; + ? 'grid w-full grid-cols-1 gap-3 sm:grid-cols-2' + : 'flex w-full max-w-lg flex-col gap-3'; + } + + /** Field layout inside the dialog — fills the Fuse-sized shell. */ + get dialogFieldsLayoutClass(): string { + return this.wideDialog + ? 'grid w-full grid-cols-1 gap-3 sm:grid-cols-2' + : 'flex w-full flex-col gap-3'; + } + + /** + * Root classes for the create/edit dialog shell (Fuse compose / card + * pattern): negative margin cancels Material dialog padding so the + * primary header sits flush to the edges. + */ + get dialogShellClass(): string { + return this.wideDialog + ? '-m-6 flex max-h-screen max-w-240 flex-col md:min-w-160 md:w-160' + : '-m-6 flex max-h-screen flex-col md:min-w-120 md:w-120'; } /** Fields that should span both columns (map + multi-line text). */ @@ -251,6 +309,15 @@ export class ResourceCrudComponent implements OnInit { if (this.pageIndex() * this.pageSize() >= rows.length) { this.pageIndex.set(0); } + const id = this.selectedId(); + if (id) { + const row = rows.find((r) => r.id === id); + if (!row) { + this.closeDetails(); + } else if (this.editing()) { + this.form = this._buildForm(row); + } + } return rows; } @@ -286,6 +353,7 @@ export class ResourceCrudComponent implements OnInit { onSearch(value: string): void { this.search.set(value); this.pageIndex.set(0); + this.closeDetails(); } onFilterChange(filter: CrudFilter, value: string): void { @@ -294,6 +362,7 @@ export class ResourceCrudComponent implements OnInit { [filter.name]: value, })); this.pageIndex.set(0); + this.closeDetails(); } /** Resets the search box and every page-level filter to "all". */ @@ -301,11 +370,13 @@ export class ResourceCrudComponent implements OnInit { this.search.set(''); this.filterValues.set({}); this.pageIndex.set(0); + this.closeDetails(); } onPageChange(event: PageEvent): void { this.pageIndex.set(event.pageIndex); this.pageSize.set(event.pageSize); + this.closeDetails(); } private async _loadFilterOptions(rows: CrudRow[]): Promise { @@ -339,7 +410,42 @@ export class ResourceCrudComponent implements OnInit { }); } + /** True when the row is explicitly inactive (matches markets/products pills). */ + isInactive(row: CrudRow): boolean { + return row.isActive === false; + } + + toggleDetails(row: CrudRow): void { + if (this.selectedId() === row.id) { + this.closeDetails(); + return; + } + this.closeDialog(); + this.selectedId.set(row.id); + this.editing.set(true); + this.editingId.set(row.id); + this.flashMessage.set(null); + this.form = this._buildForm(row); + this.optionSearch.set({}); + void this._loadSelectOptions(); + } + + closeDetails(): void { + if (!this.selectedId()) { + return; + } + this.selectedId.set(null); + this.editing.set(false); + this.editingId.set(null); + this.flashMessage.set(null); + // Keep create-dialog form intact when only closing details. + if (!this._dialogRef) { + this.form = null; + } + } + openCreate(): void { + this.closeDetails(); this.editing.set(false); this.editingId.set(null); this.form = this._buildForm(null); @@ -348,13 +454,25 @@ export class ResourceCrudComponent implements OnInit { this._open(); } + /** + * Opens a row for editing — inline detail panel when + * {@link usesInlineDetail}, otherwise the create/edit dialog. + */ openEdit(row: CrudRow): void { + this.closeDialog(); this.editing.set(true); this.editingId.set(row.id); + this.flashMessage.set(null); this.form = this._buildForm(row); this.optionSearch.set({}); void this._loadSelectOptions(); - this._open(); + + if (this.usesInlineDetail) { + this.selectedId.set(row.id); + } else { + this.selectedId.set(null); + this._open(); + } } closeDialog(): void { @@ -368,6 +486,8 @@ export class ResourceCrudComponent implements OnInit { } const value = this._payload(); const id = this.editingId(); + const inline = + this.usesInlineDetail && !!this.selectedId() && this.editing(); // Whether this is an edit is tracked separately from the id, because a // row whose id the API named something unexpected yields a blank id — @@ -384,16 +504,54 @@ export class ResourceCrudComponent implements OnInit { : this.resource.create(value); request .then(() => { - this._notify( - id ? 'admin.crud.updateSuccess' : 'admin.crud.createSuccess' - ); - this.closeDialog(); + if (id) { + if (inline) { + this.showFlashMessage('success'); + } else { + this._notify('admin.crud.updateSuccess'); + this.closeDialog(); + } + } else { + this._notify('admin.crud.createSuccess'); + this.closeDialog(); + } this.load(); }) - .catch((err) => this._notifyError(err, 'admin.crud.saveError')) + .catch((err) => { + if (inline) { + this.showFlashMessage('error'); + } + void this._notifyError(err, 'admin.crud.saveError'); + }) .finally(() => this.saving.set(false)); } + showFlashMessage(type: 'success' | 'error'): void { + this.flashMessage.set(type); + window.setTimeout(() => { + if (this.flashMessage() === type) { + this.flashMessage.set(null); + } + }, 3000); + } + + /** Deactivate / delete / reactivate the expanded row from the detail footer. */ + removeSelected(): void { + const id = this.selectedId(); + if (!id) { + return; + } + const row = this.rows().find((r) => r.id === id); + if (row) { + this.remove(row); + } + } + + selectedRow(): CrudRow | undefined { + const id = this.selectedId(); + return id ? this.rows().find((r) => r.id === id) : undefined; + } + /** * True when the row's action is a deactivate and the row is already * inactive, leaving nothing for the button to do. @@ -421,7 +579,15 @@ export class ResourceCrudComponent implements OnInit { : this.resource.removeIcon ?? 'trash'; } - /** Tooltip for the row action, per its direction and availability. */ + /** Visible label for the detail-footer remove/deactivate/reactivate button. */ + removeLabelFor(row: CrudRow): string { + if (this.isReactivate(row)) { + return 'admin.crud.reactivate'; + } + return this.resource.removeLabel ?? 'admin.crud.remove'; + } + + /** Tooltip for the remove action, per its direction and availability. */ removeTooltipFor(row: CrudRow): string { if (this.isReactivate(row)) { return 'admin.crud.reactivate'; @@ -466,6 +632,9 @@ export class ResourceCrudComponent implements OnInit { removeFn(row) .then(() => { this._notify('admin.crud.removeSuccess'); + if (this.selectedId() === row.id) { + this.closeDetails(); + } this.load(); }) .catch((err) => this._notifyError(err, 'admin.crud.saveError')); @@ -511,16 +680,13 @@ export class ResourceCrudComponent implements OnInit { */ visibleOptions(field: CrudField): CrudOption[] { const options = this.selectOptions()[field.name] ?? []; - const term = (this.optionSearch()[field.name] ?? '') - .trim() - .toLowerCase(); - if (!field.searchable || !term) { + const term = this.optionSearch()[field.name] ?? ''; + if (!field.searchable || !term.trim()) { return options; } const selected = this.controlOf(field.name)?.value; return options.filter( - (opt) => - opt.value === selected || opt.label.toLowerCase().includes(term) + (opt) => opt.value === selected || includesFolded(opt.label, term) ); } @@ -600,11 +766,11 @@ export class ResourceCrudComponent implements OnInit { } private _open(): void { + // Size comes from Tailwind on the dialog content (Fuse compose pattern), + // not MatDialog width — keeps the primary header flush to the edges. this._dialogRef = this._dialog.open(this._formDialog, { - width: this.wideDialog ? '46rem' : '30rem', - maxWidth: 'calc(100vw - 2rem)', - maxHeight: '90vh', autoFocus: false, + maxWidth: '100vw', }); this._dialogRef.afterClosed().subscribe(() => (this._dialogRef = null)); } @@ -736,9 +902,11 @@ export class ResourceCrudComponent implements OnInit { err: unknown, fallbackKey: string ): Promise { - const message = - (await apiErrorMessage(err)) ?? - this._transloco.translate(fallbackKey); + const message = await describeApiError( + err, + (key) => this._transloco.translate(key), + fallbackKey + ); this._snackBar.open(message, undefined, { duration: 6000 }); } } diff --git a/src/app/modules/admin/shared/resource-crud.types.ts b/src/app/modules/admin/shared/resource-crud.types.ts index 1e9f03e..4b6f921 100644 --- a/src/app/modules/admin/shared/resource-crud.types.ts +++ b/src/app/modules/admin/shared/resource-crud.types.ts @@ -168,4 +168,10 @@ export interface CrudResource { rowActions?: CrudRowAction[]; /** Extra page-level buttons shown beside "Create" (e.g. "view map"). */ headerActions?: CrudHeaderAction[]; + /** + * When `false`, rows use edit/remove action buttons + a dialog instead of + * the expandable detail panel. Prefer for resources with only a couple of + * fields (e.g. categories). Default `true` (markets inventory pattern). + */ + inlineDetail?: boolean; } diff --git a/src/app/modules/admin/users/user-detail.component.html b/src/app/modules/admin/users/user-detail.component.html deleted file mode 100644 index 2a8a0cb..0000000 --- a/src/app/modules/admin/users/user-detail.component.html +++ /dev/null @@ -1,176 +0,0 @@ -
    -
    - -
    -

    - {{ user()?.email || t('admin.userDetail.title') }} -

    -

    {{ userId }}

    -
    -
    - -
    - -
    -

    - {{ t('admin.userDetail.profile.title') }} -

    - -
    - {{ - t('admin.userDetail.profile.email') - }} - {{ user()?.email || '—' }} - - {{ - t('admin.userDetail.profile.phone') - }} - {{ user()?.phone || '—' }} - - {{ - t('admin.userDetail.profile.restaurant') - }} - {{ user()?.restaurantName || '—' }} - - {{ - t('admin.userDetail.profile.status') - }} - - @if (user()?.isActive) { - - {{ t('admin.users.filters.active') }} - - } @else { - - {{ t('admin.users.filters.inactive') }} - - } - @if (user()?.lockedUntil) { - - {{ t('admin.users.table.locked') }} - - } - -
    - -
    - - -
    -
    - - -
    -

    - {{ t('admin.userDetail.role.title') }} -

    - - - {{ - t('admin.userDetail.role.label') - }} - - @for (role of roles(); track role) { - {{ role }} - } - - - - -
    - - -
    -
    -

    - {{ t('admin.userDetail.assignments.title') }} -

    - @if (canAssignMarkets()) { - - } -
    - - @if (!canAssignMarkets()) { -

    - {{ t('admin.userDetail.assignments.agentOnly') }} -

    - } @else { - @if (loadingAssignments()) { - - } - - @if (!loadingAssignments() && markets().length === 0) { -

    - {{ t('admin.userDetail.assignments.empty') }} -

    - } - -
    - @for (market of markets(); track market.id) { - - {{ market.name }} - - } -
    - } -
    -
    -
    diff --git a/src/app/modules/admin/users/user-detail.component.ts b/src/app/modules/admin/users/user-detail.component.ts deleted file mode 100644 index f364f82..0000000 --- a/src/app/modules/admin/users/user-detail.component.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Location } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - ViewEncapsulation, - computed, - inject, - signal, -} from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; -import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; -import { AdminService, apiErrorMessage } from '../admin.service'; -import { AdminUserRow } from '../admin.types'; - -/** Role code eligible for market assignments (see ROLE_MATRIX). */ -const MARKET_AGENT_ROLE = 'market_agent'; - -/** - * Admin ▸ User detail. There is no `GET /admin/users/{userId}` endpoint, so - * the base profile (email/role/status) comes from the row the list screen - * passed via router navigation `state` (falls back to just the id from the - * route param). Market assignments always come live from their own GET. - */ -@Component({ - selector: 'admin-user-detail', - templateUrl: './user-detail.component.html', - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - // Full-width flex host so the page fills the screen (see ResourceCrudComponent). - host: { class: 'flex flex-auto flex-col' }, - imports: [ - MatButtonModule, - MatCheckboxModule, - MatFormFieldModule, - MatIconModule, - MatProgressBarModule, - MatSelectModule, - MatSnackBarModule, - ReactiveFormsModule, - TranslocoModule, - ], -}) -export class UserDetailComponent implements OnInit { - private readonly _route = inject(ActivatedRoute); - private readonly _router = inject(Router); - private readonly _location = inject(Location); - private readonly _admin = inject(AdminService); - private readonly _snackBar = inject(MatSnackBar); - private readonly _transloco = inject(TranslocoService); - private readonly _formBuilder = inject(FormBuilder); - - readonly userId = this._route.snapshot.params['userId'] as string; - readonly user = signal(null); - readonly roles = signal([]); - readonly markets = signal<{ id: string; name: string }[]>([]); - readonly assignedMarketIds = signal>(new Set()); - - /** - * Market assignments are only valid for market-agent users (PRD § M3 — - * "assign markets to agents"); the backend rejects assigning markets to any - * other role with a 422. Gate the picker on the user's saved role. - */ - readonly canAssignMarkets = computed( - () => this.user()?.role === MARKET_AGENT_ROLE - ); - - readonly loadingAssignments = signal(false); - readonly savingRole = signal(false); - readonly savingActive = signal(false); - readonly savingAssignments = signal(false); - readonly unlocking = signal(false); - - readonly roleForm = this._formBuilder.nonNullable.group({ - role: ['', Validators.required], - }); - - ngOnInit(): void { - const stateUser = (this._location.getState() as { user?: AdminUserRow }) - ?.user; - this.user.set(stateUser ?? { id: this.userId }); - this.roleForm.patchValue({ role: stateUser?.role ?? '' }); - - this._admin - .getRoles() - .then((roles) => this.roles.set(roles)) - .catch(() => this.roles.set([])); - - this._admin - .getMarkets() - .then((markets) => this.markets.set(markets)) - .catch(() => this.markets.set([])); - - this._loadAssignments(); - } - - goBack(): void { - this._router.navigate(['/admin/users']); - } - - saveRole(): void { - if (this.roleForm.invalid) { - return; - } - const roleName = this.roleForm.getRawValue().role; - this.savingRole.set(true); - this._admin - .assignRole(this.userId, roleName) - .then(() => { - this.user.update((u) => (u ? { ...u, role: roleName } : u)); - this._notify('admin.userDetail.role.success'); - }) - .catch((err) => this._notifyError(err)) - .finally(() => this.savingRole.set(false)); - } - - toggleActive(): void { - const current = this.user(); - if (!current) { - return; - } - const nextActive = !current.isActive; - this.savingActive.set(true); - this._admin - .setUserActive(this.userId, nextActive) - .then(() => { - this.user.update((u) => - u ? { ...u, isActive: nextActive } : u - ); - this._notify( - nextActive - ? 'admin.users.activate.success' - : 'admin.users.deactivate.success' - ); - }) - .catch((err) => this._notifyError(err)) - .finally(() => this.savingActive.set(false)); - } - - unlock(): void { - this.unlocking.set(true); - this._admin - .unlockUser(this.userId) - .then(() => { - this.user.update((u) => (u ? { ...u, lockedUntil: null } : u)); - this._notify('admin.users.unlock.success'); - }) - .catch((err) => this._notifyError(err)) - .finally(() => this.unlocking.set(false)); - } - - toggleMarket(marketId: string): void { - this.assignedMarketIds.update((set) => { - const next = new Set(set); - if (next.has(marketId)) { - next.delete(marketId); - } else { - next.add(marketId); - } - return next; - }); - } - - isMarketAssigned(marketId: string): boolean { - return this.assignedMarketIds().has(marketId); - } - - saveAssignments(): void { - this.savingAssignments.set(true); - this._admin - .replaceMarketAssignments( - this.userId, - Array.from(this.assignedMarketIds()) - ) - .then(() => { - this._notify('admin.userDetail.assignments.success'); - // The checkboxes show what was submitted; read back what the - // server actually kept. - this._loadAssignments(); - }) - .catch((err) => this._notifyError(err)) - .finally(() => this.savingAssignments.set(false)); - } - - private _loadAssignments(): void { - this.loadingAssignments.set(true); - this._admin - .getMarketAssignments(this.userId) - .then((ids) => this.assignedMarketIds.set(new Set(ids))) - .catch(() => this.assignedMarketIds.set(new Set())) - .finally(() => this.loadingAssignments.set(false)); - } - - private _notify(key: string): void { - this._snackBar.open(this._transloco.translate(key), undefined, { - duration: 3000, - }); - } - - /** - * Shows the server's own error message when the API rejects an action - * (e.g. a 422 business-rule failure on market assignments), falling back to - * a generic translated message for network/unknown failures. - */ - private async _notifyError(err: unknown): Promise { - const message = - (await apiErrorMessage(err)) ?? - this._transloco.translate('admin.userDetail.actionError'); - this._snackBar.open(message, undefined, { duration: 5000 }); - } -} diff --git a/src/app/modules/admin/users/users-list.component.html b/src/app/modules/admin/users/users-list.component.html index 6f8d370..28cb604 100644 --- a/src/app/modules/admin/users/users-list.component.html +++ b/src/app/modules/admin/users/users-list.component.html @@ -1,297 +1,625 @@ -
    -
    -
    -

    - {{ t('admin.users.title') }} -

    -

    - {{ t('admin.users.subtitle') }} -

    +
    + +
    + @if (loading()) { +
    + +
    + } +
    +
    +
    + {{ t('admin.users.title') }} +
    +
    +
    - -
    - -
    - - {{ t('admin.users.filters.search') }} - - - + + + + + - - {{ t('admin.users.filters.role') }} - - {{ - t('admin.users.filters.allRoles') - }} - @for (role of roles(); track role) { - {{ role }} - } - - + + {{ t('admin.users.filters.role') }} + + {{ + t('admin.users.filters.allRoles') + }} + @for (role of roles(); track role) { + {{ role }} + } + + - - {{ t('admin.users.filters.status') }} - - {{ - t('admin.users.filters.allStatuses') - }} - {{ - t('admin.users.filters.active') - }} - {{ - t('admin.users.filters.inactive') - }} - - + + {{ t('admin.users.filters.status') }} + + {{ + t('admin.users.filters.allStatuses') + }} + {{ + t('admin.users.filters.active') + }} + {{ + t('admin.users.filters.inactive') + }} + + - -
    + @if (hasActiveFilters()) { + + } + +
    - @if (loading()) { - - } + +
    +
    + @if (sortedUsers().length > 0) { +
    +
    + @for ( + col of [ + { + key: 'email', + label: 'admin.users.table.email', + }, + { + key: 'role', + label: 'admin.users.table.role', + }, + { + key: 'restaurantName', + label: 'admin.users.table.restaurant', + }, + { + key: 'status', + label: 'admin.users.table.status', + }, + ]; + track col.key + ) { +
    + +
    + } +
    + {{ t('admin.users.actions.unlock') }} +
    +
    + {{ t('admin.crud.details') }} +
    +
    - -
    - - - @for ( - col of [ - { key: 'email', label: 'admin.users.table.email' }, - { key: 'role', label: 'admin.users.table.role' }, - { - key: 'restaurantName', - label: 'admin.users.table.restaurant', - }, - { - key: 'status', - label: 'admin.users.table.status', - }, - ]; - track col.key + user of sortedUsers(); + track trackById($index, user) ) { - - } - - - - - @for (user of sortedUsers(); track trackById($index, user)) { - - - - - - - - } @empty { - - - - } - -
    - - - {{ t('admin.users.table.actions') }} -
    - - {{ user.role || '—' }} - {{ user.restaurantName || '—' }} - - @if (user.isActive) { - - {{ t('admin.users.filters.active') }} - - } @else { - - {{ t('admin.users.filters.inactive') }} - - } - @if (user.lockedUntil) { - - {{ t('admin.users.table.locked') }} - - } - -
    +
    +
    {{ user.role || '—' }}
    +
    + {{ user.restaurantName || '—' }} +
    +
    + @if (user.isActive) { + + {{ t('admin.users.filters.active') }} + + } @else { + + {{ t('admin.users.filters.inactive') }} + + } + @if (user.lockedUntil) { + + {{ t('admin.users.table.locked') }} + + } +
    +
    +
    +
    -
    + + @if (selectedId() === user.id) { +
    +
    - - +
    +
    +
    + {{ + t( + 'admin.userDetail.profile.title' + ) + }} +
    +
    + {{ + t( + 'admin.userDetail.profile.email' + ) + }} + {{ + user.email || '—' + }} + {{ + t( + 'admin.userDetail.profile.phone' + ) + }} + {{ + user.phone || '—' + }} + {{ + t( + 'admin.userDetail.profile.restaurant' + ) + }} + {{ + user.restaurantName || '—' + }} + {{ + t( + 'admin.userDetail.profile.status' + ) + }} + + @if (user.isActive) { + + {{ + t( + 'admin.users.filters.active' + ) + }} + + } @else { + + {{ + t( + 'admin.users.filters.inactive' + ) + }} + + } + @if (user.lockedUntil) { + + {{ + t( + 'admin.users.table.locked' + ) + }} + + } + +
    +
    + +
    +
    + {{ + t( + 'admin.userDetail.role.title' + ) + }} +
    + + + @for ( + role of roles(); + track role + ) { + {{ + role + }} + } + + +
    +
    + +
    + + +
    +
    -
    - {{ t('admin.users.empty') }} -
    -
    + } + } +
    - + + } @else if (!loading()) { +
    + {{ t('admin.users.empty') }} +
    + } +
    +
    - +
    -
    -

    +
    +
    {{ t('admin.users.create.title') }} -

    -
    +
    - - {{ t('admin.users.create.email') }} - - - - - {{ t('admin.users.create.password') }} - - +
    + + {{ t('admin.users.create.email') }} + + @if (createForm.controls.email.hasError('required')) { + {{ + t('admin.users.create.errors.emailRequired') + }} + } @else if (createForm.controls.email.hasError('email')) { + {{ + t('admin.users.create.errors.emailInvalid') + }} + } @else if ( + createForm.controls.email.hasError('maxlength') + ) { + {{ + t('admin.users.create.errors.emailMax') + }} + } + - - {{ t('admin.users.create.role') }} - - @for (role of roles(); track role) { - {{ role }} + + {{ + t('admin.users.create.password') + }} + + @if (createForm.controls.password.hasError('required')) { + {{ + t('admin.users.create.errors.passwordRequired') + }} } - - + - - {{ t('admin.users.create.market') }} - - {{ - t('admin.users.create.noMarket') - }} - @for (market of markets(); track market.id) { - {{ - market.name - }} + @if ( + createForm.controls.password.value || + createForm.controls.password.touched + ) { +
      + @for ( + rule of [ + { key: 'minLength', label: 'minLength' }, + { key: 'uppercase', label: 'uppercase' }, + { key: 'digit', label: 'digit' }, + { key: 'special', label: 'special' }, + ]; + track rule.key + ) { +
    • + + {{ + t( + 'admin.users.create.password.' + + rule.label + ) + }} +
    • + } +
    + } + + + {{ t('admin.users.create.role') }} + + @for (role of roles(); track role) { + {{ role }} + } + + @if (createForm.controls.role.hasError('required')) { + {{ + t('admin.users.create.errors.roleRequired') + }} } -
    -
    + - - {{ - t('admin.users.create.restaurantName') - }} - - + @if (needsMarket()) { + + {{ + t('admin.users.create.market') + }} + + @for (market of markets(); track market.id) { + {{ + market.name + }} + } + + @if ( + createForm.controls.marketId.hasError('required') + ) { + {{ + t('admin.users.create.errors.marketRequired') + }} + } + + } - - {{ t('admin.users.create.phone') }} - - + @if (needsRestaurantName()) { + + {{ + t('admin.users.create.restaurantName') + }} + + @if ( + createForm.controls.restaurantName.hasError( + 'required' + ) + ) { + {{ + t( + 'admin.users.create.errors.restaurantRequired' + ) + }} + } @else if ( + createForm.controls.restaurantName.hasError( + 'maxlength' + ) + ) { + {{ + t('admin.users.create.errors.restaurantMax') + }} + } + + } + + + {{ t('admin.users.create.phone') }} + + @if (createForm.controls.phone.hasError('phoneNumber')) { + {{ + t('admin.users.create.errors.phoneInvalid') + }} + } @else if ( + createForm.controls.phone.hasError('maxlength') + ) { + {{ + t('admin.users.create.errors.phoneMax') + }} + } + +
    -
    +