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/.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/.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/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/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/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/public/i18n/en.json b/public/i18n/en.json index 4643b07..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", @@ -328,6 +427,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 +466,8 @@ "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.details": "Details", "admin.crud.image.upload": "Upload image", "admin.crud.image.hint": "Drag & drop or click to browse", "admin.crud.image.change": "Change image", @@ -383,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", @@ -390,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", @@ -479,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.", @@ -516,5 +651,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..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", @@ -328,6 +427,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 +466,8 @@ "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.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", @@ -383,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ợ", @@ -390,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", @@ -479,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.", @@ -516,5 +651,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/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); } 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..5098216 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, readApiError, withId } from './envelope'; /** * Every screen feeds `row.id` into a path parameter, so a row that arrives @@ -56,3 +57,122 @@ 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(); + }); + + 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 99bc1ae..43ffa9f 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,152 @@ export function withId>( }); } +/** + * 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} (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). + * + * Returns `undefined` for a non-HTTP failure or a bodiless response. + */ +export async function readApiError( + err: unknown +): Promise { + const response = + err instanceof ResponseError + ? err.response + : err instanceof ApiError + ? err.response + : undefined; + if (!response) { + return undefined; + } + const status = response.status || undefined; + const body = await parseJson>(response.clone()); + if (!body) { + // 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 map: Record = {}; + for (const [field, value] of Object.entries( + body['errors'] as Record + )) { + 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()) { + info.message = value; + break; + } + } + 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. */ export async function parseJson(response: Response): Promise { const text = await response.text(); 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 06e08b5..10c4fbf 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', @@ -78,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: [ @@ -96,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: [ @@ -119,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: [ @@ -142,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: [ @@ -179,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: [ @@ -209,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/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/layout/layout.component.html b/src/app/layout/layout.component.html index efe39d8..256c9be 100644 --- a/src/app/layout/layout.component.html +++ b/src/app/layout/layout.component.html @@ -62,8 +62,3 @@ @if (layout === 'thin') { } - - diff --git a/src/app/layout/layout.component.ts b/src/app/layout/layout.component.ts index 581f4b2..2f49d67 100644 --- a/src/app/layout/layout.component.ts +++ b/src/app/layout/layout.component.ts @@ -14,7 +14,6 @@ import { FuseMediaWatcherService } from '@fuse/services/media-watcher'; import { FusePlatformService } from '@fuse/services/platform'; import { FUSE_VERSION } from '@fuse/version'; import { Subject, combineLatest, filter, map, takeUntil } from 'rxjs'; -import { SettingsComponent } from './common/settings/settings.component'; import { EmptyLayoutComponent } from './layouts/empty/empty.component'; import { CenteredLayoutComponent } from './layouts/horizontal/centered/centered.component'; import { EnterpriseLayoutComponent } from './layouts/horizontal/enterprise/enterprise.component'; @@ -46,7 +45,6 @@ import { ThinLayoutComponent } from './layouts/vertical/thin/thin.component'; DenseLayoutComponent, FuturisticLayoutComponent, ThinLayoutComponent, - SettingsComponent, ], }) export class LayoutComponent implements OnInit, OnDestroy { 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 8baffa2..6cdc4ab 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, @@ -114,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 // ------------------------------------------------------------------- @@ -186,6 +257,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 +466,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/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/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..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: [ @@ -84,6 +85,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/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 9d14f33..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,31 +624,206 @@

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

+
+ + + +
+
+
+ {{ t('admin.restaurants.create.title') }} +
+
+ +
+
+ + {{ 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.password') + }} + + @if (createForm.controls.password.hasError('required')) { + {{ + t('admin.users.create.errors.passwordRequired') + }} + } + + + @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.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 a025c34..84e0b88 100644 --- a/src/app/modules/admin/restaurants/restaurants-admin.component.ts +++ b/src/app/modules/admin/restaurants/restaurants-admin.component.ts @@ -1,30 +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 { AdminRestaurantCredit } from '../admin.types'; +import { describeApiError } from 'app/core/api/error-codes'; +import { + 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'; + +const DEFAULT_PAGE_SIZE = 20; +const RESTAURANT_ROLE = 'restaurant'; +const RESTAURANT_NAME_MAX_LENGTH = 200; +const PHONE_MAX_LENGTH = 20; -/** 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; +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', @@ -32,37 +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); - - 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.pattern(UUID_PATTERN)], + [ + Validators.required, + Validators.maxLength(RESTAURANT_NAME_MAX_LENGTH), + ], + ], + phone: [ + '', + [phoneNumberValidator, Validators.maxLength(PHONE_MAX_LENGTH)], ], }); @@ -78,93 +211,187 @@ export class RestaurantsAdminComponent { note: [''], }); - get restaurantId(): string { - return this.lookupForm.getRawValue().restaurantId; + 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(); + }); + } + + 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.loadingCredit.set(true); + this.selectedId.set(user.id); + this._loadCreditSnapshot(user.restaurantId ?? null); + } + + closeDetails(): void { + this.selectedId.set(null); + this.credit.set(null); + this.editingCreditLimit.set(false); + } + + openCreatePanel(): void { + if (!this._createPanel || this._dialogRef) { + return; + } + 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(); + }); + } + + closeCreatePanel(): void { + this._dialogRef?.close(); + } + + createRestaurant(ngForm: FormGroupDirective): void { + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); + return; + } + const value = this.createForm.getRawValue(); + this.createForm.disable(); this._admin - .getRestaurantCredit(restaurantId) - .then((credit) => this.credit.set(credit)) - .finally(() => this.loadingCredit.set(false)); + .createUser({ + email: value.email.trim(), + password: value.password, + role: RESTAURANT_ROLE, + marketId: null, + restaurantName: value.restaurantName.trim() || null, + phone: value.phone.trim() || null, + }) + .then(() => { + this._notify('admin.restaurants.create.success'); + this.closeCreatePanel(); + this.pageIndex.set(0); + this._load(); + }) + .catch(async (err) => { + this.createForm.enable(); + this._notifyText( + 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, @@ -172,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, @@ -196,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 a8201cc..3ae82ee 100644 --- a/src/app/modules/admin/shared/resource-crud.component.html +++ b/src/app/modules/admin/shared/resource-crud.component.html @@ -1,575 +1,506 @@ -
-
-
-

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

-

{{ t(resource.subtitle) }}

-
-
- @for (action of resource.headerActions; track action.label) { +
+ +
+ @if (loading()) { +
+ +
+ } +
+
+
+ {{ t(resource.title) }} +
+
+
+ @if (resource.searchKeys?.length) { + + + + + } + @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) }} - - + @for (filter of resource.filters; track filter.name) { + - {{ - t('admin.crud.filterAll') - }} - @for ( - opt of filterOptions()[filter.name]; - track opt.value - ) { - {{ - opt.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 { - - - - } - -
+ - @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 91b30d1..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,9 +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 { 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, @@ -46,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', @@ -62,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, @@ -79,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; @@ -99,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. */ @@ -115,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] @@ -126,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]) @@ -137,6 +148,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(); @@ -172,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 @@ -181,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 + ? '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 - ? 'mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2' - : 'mt-4 flex flex-col gap-3'; + ? '-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). */ @@ -226,9 +292,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); } @@ -243,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; } @@ -268,8 +343,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); } @@ -278,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 { @@ -286,11 +362,21 @@ 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". */ + clearFilters(): void { + 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 { @@ -324,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); @@ -333,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 { @@ -353,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 — @@ -369,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(() => this._notify('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. @@ -406,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'; @@ -451,9 +632,12 @@ export class ResourceCrudComponent implements OnInit { removeFn(row) .then(() => { this._notify('admin.crud.removeSuccess'); + if (this.selectedId() === row.id) { + this.closeDetails(); + } this.load(); }) - .catch(() => this._notify('admin.crud.saveError')); + .catch((err) => this._notifyError(err, 'admin.crud.saveError')); }); } @@ -496,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) ); } @@ -570,7 +751,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)); } @@ -583,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)); } @@ -709,4 +892,21 @@ 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 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') + }} + } + +
-
+
+
+ + 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 diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 14d65bc..a17daf3 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -90,3 +90,25 @@ body { .admin-sort-header:hover { @apply text-default bg-hover; } + +/** + * Header search fields use `fuse-mat-dense`, which sets the Material infix to + * a fixed 88px width — placeholders then clip mid-glyph. Widen the infix and + * ellipsize overflow (including while focused). + */ +.admin-header-search { + .mat-mdc-form-field-flex { + min-width: 0; + } + + .mat-mdc-form-field-infix { + width: auto !important; + min-width: 0 !important; + } + + .mat-mdc-input-element { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } +}