diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index efe5bac..f7fc467 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -7,9 +7,23 @@ on: - 'backend/**' - '.github/workflows/backend-ci.yml' +# A newer push to the same branch makes an in-flight run redundant. +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 20 + + # Exposed at job level so steps can skip Sonar when the secret is absent, which is what + # happens on a fork or before the token has been configured. + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} steps: - name: Checkout code @@ -23,17 +37,18 @@ jobs: global-json-file: backend/global.json - name: Setup Java (required by Sonar scanner) + if: env.SONAR_TOKEN != '' uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Install SonarCloud scanner + if: env.SONAR_TOKEN != '' run: dotnet tool install --global dotnet-sonarscanner - name: Begin SonarCloud analysis - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} + if: env.SONAR_TOKEN != '' run: | dotnet sonarscanner begin \ /k:"RidentIT_RestaurantPOS" \ @@ -72,13 +87,14 @@ jobs: working-directory: backend - name: End SonarCloud analysis - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} + if: env.SONAR_TOKEN != '' run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN_BACKEND }}" working-directory: backend - name: Upload coverage report + if: always() uses: actions/upload-artifact@v4 with: name: backend-coverage - path: backend/**/TestResults/**/coverage.opencover.xml \ No newline at end of file + path: backend/**/TestResults/**/coverage.opencover.xml + if-no-files-found: warn diff --git a/.github/workflows/backend-pr.yml b/.github/workflows/backend-pr.yml index 372e145..cc6c99d 100644 --- a/.github/workflows/backend-pr.yml +++ b/.github/workflows/backend-pr.yml @@ -8,9 +8,18 @@ on: - 'backend/**' - '.github/workflows/backend-pr.yml' +concurrency: + group: backend-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + jobs: pr-validation: runs-on: ubuntu-latest + timeout-minutes: 20 # Expose secret as job-level env so steps can check if it is set env: diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d31c992..664096d 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -7,9 +7,20 @@ on: - 'frontend/**' - '.github/workflows/frontend-ci.yml' +concurrency: + group: frontend-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} steps: - name: Checkout code @@ -41,11 +52,14 @@ jobs: working-directory: frontend - name: Upload Coverage to Artifacts + if: always() uses: actions/upload-artifact@v4 with: name: frontend-coverage path: frontend/coverage + if-no-files-found: warn + # Playwright serves the built output via `vite preview`, so the build must come first. - name: Build Application (Vite) run: npm run build working-directory: frontend @@ -64,11 +78,13 @@ jobs: with: name: playwright-report path: frontend/playwright-report + if-no-files-found: ignore - name: SonarCloud Main Analysis + if: env.SONAR_TOKEN != '' uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} with: - projectBaseDir: frontend \ No newline at end of file + projectBaseDir: frontend diff --git a/.github/workflows/frontend-pr.yml b/.github/workflows/frontend-pr.yml index bc81d68..49fe827 100644 --- a/.github/workflows/frontend-pr.yml +++ b/.github/workflows/frontend-pr.yml @@ -8,9 +8,18 @@ on: - 'frontend/**' - '.github/workflows/frontend-pr.yml' +concurrency: + group: frontend-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + jobs: pr-validation: runs-on: ubuntu-latest + timeout-minutes: 20 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} diff --git a/backend/.gitignore b/backend/.gitignore index bd3015d..984e33e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -107,7 +107,8 @@ StyleCopReport.xml *.scc logs/ *.db -*.db +*.db-shm +*.db-wal integrationtests.db # Chutzpah Test files @@ -484,3 +485,8 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Auto-generated JWT signing key (see JwtSigningKeyProvider). +# Machine-local secret: never commit it, and never share it between installations. +keys/ +*.key diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 7808224..9ad9453 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -1,6 +1,9 @@ net9.0 + + Major enable enable true diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index dbd0fa6..35ea973 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -13,17 +13,22 @@ - - + + + + + + + diff --git a/backend/global.json b/backend/global.json index 065c16b..7ac218c 100644 --- a/backend/global.json +++ b/backend/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "9.0.311", - "rollForward": "latestFeature" + "rollForward": "latestMajor" } } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs new file mode 100644 index 0000000..d5c7868 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs @@ -0,0 +1,24 @@ +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.API.Contracts.Auth; + +/// Sign-in credentials. +public sealed record LoginRequest(string Username, string Password); + +/// Exchanges a refresh token for a new session. +public sealed record RefreshRequest(string RefreshToken); + +/// Ends a session. The token is optional so sign-out never fails. +public sealed record LogoutRequest(string? RefreshToken); + +/// Changes the signed-in user's own password. +public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); + +/// +/// Sets the signed-in administrator's approval PIN. Leave null to have +/// the server generate a random -digit PIN. +/// +public sealed record SetApprovalPinRequest(string CurrentPassword, string? Pin); + +/// Presents a PIN for authorisation of a privileged action. +public sealed record VerifyApprovalPinRequest(string Pin, string? Reason); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs new file mode 100644 index 0000000..bcbd9fa --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Inventory/RawMaterialRequests.cs @@ -0,0 +1,11 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Inventory; + +public sealed record CreateRawMaterialRequest( + string Name, UnitOfMeasurement UnitOfMeasurement, decimal? MainStoreReorderLevel, decimal? KitchenParLevel); + +public sealed record UpdateRawMaterialRequest( + string Name, UnitOfMeasurement UnitOfMeasurement, decimal? MainStoreReorderLevel, decimal? KitchenParLevel); + +public sealed record SetRawMaterialActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs new file mode 100644 index 0000000..3e041a7 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Inventory/StockRequests.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Inventory; + +public sealed record StockLineRequest(Guid RawMaterialId, decimal Quantity); + +public sealed record CreateGoodsReceivedNoteRequest( + Guid SupplierId, + IReadOnlyCollection Lines, + string? Notes, + Guid? PurchaseOrderId = null, + int? QualityRating = null, + bool HasIssue = false); + +/// +/// Both the raw material quantities and the administrator's approval PIN travel in one request, +/// since a release is created only once it is already approved. +/// +public sealed record CreateStockReleaseRequest( + IReadOnlyCollection Lines, string Pin, string? Notes); + +public sealed record CreateStockAdjustmentRequest( + Guid RawMaterialId, StoreType Store, decimal QuantityDelta, string Reason); + +public sealed record ConsumeStockRequest(decimal QuantitySold); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Kitchen/KitchenRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Kitchen/KitchenRequests.cs new file mode 100644 index 0000000..f4c3e61 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Kitchen/KitchenRequests.cs @@ -0,0 +1,5 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Kitchen; + +public sealed record AdvanceKitchenTicketRequest(KitchenTicketStatus Status); diff --git a/backend/src/RestaurantPOS.API/Contracts/Orders/OrderRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Orders/OrderRequests.cs new file mode 100644 index 0000000..df278e6 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Orders/OrderRequests.cs @@ -0,0 +1,27 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Orders; + +public sealed record CreateOrderRequest(Guid TableId); + +public sealed record OrderItemRequest(Guid MenuItemId, int Quantity, string? SpecialInstructions); + +public sealed record AddOrderItemsRequest(IReadOnlyCollection Items); + +/// +/// The PIN travels with the change rather than being exchanged for a token first, matching how +/// stock releases are approved elsewhere in this system: one request carries both the action and +/// the authority for it, so there is no window where an approval exists unused. +/// +public sealed record ChangeOrderItemQuantityRequest(int Quantity, string? Pin); + +public sealed record RemoveOrderItemRequest(string? Pin); + +public sealed record CancelOrderRequest(string? Pin, string? Reason); + +public sealed record SetOrderDiscountRequest(DiscountType Type, decimal Value); + +public sealed record OrderPaymentRequest( + OrderPaymentMethod Method, decimal Amount, decimal? TenderedAmount, string? Reference); + +public sealed record CompleteOrderPaymentRequest(IReadOnlyCollection Payments); diff --git a/backend/src/RestaurantPOS.API/Contracts/Orders/TableRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Orders/TableRequests.cs new file mode 100644 index 0000000..46d841e --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Orders/TableRequests.cs @@ -0,0 +1,7 @@ +namespace RestaurantPOS.API.Contracts.Orders; + +public sealed record CreateTableRequest(string Number, int Seats = 0, string? Notes = null); + +public sealed record UpdateTableRequest(string Number, int Seats, string? Notes); + +public sealed record SetTableActiveRequest(bool IsActive); diff --git a/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs new file mode 100644 index 0000000..03d6511 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Recipes/MenuItemRequests.cs @@ -0,0 +1,7 @@ +namespace RestaurantPOS.API.Contracts.Recipes; + +public sealed record CreateMenuItemRequest(string Name, string Category, decimal Price); + +public sealed record UpdateMenuItemRequest(string Name, string Category, decimal Price); + +public sealed record SetMenuItemActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs new file mode 100644 index 0000000..7a93fbc --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Recipes/RecipeRequests.cs @@ -0,0 +1,8 @@ +namespace RestaurantPOS.API.Contracts.Recipes; + +public sealed record RecipeLineRequest(Guid RawMaterialId, decimal Quantity); + +/// Creates the menu item's recipe if it has none, or replaces its lines if it does. +public sealed record UpsertRecipeRequest(IReadOnlyCollection Lines); + +public sealed record SetRecipeEnabledRequest(bool IsEnabled); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs new file mode 100644 index 0000000..f0ab67f --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/PurchaseOrderRequests.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record PurchaseOrderLineRequest(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +public sealed record CreatePurchaseOrderRequest( + Guid SupplierId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes); + +public sealed record UpdatePurchaseOrderRequest( + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs new file mode 100644 index 0000000..155b765 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPaymentRequests.cs @@ -0,0 +1,10 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record RecordSupplierPaymentRequest( + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs new file mode 100644 index 0000000..951baed --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierPriceRequests.cs @@ -0,0 +1,3 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record SetSupplierPriceRequest(Guid RawMaterialId, decimal Price); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs new file mode 100644 index 0000000..4eeaf5b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Suppliers/SupplierRequests.cs @@ -0,0 +1,23 @@ +namespace RestaurantPOS.API.Contracts.Suppliers; + +public sealed record CreateSupplierRequest( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays); + +public sealed record UpdateSupplierRequest( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays); + +public sealed record SetSupplierActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs new file mode 100644 index 0000000..cf4f41b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs @@ -0,0 +1,26 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Users; + +/// Creates a staff account. +/// Ignored for the Admin role, which holds every module. +public sealed record CreateUserRequest( + string Username, + string FullName, + string? Email, + string Password, + UserRole Role, + IReadOnlyCollection? Modules); + +/// Updates a staff account's profile, role and module grants. +public sealed record UpdateUserRequest( + string FullName, + string? Email, + UserRole Role, + IReadOnlyCollection? Modules); + +/// Sets a temporary password that the user must then change. +public sealed record ResetUserPasswordRequest(string NewPassword); + +/// Enables or disables a staff account. +public sealed record SetUserActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..5f12a46 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs @@ -0,0 +1,123 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Auth; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Authentication.Commands.ChangePassword; +using RestaurantPOS.Application.Authentication.Commands.ClearApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.Login; +using RestaurantPOS.Application.Authentication.Commands.Logout; +using RestaurantPOS.Application.Authentication.Commands.RefreshSession; +using RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Authentication.Queries.GetCurrentUser; + +namespace RestaurantPOS.API.Endpoints; + +/// Sign-in, session lifecycle and the administrator approval PIN. +public static class AuthEndpoints +{ + /// Rate-limiter policy guarding endpoints that accept a guessable secret. + public const string SensitiveRateLimitPolicy = "sensitive"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/auth").WithTags("Authentication"); + + group.MapPost("/login", async (LoginRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new LoginCommand(request.Username, request.Password), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("Login") + .WithSummary("Signs in with a username and password."); + + group.MapPost("/refresh", async (RefreshRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new RefreshSessionCommand(request.RefreshToken), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("RefreshSession") + .WithSummary("Exchanges a refresh token for a new session."); + + group.MapPost("/logout", async (LogoutRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new LogoutCommand(request.RefreshToken), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("Logout") + .WithSummary("Revokes a refresh token."); + + group.MapGet("/me", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetCurrentUserQuery(), ct); + return result.ToHttpResult(); + }) + .RequireAuthorization() + // Reachable mid-reset so the client can render the user's name on that screen. + .WithMetadata(new AllowPendingPasswordChangeAttribute()) + .WithName("GetCurrentUser") + .WithSummary("Returns the signed-in user and their effective module access."); + + group.MapPost("/change-password", + async (ChangePasswordRequest request, ISender sender, CancellationToken ct) => + { + var command = new ChangePasswordCommand(request.CurrentPassword, request.NewPassword); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .RequireAuthorization() + // The whole point of this endpoint is to clear the pending-change state. + .WithMetadata(new AllowPendingPasswordChangeAttribute()) + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("ChangePassword") + .WithSummary("Changes the signed-in user's password and returns a fresh session."); + + MapApprovalPinEndpoints(group); + + return routes; + } + + private static void MapApprovalPinEndpoints(RouteGroupBuilder group) + { + group.MapPost("/pin", async (SetApprovalPinRequest request, ISender sender, CancellationToken ct) => + { + var command = new SetApprovalPinCommand(request.CurrentPassword, request.Pin); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .RequireAuthorization(AuthorizationPolicies.AdminOnly) + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("SetApprovalPin") + .WithSummary("Sets or generates the administrator's 4-digit approval PIN."); + + group.MapDelete("/pin", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ClearApprovalPinCommand(), ct); + return result.ToHttpResult(); + }) + .RequireAuthorization(AuthorizationPolicies.AdminOnly) + .WithName("ClearApprovalPin") + .WithSummary("Removes the administrator's approval PIN."); + + group.MapPost("/pin/verify", + async (VerifyApprovalPinRequest request, ISender sender, CancellationToken ct) => + { + var command = new VerifyApprovalPinCommand(request.Pin, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + // Any signed-in user may present a PIN: the point is that a cashier calls this with + // an administrator standing over their shoulder to authorise, say, a void. + .RequireAuthorization() + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("VerifyApprovalPin") + .WithSummary("Authorises a privileged action with an administrator's PIN."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs new file mode 100644 index 0000000..6090af9 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/InventoryEndpoints.cs @@ -0,0 +1,234 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Inventory; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Inventory.Commands.ConsumeStockForSale; +using RestaurantPOS.Application.Inventory.Commands.CreateGoodsReceivedNote; +using RestaurantPOS.Application.Inventory.Commands.CreateRawMaterial; +using RestaurantPOS.Application.Inventory.Commands.CreateStockAdjustment; +using RestaurantPOS.Application.Inventory.Commands.CreateStockRelease; +using RestaurantPOS.Application.Inventory.Commands.SetRawMaterialActive; +using RestaurantPOS.Application.Inventory.Commands.UpdateRawMaterial; +using RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNoteById; +using RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNotes; +using RestaurantPOS.Application.Inventory.Queries.GetRawMaterials; +using RestaurantPOS.Application.Inventory.Queries.GetStockLevels; +using RestaurantPOS.Application.Inventory.Queries.GetStockMovements; +using RestaurantPOS.Application.Inventory.Queries.GetStockReleaseById; +using RestaurantPOS.Application.Inventory.Queries.GetStockReleases; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Raw materials, and the two-store stock ledger (Main Store and Kitchen). +public static class InventoryEndpoints +{ + public static IEndpointRouteBuilder MapInventoryEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + MapRawMaterialEndpoints(routes); + MapMainStoreEndpoints(routes); + MapKitchenEndpoints(routes); + MapReleaseEndpoints(routes); + + return routes; + } + + private static void MapRawMaterialEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/raw-materials") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.StoreStockManagement)); + + group.MapGet("/", async (string? search, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetRawMaterialsQuery(search, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetRawMaterials") + .WithSummary("Lists raw materials, optionally filtered."); + + group.MapPost("/", async (CreateRawMaterialRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateRawMaterialCommand( + request.Name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(r => $"/api/v1/raw-materials/{r.Id}"); + }) + .WithName("CreateRawMaterial") + .WithSummary("Creates a raw material."); + + group.MapPut("/{id:guid}", async ( + Guid id, UpdateRawMaterialRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateRawMaterialCommand( + id, request.Name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateRawMaterial") + .WithSummary("Updates a raw material's details."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetRawMaterialActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetRawMaterialActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetRawMaterialActive") + .WithSummary("Activates or deactivates a raw material."); + } + + private static void MapMainStoreEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/main-store") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.StoreStockManagement)); + + group.MapGet("/stock", async (bool? lowStockOnly, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockLevelsQuery(StoreType.MainStore, lowStockOnly ?? false), ct); + return result.ToHttpResult(); + }) + .WithName("GetMainStoreStock") + .WithSummary("The current stock level of every active raw material in the Main Store."); + + group.MapGet("/movements", async ( + Guid? rawMaterialId, DateTime? from, DateTime? to, ISender sender, CancellationToken ct) => + { + var result = await sender.Send( + new GetStockMovementsQuery(StoreType.MainStore, rawMaterialId, from, to), ct); + return result.ToHttpResult(); + }) + .WithName("GetMainStoreMovements") + .WithSummary("The Main Store's stock history, optionally filtered."); + + group.MapGet("/goods-received", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetGoodsReceivedNotesQuery(), ct); + return result.ToHttpResult(); + }) + .WithName("GetGoodsReceivedNotes") + .WithSummary("Lists Goods Received Notes."); + + group.MapGet("/goods-received/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetGoodsReceivedNoteByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetGoodsReceivedNoteById") + .WithSummary("Loads a single Goods Received Note with its lines."); + + group.MapPost("/goods-received", async ( + CreateGoodsReceivedNoteRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateGoodsReceivedNoteCommand( + request.SupplierId, + [.. request.Lines.Select(l => new GrnLineInput(l.RawMaterialId, l.Quantity))], + request.Notes, + request.PurchaseOrderId, + request.QualityRating, + request.HasIssue); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(n => $"/api/v1/inventory/main-store/goods-received/{n.Id}"); + }) + .WithName("CreateGoodsReceivedNote") + .WithSummary("Records stock received from a supplier. Main Store stock increases immediately."); + + group.MapPost("/adjustments", async ( + CreateStockAdjustmentRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockAdjustmentCommand( + request.RawMaterialId, StoreType.MainStore, request.QuantityDelta, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("CreateMainStoreAdjustment") + .WithSummary("Corrects a raw material's Main Store balance to match a physical count."); + } + + private static void MapKitchenEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/kitchen") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.KitchenStockTracking)); + + group.MapGet("/stock", async (bool? lowStockOnly, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockLevelsQuery(StoreType.Kitchen, lowStockOnly ?? false), ct); + return result.ToHttpResult(); + }) + .WithName("GetKitchenStock") + .WithSummary("The current stock level of every active raw material in the Kitchen."); + + group.MapGet("/movements", async ( + Guid? rawMaterialId, DateTime? from, DateTime? to, ISender sender, CancellationToken ct) => + { + var result = await sender.Send( + new GetStockMovementsQuery(StoreType.Kitchen, rawMaterialId, from, to), ct); + return result.ToHttpResult(); + }) + .WithName("GetKitchenMovements") + .WithSummary("The Kitchen's stock history, optionally filtered."); + + group.MapPost("/adjustments", async ( + CreateStockAdjustmentRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockAdjustmentCommand( + request.RawMaterialId, StoreType.Kitchen, request.QuantityDelta, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("CreateKitchenAdjustment") + .WithSummary("Corrects a raw material's Kitchen balance to match a physical count."); + + // Ahead of Point of Sale existing: the "complete order" flow will call this to deduct + // Kitchen stock per the sold item's recipe. Kept under Kitchen Stock Tracking rather than + // exposed to every module, since only the checkout flow is meant to call it. + group.MapPost("/consumption/{menuItemId:guid}", async ( + Guid menuItemId, ConsumeStockRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ConsumeStockForSaleCommand(menuItemId, request.QuantitySold), ct); + return result.ToHttpResult(); + }) + .WithName("ConsumeStockForSale") + .WithSummary("Deducts Kitchen stock per the menu item's recipe for a completed sale."); + } + + private static void MapReleaseEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/inventory/releases") + .WithTags("Inventory") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.KitchenStockRelease)); + + group.MapGet("/", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockReleasesQuery(), ct); + return result.ToHttpResult(); + }) + .WithName("GetStockReleases") + .WithSummary("Lists Main Store → Kitchen stock releases."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetStockReleaseByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetStockReleaseById") + .WithSummary("Loads a single stock release with its lines."); + + group.MapPost("/", async (CreateStockReleaseRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateStockReleaseCommand( + [.. request.Lines.Select(l => new StockReleaseLineInput(l.RawMaterialId, l.Quantity))], + request.Pin, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(r => $"/api/v1/inventory/releases/{r.Id}"); + }) + .WithName("CreateStockRelease") + .WithSummary("Releases stock from the Main Store to the Kitchen, authorised by an administrator's PIN."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/KitchenEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/KitchenEndpoints.cs new file mode 100644 index 0000000..c314871 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/KitchenEndpoints.cs @@ -0,0 +1,51 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Kitchen; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Kitchen.Commands.AdvanceKitchenTicket; +using RestaurantPOS.Application.Kitchen.Commands.ReprintKitchenTicket; +using RestaurantPOS.Application.Kitchen.Queries.GetKitchenTickets; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// The kitchen display: the ticket queue and its preparation status. +public static class KitchenEndpoints +{ + public static IEndpointRouteBuilder MapKitchenEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/kitchen") + .WithTags("Kitchen") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.KitchenOperations)); + + group.MapGet("/tickets", async (bool? includeServed, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetKitchenTicketsQuery(includeServed ?? false), ct); + return result.ToHttpResult(); + }) + .WithName("GetKitchenTickets") + .WithSummary("Every slip still being worked, oldest first."); + + group.MapPut("/tickets/{id:guid}/status", async ( + Guid id, AdvanceKitchenTicketRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new AdvanceKitchenTicketCommand(id, request.Status), ct); + return result.ToHttpResult(); + }) + .WithName("AdvanceKitchenTicket") + .WithSummary("Moves a ticket to started, ready or served."); + + group.MapPost("/tickets/{id:guid}/reprint", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ReprintKitchenTicketCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ReprintKitchenTicket") + .WithSummary("Prints a kitchen slip again, exactly as it was first sent."); + + return routes; + } +} diff --git a/backend/src/RestaurantPOS.API/Endpoints/OrderEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/OrderEndpoints.cs new file mode 100644 index 0000000..f38e120 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/OrderEndpoints.cs @@ -0,0 +1,221 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Orders; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Orders.Commands.AddOrderItems; +using RestaurantPOS.Application.Orders.Commands.CancelOrder; +using RestaurantPOS.Application.Orders.Commands.ChangeOrderItemQuantity; +using RestaurantPOS.Application.Orders.Commands.CompleteOrderPayment; +using RestaurantPOS.Application.Orders.Commands.ConfirmOrder; +using RestaurantPOS.Application.Orders.Commands.CreateOrder; +using RestaurantPOS.Application.Orders.Commands.CreateTable; +using RestaurantPOS.Application.Orders.Commands.RemoveOrderItem; +using RestaurantPOS.Application.Orders.Commands.ReopenOrder; +using RestaurantPOS.Application.Orders.Commands.ReprintReceipt; +using RestaurantPOS.Application.Orders.Commands.SetOrderDiscount; +using RestaurantPOS.Application.Orders.Commands.SetTableActive; +using RestaurantPOS.Application.Orders.Commands.StartCheckout; +using RestaurantPOS.Application.Orders.Commands.UpdateTable; +using RestaurantPOS.Application.Orders.Queries.GetOrderById; +using RestaurantPOS.Application.Orders.Queries.GetOrders; +using RestaurantPOS.Application.Orders.Queries.GetTables; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Tables, orders, payments and receipts — everything the till does. +public static class OrderEndpoints +{ + public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var tables = routes.MapGroup("/tables") + .WithTags("Tables") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.PosBilling)); + + MapTables(tables); + + var orders = routes.MapGroup("/orders") + .WithTags("Orders") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.PosBilling)); + + MapOrders(orders); + MapOrderItems(orders); + MapCheckout(orders); + + return routes; + } + + private static void MapTables(RouteGroupBuilder group) + { + group.MapGet("/", async (bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetTablesQuery(isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetTables") + .WithSummary("The floor plan, each table with whatever order is sitting on it."); + + group.MapPost("/", async (CreateTableRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new CreateTableCommand(request.Number, request.Seats, request.Notes), ct); + return result.ToCreatedResult(t => $"/api/v1/tables/{t.Id}"); + }) + .WithName("CreateTable") + .WithSummary("Adds a table to the floor plan."); + + group.MapPut("/{id:guid}", async ( + Guid id, UpdateTableRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateTableCommand(id, request.Number, request.Seats, request.Notes); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateTable") + .WithSummary("Renames a table or changes its seat count."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetTableActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetTableActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetTableActive") + .WithSummary("Takes a table in or out of service."); + } + + private static void MapOrders(RouteGroupBuilder group) + { + group.MapGet("/", async ( + bool? openOnly, OrderStatus? status, string? search, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetOrdersQuery(openOnly ?? false, status, search), ct); + return result.ToHttpResult(); + }) + .WithName("GetOrders") + .WithSummary("Lists orders, optionally only those still holding a table."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetOrderByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetOrderById") + .WithSummary("Loads one bill in full."); + + group.MapPost("/", async (CreateOrderRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new CreateOrderCommand(request.TableId), ct); + return result.ToCreatedResult(o => $"/api/v1/orders/{o.Id}"); + }) + .WithName("CreateOrder") + .WithSummary("Opens a draft bill on a table."); + + group.MapPost("/{id:guid}/confirm", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ConfirmOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ConfirmOrder") + .WithSummary("Saves the order, numbers it and returns the KOT to print."); + + group.MapPost("/{id:guid}/cancel", async ( + Guid id, CancelOrderRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new CancelOrderCommand(id, request.Pin, request.Reason), ct); + return result.ToHttpResult(); + }) + .WithName("CancelOrder") + .WithSummary("Abandons an order without payment. Needs an approval PIN once confirmed."); + + group.MapPut("/{id:guid}/discount", async ( + Guid id, SetOrderDiscountRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetOrderDiscountCommand(id, request.Type, request.Value), ct); + return result.ToHttpResult(); + }) + .WithName("SetOrderDiscount") + .WithSummary("Applies money off the bill."); + } + + private static void MapOrderItems(RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/items", async ( + Guid id, AddOrderItemsRequest request, ISender sender, CancellationToken ct) => + { + var command = new AddOrderItemsCommand( + id, + [.. request.Items.Select(i => new AddOrderItemInput(i.MenuItemId, i.Quantity, i.SpecialInstructions))]); + + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("AddOrderItems") + .WithSummary("Adds dishes to a bill. Never needs approval; prints a KOT once open."); + + group.MapPut("/{id:guid}/items/{itemId:guid}/quantity", async ( + Guid id, Guid itemId, ChangeOrderItemQuantityRequest request, ISender sender, CancellationToken ct) => + { + var command = new ChangeOrderItemQuantityCommand(id, itemId, request.Quantity, request.Pin); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("ChangeOrderItemQuantity") + .WithSummary("Changes how many of a dish were ordered. Needs an approval PIN once open."); + + // POST rather than DELETE: the PIN has to travel with the request, and a DELETE body is + // both awkward to send and liable to be dropped in transit. On an open order this voids + // the line rather than erasing it, so "void" is the truer verb in any case. + group.MapPost("/{id:guid}/items/{itemId:guid}/void", async ( + Guid id, Guid itemId, RemoveOrderItemRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new RemoveOrderItemCommand(id, itemId, request.Pin), ct); + return result.ToHttpResult(); + }) + .WithName("VoidOrderItem") + .WithSummary("Takes a dish off a bill. Needs an approval PIN once open."); + } + + private static void MapCheckout(RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/checkout", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new StartCheckoutCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("StartCheckout") + .WithSummary("Freezes the bill and moves to the payment screen."); + + group.MapPost("/{id:guid}/reopen", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ReopenOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ReopenOrder") + .WithSummary("Backs out of the payment screen so more items can be added."); + + group.MapPost("/{id:guid}/payments", async ( + Guid id, CompleteOrderPaymentRequest request, ISender sender, CancellationToken ct) => + { + var command = new CompleteOrderPaymentCommand( + id, + [.. request.Payments.Select(p => new OrderPaymentInput( + p.Method, p.Amount, p.TenderedAmount, p.Reference))]); + + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("CompleteOrderPayment") + .WithSummary("Settles the bill, issues the receipt and releases the table."); + + group.MapPost("/{id:guid}/receipt/reprint", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ReprintReceiptCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ReprintReceipt") + .WithSummary("Prints a settled bill's receipt again."); + } +} diff --git a/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs new file mode 100644 index 0000000..84ea4a7 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/RecipeEndpoints.cs @@ -0,0 +1,113 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Recipes; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Recipes.Commands.CreateMenuItem; +using RestaurantPOS.Application.Recipes.Commands.DeleteRecipe; +using RestaurantPOS.Application.Recipes.Commands.SetMenuItemActive; +using RestaurantPOS.Application.Recipes.Commands.SetRecipeEnabled; +using RestaurantPOS.Application.Recipes.Commands.UpdateMenuItem; +using RestaurantPOS.Application.Recipes.Commands.UpsertRecipe; +using RestaurantPOS.Application.Recipes.Queries.GetMenuItemById; +using RestaurantPOS.Application.Recipes.Queries.GetMenuItems; +using RestaurantPOS.Application.Recipes.Queries.GetRecipeByMenuItem; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Menu items and the recipe (bill of materials) attached to each one. +public static class RecipeEndpoints +{ + public static IEndpointRouteBuilder MapRecipeEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/menu-items") + .WithTags("Recipes") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.RecipeManagement)); + + group.MapGet("/", async ( + string? search, string? category, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetMenuItemsQuery(search, category, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetMenuItems") + .WithSummary("Lists menu items, optionally filtered."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetMenuItemByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetMenuItemById") + .WithSummary("Loads a single menu item."); + + group.MapPost("/", async (CreateMenuItemRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateMenuItemCommand(request.Name, request.Category, request.Price); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(item => $"/api/v1/menu-items/{item.Id}"); + }) + .WithName("CreateMenuItem") + .WithSummary("Creates a menu item."); + + group.MapPut("/{id:guid}", async ( + Guid id, UpdateMenuItemRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateMenuItemCommand(id, request.Name, request.Category, request.Price); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateMenuItem") + .WithSummary("Updates a menu item's name, category and price."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetMenuItemActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetMenuItemActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetMenuItemActive") + .WithSummary("Activates or deactivates a menu item."); + + group.MapGet("/{id:guid}/recipe", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetRecipeByMenuItemQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetRecipeByMenuItem") + .WithSummary("Displays every ingredient associated with a menu item, or null if it has no recipe."); + + group.MapPut("/{id:guid}/recipe", async ( + Guid id, UpsertRecipeRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpsertRecipeCommand( + id, [.. request.Lines.Select(l => new RecipeLineInput(l.RawMaterialId, l.Quantity))]); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpsertRecipe") + .WithSummary("Creates the menu item's recipe, or replaces its lines if one already exists."); + + group.MapPut("/{id:guid}/recipe/status", async ( + Guid id, SetRecipeEnabledRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetRecipeEnabledCommand(id, request.IsEnabled), ct); + return result.ToHttpResult(); + }) + .WithName("SetRecipeEnabled") + .WithSummary("Enables or disables a recipe. A disabled recipe cannot be used for a new sale."); + + group.MapDelete("/{id:guid}/recipe", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new DeleteRecipeCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("DeleteRecipe") + .WithSummary("Removes a menu item's recipe. The change is kept in the audit log."); + + return routes; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs new file mode 100644 index 0000000..98b2328 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/SupplierEndpoints.cs @@ -0,0 +1,230 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Suppliers; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Suppliers.Commands.CancelPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.ConfirmPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.CreatePurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.CreateSupplier; +using RestaurantPOS.Application.Suppliers.Commands.RecordSupplierPayment; +using RestaurantPOS.Application.Suppliers.Commands.SetSupplierActive; +using RestaurantPOS.Application.Suppliers.Commands.SetSupplierPrice; +using RestaurantPOS.Application.Suppliers.Commands.SubmitPurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.UpdatePurchaseOrder; +using RestaurantPOS.Application.Suppliers.Commands.UpdateSupplier; +using RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrderById; +using RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrders; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPayments; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPerformance; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPriceHistory; +using RestaurantPOS.Application.Suppliers.Queries.GetSupplierPrices; +using RestaurantPOS.Application.Suppliers.Queries.GetSuppliers; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Supplier master data, purchase orders, pricing, payments and performance. +public static class SupplierEndpoints +{ + public static IEndpointRouteBuilder MapSupplierEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + // Reading the supplier list is also needed by Main Store staff picking a supplier on a + // GRN, so it is mapped separately under the more permissive SupplierLookup policy rather + // than the SupplierManagement-only group below. + routes.MapGet("/suppliers", async (string? search, bool? isActive, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSuppliersQuery(search, isActive), ct); + return result.ToHttpResult(); + }) + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.SupplierLookup) + .WithName("GetSuppliers") + .WithSummary("Lists suppliers, optionally filtered."); + + var group = routes.MapGroup("/suppliers") + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.SupplierManagement)); + + MapSupplierCrud(group); + MapPricing(group); + MapPerformance(group); + + var orders = routes.MapGroup("/purchase-orders") + .WithTags("Suppliers") + .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.SupplierManagement)); + + MapPurchaseOrders(orders); + MapPayments(orders); + + return routes; + } + + private static void MapSupplierCrud(RouteGroupBuilder group) + { + group.MapPost("/", async (CreateSupplierRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateSupplierCommand( + request.Name, request.ContactName, request.Phone, request.Email, request.Address, + request.PaymentTermsDays, request.CreditLimit, request.LeadTimeDays); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(s => $"/api/v1/suppliers/{s.Id}"); + }) + .WithName("CreateSupplier") + .WithSummary("Creates a supplier."); + + group.MapPut("/{id:guid}", async (Guid id, UpdateSupplierRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdateSupplierCommand( + id, request.Name, request.ContactName, request.Phone, request.Email, request.Address, + request.PaymentTermsDays, request.CreditLimit, request.LeadTimeDays); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateSupplier") + .WithSummary("Updates a supplier's details, contact and terms."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, SetSupplierActiveRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetSupplierActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetSupplierActive") + .WithSummary("Activates or deactivates a supplier."); + } + + private static void MapPricing(RouteGroupBuilder group) + { + group.MapGet("/prices", async (Guid? supplierId, Guid? rawMaterialId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPricesQuery(supplierId, rawMaterialId), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPrices") + .WithSummary("Current prices, filterable by supplier (a price list) or raw material (a comparison)."); + + group.MapPut("/{id:guid}/prices", async ( + Guid id, SetSupplierPriceRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SetSupplierPriceCommand(id, request.RawMaterialId, request.Price), ct); + return result.ToHttpResult(); + }) + .WithName("SetSupplierPrice") + .WithSummary("Records the current price a supplier charges for a raw material, keeping the change in history."); + + group.MapGet("/{id:guid}/prices/{rawMaterialId:guid}/history", async ( + Guid id, Guid rawMaterialId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPriceHistoryQuery(id, rawMaterialId), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPriceHistory") + .WithSummary("Every price a supplier has been recorded as charging for a raw material, newest first."); + } + + private static void MapPerformance(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}/performance", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPerformanceQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetSupplierPerformance") + .WithSummary("On-time delivery, average lead time, quality rating and issue count for a supplier."); + } + + private static void MapPurchaseOrders(RouteGroupBuilder group) + { + group.MapGet("/", async ( + Guid? supplierId, PurchaseOrderStatus? status, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetPurchaseOrdersQuery(supplierId, status), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrders") + .WithSummary("Lists purchase orders, optionally filtered by supplier or status."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetPurchaseOrderByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrderById") + .WithSummary("Loads a single purchase order with its lines and payment balance."); + + group.MapPost("/", async (CreatePurchaseOrderRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreatePurchaseOrderCommand( + request.SupplierId, + [.. request.Lines.Select(l => new PurchaseOrderLineInput(l.RawMaterialId, l.Quantity, l.UnitPrice))], + request.ExpectedDeliveryDate, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToCreatedResult(o => $"/api/v1/purchase-orders/{o.Id}"); + }) + .WithName("CreatePurchaseOrder") + .WithSummary("Creates a new purchase order in Draft."); + + group.MapPut("/{id:guid}", async (Guid id, UpdatePurchaseOrderRequest request, ISender sender, CancellationToken ct) => + { + var command = new UpdatePurchaseOrderCommand( + id, + [.. request.Lines.Select(l => new UpdatePurchaseOrderLineInput(l.RawMaterialId, l.Quantity, l.UnitPrice))], + request.ExpectedDeliveryDate, + request.Notes); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdatePurchaseOrder") + .WithSummary("Replaces a draft purchase order's lines and details."); + + group.MapPost("/{id:guid}/submit", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new SubmitPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("SubmitPurchaseOrder") + .WithSummary("Sends a draft purchase order to its supplier."); + + group.MapPost("/{id:guid}/confirm", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ConfirmPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("ConfirmPurchaseOrder") + .WithSummary("Records that the supplier has agreed to fulfil the order."); + + group.MapPost("/{id:guid}/cancel", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new CancelPurchaseOrderCommand(id), ct); + return result.ToHttpResult(); + }) + .WithName("CancelPurchaseOrder") + .WithSummary("Cancels a purchase order that has not yet been delivered."); + } + + private static void MapPayments(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}/payments", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetSupplierPaymentsQuery(id, null), ct); + return result.ToHttpResult(); + }) + .WithName("GetPurchaseOrderPayments") + .WithSummary("Payments recorded against a purchase order."); + + group.MapPost("/{id:guid}/payments", async ( + Guid id, RecordSupplierPaymentRequest request, ISender sender, CancellationToken ct) => + { + var command = new RecordSupplierPaymentCommand( + id, request.Amount, request.PaymentDateUtc, request.Method, request.InvoiceReference, request.Notes); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("RecordSupplierPayment") + .WithSummary("Records a payment toward a purchase order."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..5745098 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs @@ -0,0 +1,133 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Users; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Users.Commands.CreateUser; +using RestaurantPOS.Application.Users.Commands.ResetUserPassword; +using RestaurantPOS.Application.Users.Commands.SetUserActive; +using RestaurantPOS.Application.Users.Commands.UpdateUser; +using RestaurantPOS.Application.Users.Queries.GetModules; +using RestaurantPOS.Application.Users.Queries.GetUserById; +using RestaurantPOS.Application.Users.Queries.GetUsers; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Staff account administration. Every endpoint here requires the Admin role. +public static class UserEndpoints +{ + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/users") + .WithTags("Users") + // Creating accounts and assigning modules is itself an administrative power, so it + // is gated on the role rather than on a grantable module. + .RequireAuthorization(AuthorizationPolicies.AdminOnly); + + group.MapGet("/", async ( + string? search, + UserRole? role, + bool? isActive, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new GetUsersQuery(search, role, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetUsers") + .WithSummary("Lists staff accounts, optionally filtered."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetUserByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetUserById") + .WithSummary("Loads a single staff account."); + + group.MapPost("/", async (CreateUserRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateUserCommand( + request.Username, + request.FullName, + request.Email, + request.Password, + request.Role, + request.Modules); + + var result = await sender.Send(command, ct); + return result.ToCreatedResult(user => $"/api/v1/users/{user.Id}"); + }) + .WithName("CreateUser") + .WithSummary("Creates a staff account that must change its password at first sign-in."); + + group.MapPut("/{id:guid}", async ( + Guid id, + UpdateUserRequest request, + ISender sender, + CancellationToken ct) => + { + var command = new UpdateUserCommand( + id, + request.FullName, + request.Email, + request.Role, + request.Modules); + + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateUser") + .WithSummary("Updates a staff account's profile, role and module grants."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, + SetUserActiveRequest request, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new SetUserActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetUserActive") + .WithSummary("Activates or deactivates a staff account."); + + group.MapPost("/{id:guid}/password", async ( + Guid id, + ResetUserPasswordRequest request, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new ResetUserPasswordCommand(id, request.NewPassword), ct); + return result.ToHttpResult(); + }) + .WithName("ResetUserPassword") + .WithSummary("Sets a temporary password the user must then change."); + + return routes; + } + + /// + /// Exposes the module catalog. Readable by any signed-in user because the client builds its + /// navigation from it; the list itself is not sensitive. + /// + public static IEndpointRouteBuilder MapModuleEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + routes.MapGet("/modules", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetModulesQuery(), ct); + return result.ToHttpResult(); + }) + .WithTags("Modules") + .RequireAuthorization() + .WithName("GetModules") + .WithSummary("Returns every module that can appear in navigation or be granted."); + + return routes; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs b/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..490d56b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Threading.RateLimiting; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using RestaurantPOS.API.Endpoints; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Infrastructure.Identity; + +namespace RestaurantPOS.API.Extensions; + +/// Wires up bearer authentication, the module policies and abuse protection. +public static class AuthenticationExtensions +{ + public static IServiceCollection AddApiAuthentication(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpContextAccessor(); + services.AddScoped(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // The signing key and issuer settings come from the same options the token + // service issues with, so the two can never disagree. + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + // A till and the server share a clock, so there is no reason to accept + // tokens past their stated expiry. + ClockSkew = TimeSpan.Zero, + }; + }); + + // Bound late so the signing key provider (a singleton) is resolved from the container + // rather than constructed twice. + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure, JwtSigningKeyProvider>((bearer, jwtOptions, keyProvider) => + { + bearer.TokenValidationParameters.ValidIssuer = jwtOptions.Value.Issuer; + bearer.TokenValidationParameters.ValidAudience = jwtOptions.Value.Audience; + bearer.TokenValidationParameters.IssuerSigningKey = keyProvider.SecurityKey; + }); + + services.AddAuthorizationBuilder() + .AddAppPolicies() + // Nothing is public unless it opts out with AllowAnonymous. + .SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + + return services; + } + + /// + /// Throttles endpoints that accept a guessable secret. The 4-digit approval PIN has only + /// ten thousand combinations, so without this an attacker at an authenticated till could + /// simply enumerate it. + /// + public static IServiceCollection AddApiRateLimiting(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRateLimiter(options => + { + options.AddPolicy(AuthEndpoints.SensitiveRateLimitPolicy, context => + RateLimitPartition.GetFixedWindowLimiter( + // Partition by user when signed in, otherwise by address, so one till + // hammering the PIN cannot lock out the others. + partitionKey: context.User.Identity?.Name + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); + + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.HttpContext.Response.ContentType = "application/problem+json"; + + var problem = new ProblemDetails + { + Title = "Too many attempts. Please wait a moment and try again.", + Status = StatusCodes.Status429TooManyRequests, + Extensions = { ["code"] = "Auth.TooManyAttempts" }, + }; + + await context.HttpContext.Response.WriteAsync( + JsonSerializer.Serialize(problem, ProblemJsonOptions), + cancellationToken); + }; + }); + + return services; + } + + private static readonly JsonSerializerOptions ProblemJsonOptions = + new(JsonSerializerDefaults.Web); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs b/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..8ef0f82 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs @@ -0,0 +1,57 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.API.Extensions; + +/// +/// Translates the application layer's into HTTP responses, so endpoints +/// never decide status codes for themselves and every failure looks the same on the wire. +/// +public static class ResultExtensions +{ + /// Maps a valueless result to 204 No Content, or an RFC 7807 problem on failure. + public static IResult ToHttpResult(this Result result) + { + ArgumentNullException.ThrowIfNull(result); + + return result.IsSuccess ? Results.NoContent() : Problem(result.Error); + } + + /// Maps a result to 200 OK with its value, or an RFC 7807 problem on failure. + public static IResult ToHttpResult(this Result result) + { + ArgumentNullException.ThrowIfNull(result); + + return result.IsSuccess ? Results.Ok(result.Value) : Problem(result.Error); + } + + /// Maps a successful result to 201 Created at . + public static IResult ToCreatedResult(this Result result, Func location) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(location); + + return result.IsSuccess + ? Results.Created(location(result.Value), result.Value) + : Problem(result.Error); + } + + private static IResult Problem(Error error) + { + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Problem( + title: error.Description, + statusCode: statusCode, + // The stable machine-readable code lets the client branch on a specific failure + // (for example, routing to the password-reset screen) without matching on prose. + extensions: new Dictionary { ["code"] = error.Code }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs b/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs index 888e25a..dbc5fc3 100644 --- a/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs +++ b/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs @@ -1,8 +1,13 @@ -using System.Net; -using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +using RestaurantPOS.Application.Common.Exceptions; namespace RestaurantPOS.API.Middleware; +/// +/// Converts unhandled exceptions into RFC 7807 problem responses, matching the shape produced +/// by so clients only parse one error format. +/// public class GlobalExceptionMiddleware { private readonly RequestDelegate _next; @@ -19,24 +24,46 @@ public GlobalExceptionMiddleware(RequestDelegate next, ILogger e.Key, e => e.Value, StringComparer.Ordinal)) + { + Title = "One or more validation errors occurred.", + Status = StatusCodes.Status400BadRequest, + }); + } catch (Exception ex) { LogUnhandledException(_logger, "Unhandled exception occurred", ex); - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - - var response = new + await WriteProblemAsync(context, new ProblemDetails { - statusCode = context.Response.StatusCode, - message = "An unexpected error occurred. Please try again later." - }; + Title = "An unexpected error occurred. Please try again later.", + Status = StatusCodes.Status500InternalServerError, + }); + } + } - await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + private static async Task WriteProblemAsync(HttpContext context, ProblemDetails problem) + { + if (context.Response.HasStarted) + { + // Too late to change the response; swallowing here avoids masking the original error. + return; } + + context.Response.Clear(); + context.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(problem, problem.GetType()); } } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs b/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs new file mode 100644 index 0000000..2065840 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; + +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Common.Security; + +namespace RestaurantPOS.API.Middleware; + +/// +/// Confines a user who has not yet chosen their own password to the password-change flow. +/// +/// +/// The frontend also routes such users straight to the reset screen, but enforcing it here as +/// well means a temporary password issued by an administrator cannot be used to do real work +/// by calling the API directly. +/// +public sealed class PasswordChangeRequiredMiddleware(RequestDelegate next) +{ + /// Machine-readable code the client keys on to show the reset screen. + public const string ErrorCode = "Auth.PasswordChangeRequired"; + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var mustChange = context.User.HasClaim(AppClaimTypes.MustChangePassword, "true"); + + var endpointIsExempt = context.GetEndpoint()?.Metadata + .GetMetadata() is not null; + + if (!mustChange || endpointIsExempt) + { + await next(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "You must choose a new password before continuing.", + Status = StatusCodes.Status403Forbidden, + Extensions = { ["code"] = ErrorCode }, + }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Program.cs b/backend/src/RestaurantPOS.API/Program.cs index ac0e2c1..48739cf 100644 --- a/backend/src/RestaurantPOS.API/Program.cs +++ b/backend/src/RestaurantPOS.API/Program.cs @@ -1,7 +1,14 @@ +using System.Text.Json.Serialization; + using Asp.Versioning; + +using RestaurantPOS.API.Endpoints; +using RestaurantPOS.API.Extensions; using RestaurantPOS.API.Middleware; using RestaurantPOS.Application; using RestaurantPOS.Infrastructure; +using RestaurantPOS.Infrastructure.Persistence.Seeding; + using Serilog; #pragma warning disable CA1305 @@ -20,18 +27,29 @@ var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog(); - // CORS policy setup for frontend apps (Electron/React) + // The Electron renderer loads from file:// (origin "null") in production and from + // localhost in development, so origins are configurable rather than hard-coded. + var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + builder.Services.AddCors(options => { - options.AddPolicy("AllowAll", policy => + options.AddPolicy("PosClient", policy => { - policy.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins); + } + else + { + policy.SetIsOriginAllowed(_ => true); + } + + policy.AllowAnyHeader().AllowAnyMethod(); }); }); - // API Versioning builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); @@ -44,14 +62,28 @@ options.SubstituteApiVersionInUrl = true; }); - // Add Layer DI builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); + builder.Services.AddApiAuthentication(); + builder.Services.AddApiRateLimiting(); + + // Enums cross the wire as their names ("Admin", "PosBilling") rather than as integers, so + // the client never has to mirror numeric values and payloads stay readable in logs. + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); var app = builder.Build(); + // Bring the database up to date and guarantee an administrator exists before serving. + await using (var scope = app.Services.CreateAsyncScope()) + { + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(); + } + app.UseMiddleware(); if (app.Environment.IsDevelopment()) @@ -59,20 +91,50 @@ app.MapOpenApi(); } - app.UseCors("AllowAll"); - app.UseHttpsRedirection(); + app.UseCors("PosClient"); + app.UseRateLimiter(); + + app.UseAuthentication(); + app.UseAuthorization(); + + // Runs after authentication so it can read the claim, and after authorization so an + // anonymous caller gets a 401 rather than this middleware's 403. + app.UseMiddleware(); + + var versionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1, 0)) + .ReportApiVersions() + .Build(); - app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow })); + var api = app.MapGroup("/api/v{version:apiVersion}").WithApiVersionSet(versionSet); - app.Run(); + api.MapAuthEndpoints(); + api.MapUserEndpoints(); + api.MapModuleEndpoints(); + api.MapRecipeEndpoints(); + api.MapInventoryEndpoints(); + api.MapSupplierEndpoints(); + api.MapOrderEndpoints(); + api.MapKitchenEndpoints(); + + app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow })) + .AllowAnonymous() + .WithTags("Diagnostics"); + + await app.RunAsync(); } -catch (Exception ex) +// HostAbortedException is how the EF Core design-time tools stop the host after building the +// service provider; it is normal control flow, not a crash. +catch (Exception ex) when (ex is not HostAbortedException) { Log.Fatal(ex, "RestaurantPOS API terminated unexpectedly"); + + // Rethrow so the process exits non-zero and a service manager notices the failure. + throw; } finally { - Log.CloseAndFlush(); + await Log.CloseAndFlushAsync(); } public partial class Program { } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj b/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj index 94418c4..8bef238 100644 --- a/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj +++ b/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj @@ -18,6 +18,7 @@ + diff --git a/backend/src/RestaurantPOS.API/RestaurantPOS.API.http b/backend/src/RestaurantPOS.API/RestaurantPOS.API.http index e34aaad..4e3b093 100644 --- a/backend/src/RestaurantPOS.API/RestaurantPOS.API.http +++ b/backend/src/RestaurantPOS.API/RestaurantPOS.API.http @@ -1,6 +1,62 @@ @RestaurantPOS.API_HostAddress = http://localhost:5207 +@api = {{RestaurantPOS.API_HostAddress}}/api/v1 -GET {{RestaurantPOS.API_HostAddress}}/weatherforecast/ +### Health check +GET {{RestaurantPOS.API_HostAddress}}/health Accept: application/json -### +### Sign in as the seeded administrator (see appsettings.json > SeedAdmin) +# @name login +POST {{api}}/auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "ChangeMe!123" +} + +### Change password (required on first sign-in — swap in the accessToken from login above) +POST {{api}}/auth/change-password +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "currentPassword": "ChangeMe!123", + "newPassword": "Lakshmi@2026" +} + +### Who am I +GET {{api}}/auth/me +Authorization: Bearer {{login.response.body.accessToken}} + +### Module catalog +GET {{api}}/modules +Authorization: Bearer {{login.response.body.accessToken}} + +### List staff accounts +GET {{api}}/users +Authorization: Bearer {{login.response.body.accessToken}} + +### Create a staff account +POST {{api}}/users +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "username": "cashier01", + "fullName": "Ravi Kumar", + "email": null, + "password": "Cashier@2026", + "role": "User", + "modules": ["PosBilling", "KitchenOperations"] +} + +### Set the administrator's approval PIN +POST {{api}}/auth/pin +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "currentPassword": "Lakshmi@2026", + "pin": null +} diff --git a/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs b/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs new file mode 100644 index 0000000..b64652b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs @@ -0,0 +1,8 @@ +namespace RestaurantPOS.API.Security; + +/// +/// Marks an endpoint as reachable by a user who still owes a password change. Applied to the +/// handful of endpoints the reset screen itself needs. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class AllowPendingPasswordChangeAttribute : Attribute; \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs new file mode 100644 index 0000000..269a741 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Authorization; + +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.API.Security; + +/// +/// Named authorization policies. One is registered per module so endpoints can simply say +/// which module they belong to. +/// +public static class AuthorizationPolicies +{ + /// Requires the Admin role. + public const string AdminOnly = "role:admin"; + + /// + /// Reading the supplier list is needed by Main Store staff picking a supplier on a GRN, not + /// just by Supplier Management itself — so it accepts either grant, unlike every other + /// supplier endpoint (create/edit supplier, purchase orders, pricing, payments), which stay + /// -only. + /// + public const string SupplierLookup = "suppliers:read"; + + /// Builds the policy name guarding . + public static string ForModule(AppModule module) => $"module:{module}"; + + /// Registers the admin policy plus one policy per catalog module. + public static AuthorizationBuilder AddAppPolicies(this AuthorizationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddPolicy(AdminOnly, policy => policy.RequireRole(nameof(UserRole.Admin))); + + builder.AddPolicy(SupplierLookup, policy => + policy.RequireAssertion(context => + context.User.IsInRole(nameof(UserRole.Admin)) || + context.User.HasClaim(AppClaimTypes.Module, AppModule.SupplierManagement.ToString()) || + context.User.HasClaim(AppClaimTypes.Module, AppModule.StoreStockManagement.ToString()))); + + foreach (var descriptor in ModuleCatalog.All) + { + var module = descriptor.Module; + + builder.AddPolicy(ForModule(module), policy => + policy.RequireAssertion(context => + // Administrators hold every module implicitly, so their tokens carry no + // module claims; everyone else needs the specific grant. + context.User.IsInRole(nameof(UserRole.Admin)) || + context.User.HasClaim(AppClaimTypes.Module, module.ToString()))); + } + + return builder; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs b/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs new file mode 100644 index 0000000..efde737 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Security; + +/// +/// Reads the acting user out of the current request's principal. +/// +/// +/// Lives in the API project rather than infrastructure because it depends on +/// , which is a web concern. +/// +internal sealed class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser +{ + public Guid? UserId => + Guid.TryParse(Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + + public string? Username => Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAdmin => Principal?.IsInRole(nameof(UserRole.Admin)) ?? false; + + private ClaimsPrincipal? Principal => accessor.HttpContext?.User; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/appsettings.json b/backend/src/RestaurantPOS.API/appsettings.json index 39e398d..939b6d7 100644 --- a/backend/src/RestaurantPOS.API/appsettings.json +++ b/backend/src/RestaurantPOS.API/appsettings.json @@ -2,6 +2,21 @@ "ConnectionStrings": { "DefaultConnection": "Data Source=restaurantpos.db" }, + "Jwt": { + "Issuer": "RestaurantPOS", + "Audience": "RestaurantPOS.Client", + "SigningKey": "", + "AccessTokenMinutes": 60, + "RefreshTokenDays": 14 + }, + "SeedAdmin": { + "Username": "admin", + "FullName": "System Administrator", + "Password": "ChangeMe!123" + }, + "Cors": { + "AllowedOrigins": [] + }, "Logging": { "LogLevel": { "Default": "Information", @@ -9,4 +24,4 @@ } }, "AllowedHosts": "*" -} \ No newline at end of file +} diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..d6a23aa --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,82 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.ChangePassword; + +/// +/// Changes the signed-in user's own password. Also the screen shown to accounts flagged +/// MustChangePassword — the seeded administrator and anyone an admin has just created +/// or reset. +/// +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) + : IRequest>; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Your current password is required."); + RuleFor(x => x.NewPassword).MustMeetPasswordPolicy(); + } +} + +internal sealed class ChangePasswordCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IPasswordHasher passwordHasher, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + ChangePasswordCommand request, + CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordMismatch); + } + + if (passwordHasher.Verify(request.NewPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordReused); + } + + user.SetPassword(passwordHasher.Hash(request.NewPassword)); + + // Every other session is invalidated, then a fresh one is issued to this caller so the + // user stays signed in on the device that made the change. + user.RevokeAllRefreshTokens(clock.UtcNow); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs new file mode 100644 index 0000000..4dc6cfa --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.ClearApprovalPin; + +/// Removes the signed-in administrator's approval PIN. +public sealed record ClearApprovalPinCommand : IRequest; + +internal sealed class ClearApprovalPinCommandHandler( + IAppDbContext db, + ICurrentUser currentUser) + : IRequestHandler +{ + public async Task Handle(ClearApprovalPinCommand request, CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!user.HasApprovalPin) + { + return Result.Failure(AuthErrors.PinNotSet); + } + + user.ClearApprovalPin(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs new file mode 100644 index 0000000..2201568 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs @@ -0,0 +1,72 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.Login; + +/// Exchanges a username and password for a session. +public sealed record LoginCommand(string Username, string Password) : IRequest>; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required."); + RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required."); + } +} + +internal sealed class LoginCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + LoginCommand request, + CancellationToken cancellationToken) + { + var username = request.Username.Trim().ToLowerInvariant(); + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Username == username, cancellationToken); + + if (user is null) + { + // Burn a comparable amount of time so an unknown username is not measurably + // faster than a wrong password, which would allow username enumeration. + passwordHasher.Hash(request.Password); + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!passwordHasher.Verify(request.Password, user.PasswordHash)) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + // Checked only after the password so a deactivated account is not distinguishable + // from a wrong password to someone who does not know the credentials. + if (!user.IsActive) + { + return Result.Failure(AuthErrors.AccountDeactivated); + } + + user.RecordSuccessfulLogin(clock.UtcNow); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs new file mode 100644 index 0000000..e31fd14 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Authentication.Commands.Logout; + +/// +/// Ends a session by revoking its refresh token. Succeeds even for an unknown token so that +/// signing out is always safe to call and never leaks whether a token was real. +/// +public sealed record LogoutCommand(string? RefreshToken) : IRequest; + +internal sealed class LogoutCommandHandler( + IAppDbContext db, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(LogoutCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return Result.Success(); + } + + var hash = tokens.HashRefreshToken(request.RefreshToken); + + var user = await db.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.TokenHash == hash), cancellationToken); + + var token = user?.FindActiveRefreshToken(hash, clock.UtcNow); + if (user is not null && token is not null) + { + user.RevokeRefreshToken(token, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs new file mode 100644 index 0000000..e9c14b9 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs @@ -0,0 +1,69 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.RefreshSession; + +/// Trades a valid refresh token for a new access/refresh pair. +public sealed record RefreshSessionCommand(string RefreshToken) : IRequest>; + +public sealed class RefreshSessionCommandValidator : AbstractValidator +{ + public RefreshSessionCommandValidator() => + RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("A refresh token is required."); +} + +internal sealed class RefreshSessionCommandHandler( + IAppDbContext db, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + RefreshSessionCommand request, + CancellationToken cancellationToken) + { + var hash = tokens.HashRefreshToken(request.RefreshToken); + var now = clock.UtcNow; + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.TokenHash == hash), cancellationToken); + + if (user is null) + { + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + var existing = user.FindActiveRefreshToken(hash, now); + if (existing is null) + { + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + if (!user.IsActive) + { + // Revoke outstanding sessions so a deactivated account cannot keep refreshing. + user.RevokeAllRefreshTokens(now); + await db.SaveChangesAsync(cancellationToken); + return Result.Failure(AuthErrors.AccountDeactivated); + } + + // Rotation: the presented token is burned as the replacement is issued. + user.RevokeRefreshToken(existing, now); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs new file mode 100644 index 0000000..c28c10f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Security.Cryptography; + +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; + +/// +/// Sets the signed-in administrator's 4-digit approval PIN, used to authorise privileged +/// actions taken at the till such as cancelling an order. +/// +/// Re-authenticates the admin before the PIN is changed. +/// A chosen PIN, or null to have the server generate a random one. +public sealed record SetApprovalPinCommand(string CurrentPassword, string? Pin) + : IRequest>; + +/// +/// The PIN in plaintext. Returned exactly once, at the moment it is set — only the hash is +/// stored, so it cannot be read back afterwards. +/// +public sealed record SetApprovalPinResult(string Pin); + +public sealed class SetApprovalPinCommandValidator : AbstractValidator +{ + public SetApprovalPinCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Your current password is required."); + + RuleFor(x => x.Pin) + .Must(pin => pin!.Length == User.ApprovalPinLength && pin.All(char.IsAsciiDigit)) + .When(x => x.Pin is not null) + .WithMessage($"The approval PIN must be exactly {User.ApprovalPinLength} digits."); + } +} + +internal sealed class SetApprovalPinCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IPasswordHasher passwordHasher, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + SetApprovalPinCommand request, + CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (user.Role != UserRole.Admin) + { + return Result.Failure(AuthErrors.PinRequiresAdmin); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordMismatch); + } + + var pin = request.Pin ?? GeneratePin(); + + user.SetApprovalPin(passwordHasher.Hash(pin), clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new SetApprovalPinResult(pin)); + } + + private static string GeneratePin() => + RandomNumberGenerator + .GetInt32(0, (int)Math.Pow(10, User.ApprovalPinLength)) + .ToString($"D{User.ApprovalPinLength}", CultureInfo.InvariantCulture); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs new file mode 100644 index 0000000..7ef6e8b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs @@ -0,0 +1,91 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; + +/// +/// Checks a 4-digit PIN against every active administrator, identifying who authorised the +/// action. This is the shared approval gate other modules call — for example, an order +/// cancellation at the till prompts for a PIN and records the approving administrator. +/// +/// The PIN typed by the administrator standing at the till. +/// Free text describing what is being approved, for the audit trail. +public sealed record VerifyApprovalPinCommand(string Pin, string? Reason) + : IRequest>; + +/// Identifies the administrator whose PIN authorised an action. +public sealed record ApprovalResult(Guid ApprovedByUserId, string ApprovedByName, DateTime ApprovedAtUtc); + +public sealed class VerifyApprovalPinCommandValidator : AbstractValidator +{ + public VerifyApprovalPinCommandValidator() => + RuleFor(x => x.Pin) + .Must(pin => !string.IsNullOrEmpty(pin) + && pin.Length == User.ApprovalPinLength + && pin.All(char.IsAsciiDigit)) + .WithMessage($"The approval PIN must be exactly {User.ApprovalPinLength} digits."); +} + +internal sealed class VerifyApprovalPinCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + IDateTimeProvider clock, + ICurrentUser currentUser, + IApprovalPinThrottle throttle) + : IRequestHandler> +{ + public async Task> Handle( + VerifyApprovalPinCommand request, + CancellationToken cancellationToken) + { + // The signed-in user stands in for "this terminal": the restaurant runs one till per + // cashier (BR-POS-020), so their session is the closest thing to a physical terminal. + var terminalKey = currentUser.UserId?.ToString() ?? "anonymous"; + + var state = throttle.Check(terminalKey); + if (state.IsLocked) + { + return Result.Failure(AuthErrors.PinAttemptsExhausted(state.RetryAfter)); + } + + var admins = await db.Users + .Where(u => u.IsActive && u.Role == UserRole.Admin && u.ApprovalPinHash != null) + .Select(u => new { u.Id, u.FullName, u.ApprovalPinHash }) + .ToListAsync(cancellationToken); + + if (admins.Count == 0) + { + return Result.Failure(AuthErrors.NoAdminPinConfigured); + } + + // Every candidate is checked rather than breaking on the first match, so the time taken + // does not reveal which administrator's PIN was supplied. + var matched = admins.Aggregate( + (Id: Guid.Empty, Name: string.Empty, Found: false), + (acc, admin) => passwordHasher.Verify(request.Pin, admin.ApprovalPinHash!) + ? (admin.Id, admin.FullName, true) + : acc); + + if (!matched.Found) + { + var afterFailure = throttle.RecordFailure(terminalKey); + + return Result.Failure(afterFailure.IsLocked + ? AuthErrors.PinAttemptsExhausted(afterFailure.RetryAfter) + : AuthErrors.InvalidPinWithAttemptsLeft(afterFailure.AttemptsRemaining)); + } + + throttle.Reset(terminalKey); + + return Result.Success(new ApprovalResult(matched.Id, matched.Name, clock.UtcNow)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs b/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs new file mode 100644 index 0000000..3b4db3c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs @@ -0,0 +1,34 @@ +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Authentication.Common; + +/// +/// Builds a signed-in session for a user. Shared by sign-in, token refresh and password change +/// so all three issue tokens identically. +/// +internal static class SessionFactory +{ + /// + /// Mints an access/refresh token pair and records the refresh token on the user. The caller + /// is responsible for persisting the change. + /// + public static AuthenticationResult Issue(User user, ITokenService tokens, IDateTimeProvider clock) + { + var access = tokens.CreateAccessToken(user); + var refresh = tokens.CreateRefreshToken(); + + user.IssueRefreshToken(refresh.Hash, refresh.ExpiresAtUtc, clock.UtcNow); + + return new AuthenticationResult + { + AccessToken = access.Value, + AccessTokenExpiresAtUtc = access.ExpiresAtUtc, + RefreshToken = refresh.Value, + RefreshTokenExpiresAtUtc = refresh.ExpiresAtUtc, + User = user.ToDto(), + }; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs b/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs new file mode 100644 index 0000000..93590b6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs @@ -0,0 +1,23 @@ +using RestaurantPOS.Application.Users.Dtos; + +namespace RestaurantPOS.Application.Authentication.Dtos; + +/// Everything a client needs to establish a signed-in session. +public sealed record AuthenticationResult +{ + /// Short-lived signed JWT sent on every subsequent request. + public required string AccessToken { get; init; } + + public required DateTime AccessTokenExpiresAtUtc { get; init; } + + /// Long-lived opaque token used to obtain a new access token. + public required string RefreshToken { get; init; } + + public required DateTime RefreshTokenExpiresAtUtc { get; init; } + + /// + /// The signed-in user. When is true the session is + /// restricted to the password-change endpoints until a new password is chosen. + /// + public required UserDto User { get; init; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs b/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs new file mode 100644 index 0000000..2245044 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs @@ -0,0 +1,39 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Queries.GetCurrentUser; + +/// +/// Returns the signed-in user's profile and effective module access. The frontend calls this +/// on start-up to rebuild its navigation without trusting anything cached on the client. +/// +public sealed record GetCurrentUserQuery : IRequest>; + +internal sealed class GetCurrentUserQueryHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle(GetCurrentUserQuery request, CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + + return user is null + ? Result.Failure(AuthErrors.InvalidCredentials) + : Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs b/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs new file mode 100644 index 0000000..e120776 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Audit/AuditLog.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Audit; + +/// Thin helper so command handlers can write an in one line. +public static class AuditLog +{ + public static void Record( + IAppDbContext db, + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime nowUtc, + string summary, + string? detailsJson = null) + { + ArgumentNullException.ThrowIfNull(db); + + db.AuditLogEntries.Add(AuditLogEntry.Record( + entityType, entityId, action, performedByUserId, performedByName, nowUtc, summary, detailsJson)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..17a34e1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Common.Behaviors; + +/// +/// Records who ran which use case and whether it succeeded. On a single-site install this log +/// is the primary audit trail, so it names the acting user but never the request payload, +/// which would contain passwords and PINs. +/// +public sealed partial class LoggingBehavior( + ILogger> logger, + ICurrentUser currentUser) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var requestName = typeof(TRequest).Name; + var actor = currentUser.Username ?? "anonymous"; + var timer = Stopwatch.StartNew(); + + var response = await next(cancellationToken); + + timer.Stop(); + + if (response is Result { IsFailure: true } failure) + { + RequestFailed(logger, requestName, actor, failure.Error.Code, timer.ElapsedMilliseconds); + } + else + { + RequestSucceeded(logger, requestName, actor, timer.ElapsedMilliseconds); + } + + return response; + } + + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "{RequestName} handled for {Actor} in {ElapsedMs}ms")] + private static partial void RequestSucceeded( + ILogger logger, string requestName, string actor, long elapsedMs); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Warning, + Message = "{RequestName} rejected for {Actor} with {ErrorCode} in {ElapsedMs}ms")] + private static partial void RequestFailed( + ILogger logger, string requestName, string actor, string errorCode, long elapsedMs); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..a5009e3 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,44 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Exceptions; + +namespace RestaurantPOS.Application.Common.Behaviors; + +/// +/// Runs every FluentValidation validator registered for a request before the handler sees it, +/// so handlers can assume well-formed input. +/// +public sealed class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (!validators.Any()) + { + return await next(cancellationToken); + } + + var context = new ValidationContext(request); + + var results = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + return failures.Count > 0 + ? throw new ValidationAppException(failures) + : await next(cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs b/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs new file mode 100644 index 0000000..8b4e730 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; + +using FluentValidation.Results; + +namespace RestaurantPOS.Application.Common.Exceptions; + +/// +/// Raised when request validation fails. The API surfaces this as an RFC 7807 validation +/// problem with one entry per offending field. +/// +public sealed class ValidationAppException : Exception +{ + public ValidationAppException() + : this("One or more validation errors occurred.") + { + } + + public ValidationAppException(string message) + : base(message) + { + Errors = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + public ValidationAppException(string message, Exception innerException) + : base(message, innerException) + { + Errors = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + public ValidationAppException(IEnumerable failures) + : this("One or more validation errors occurred.") + { + ArgumentNullException.ThrowIfNull(failures); + + var grouped = failures + .GroupBy(f => f.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).Distinct(StringComparer.Ordinal).ToArray(), + StringComparer.Ordinal); + + Errors = new ReadOnlyDictionary(grouped); + } + + /// Validation messages keyed by the property that failed. + public IReadOnlyDictionary Errors { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs new file mode 100644 index 0000000..935d57c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Interfaces; + +/// +/// The persistence surface the application layer is allowed to touch. Keeping handlers on +/// this abstraction rather than the concrete AppDbContext preserves the dependency +/// rule enforced by the architecture tests. +/// +public interface IAppDbContext +{ + DbSet Users { get; } + + DbSet UserModulePermissions { get; } + + DbSet RefreshTokens { get; } + + DbSet MenuItems { get; } + + DbSet RawMaterials { get; } + + DbSet Recipes { get; } + + DbSet Suppliers { get; } + + DbSet StockLevels { get; } + + DbSet StockMovements { get; } + + DbSet GoodsReceivedNotes { get; } + + DbSet StockReleases { get; } + + DbSet AuditLogEntries { get; } + + DbSet PurchaseOrders { get; } + + DbSet SupplierPrices { get; } + + DbSet SupplierPriceHistoryEntries { get; } + + DbSet SupplierPayments { get; } + + DbSet RestaurantTables { get; } + + DbSet Orders { get; } + + DbSet OrderItems { get; } + + DbSet KitchenTickets { get; } + + DbSet OrderPayments { get; } + + DbSet Receipts { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IApprovalPinThrottle.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IApprovalPinThrottle.cs new file mode 100644 index 0000000..93f9237 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IApprovalPinThrottle.cs @@ -0,0 +1,28 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// The state of one terminal's approval-PIN attempts. +/// True when PIN entry is paused and no attempt should be checked. +/// How long until entry reopens. Zero unless locked. +/// Tries left before a lockout. Zero once locked. +public readonly record struct ApprovalPinAttemptState(bool IsLocked, TimeSpan RetryAfter, int AttemptsRemaining); + +/// +/// Rate-limits approval-PIN guessing per terminal. +/// +/// +/// Scoped to the signed-in cashier rather than to an administrator, because the PIN is checked +/// against every administrator at once — there is no single account a failed guess belongs to. +/// Pausing the terminal also keeps a mistyped PIN from disabling the manager who was not even +/// standing there, which on a single-admin install would lock the restaurant out of its own till. +/// +public interface IApprovalPinThrottle +{ + /// Reports whether may attempt a PIN right now. + ApprovalPinAttemptState Check(string terminalKey); + + /// Counts a wrong PIN, locking the terminal once the allowance runs out. + ApprovalPinAttemptState RecordFailure(string terminalKey); + + /// Clears the count after a correct PIN. + void Reset(string terminalKey); +} diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..4c705d5 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,12 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Describes the user making the current request. +public interface ICurrentUser +{ + /// The authenticated user's id, or null for anonymous requests. + Guid? UserId { get; } + + string? Username { get; } + + bool IsAdmin { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs new file mode 100644 index 0000000..b2ea4a2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs @@ -0,0 +1,19 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Abstracts the system clock so time-dependent behaviour can be tested. +public interface IDateTimeProvider +{ + DateTime UtcNow { get; } + + /// + /// The restaurant's own date, in the machine's local time. + /// + /// + /// Anything a human reads as "today" has to come from here rather than be derived from + /// . The restaurant runs on Sri Lanka time, five and a half hours ahead of + /// UTC, so a UTC date would roll the daily order sequence over at half past five in the + /// morning — restarting the numbering mid-service and dating a late-evening order to the day + /// before. + /// + DateOnly Today { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..5deee47 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Hashes and verifies passwords and approval PINs. +public interface IPasswordHasher +{ + /// Produces a salted, slow hash suitable for storage. + string Hash(string plaintext); + + /// + /// Verifies a plaintext value against a stored hash in constant time with respect to the + /// hash contents. + /// + bool Verify(string plaintext, string hash); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IRestaurantProfile.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IRestaurantProfile.cs new file mode 100644 index 0000000..423ffa3 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IRestaurantProfile.cs @@ -0,0 +1,19 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// +/// The restaurant's own details, as printed at the top of every receipt (POS-027). Supplied by +/// configuration rather than compiled in so the same build can be installed for a second branch +/// without a code change. +/// +public interface IRestaurantProfile +{ + string Name { get; } + + string AddressLine1 { get; } + + string? AddressLine2 { get; } + + string? City { get; } + + string? Phone { get; } +} diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs new file mode 100644 index 0000000..4a5af8d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs @@ -0,0 +1,27 @@ +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Interfaces; + +/// A freshly minted access token and its expiry. +/// The signed JWT. +/// When the token stops being accepted. +public readonly record struct AccessToken(string Value, DateTime ExpiresAtUtc); + +/// An opaque refresh token, held by the client, alongside the hash to persist. +/// The opaque token returned to the client. Never stored. +/// Digest of , safe to persist. +/// When the token stops being accepted. +public readonly record struct RefreshTokenPair(string Value, string Hash, DateTime ExpiresAtUtc); + +/// Issues and hashes the tokens that back a signed-in session. +public interface ITokenService +{ + /// Signs a JWT carrying the user's identity, role and granted modules. + AccessToken CreateAccessToken(User user); + + /// Generates a cryptographically random refresh token and its storage hash. + RefreshTokenPair CreateRefreshToken(); + + /// Hashes a client-supplied refresh token so it can be matched against storage. + string HashRefreshToken(string token); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/OrderMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/OrderMappings.cs new file mode 100644 index 0000000..a30829a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/OrderMappings.cs @@ -0,0 +1,131 @@ +using RestaurantPOS.Application.Kitchen.Dtos; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projections from the order aggregate to the shapes the till and kitchen read. +public static class OrderMappings +{ + /// + /// How far the kitchen has got with an order overall: the least-advanced ticket that still + /// represents food to cook. Cancellation slips are skipped — they are notices, and letting + /// one count would drag a finished table back to "new" the moment an item was voided. + /// Null when nothing has been sent to the kitchen yet. + /// + public static KitchenTicketStatus? DeriveKitchenStatus(IEnumerable tickets) + { + ArgumentNullException.ThrowIfNull(tickets); + + var workable = tickets.Where(t => t.IsWorkable).ToList(); + + return workable.Count == 0 ? null : workable.Min(t => t.Status); + } + + public static OrderDto ToDto(this Order order, string tableNumber, string cashierName) + { + ArgumentNullException.ThrowIfNull(order); + + return new OrderDto( + order.Id, + order.OrderNumber, + order.OrderDate, + order.TableId, + tableNumber, + order.Status, + order.CashierUserId, + cashierName, + order.CreatedAtUtc, + order.ConfirmedAtUtc, + order.CompletedAtUtc, + order.DiscountType, + order.DiscountValue, + order.Subtotal, + order.DiscountAmount, + order.Total, + order.AmountPaid, + order.ChangeDue, + DeriveKitchenStatus(order.Tickets), + order.Receipt?.Number, + [.. order.Items + .OrderBy(i => i.CreatedAtUtc) + .Select(i => new OrderItemDto( + i.Id, i.MenuItemId, i.MenuItemName, i.UnitPrice, i.Quantity, + i.SpecialInstructions, i.IsCancelled, i.LineTotal))], + [.. order.Payments + .OrderBy(p => p.CreatedAtUtc) + .Select(p => p.ToDto())]); + } + + public static OrderPaymentDto ToDto(this OrderPayment payment) + { + ArgumentNullException.ThrowIfNull(payment); + + return new OrderPaymentDto( + payment.Id, payment.Method, payment.Amount, payment.TenderedAmount, + payment.ChangeGiven, payment.Reference, payment.CreatedAtUtc); + } + + public static OrderSummaryDto ToSummaryDto(this Order order, string tableNumber, string cashierName) + { + ArgumentNullException.ThrowIfNull(order); + + return new OrderSummaryDto( + order.Id, + order.OrderNumber, + order.TableId, + tableNumber, + order.Status, + cashierName, + order.CreatedAtUtc, + order.ConfirmedAtUtc, + order.ActiveItems.Count(), + order.Total, + DeriveKitchenStatus(order.Tickets)); + } + + /// Builds the printable slip for a ticket the kitchen has just been sent. + public static KotDocumentDto ToKotDocument( + this KitchenTicket ticket, Order order, string restaurantName, string tableNumber, string cashierName) + { + ArgumentNullException.ThrowIfNull(ticket); + ArgumentNullException.ThrowIfNull(order); + + return new KotDocumentDto( + ticket.Id, + restaurantName, + order.OrderNumber, + tableNumber, + ticket.TicketNumber, + ticket.Kind, + cashierName, + ticket.PrintedAtUtc, + ticket.PrintCount, + [.. ticket.Lines.Select(l => new KotDocumentLineDto( + l.MenuItemName, l.Quantity, l.SpecialInstructions, l.Note))]); + } + + public static KitchenTicketDto ToDto( + this KitchenTicket ticket, int? orderNumber, string tableNumber, DateTime nowUtc) + { + ArgumentNullException.ThrowIfNull(ticket); + + return new KitchenTicketDto( + ticket.Id, + ticket.OrderId, + orderNumber, + tableNumber, + ticket.TicketNumber, + ticket.Kind, + ticket.Status, + ticket.PrintedAtUtc, + ticket.StartedAtUtc, + ticket.ReadyAtUtc, + ticket.ServedAtUtc, + ticket.PrintCount, + Math.Max(0, (int)(nowUtc - ticket.PrintedAtUtc).TotalMinutes), + [.. ticket.Lines.Select(l => new KitchenTicketLineDto( + l.OrderItemId, l.MenuItemName, l.Quantity, l.SpecialInstructions, l.Note))]); + } +} diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs new file mode 100644 index 0000000..ea53b49 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/PurchaseOrderMappings.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read model. +public static class PurchaseOrderMappings +{ + /// + /// Builds the full DTO, denormalising the supplier's and creator's names and each line's raw + /// material details, and computing the amount paid so far from recorded payments. + /// + public static async Task ToDtoAsync( + this PurchaseOrder order, IAppDbContext db, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(order); + ArgumentNullException.ThrowIfNull(db); + + var supplierName = await db.Suppliers.AsNoTracking() + .Where(s => s.Id == order.SupplierId).Select(s => s.Name).FirstAsync(cancellationToken); + + var createdByName = await db.Users.AsNoTracking() + .Where(u => u.Id == order.CreatedByUserId).Select(u => u.FullName).FirstAsync(cancellationToken); + + var rawMaterialIds = order.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var amountPaid = await db.SupplierPayments.AsNoTracking() + .Where(p => p.PurchaseOrderId == order.Id) + .SumAsync(p => (decimal?)p.Amount, cancellationToken) ?? 0m; + + var lines = order.Lines + .Select(l => new PurchaseOrderLineDto( + l.RawMaterialId, + rawMaterials[l.RawMaterialId].Name, + rawMaterials[l.RawMaterialId].UnitOfMeasurement, + l.Quantity, + l.UnitPrice, + l.LineTotal)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return new PurchaseOrderDto( + order.Id, + order.SupplierId, + supplierName, + order.Status, + order.CreatedByUserId, + createdByName, + order.CreatedAtUtc, + order.ExpectedDeliveryDate, + order.SubmittedAtUtc, + order.Notes, + order.TotalAmount, + amountPaid, + order.TotalAmount - amountPaid, + lines); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs new file mode 100644 index 0000000..a5edb21 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/RawMaterialMappings.cs @@ -0,0 +1,21 @@ +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +public static class RawMaterialMappings +{ + public static RawMaterialDto ToDto(this RawMaterial rawMaterial) + { + ArgumentNullException.ThrowIfNull(rawMaterial); + + return new RawMaterialDto( + rawMaterial.Id, + rawMaterial.Name, + rawMaterial.UnitOfMeasurement, + rawMaterial.MainStoreReorderLevel, + rawMaterial.KitchenParLevel, + rawMaterial.IsActive, + rawMaterial.CreatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs new file mode 100644 index 0000000..61c7e1e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/RecipeMappings.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read model. +public static class RecipeMappings +{ + /// + /// Builds the DTO, denormalising each line's raw material name and unit in — a lookup + /// rather than a plain property mapping, since that data lives on , + /// not on itself. + /// + public static async Task ToDtoAsync( + this Recipe recipe, IAppDbContext db, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(db); + + var rawMaterialIds = recipe.Lines.Select(l => l.RawMaterialId).ToList(); + + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = recipe.Lines + .Select(l => new RecipeLineDto( + l.RawMaterialId, + rawMaterials[l.RawMaterialId].Name, + rawMaterials[l.RawMaterialId].UnitOfMeasurement, + l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return new RecipeDto(recipe.Id, recipe.MenuItemId, recipe.IsEnabled, lines, recipe.CreatedAtUtc, recipe.UpdatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs new file mode 100644 index 0000000..4dcfb3b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/SupplierMappings.cs @@ -0,0 +1,25 @@ +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +public static class SupplierMappings +{ + public static SupplierDto ToDto(this Supplier supplier) + { + ArgumentNullException.ThrowIfNull(supplier); + + return new SupplierDto( + supplier.Id, + supplier.Name, + supplier.ContactName, + supplier.Phone, + supplier.Email, + supplier.Address, + supplier.PaymentTermsDays, + supplier.CreditLimit, + supplier.LeadTimeDays, + supplier.IsActive, + supplier.CreatedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs new file mode 100644 index 0000000..bb7acee --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs @@ -0,0 +1,29 @@ +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read models. +public static class UserMappings +{ + public static UserDto ToDto(this User user) + { + ArgumentNullException.ThrowIfNull(user); + + return new UserDto + { + Id = user.Id, + Username = user.Username, + FullName = user.FullName, + Email = user.Email, + Role = user.Role, + IsActive = user.IsActive, + MustChangePassword = user.MustChangePassword, + IsSystemAdmin = user.IsSystemAdmin, + HasApprovalPin = user.HasApprovalPin, + LastLoginAtUtc = user.LastLoginAtUtc, + CreatedAtUtc = user.CreatedAtUtc, + Modules = user.EffectiveModules(), + }; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs b/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs new file mode 100644 index 0000000..1c690b6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs @@ -0,0 +1,17 @@ +namespace RestaurantPOS.Application.Common.Security; + +/// +/// Custom claim names carried by the access token. Shared so the issuer (infrastructure) and +/// the reader (API) cannot drift apart. +/// +public static class AppClaimTypes +{ + /// One claim per module the user may open. Absent for administrators, who hold all. + public const string Module = "module"; + + /// + /// Present and "true" while the user still owes a password change. The API refuses every + /// endpoint except the password-change flow while this is set. + /// + public const string MustChangePassword = "must_change_password"; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs b/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs new file mode 100644 index 0000000..b248030 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs @@ -0,0 +1,38 @@ +using FluentValidation; + +namespace RestaurantPOS.Application.Common.Security; + +/// +/// The single definition of what makes an acceptable password. Applied by every command that +/// accepts one so the rules cannot drift between sign-up, reset and change flows. +/// +public static class PasswordPolicy +{ + public const int MinLength = 8; + public const int MaxLength = 128; + + /// Human-readable summary, shown by the frontend next to password fields. + public const string Description = + "Password must be at least 8 characters and include an upper-case letter, a lower-case letter and a digit."; + + /// Applies the policy to a string property on a FluentValidation rule chain. + public static IRuleBuilderOptions MustMeetPasswordPolicy( + this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder); + + return ruleBuilder + .NotEmpty().WithMessage("Password is required.") + .MinimumLength(MinLength).WithMessage($"Password must be at least {MinLength} characters.") + .MaximumLength(MaxLength).WithMessage($"Password cannot exceed {MaxLength} characters.") + .Must(ContainsUpper).WithMessage("Password must contain an upper-case letter.") + .Must(ContainsLower).WithMessage("Password must contain a lower-case letter.") + .Must(ContainsDigit).WithMessage("Password must contain a digit."); + } + + private static bool ContainsUpper(string value) => value.Any(char.IsUpper); + + private static bool ContainsLower(string value) => value.Any(char.IsLower); + + private static bool ContainsDigit(string value) => value.Any(char.IsDigit); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/DependencyInjection.cs b/backend/src/RestaurantPOS.Application/DependencyInjection.cs index d45c517..b104fe7 100644 --- a/backend/src/RestaurantPOS.Application/DependencyInjection.cs +++ b/backend/src/RestaurantPOS.Application/DependencyInjection.cs @@ -1,17 +1,31 @@ +using FluentValidation; + using MediatR; + using Microsoft.Extensions.DependencyInjection; +using RestaurantPOS.Application.Common.Behaviors; + namespace RestaurantPOS.Application; public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { + var assembly = typeof(DependencyInjection).Assembly; + services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); + cfg.RegisterServicesFromAssembly(assembly); + + // Order matters: logging wraps the whole pipeline, validation runs immediately + // before the handler so handlers can assume valid input. + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); + services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true); + return services; } -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs new file mode 100644 index 0000000..2f2bbdc --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/ConsumeStockForSale/ConsumeStockForSaleCommand.cs @@ -0,0 +1,72 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.ConsumeStockForSale; + +/// +/// Deducts Kitchen stock per a menu item's recipe when a sale completes (REC-007, INV-013, +/// INV-014). This is the integration point POS & Billing's "complete order" flow will call +/// once it exists; it is fully implemented and tested ahead of that. +/// +/// How many units of the menu item were sold in this transaction. +public sealed record ConsumeStockForSaleCommand(Guid MenuItemId, decimal QuantitySold) + : IRequest>; + +public sealed class ConsumeStockForSaleCommandValidator : AbstractValidator +{ + public ConsumeStockForSaleCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + RuleFor(x => x.QuantitySold).GreaterThan(0).WithMessage("Quantity sold must be greater than zero."); + } +} + +internal sealed class ConsumeStockForSaleCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + private static readonly ConsumptionResultDto NoDeduction = new(Deducted: false, Lines: []); + + public async Task> Handle( + ConsumeStockForSaleCommand request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var consumed = await SaleStockConsumption.ApplyAsync( + db, + [new SoldItem(request.MenuItemId, request.QuantitySold)], + currentUser.UserId!.Value, + clock.UtcNow, + cancellationToken); + + if (consumed.IsFailure) + { + return Result.Failure(consumed.Error); + } + + // Not every menu item has a recipe, and a disabled one is not usable for a new sale — + // neither is a failure, since the sale itself has already happened by the time this + // runs. There is simply nothing to deduct. + if (consumed.Value.Count == 0) + { + return Result.Success(NoDeduction); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new ConsumptionResultDto(Deducted: true, consumed.Value)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs new file mode 100644 index 0000000..6dba9de --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateGoodsReceivedNote/CreateGoodsReceivedNoteCommand.cs @@ -0,0 +1,152 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateGoodsReceivedNote; + +public sealed record GrnLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Records stock received from a supplier into the Main Store. There is no separate draft or +/// confirmation step (INV-002, INV-003) — a GRN is only ever entered once the goods have +/// actually been counted in, so recording it and confirming it are the same action. +/// +/// +/// Optional — reconciles this delivery against an order and marks it Delivered. A GRN can also +/// stand alone for a delivery that never had a formal PO raised against it. +/// +/// Optional, 1 (worst) to 5 (best). +/// Flags this delivery for the supplier performance view. +public sealed record CreateGoodsReceivedNoteCommand( + Guid SupplierId, + IReadOnlyCollection Lines, + string? Notes, + Guid? PurchaseOrderId = null, + int? QualityRating = null, + bool HasIssue = false) : IRequest>; + +public sealed class CreateGoodsReceivedNoteCommandValidator : AbstractValidator +{ + public CreateGoodsReceivedNoteCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one raw material line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same GRN.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(GoodsReceivedNote.NotesMaxLength); + + RuleFor(x => x.QualityRating) + .InclusiveBetween(GoodsReceivedNote.MinQualityRating, GoodsReceivedNote.MaxQualityRating) + .WithMessage($"Quality rating must be between {GoodsReceivedNote.MinQualityRating} and {GoodsReceivedNote.MaxQualityRating}.") + .When(x => x.QualityRating is not null); + } +} + +internal sealed class CreateGoodsReceivedNoteCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + CreateGoodsReceivedNoteCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (!supplier.IsActive) + { + return Result.Failure(SupplierErrors.Inactive); + } + + PurchaseOrder? purchaseOrder = null; + + if (request.PurchaseOrderId is not null) + { + purchaseOrder = await db.PurchaseOrders + .FirstOrDefaultAsync(p => p.Id == request.PurchaseOrderId.Value, cancellationToken); + + if (purchaseOrder is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId.Value)); + } + + if (purchaseOrder.SupplierId != request.SupplierId) + { + return Result.Failure(SupplierErrors.SupplierMismatch); + } + + if (purchaseOrder.Status == PurchaseOrderStatus.Cancelled) + { + return Result.Failure(SupplierErrors.PurchaseOrderCancelled); + } + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds.First(id => !rawMaterials.ContainsKey(id)))); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(InventoryErrors.RawMaterialInactive); + } + + var now = clock.UtcNow; + var grn = GoodsReceivedNote.Create( + request.SupplierId, currentUser.UserId!.Value, now, request.Notes, + request.PurchaseOrderId, request.QualityRating, request.HasIssue); + db.GoodsReceivedNotes.Add(grn); + + var movements = request.Lines + .Select(l => new StockMovementRequest( + l.RawMaterialId, StoreType.MainStore, l.Quantity, StockMovementType.GoodsReceived, grn.Id, null)) + .ToList(); + + var ledgerResult = await InventoryLedger.ApplyAsync(db, movements, currentUser.UserId!.Value, now, cancellationToken); + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + purchaseOrder?.MarkDelivered(); + + await db.SaveChangesAsync(cancellationToken); + + var lines = request.Lines + .Select(l => new StockMovementLineDto(l.RawMaterialId, rawMaterials[l.RawMaterialId].Name, rawMaterials[l.RawMaterialId].UnitOfMeasurement, l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + var receivedByName = await db.Users.Where(u => u.Id == grn.ReceivedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new GoodsReceivedNoteDto( + grn.Id, supplier.Id, supplier.Name, grn.ReceivedByUserId, receivedByName, + grn.ReceivedAtUtc, grn.Notes, grn.PurchaseOrderId, grn.QualityRating, grn.HasIssue, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs new file mode 100644 index 0000000..64e84ce --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateRawMaterial/CreateRawMaterialCommand.cs @@ -0,0 +1,65 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateRawMaterial; + +public sealed record CreateRawMaterialCommand( + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel) : IRequest>; + +public sealed class CreateRawMaterialCommandValidator : AbstractValidator +{ + public CreateRawMaterialCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(RawMaterial.NameMaxLength); + + RuleFor(x => x.UnitOfMeasurement).IsInEnum().WithMessage("Select a valid unit of measurement."); + + RuleFor(x => x.MainStoreReorderLevel).GreaterThanOrEqualTo(0) + .WithMessage("Reorder level cannot be negative.") + .When(x => x.MainStoreReorderLevel is not null); + + RuleFor(x => x.KitchenParLevel).GreaterThanOrEqualTo(0) + .WithMessage("Par level cannot be negative.") + .When(x => x.KitchenParLevel is not null); + } +} + +internal sealed class CreateRawMaterialCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateRawMaterialCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.RawMaterials.AnyAsync(r => r.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(InventoryErrors.RawMaterialNameTaken); + } + + var rawMaterial = RawMaterial.Create( + name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + + db.RawMaterials.Add(rawMaterial); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs new file mode 100644 index 0000000..de273f1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockAdjustment/CreateStockAdjustmentCommand.cs @@ -0,0 +1,83 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateStockAdjustment; + +/// +/// Corrects a raw material's balance in one store to match a physical count (INV-004). A reason +/// is mandatory — an adjustment with no explanation defeats the point of keeping a ledger. +/// +public sealed record CreateStockAdjustmentCommand( + Guid RawMaterialId, StoreType Store, decimal QuantityDelta, string Reason) + : IRequest>; + +public sealed class CreateStockAdjustmentCommandValidator : AbstractValidator +{ + public CreateStockAdjustmentCommandValidator() + { + RuleFor(x => x.RawMaterialId).NotEmpty(); + + RuleFor(x => x.Store).IsInEnum().WithMessage("Select a valid store."); + + RuleFor(x => x.QuantityDelta).NotEqual(0).WithMessage("The adjustment must change the balance."); + + RuleFor(x => x.Reason) + .NotEmpty().WithMessage("A reason is required.") + .MaximumLength(StockMovement.NotesMaxLength); + } +} + +internal sealed class CreateStockAdjustmentCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + CreateStockAdjustmentCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var now = clock.UtcNow; + + var movement = new StockMovementRequest( + request.RawMaterialId, request.Store, request.QuantityDelta, StockMovementType.Adjustment, + ReferenceId: null, request.Reason); + + var ledgerResult = await InventoryLedger.ApplyAsync( + db, [movement], currentUser.UserId!.Value, now, cancellationToken); + + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + var recorded = await db.StockMovements.AsNoTracking() + .Where(m => m.RawMaterialId == request.RawMaterialId && m.Type == StockMovementType.Adjustment) + .OrderByDescending(m => m.OccurredAtUtc) + .FirstAsync(cancellationToken); + + var performedByName = await db.Users.Where(u => u.Id == recorded.PerformedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new StockMovementDto( + recorded.Id, rawMaterial.Id, rawMaterial.Name, rawMaterial.UnitOfMeasurement, recorded.Store, + recorded.QuantityDelta, recorded.Type, recorded.ReferenceId, recorded.PerformedByUserId, + performedByName, recorded.OccurredAtUtc, recorded.Notes)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs new file mode 100644 index 0000000..4d0b7e2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/CreateStockRelease/CreateStockReleaseCommand.cs @@ -0,0 +1,118 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.CreateStockRelease; + +public sealed record StockReleaseLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Transfers stock from the Main Store to the Kitchen (INV-008 through INV-011). Approval is not +/// a separate pending step: the requesting user's action and an administrator's approval PIN +/// arrive in the same request, verified here before anything moves, so a release only ever +/// exists already approved. +/// +public sealed record CreateStockReleaseCommand( + IReadOnlyCollection Lines, string Pin, string? Notes) + : IRequest>; + +public sealed class CreateStockReleaseCommandValidator : AbstractValidator +{ + public CreateStockReleaseCommandValidator() + { + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one raw material line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same release.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Pin).NotEmpty().WithMessage("An administrator's approval PIN is required."); + + RuleFor(x => x.Notes).MaximumLength(StockRelease.NotesMaxLength); + } +} + +internal sealed class CreateStockReleaseCommandHandler( + IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock, ISender sender) + : IRequestHandler> +{ + public async Task> Handle( + CreateStockReleaseCommand request, CancellationToken cancellationToken) + { + var approval = await sender.Send( + new VerifyApprovalPinCommand(request.Pin, request.Notes), cancellationToken); + + if (approval.IsFailure) + { + return Result.Failure(approval.Error); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure( + InventoryErrors.RawMaterialNotFound(rawMaterialIds.First(id => !rawMaterials.ContainsKey(id)))); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(InventoryErrors.RawMaterialInactive); + } + + var now = clock.UtcNow; + + var release = StockRelease.Create( + currentUser.UserId!.Value, now, approval.Value.ApprovedByUserId, approval.Value.ApprovedAtUtc, request.Notes); + db.StockReleases.Add(release); + + var movements = request.Lines + .SelectMany(l => new[] + { + new StockMovementRequest( + l.RawMaterialId, StoreType.MainStore, -l.Quantity, StockMovementType.StockReleaseOut, release.Id, null), + new StockMovementRequest( + l.RawMaterialId, StoreType.Kitchen, l.Quantity, StockMovementType.StockReleaseIn, release.Id, null), + }) + .ToList(); + + var ledgerResult = await InventoryLedger.ApplyAsync(db, movements, currentUser.UserId!.Value, now, cancellationToken); + if (ledgerResult.IsFailure) + { + return Result.Failure(ledgerResult.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + var lines = request.Lines + .Select(l => new StockMovementLineDto( + l.RawMaterialId, rawMaterials[l.RawMaterialId].Name, rawMaterials[l.RawMaterialId].UnitOfMeasurement, l.Quantity)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + var requestedByName = await db.Users.Where(u => u.Id == release.RequestedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new StockReleaseDto( + release.Id, release.RequestedByUserId, requestedByName, release.RequestedAtUtc, + release.ApprovedByUserId, approval.Value.ApprovedByName, release.ApprovedAtUtc, release.Notes, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs new file mode 100644 index 0000000..1c17651 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/SetRawMaterialActive/SetRawMaterialActiveCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.SetRawMaterialActive; + +public sealed record SetRawMaterialActiveCommand(Guid RawMaterialId, bool IsActive) : IRequest>; + +internal sealed class SetRawMaterialActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetRawMaterialActiveCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + if (request.IsActive) + { + rawMaterial.Activate(); + } + else + { + rawMaterial.Deactivate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs b/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs new file mode 100644 index 0000000..a0a972f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Commands/UpdateRawMaterial/UpdateRawMaterialCommand.cs @@ -0,0 +1,75 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Commands.UpdateRawMaterial; + +public sealed record UpdateRawMaterialCommand( + Guid RawMaterialId, + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel) : IRequest>; + +public sealed class UpdateRawMaterialCommandValidator : AbstractValidator +{ + public UpdateRawMaterialCommandValidator() + { + RuleFor(x => x.RawMaterialId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(RawMaterial.NameMaxLength); + + RuleFor(x => x.UnitOfMeasurement).IsInEnum().WithMessage("Select a valid unit of measurement."); + + RuleFor(x => x.MainStoreReorderLevel).GreaterThanOrEqualTo(0) + .WithMessage("Reorder level cannot be negative.") + .When(x => x.MainStoreReorderLevel is not null); + + RuleFor(x => x.KitchenParLevel).GreaterThanOrEqualTo(0) + .WithMessage("Par level cannot be negative.") + .When(x => x.KitchenParLevel is not null); + } +} + +internal sealed class UpdateRawMaterialCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateRawMaterialCommand request, CancellationToken cancellationToken) + { + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.RawMaterials.AnyAsync( + r => r.Id != request.RawMaterialId && r.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(InventoryErrors.RawMaterialNameTaken); + } + + rawMaterial.UpdateDetails(name, request.UnitOfMeasurement, request.MainStoreReorderLevel, request.KitchenParLevel); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(rawMaterial.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs b/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs new file mode 100644 index 0000000..b8b6450 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Common/InventoryLedger.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Common; + +/// One requested change to one raw material's balance in one store. +public sealed record StockMovementRequest( + Guid RawMaterialId, + StoreType Store, + decimal QuantityDelta, + StockMovementType Type, + Guid? ReferenceId, + string? Notes); + +/// +/// The single place every stock-affecting use case (GRN, release, adjustment, consumption) goes +/// through. Every quantity change is a recorded here as a +/// permanent row, with the corresponding +/// balance kept in step — this is the only code path allowed to touch either (BR-INV-006). +/// +public static class InventoryLedger +{ + /// + /// Applies every movement in as a single all-or-nothing batch: + /// if any resulting balance would go negative, nothing is applied and the failure names the + /// offending raw material. + /// + /// + /// Lets balances fall below zero instead of rejecting the batch. Set only when recording + /// something that has already physically happened and cannot be refused — a dish sold at the + /// till must never be blocked because the ingredient ledger says the kitchen ran out + /// (BR-POS-015). The resulting negative balance is a true reading: it says the kitchen has + /// been cooking from stock nobody booked in, which is exactly what the manager needs to see. + /// + public static async Task ApplyAsync( + IAppDbContext db, + IReadOnlyCollection movements, + Guid performedByUserId, + DateTime nowUtc, + CancellationToken cancellationToken, + bool allowNegative = false) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(movements); + + if (movements.Count == 0) + { + return Result.Failure(InventoryErrors.EmptyLines); + } + + var keys = movements.Select(m => (m.RawMaterialId, m.Store)).Distinct().ToList(); + + var levels = new Dictionary<(Guid, StoreType), StockLevel>(); + + foreach (var (rawMaterialId, store) in keys) + { + var level = await db.StockLevels + .FirstOrDefaultAsync(l => l.RawMaterialId == rawMaterialId && l.Store == store, cancellationToken); + + if (level is null) + { + level = new StockLevel(rawMaterialId, store); + db.StockLevels.Add(level); + } + + levels[(rawMaterialId, store)] = level; + } + + // Checked as one batch before anything is written, so a multi-line transaction never + // applies some of its lines and then fails partway through the rest. + var projectedBalances = levels.ToDictionary(kv => kv.Key, kv => kv.Value.QuantityOnHand); + + foreach (var movement in movements) + { + var key = (movement.RawMaterialId, movement.Store); + var projected = projectedBalances[key] + movement.QuantityDelta; + + if (projected < 0 && !allowNegative) + { + return Result.Failure(InventoryErrors.InsufficientStock); + } + + projectedBalances[key] = projected; + } + + foreach (var movement in movements) + { + levels[(movement.RawMaterialId, movement.Store)].ApplyDelta(movement.QuantityDelta, nowUtc, allowNegative); + + db.StockMovements.Add(new StockMovement( + movement.RawMaterialId, + movement.Store, + movement.QuantityDelta, + movement.Type, + movement.ReferenceId, + performedByUserId, + nowUtc, + movement.Notes)); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Common/SaleStockConsumption.cs b/backend/src/RestaurantPOS.Application/Inventory/Common/SaleStockConsumption.cs new file mode 100644 index 0000000..395083f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Common/SaleStockConsumption.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Common; + +/// One menu item and how many of it were sold. +public sealed record SoldItem(Guid MenuItemId, decimal Quantity); + +/// +/// Turns a completed sale into kitchen stock deductions, following each dish's recipe +/// (REC-007, INV-013). +/// +/// +/// Shared by the single-item command and the till's settle-the-bill flow so the two can never +/// disagree about what a sale consumes. Everything for one bill is applied as a single batch, +/// which matters when two dishes share an ingredient: separate batches would write two movements +/// against a balance read at different moments. +/// +public static class SaleStockConsumption +{ + /// + /// Records the deductions for . Dishes with no recipe, or whose + /// recipe is switched off, simply consume nothing — that is a normal state for a bottled + /// drink, not a failure. + /// + public static async Task>> ApplyAsync( + IAppDbContext db, + IReadOnlyCollection soldItems, + Guid performedByUserId, + DateTime nowUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(soldItems); + + var menuItemIds = soldItems.Select(s => s.MenuItemId).Distinct().ToList(); + + var recipes = await db.Recipes.AsNoTracking() + .Include(r => r.Lines) + .Where(r => menuItemIds.Contains(r.MenuItemId) && r.IsEnabled) + .ToListAsync(cancellationToken); + + if (recipes.Count == 0) + { + return Result.Success>([]); + } + + var recipesByMenuItem = recipes.ToDictionary(r => r.MenuItemId); + + // Quantities are totalled per raw material first: one movement per ingredient reads far + // better in the stock history than one per dish that happened to use it. + var totals = new Dictionary(); + + foreach (var sold in soldItems) + { + if (!recipesByMenuItem.TryGetValue(sold.MenuItemId, out var recipe)) + { + continue; + } + + foreach (var line in recipe.Lines) + { + totals[line.RawMaterialId] = totals.GetValueOrDefault(line.RawMaterialId) + + (line.Quantity * sold.Quantity); + } + } + + if (totals.Count == 0) + { + return Result.Success>([]); + } + + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => totals.Keys.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var movements = totals + .Select(kv => new StockMovementRequest( + kv.Key, StoreType.Kitchen, -kv.Value, StockMovementType.Consumption, ReferenceId: null, Notes: null)) + .ToList(); + + // A sale is never refused for want of stock (BR-POS-015). The food has already gone out; + // blocking the deduction would not un-cook it, it would only leave the ledger pretending + // the ingredients are still on the shelf. + var ledgerResult = await InventoryLedger.ApplyAsync( + db, movements, performedByUserId, nowUtc, cancellationToken, allowNegative: true); + + if (ledgerResult.IsFailure) + { + return Result.Failure>(ledgerResult.Error); + } + + var consumed = totals + .Select(kv => new ConsumedLineDto( + kv.Key, rawMaterials[kv.Key].Name, kv.Value, rawMaterials[kv.Key].UnitOfMeasurement)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return Result.Success>(consumed); + } +} diff --git a/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs b/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs new file mode 100644 index 0000000..fb12a27 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Dtos/RawMaterialDto.cs @@ -0,0 +1,12 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Dtos; + +public sealed record RawMaterialDto( + Guid Id, + string Name, + UnitOfMeasurement UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel, + bool IsActive, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs b/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs new file mode 100644 index 0000000..f39c286 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Dtos/StockDtos.cs @@ -0,0 +1,93 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Dtos; + +/// A raw material's current balance in one store. +public sealed record StockLevelDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + StoreType Store, + decimal QuantityOnHand, + bool IsLowStock); + +/// One row in the permanent stock ledger, with display details denormalised in. +public sealed record StockMovementDto( + Guid Id, + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + StoreType Store, + decimal QuantityDelta, + StockMovementType Type, + Guid? ReferenceId, + Guid PerformedByUserId, + string PerformedByName, + DateTime OccurredAtUtc, + string? Notes); + +/// A Goods Received Note with its lines reconstructed from the stock ledger. +public sealed record GoodsReceivedNoteDto( + Guid Id, + Guid SupplierId, + string SupplierName, + Guid ReceivedByUserId, + string ReceivedByName, + DateTime ReceivedAtUtc, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + IReadOnlyCollection Lines); + +/// A Main Store → Kitchen stock release with its lines reconstructed from the stock ledger. +public sealed record StockReleaseDto( + Guid Id, + Guid RequestedByUserId, + string RequestedByName, + DateTime RequestedAtUtc, + Guid ApprovedByUserId, + string ApprovedByName, + DateTime ApprovedAtUtc, + string? Notes, + IReadOnlyCollection Lines); + +/// One ingredient quantity within a GRN or release, without the ledger bookkeeping fields. +public sealed record StockMovementLineDto( + Guid RawMaterialId, string RawMaterialName, UnitOfMeasurement UnitOfMeasurement, decimal Quantity); + +/// A GRN's header for a list screen, without its lines. +public sealed record GoodsReceivedNoteSummaryDto( + Guid Id, + Guid SupplierId, + string SupplierName, + Guid ReceivedByUserId, + string ReceivedByName, + DateTime ReceivedAtUtc, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + int LineCount); + +/// A stock release's header for a list screen, without its lines. +public sealed record StockReleaseSummaryDto( + Guid Id, + Guid RequestedByUserId, + string RequestedByName, + DateTime RequestedAtUtc, + Guid ApprovedByUserId, + string ApprovedByName, + DateTime ApprovedAtUtc, + string? Notes, + int LineCount); + +/// One raw material and quantity deducted when a menu item's recipe was applied to a sale. +public sealed record ConsumedLineDto( + Guid RawMaterialId, string RawMaterialName, decimal QuantityDeducted, UnitOfMeasurement UnitOfMeasurement); + +/// +/// The outcome of applying a menu item's recipe to a completed sale. is +/// false — not a failure — when the item simply has no recipe or its recipe is disabled. +/// +public sealed record ConsumptionResultDto(bool Deducted, IReadOnlyCollection Lines); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs new file mode 100644 index 0000000..aa9dedf --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNoteById/GetGoodsReceivedNoteByIdQuery.cs @@ -0,0 +1,52 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNoteById; + +public sealed record GetGoodsReceivedNoteByIdQuery(Guid GoodsReceivedNoteId) : IRequest>; + +internal sealed class GetGoodsReceivedNoteByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetGoodsReceivedNoteByIdQuery request, CancellationToken cancellationToken) + { + var note = await db.GoodsReceivedNotes.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == request.GoodsReceivedNoteId, cancellationToken); + + if (note is null) + { + return Result.Failure(InventoryErrors.GoodsReceivedNoteNotFound(request.GoodsReceivedNoteId)); + } + + var supplier = await db.Suppliers.AsNoTracking().FirstAsync(s => s.Id == note.SupplierId, cancellationToken); + var receivedByName = await db.Users.AsNoTracking().Where(u => u.Id == note.ReceivedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + var movements = await db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == note.Id && m.Type == StockMovementType.GoodsReceived) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = movements + .Select(m => new StockMovementLineDto( + m.RawMaterialId, rawMaterials[m.RawMaterialId].Name, rawMaterials[m.RawMaterialId].UnitOfMeasurement, m.QuantityDelta)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return Result.Success(new GoodsReceivedNoteDto( + note.Id, note.SupplierId, supplier.Name, note.ReceivedByUserId, receivedByName, + note.ReceivedAtUtc, note.Notes, note.PurchaseOrderId, note.QualityRating, note.HasIssue, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs new file mode 100644 index 0000000..44d148a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetGoodsReceivedNotes/GetGoodsReceivedNotesQuery.cs @@ -0,0 +1,59 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetGoodsReceivedNotes; + +public sealed record GetGoodsReceivedNotesQuery : IRequest>>; + +internal sealed class GetGoodsReceivedNotesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 200; + + public async Task>> Handle( + GetGoodsReceivedNotesQuery request, CancellationToken cancellationToken) + { + var notes = await db.GoodsReceivedNotes.AsNoTracking() + .OrderByDescending(n => n.ReceivedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + // Every GoodsReceived movement carries a non-null ReferenceId (the GRN's own id), so the + // group key can be safely unwrapped once nulls are excluded. + var lineCounts = await db.StockMovements.AsNoTracking() + .Where(m => m.Type == StockMovementType.GoodsReceived && m.ReferenceId != null) + .GroupBy(m => m.ReferenceId!.Value) + .Select(g => new { ReferenceId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ReferenceId, g => g.Count, cancellationToken); + + IReadOnlyCollection result = + [ + .. notes.Select(n => new GoodsReceivedNoteSummaryDto( + n.Id, + n.SupplierId, + supplierNames.GetValueOrDefault(n.SupplierId, "(unknown)"), + n.ReceivedByUserId, + userNames.GetValueOrDefault(n.ReceivedByUserId, "(unknown)"), + n.ReceivedAtUtc, + n.Notes, + n.PurchaseOrderId, + n.QualityRating, + n.HasIssue, + lineCounts.GetValueOrDefault(n.Id, 0))), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs new file mode 100644 index 0000000..89c13c7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetRawMaterials/GetRawMaterialsQuery.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Inventory.Queries.GetRawMaterials; + +public sealed record GetRawMaterialsQuery(string? Search, bool? IsActive) + : IRequest>>; + +internal sealed class GetRawMaterialsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetRawMaterialsQuery request, + CancellationToken cancellationToken) + { + var query = db.RawMaterials.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(r => r.Name.ToLower().Contains(term)); + } + + if (request.IsActive is not null) + { + query = query.Where(r => r.IsActive == request.IsActive.Value); + } + + var rawMaterials = await query + .OrderByDescending(r => r.IsActive) + .ThenBy(r => r.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. rawMaterials.Select(r => r.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs new file mode 100644 index 0000000..39e7599 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockLevels/GetStockLevelsQuery.cs @@ -0,0 +1,53 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockLevels; + +/// +/// The current stock view for one store. Every active raw material appears, even ones with no +/// movements yet (balance 0) — this is a snapshot of what should be on the shelf, not just a +/// list of things that have happened. +/// +public sealed record GetStockLevelsQuery(StoreType Store, bool LowStockOnly = false) + : IRequest>>; + +internal sealed class GetStockLevelsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetStockLevelsQuery request, + CancellationToken cancellationToken) + { + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => r.IsActive) + .ToListAsync(cancellationToken); + + var balances = await db.StockLevels.AsNoTracking() + .Where(l => l.Store == request.Store) + .ToDictionaryAsync(l => l.RawMaterialId, l => l.QuantityOnHand, cancellationToken); + + var rows = rawMaterials.Select(r => + { + var quantity = balances.GetValueOrDefault(r.Id, 0m); + var threshold = request.Store == StoreType.MainStore ? r.MainStoreReorderLevel : r.KitchenParLevel; + var isLow = threshold is not null && quantity <= threshold.Value; + + return new StockLevelDto(r.Id, r.Name, r.UnitOfMeasurement, request.Store, quantity, isLow); + }); + + if (request.LowStockOnly) + { + rows = rows.Where(r => r.IsLowStock); + } + + IReadOnlyCollection result = [.. rows.OrderBy(r => r.RawMaterialName)]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs new file mode 100644 index 0000000..dd20c76 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockMovements/GetStockMovementsQuery.cs @@ -0,0 +1,79 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockMovements; + +/// The stock history for a store, optionally narrowed to one raw material or date range. +public sealed record GetStockMovementsQuery( + StoreType Store, + Guid? RawMaterialId, + DateTime? FromUtc, + DateTime? ToUtc) : IRequest>>; + +internal sealed class GetStockMovementsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 500; + + public async Task>> Handle( + GetStockMovementsQuery request, + CancellationToken cancellationToken) + { + var query = db.StockMovements.AsNoTracking().Where(m => m.Store == request.Store); + + if (request.RawMaterialId is not null) + { + query = query.Where(m => m.RawMaterialId == request.RawMaterialId.Value); + } + + if (request.FromUtc is not null) + { + query = query.Where(m => m.OccurredAtUtc >= request.FromUtc.Value); + } + + if (request.ToUtc is not null) + { + query = query.Where(m => m.OccurredAtUtc <= request.ToUtc.Value); + } + + var movements = await query + .OrderByDescending(m => m.OccurredAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).Distinct().ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var userIds = movements.Select(m => m.PerformedByUserId).Distinct().ToList(); + var userNames = await db.Users.AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. movements.Select(m => new StockMovementDto( + m.Id, + m.RawMaterialId, + rawMaterials.TryGetValue(m.RawMaterialId, out var rawMaterial) ? rawMaterial.Name : "(deleted)", + rawMaterial?.UnitOfMeasurement ?? default, + m.Store, + m.QuantityDelta, + m.Type, + m.ReferenceId, + m.PerformedByUserId, + userNames.GetValueOrDefault(m.PerformedByUserId, "(unknown)"), + m.OccurredAtUtc, + m.Notes)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs new file mode 100644 index 0000000..091b082 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleaseById/GetStockReleaseByIdQuery.cs @@ -0,0 +1,55 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockReleaseById; + +public sealed record GetStockReleaseByIdQuery(Guid StockReleaseId) : IRequest>; + +internal sealed class GetStockReleaseByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetStockReleaseByIdQuery request, CancellationToken cancellationToken) + { + var release = await db.StockReleases.AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == request.StockReleaseId, cancellationToken); + + if (release is null) + { + return Result.Failure(InventoryErrors.StockReleaseNotFound(request.StockReleaseId)); + } + + var requestedByName = await db.Users.AsNoTracking().Where(u => u.Id == release.RequestedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + var approvedByName = await db.Users.AsNoTracking().Where(u => u.Id == release.ApprovedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + // Only the Main Store side is read: it carries the same raw material set and magnitude + // as the Kitchen side, just with the opposite sign, so one side is enough to rebuild the lines. + var movements = await db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == release.Id && m.Type == StockMovementType.StockReleaseOut) + .ToListAsync(cancellationToken); + + var rawMaterialIds = movements.Select(m => m.RawMaterialId).ToList(); + var rawMaterials = await db.RawMaterials.AsNoTracking() + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var lines = movements + .Select(m => new StockMovementLineDto( + m.RawMaterialId, rawMaterials[m.RawMaterialId].Name, rawMaterials[m.RawMaterialId].UnitOfMeasurement, -m.QuantityDelta)) + .OrderBy(l => l.RawMaterialName) + .ToList(); + + return Result.Success(new StockReleaseDto( + release.Id, release.RequestedByUserId, requestedByName, release.RequestedAtUtc, + release.ApprovedByUserId, approvedByName, release.ApprovedAtUtc, release.Notes, lines)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs new file mode 100644 index 0000000..67e479e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Inventory/Queries/GetStockReleases/GetStockReleasesQuery.cs @@ -0,0 +1,54 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Inventory.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Inventory.Queries.GetStockReleases; + +public sealed record GetStockReleasesQuery : IRequest>>; + +internal sealed class GetStockReleasesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 200; + + public async Task>> Handle( + GetStockReleasesQuery request, CancellationToken cancellationToken) + { + var releases = await db.StockReleases.AsNoTracking() + .OrderByDescending(r => r.RequestedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + // Two movement rows (out + in) are written per raw material line, so halve the count to + // report the number of raw materials released, not the number of ledger rows. + var lineCounts = await db.StockMovements.AsNoTracking() + .Where(m => m.Type == StockMovementType.StockReleaseOut && m.ReferenceId != null) + .GroupBy(m => m.ReferenceId!.Value) + .Select(g => new { ReferenceId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ReferenceId, g => g.Count, cancellationToken); + + IReadOnlyCollection result = + [ + .. releases.Select(r => new StockReleaseSummaryDto( + r.Id, + r.RequestedByUserId, + userNames.GetValueOrDefault(r.RequestedByUserId, "(unknown)"), + r.RequestedAtUtc, + r.ApprovedByUserId, + userNames.GetValueOrDefault(r.ApprovedByUserId, "(unknown)"), + r.ApprovedAtUtc, + r.Notes, + lineCounts.GetValueOrDefault(r.Id, 0))), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Kitchen/Commands/AdvanceKitchenTicket/AdvanceKitchenTicketCommand.cs b/backend/src/RestaurantPOS.Application/Kitchen/Commands/AdvanceKitchenTicket/AdvanceKitchenTicketCommand.cs new file mode 100644 index 0000000..69fa19d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Kitchen/Commands/AdvanceKitchenTicket/AdvanceKitchenTicketCommand.cs @@ -0,0 +1,68 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Kitchen.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Kitchen.Commands.AdvanceKitchenTicket; + +/// +/// Moves a ticket along the kitchen's queue — started, plated, carried out. Forward only: the +/// timestamps feed how long food is taking, so a ticket cannot be walked backwards. +/// +public sealed record AdvanceKitchenTicketCommand(Guid TicketId, KitchenTicketStatus Status) + : IRequest>; + +public sealed class AdvanceKitchenTicketCommandValidator : AbstractValidator +{ + public AdvanceKitchenTicketCommandValidator() + { + RuleFor(x => x.TicketId).NotEmpty(); + RuleFor(x => x.Status).IsInEnum(); + } +} + +internal sealed class AdvanceKitchenTicketCommandHandler(IAppDbContext db, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + AdvanceKitchenTicketCommand request, CancellationToken cancellationToken) + { + var ticket = await db.KitchenTickets + .Include(t => t.Lines) + .FirstOrDefaultAsync(t => t.Id == request.TicketId, cancellationToken); + + if (ticket is null) + { + return Result.Failure(OrderErrors.TicketNotFound(request.TicketId)); + } + + if (request.Status <= ticket.Status) + { + return Result.Failure( + OrderErrors.TicketCannotGoBack(ticket.Status.ToString(), request.Status.ToString())); + } + + ticket.Advance(request.Status, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + var order = await db.Orders.AsNoTracking() + .Where(o => o.Id == ticket.OrderId) + .Select(o => new { o.OrderNumber, o.TableId }) + .FirstAsync(cancellationToken); + + var tableNumber = await db.RestaurantTables.AsNoTracking() + .Where(t => t.Id == order.TableId) + .Select(t => t.Number) + .FirstAsync(cancellationToken); + + return Result.Success(ticket.ToDto(order.OrderNumber, tableNumber, clock.UtcNow)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Kitchen/Commands/ReprintKitchenTicket/ReprintKitchenTicketCommand.cs b/backend/src/RestaurantPOS.Application/Kitchen/Commands/ReprintKitchenTicket/ReprintKitchenTicketCommand.cs new file mode 100644 index 0000000..cf9adf8 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Kitchen/Commands/ReprintKitchenTicket/ReprintKitchenTicketCommand.cs @@ -0,0 +1,65 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Kitchen.Commands.ReprintKitchenTicket; + +/// +/// Prints a kitchen slip again — the printer jammed, or the docket went missing off the pass. +/// Reprints the ticket as it was originally sent, not as the order stands now, so the kitchen is +/// never handed a slip that quietly contradicts one they already have. +/// +public sealed record ReprintKitchenTicketCommand(Guid TicketId) : IRequest>; + +public sealed class ReprintKitchenTicketCommandValidator : AbstractValidator +{ + public ReprintKitchenTicketCommandValidator() => RuleFor(x => x.TicketId).NotEmpty(); +} + +internal sealed class ReprintKitchenTicketCommandHandler(IAppDbContext db, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + ReprintKitchenTicketCommand request, CancellationToken cancellationToken) + { + var ticket = await db.KitchenTickets + .Include(t => t.Lines) + .FirstOrDefaultAsync(t => t.Id == request.TicketId, cancellationToken); + + if (ticket is null) + { + return Result.Failure(OrderErrors.TicketNotFound(request.TicketId)); + } + + var order = await OrderRepository.FindAsync(db, ticket.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(ticket.OrderId)); + } + + ticket.RecordReprint(); + await db.SaveChangesAsync(cancellationToken); + + var tableNumber = await db.RestaurantTables + .Where(t => t.Id == order.TableId) + .Select(t => t.Number) + .FirstAsync(cancellationToken); + + var cashierName = await db.Users + .Where(u => u.Id == order.CashierUserId) + .Select(u => u.FullName) + .FirstAsync(cancellationToken); + + return Result.Success(ticket.ToKotDocument(order, restaurant.Name, tableNumber, cashierName)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Kitchen/Dtos/KitchenDtos.cs b/backend/src/RestaurantPOS.Application/Kitchen/Dtos/KitchenDtos.cs new file mode 100644 index 0000000..b2a9533 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Kitchen/Dtos/KitchenDtos.cs @@ -0,0 +1,28 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Kitchen.Dtos; + +/// One card on the kitchen display. +public sealed record KitchenTicketDto( + Guid Id, + Guid OrderId, + int? OrderNumber, + string TableNumber, + int TicketNumber, + KitchenTicketKind Kind, + KitchenTicketStatus Status, + DateTime PrintedAtUtc, + DateTime? StartedAtUtc, + DateTime? ReadyAtUtc, + DateTime? ServedAtUtc, + int PrintCount, + /// Minutes since the slip was printed — what tells the kitchen what is going cold. + int WaitingMinutes, + IReadOnlyCollection Lines); + +public sealed record KitchenTicketLineDto( + Guid OrderItemId, + string MenuItemName, + int Quantity, + string? SpecialInstructions, + string? Note); diff --git a/backend/src/RestaurantPOS.Application/Kitchen/Queries/GetKitchenTickets/GetKitchenTicketsQuery.cs b/backend/src/RestaurantPOS.Application/Kitchen/Queries/GetKitchenTickets/GetKitchenTicketsQuery.cs new file mode 100644 index 0000000..a401de0 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Kitchen/Queries/GetKitchenTickets/GetKitchenTicketsQuery.cs @@ -0,0 +1,65 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Kitchen.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Kitchen.Queries.GetKitchenTickets; + +/// +/// The kitchen display: every slip still being worked, oldest first so the pass works the queue +/// in the order tickets landed. +/// +/// +/// Adds tickets already carried out. Off by default — the display is a work queue, and finished +/// tickets would push live ones off the screen. +/// +public sealed record GetKitchenTicketsQuery(bool IncludeServed) + : IRequest>>; + +internal sealed class GetKitchenTicketsQueryHandler(IAppDbContext db, IDateTimeProvider clock) + : IRequestHandler>> +{ + public async Task>> Handle( + GetKitchenTicketsQuery request, CancellationToken cancellationToken) + { + var query = db.KitchenTickets.AsNoTracking().Include(t => t.Lines).AsQueryable(); + + if (!request.IncludeServed) + { + query = query.Where(t => t.Status != KitchenTicketStatus.Served); + } + + var tickets = await query.ToListAsync(cancellationToken); + + var orderIds = tickets.Select(t => t.OrderId).Distinct().ToList(); + + var orders = await db.Orders.AsNoTracking() + .Where(o => orderIds.Contains(o.Id)) + .Select(o => new { o.Id, o.OrderNumber, o.TableId, o.Status }) + .ToListAsync(cancellationToken); + + var tableNumbers = await db.RestaurantTables.AsNoTracking() + .ToDictionaryAsync(t => t.Id, t => t.Number, cancellationToken); + + var ordersById = orders.ToDictionary(o => o.Id); + var now = clock.UtcNow; + + var dtos = tickets + .Where(t => ordersById.ContainsKey(t.OrderId)) + .Select(t => + { + var order = ordersById[t.OrderId]; + + return t.ToDto(order.OrderNumber, tableNumbers.GetValueOrDefault(order.TableId, string.Empty), now); + }) + .OrderBy(t => t.PrintedAtUtc) + .ToList(); + + return Result.Success>(dtos); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/AddOrderItems/AddOrderItemsCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/AddOrderItems/AddOrderItemsCommand.cs new file mode 100644 index 0000000..38c9e56 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/AddOrderItems/AddOrderItemsCommand.cs @@ -0,0 +1,94 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.AddOrderItems; + +/// A dish being added to a bill, with anything the customer asked for. +public sealed record AddOrderItemInput(Guid MenuItemId, int Quantity, string? SpecialInstructions); + +/// +/// Adds dishes to a bill (POS-003, POS-004, POS-005). Deliberately needs no approval, at any +/// point in an order's life (BR-POS-005) — a customer ordering another drink is the most ordinary +/// thing that happens at a table, and making a cashier fetch a manager for it would be absurd. +/// Once the order is open each addition prints its own KOT (POS-014). +/// +public sealed record AddOrderItemsCommand(Guid OrderId, IReadOnlyCollection Items) + : IRequest>; + +public sealed class AddOrderItemsCommandValidator : AbstractValidator +{ + public AddOrderItemsCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.Items).NotEmpty().WithMessage("Add at least one item."); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.MenuItemId).NotEmpty(); + item.RuleFor(i => i.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + item.RuleFor(i => i.SpecialInstructions).MaximumLength(OrderItem.SpecialInstructionsMaxLength); + }); + } +} + +internal sealed class AddOrderItemsCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + AddOrderItemsCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status is not (OrderStatus.Draft or OrderStatus.Open)) + { + return Result.Failure(OrderErrors.NotEditable); + } + + var menuItemIds = request.Items.Select(i => i.MenuItemId).Distinct().ToList(); + var menuItems = await db.MenuItems.AsNoTracking() + .Where(m => menuItemIds.Contains(m.Id)) + .ToDictionaryAsync(m => m.Id, cancellationToken); + + var missing = menuItemIds.FirstOrDefault(id => !menuItems.ContainsKey(id)); + if (missing != Guid.Empty) + { + return Result.Failure(OrderErrors.MenuItemNotFound(missing)); + } + + if (menuItems.Values.Any(m => !m.IsActive)) + { + return Result.Failure(OrderErrors.MenuItemInactive); + } + + var newItems = request.Items + .Select(i => new NewOrderItem( + i.MenuItemId, + menuItems[i.MenuItemId].Name, + menuItems[i.MenuItemId].Price, + i.Quantity, + i.SpecialInstructions)) + .ToList(); + + var ticket = order.AddItems(newItems, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, ticket, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/CancelOrder/CancelOrderCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/CancelOrder/CancelOrderCommand.cs new file mode 100644 index 0000000..6ab8411 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/CancelOrder/CancelOrderCommand.cs @@ -0,0 +1,74 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.CancelOrder; + +/// +/// Abandons an order without payment (POS-021, BR-POS-009), releasing the table. The kitchen gets +/// one cancellation slip covering everything still live on the bill. +/// +public sealed record CancelOrderCommand(Guid OrderId, string? Pin, string? Reason) + : IRequest>; + +public sealed class CancelOrderCommandValidator : AbstractValidator +{ + public CancelOrderCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(250); + } +} + +internal sealed class CancelOrderCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant, ISender sender) + : IRequestHandler> +{ + public async Task> Handle( + CancelOrderCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status is OrderStatus.Completed or OrderStatus.Cancelled) + { + return Result.Failure(OrderErrors.NotCancellable); + } + + // A draft has never reached the kitchen and holds nothing but the table, so binning it is + // the cashier's own business. Once confirmed, food has been started and a numbered order + // exists in the day's takings, so writing it off needs a manager (BR-POS-009). + var approvedBy = order.CashierUserId; + + if (order.Status is OrderStatus.Open or OrderStatus.Checkout) + { + var approval = await sender.Send( + new VerifyApprovalPinCommand(request.Pin ?? string.Empty, request.Reason ?? "Cancel order"), + cancellationToken); + + if (approval.IsFailure) + { + return Result.Failure(approval.Error); + } + + approvedBy = approval.Value.ApprovedByUserId; + } + + var ticket = order.Cancel(approvedBy, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, ticket, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/ChangeOrderItemQuantity/ChangeOrderItemQuantityCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/ChangeOrderItemQuantity/ChangeOrderItemQuantityCommand.cs new file mode 100644 index 0000000..03e404a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/ChangeOrderItemQuantity/ChangeOrderItemQuantityCommand.cs @@ -0,0 +1,81 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.ChangeOrderItemQuantity; + +/// +/// Changes how many of a dish were ordered (POS-015, BR-POS-007). +/// +/// +/// The PIN is verified here rather than trusted from the till. A client-side check would be +/// decoration: anything that can call this endpoint could simply skip the prompt and send the +/// change. The approval only means something if the server is the one checking it. +/// +/// A draft carries no PIN requirement — nothing has been sent to the kitchen and no bill exists +/// yet, so making a cashier fetch a manager to fix a mis-key while still taking the order would +/// be pure friction. Approval starts mattering once the order is open. +/// +/// +public sealed record ChangeOrderItemQuantityCommand(Guid OrderId, Guid OrderItemId, int Quantity, string? Pin) + : IRequest>; + +public sealed class ChangeOrderItemQuantityCommandValidator : AbstractValidator +{ + public ChangeOrderItemQuantityCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.OrderItemId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + } +} + +internal sealed class ChangeOrderItemQuantityCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant, ISender sender) + : IRequestHandler> +{ + public async Task> Handle( + ChangeOrderItemQuantityCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status is not (OrderStatus.Draft or OrderStatus.Open)) + { + return Result.Failure(OrderErrors.NotEditable); + } + + if (!order.Items.Any(i => i.Id == request.OrderItemId)) + { + return Result.Failure(OrderErrors.ItemNotFound(request.OrderItemId)); + } + + if (order.Status == OrderStatus.Open) + { + var approval = await sender.Send( + new VerifyApprovalPinCommand(request.Pin ?? string.Empty, "Change item quantity"), cancellationToken); + + if (approval.IsFailure) + { + return Result.Failure(approval.Error); + } + } + + var ticket = order.ChangeItemQuantity(request.OrderItemId, request.Quantity, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, ticket, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/CompleteOrderPayment/CompleteOrderPaymentCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/CompleteOrderPayment/CompleteOrderPaymentCommand.cs new file mode 100644 index 0000000..8effef5 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/CompleteOrderPayment/CompleteOrderPaymentCommand.cs @@ -0,0 +1,112 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Inventory.Common; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.CompleteOrderPayment; + +/// One tender offered against the bill. +/// +/// Cash actually handed over, when it is more than . The difference is +/// the change (POS-024). Null for anything that cannot be over-tendered, like a card. +/// +public sealed record OrderPaymentInput( + OrderPaymentMethod Method, decimal Amount, decimal? TenderedAmount, string? Reference); + +/// +/// Settles a bill and issues the receipt (POS-025), releasing the table (POS-032). +/// +/// +/// This is also where a sale finally reaches the inventory: the dishes on the bill are turned +/// into kitchen stock deductions through each one's recipe (REC-007). Deducting at payment rather +/// than when the KOT prints is what makes voided items cost nothing (BR-POS-014) — a line taken +/// off the bill is simply not on it by the time this runs. +/// +public sealed record CompleteOrderPaymentCommand(Guid OrderId, IReadOnlyCollection Payments) + : IRequest>; + +public sealed class CompleteOrderPaymentCommandValidator : AbstractValidator +{ + public CompleteOrderPaymentCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.Payments).NotEmpty().WithMessage("Record at least one payment."); + + RuleForEach(x => x.Payments).ChildRules(payment => + { + payment.RuleFor(p => p.Amount).GreaterThan(0).WithMessage("A payment must be greater than zero."); + + payment.RuleFor(p => p.TenderedAmount) + .GreaterThanOrEqualTo(p => p.Amount) + .When(p => p.TenderedAmount.HasValue) + .WithMessage("The amount tendered cannot be less than the amount it settles."); + }); + } +} + +internal sealed class CompleteOrderPaymentCommandHandler( + IAppDbContext db, IDateTimeProvider clock, ICurrentUser currentUser, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + CompleteOrderPaymentCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status != OrderStatus.Checkout) + { + return Result.Failure(OrderErrors.NotInCheckout); + } + + var offered = request.Payments.Sum(p => p.Amount); + + if (offered != order.Total) + { + return Result.Failure(OrderErrors.PaymentMismatch(order.Total, offered)); + } + + foreach (var payment in request.Payments) + { + order.AddPayment(payment.Method, payment.Amount, payment.TenderedAmount, payment.Reference); + } + + var now = clock.UtcNow; + var receipt = order.Complete(BuildReceiptNumber(order.OrderNumber, order.OrderDate ?? clock.Today), now); + + var consumption = await SaleStockConsumption.ApplyAsync( + db, + [.. order.ActiveItems.Select(i => new SoldItem(i.MenuItemId, i.Quantity))], + currentUser.UserId!.Value, + now, + cancellationToken); + + if (consumption.IsFailure) + { + return Result.Failure(consumption.Error); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await ReceiptDocumentFactory.BuildAsync(db, order, receipt, restaurant, cancellationToken)); + } + + /// + /// Builds the customer-facing reference, e.g. "REC-001-2026" — the order's own number and the + /// year it was taken, which is enough to find it again from a slip of paper. + /// + private static string BuildReceiptNumber(int? orderNumber, DateOnly orderDate) => + $"REC-{orderNumber ?? 0:000}-{orderDate.Year}"; +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/ConfirmOrder/ConfirmOrderCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/ConfirmOrder/ConfirmOrderCommand.cs new file mode 100644 index 0000000..1c5c27e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/ConfirmOrder/ConfirmOrderCommand.cs @@ -0,0 +1,73 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.ConfirmOrder; + +/// +/// Saves the order and sends it to the kitchen (POS-008, POS-010, BR-POS-003, BR-POS-004). This +/// is where the order gets the number staff will call it by for the rest of the night (POS-009). +/// +public sealed record ConfirmOrderCommand(Guid OrderId) : IRequest>; + +public sealed class ConfirmOrderCommandValidator : AbstractValidator +{ + public ConfirmOrderCommandValidator() => RuleFor(x => x.OrderId).NotEmpty(); +} + +internal sealed class ConfirmOrderCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + ConfirmOrderCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status != OrderStatus.Draft) + { + return Result.Failure(OrderErrors.NotDraft); + } + + if (!order.ActiveItems.Any()) + { + return Result.Failure(OrderErrors.NoItems); + } + + var today = clock.Today; + var orderNumber = await NextOrderNumberAsync(today, cancellationToken); + + var ticket = order.Confirm(orderNumber, today, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, ticket, restaurant, cancellationToken)); + } + + /// + /// The next number in today's sequence. Taken from the highest number already issued today + /// rather than from a count, so cancelled orders keep their number and the sequence never + /// hands the same one out twice (BR-POS-016). + /// + private async Task NextOrderNumberAsync(DateOnly today, CancellationToken cancellationToken) + { + var highest = await db.Orders + .Where(o => o.OrderDate == today) + .MaxAsync(o => (int?)o.OrderNumber, cancellationToken); + + return (highest ?? 0) + 1; + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs new file mode 100644 index 0000000..b60916d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs @@ -0,0 +1,74 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.CreateOrder; + +/// +/// Opens a draft bill on a table (POS-001). The draft is persisted straight away rather than kept +/// at the till, so a cashier can walk between tables mid-order and nothing is lost if the machine +/// is restarted. It holds the table from this moment (POS-031). +/// +public sealed record CreateOrderCommand(Guid TableId) : IRequest>; + +public sealed class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() => RuleFor(x => x.TableId).NotEmpty(); +} + +internal sealed class CreateOrderCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + private static readonly OrderStatus[] LiveStatuses = + [OrderStatus.Draft, OrderStatus.Open, OrderStatus.Checkout]; + + public async Task> Handle(CreateOrderCommand request, CancellationToken cancellationToken) + { + var table = await db.RestaurantTables + .FirstOrDefaultAsync(t => t.Id == request.TableId, cancellationToken); + + if (table is null) + { + return Result.Failure(OrderErrors.TableNotFound(request.TableId)); + } + + if (!table.IsActive) + { + return Result.Failure(OrderErrors.TableInactive); + } + + // One live order per table: two bills on the same table would each show a partial total + // and the customer would be asked to pay twice for one sitting. + var occupied = await db.Orders + .AnyAsync(o => o.TableId == table.Id && LiveStatuses.Contains(o.Status), cancellationToken); + + if (occupied) + { + return Result.Failure(OrderErrors.TableOccupied); + } + + var order = Order.Create(table.Id, currentUser.UserId!.Value); + db.Orders.Add(order); + await db.SaveChangesAsync(cancellationToken); + + var cashierName = await db.Users + .Where(u => u.Id == order.CashierUserId) + .Select(u => u.FullName) + .FirstAsync(cancellationToken); + + var saved = await OrderRepository.FindAsync(db, order.Id, cancellationToken); + + return Result.Success(saved!.ToDto(table.Number, cashierName)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/CreateTable/CreateTableCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/CreateTable/CreateTableCommand.cs new file mode 100644 index 0000000..0de6cf7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/CreateTable/CreateTableCommand.cs @@ -0,0 +1,50 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.CreateTable; + +/// Adds a table to the floor plan so orders can be taken against it. +public sealed record CreateTableCommand(string Number, int Seats, string? Notes) : IRequest>; + +public sealed class CreateTableCommandValidator : AbstractValidator +{ + public CreateTableCommandValidator() + { + RuleFor(x => x.Number).NotEmpty().MaximumLength(RestaurantTable.NumberMaxLength); + RuleFor(x => x.Seats).GreaterThanOrEqualTo(0); + RuleFor(x => x.Notes).MaximumLength(RestaurantTable.NotesMaxLength); + } +} + +internal sealed class CreateTableCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateTableCommand request, CancellationToken cancellationToken) + { + var number = request.Number.Trim(); + + var exists = await db.RestaurantTables + .AnyAsync(t => t.Number.ToLower() == number.ToLower(), cancellationToken); + + if (exists) + { + return Result.Failure(OrderErrors.TableNumberTaken); + } + + var table = RestaurantTable.Create(number, request.Seats, request.Notes); + db.RestaurantTables.Add(table); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new TableDto( + table.Id, table.Number, table.Seats, table.Notes, table.IsActive, CurrentOrder: null)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/RemoveOrderItem/RemoveOrderItemCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/RemoveOrderItem/RemoveOrderItemCommand.cs new file mode 100644 index 0000000..8a6dad7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/RemoveOrderItem/RemoveOrderItemCommand.cs @@ -0,0 +1,80 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.RemoveOrderItem; + +/// +/// Takes a dish off a bill (POS-019, BR-POS-008). On an open order this voids the line and sends +/// the kitchen a cancellation slip (POS-020); on a draft it simply deletes it, since the kitchen +/// never heard about it. Kitchen stock is not credited back either way (BR-POS-014) — anything +/// already cooked has genuinely been used up. +/// +public sealed record RemoveOrderItemCommand(Guid OrderId, Guid OrderItemId, string? Pin) + : IRequest>; + +public sealed class RemoveOrderItemCommandValidator : AbstractValidator +{ + public RemoveOrderItemCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.OrderItemId).NotEmpty(); + } +} + +internal sealed class RemoveOrderItemCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant, ISender sender) + : IRequestHandler> +{ + public async Task> Handle( + RemoveOrderItemCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status is not (OrderStatus.Draft or OrderStatus.Open)) + { + return Result.Failure(OrderErrors.NotEditable); + } + + var item = order.Items.FirstOrDefault(i => i.Id == request.OrderItemId); + + if (item is null) + { + return Result.Failure(OrderErrors.ItemNotFound(request.OrderItemId)); + } + + if (item.IsCancelled) + { + return Result.Failure(OrderErrors.NotEditable); + } + + if (order.Status == OrderStatus.Open) + { + var approval = await sender.Send( + new VerifyApprovalPinCommand(request.Pin ?? string.Empty, "Remove item from order"), cancellationToken); + + if (approval.IsFailure) + { + return Result.Failure(approval.Error); + } + } + + var ticket = order.RemoveItem(request.OrderItemId, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, ticket, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/ReopenOrder/ReopenOrderCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/ReopenOrder/ReopenOrderCommand.cs new file mode 100644 index 0000000..ed38785 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/ReopenOrder/ReopenOrderCommand.cs @@ -0,0 +1,51 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.ReopenOrder; + +/// +/// Backs out of the payment screen — the customer decided on one more drink. Any tenders keyed in +/// but not completed are discarded, since the bill they were counted against is about to change. +/// +public sealed record ReopenOrderCommand(Guid OrderId) : IRequest>; + +public sealed class ReopenOrderCommandValidator : AbstractValidator +{ + public ReopenOrderCommandValidator() => RuleFor(x => x.OrderId).NotEmpty(); +} + +internal sealed class ReopenOrderCommandHandler(IAppDbContext db, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + ReopenOrderCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status != OrderStatus.Checkout) + { + return Result.Failure(OrderErrors.NotInCheckout); + } + + var abandoned = order.Payments.ToList(); + order.ReturnToOpen(); + db.OrderPayments.RemoveRange(abandoned); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, null, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/ReprintReceipt/ReprintReceiptCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/ReprintReceipt/ReprintReceiptCommand.cs new file mode 100644 index 0000000..fa53864 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/ReprintReceipt/ReprintReceiptCommand.cs @@ -0,0 +1,50 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.ReprintReceipt; + +/// +/// Prints a receipt again (POS-029). The reprint is counted so a bill that has been printed four +/// times is visible as such — the usual reason for asking is a customer wanting a second copy, +/// but a run of reprints on one order is worth a manager's attention. +/// +public sealed record ReprintReceiptCommand(Guid OrderId) : IRequest>; + +public sealed class ReprintReceiptCommandValidator : AbstractValidator +{ + public ReprintReceiptCommandValidator() => RuleFor(x => x.OrderId).NotEmpty(); +} + +internal sealed class ReprintReceiptCommandHandler( + IAppDbContext db, IDateTimeProvider clock, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + ReprintReceiptCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Receipt is null) + { + return Result.Failure(OrderErrors.NoReceipt); + } + + order.Receipt.RecordReprint(clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success( + await ReceiptDocumentFactory.BuildAsync(db, order, order.Receipt, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/SetOrderDiscount/SetOrderDiscountCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/SetOrderDiscount/SetOrderDiscountCommand.cs new file mode 100644 index 0000000..6e53be0 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/SetOrderDiscount/SetOrderDiscountCommand.cs @@ -0,0 +1,60 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.SetOrderDiscount; + +/// Applies money off a bill, as a percentage or a flat amount (POS-007). +public sealed record SetOrderDiscountCommand(Guid OrderId, DiscountType Type, decimal Value) + : IRequest>; + +public sealed class SetOrderDiscountCommandValidator : AbstractValidator +{ + public SetOrderDiscountCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.Value).GreaterThanOrEqualTo(0).WithMessage("A discount cannot be negative."); + + RuleFor(x => x.Value) + .LessThanOrEqualTo(100) + .When(x => x.Type == DiscountType.Percentage) + .WithMessage("A percentage discount cannot exceed 100%."); + } +} + +internal sealed class SetOrderDiscountCommandHandler(IAppDbContext db, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + SetOrderDiscountCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status is not (OrderStatus.Draft or OrderStatus.Open)) + { + return Result.Failure(OrderErrors.NotEditable); + } + + if (request.Type == DiscountType.Fixed && request.Value > order.Subtotal) + { + return Result.Failure(OrderErrors.DiscountExceedsSubtotal(order.Subtotal)); + } + + order.SetDiscount(request.Type, request.Value); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, null, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/SetTableActive/SetTableActiveCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/SetTableActive/SetTableActiveCommand.cs new file mode 100644 index 0000000..ad46996 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/SetTableActive/SetTableActiveCommand.cs @@ -0,0 +1,65 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.SetTableActive; + +/// Takes a table in or out of service, e.g. while it is being repaired. +public sealed record SetTableActiveCommand(Guid TableId, bool IsActive) : IRequest>; + +public sealed class SetTableActiveCommandValidator : AbstractValidator +{ + public SetTableActiveCommandValidator() => RuleFor(x => x.TableId).NotEmpty(); +} + +internal sealed class SetTableActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + private static readonly OrderStatus[] LiveStatuses = + [OrderStatus.Draft, OrderStatus.Open, OrderStatus.Checkout]; + + public async Task> Handle(SetTableActiveCommand request, CancellationToken cancellationToken) + { + var table = await db.RestaurantTables.FirstOrDefaultAsync(t => t.Id == request.TableId, cancellationToken); + + if (table is null) + { + return Result.Failure(OrderErrors.TableNotFound(request.TableId)); + } + + // Pulling a table with customers sitting at it would strand their bill: the order could + // no longer be reached from the floor plan, but it would still be holding the table. + if (!request.IsActive) + { + var hasLiveOrder = await db.Orders + .AnyAsync(o => o.TableId == table.Id && LiveStatuses.Contains(o.Status), cancellationToken); + + if (hasLiveOrder) + { + return Result.Failure(OrderErrors.TableInUse); + } + } + + if (request.IsActive) + { + table.Activate(); + } + else + { + table.Deactivate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new TableDto( + table.Id, table.Number, table.Seats, table.Notes, table.IsActive, CurrentOrder: null)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/StartCheckout/StartCheckoutCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/StartCheckout/StartCheckoutCommand.cs new file mode 100644 index 0000000..5b8672a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/StartCheckout/StartCheckoutCommand.cs @@ -0,0 +1,48 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.StartCheckout; + +/// +/// Moves a table to the payment screen, freezing the bill so it cannot change while the customer +/// is counting out money (POS-038 — one table settled per transaction). +/// +public sealed record StartCheckoutCommand(Guid OrderId) : IRequest>; + +public sealed class StartCheckoutCommandValidator : AbstractValidator +{ + public StartCheckoutCommandValidator() => RuleFor(x => x.OrderId).NotEmpty(); +} + +internal sealed class StartCheckoutCommandHandler(IAppDbContext db, IRestaurantProfile restaurant) + : IRequestHandler> +{ + public async Task> Handle( + StartCheckoutCommand request, CancellationToken cancellationToken) + { + var order = await OrderRepository.FindAsync(db, request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + if (order.Status != OrderStatus.Open) + { + return Result.Failure(OrderErrors.NotOpen); + } + + order.StartCheckout(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await OrderResultFactory.BuildAsync(db, order, null, restaurant, cancellationToken)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Commands/UpdateTable/UpdateTableCommand.cs b/backend/src/RestaurantPOS.Application/Orders/Commands/UpdateTable/UpdateTableCommand.cs new file mode 100644 index 0000000..b2b6078 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Commands/UpdateTable/UpdateTableCommand.cs @@ -0,0 +1,57 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Commands.UpdateTable; + +public sealed record UpdateTableCommand(Guid TableId, string Number, int Seats, string? Notes) + : IRequest>; + +public sealed class UpdateTableCommandValidator : AbstractValidator +{ + public UpdateTableCommandValidator() + { + RuleFor(x => x.TableId).NotEmpty(); + RuleFor(x => x.Number).NotEmpty().MaximumLength(RestaurantTable.NumberMaxLength); + RuleFor(x => x.Seats).GreaterThanOrEqualTo(0); + RuleFor(x => x.Notes).MaximumLength(RestaurantTable.NotesMaxLength); + } +} + +internal sealed class UpdateTableCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateTableCommand request, CancellationToken cancellationToken) + { + var table = await db.RestaurantTables.FirstOrDefaultAsync(t => t.Id == request.TableId, cancellationToken); + + if (table is null) + { + return Result.Failure(OrderErrors.TableNotFound(request.TableId)); + } + + var number = request.Number.Trim(); + + var taken = await db.RestaurantTables + .AnyAsync(t => t.Id != request.TableId && t.Number.ToLower() == number.ToLower(), cancellationToken); + + if (taken) + { + return Result.Failure(OrderErrors.TableNumberTaken); + } + + table.UpdateDetails(number, request.Seats, request.Notes); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new TableDto( + table.Id, table.Number, table.Seats, table.Notes, table.IsActive, CurrentOrder: null)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Common/OrderRepository.cs b/backend/src/RestaurantPOS.Application/Orders/Common/OrderRepository.cs new file mode 100644 index 0000000..f633544 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Common/OrderRepository.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Orders.Common; + +/// +/// The one way to load an order. +/// +/// +/// Every collection on the aggregate sits behind a private backing field, so a handler that +/// loads an order without eager-loading them sees an empty bill and silently computes a total of +/// zero — no exception, just a wrong number. That failure has already been shipped twice in this +/// codebase on other aggregates, so loading is centralised here rather than left to each handler +/// to remember. +/// +public static class OrderRepository +{ + /// Loads an order with everything the aggregate needs to compute totals and tickets. + public static Task FindAsync(IAppDbContext db, Guid orderId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + + return WithAggregate(db.Orders).FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken); + } + + /// Applies the full set of includes to an order query. + public static IQueryable WithAggregate(IQueryable orders) + { + ArgumentNullException.ThrowIfNull(orders); + + return orders + .Include(o => o.Items) + .Include(o => o.Tickets) + .ThenInclude(t => t.Lines) + .Include(o => o.Payments) + .Include(o => o.Receipt); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Common/OrderResultFactory.cs b/backend/src/RestaurantPOS.Application/Orders/Common/OrderResultFactory.cs new file mode 100644 index 0000000..143c6b0 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Common/OrderResultFactory.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Orders.Common; + +/// +/// Assembles the response every order command returns: the order as it now stands, plus the +/// kitchen slip the change obliges — looking up the table number and cashier name each needs. +/// +public static class OrderResultFactory +{ + public static async Task BuildAsync( + IAppDbContext db, + Order order, + KitchenTicket? ticket, + IRestaurantProfile restaurant, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(order); + ArgumentNullException.ThrowIfNull(restaurant); + + var tableNumber = await db.RestaurantTables + .Where(t => t.Id == order.TableId) + .Select(t => t.Number) + .FirstAsync(cancellationToken); + + var cashierName = await db.Users + .Where(u => u.Id == order.CashierUserId) + .Select(u => u.FullName) + .FirstAsync(cancellationToken); + + var kot = ticket?.ToKotDocument(order, restaurant.Name, tableNumber, cashierName); + + return new OrderMutationDto(order.ToDto(tableNumber, cashierName), kot); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Common/ReceiptDocumentFactory.cs b/backend/src/RestaurantPOS.Application/Orders/Common/ReceiptDocumentFactory.cs new file mode 100644 index 0000000..627f315 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Common/ReceiptDocumentFactory.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Orders.Common; + +/// +/// Composes the printable receipt from a settled order. +/// +/// +/// Rebuilt from the order every time rather than stored as rendered text, which is what makes a +/// reprint (POS-029) provably identical to the original: a completed order can no longer be +/// edited, so there is exactly one set of numbers it can ever produce. +/// +public static class ReceiptDocumentFactory +{ + public static async Task BuildAsync( + IAppDbContext db, + Order order, + Receipt receipt, + IRestaurantProfile restaurant, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(order); + ArgumentNullException.ThrowIfNull(receipt); + ArgumentNullException.ThrowIfNull(restaurant); + + var tableNumber = await db.RestaurantTables + .Where(t => t.Id == order.TableId) + .Select(t => t.Number) + .FirstAsync(cancellationToken); + + var cashierName = await db.Users + .Where(u => u.Id == order.CashierUserId) + .Select(u => u.FullName) + .FirstAsync(cancellationToken); + + return new ReceiptDocumentDto( + receipt.Number, + restaurant.Name, + restaurant.AddressLine1, + restaurant.AddressLine2, + restaurant.City, + restaurant.Phone, + order.OrderNumber, + tableNumber, + cashierName, + receipt.IssuedAtUtc, + receipt.PrintCount, + [.. order.ActiveItems + .OrderBy(i => i.CreatedAtUtc) + .Select(i => new ReceiptLineDto(i.MenuItemName, i.Quantity, i.UnitPrice, i.LineTotal))], + order.Subtotal, + order.DiscountAmount, + TaxAmount: 0m, + order.Total, + order.ChangeDue, + [.. order.Payments.OrderBy(p => p.CreatedAtUtc).Select(p => p.ToDto())], + receipt.Number); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Dtos/OrderDtos.cs b/backend/src/RestaurantPOS.Application/Orders/Dtos/OrderDtos.cs new file mode 100644 index 0000000..24f3b93 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Dtos/OrderDtos.cs @@ -0,0 +1,71 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Orders.Dtos; + +/// A full bill with its lines, tickets and tenders. +public sealed record OrderDto( + Guid Id, + int? OrderNumber, + DateOnly? OrderDate, + Guid TableId, + string TableNumber, + OrderStatus Status, + Guid CashierUserId, + string CashierName, + DateTime CreatedAtUtc, + DateTime? ConfirmedAtUtc, + DateTime? CompletedAtUtc, + DiscountType DiscountType, + decimal DiscountValue, + decimal Subtotal, + decimal DiscountAmount, + decimal Total, + decimal AmountPaid, + decimal ChangeDue, + KitchenTicketStatus? KitchenStatus, + string? ReceiptNumber, + IReadOnlyCollection Items, + IReadOnlyCollection Payments); + +public sealed record OrderItemDto( + Guid Id, + Guid MenuItemId, + string MenuItemName, + decimal UnitPrice, + int Quantity, + string? SpecialInstructions, + bool IsCancelled, + decimal LineTotal); + +public sealed record OrderPaymentDto( + Guid Id, + OrderPaymentMethod Method, + decimal Amount, + decimal? TenderedAmount, + decimal ChangeGiven, + string? Reference, + DateTime CreatedAtUtc); + +/// +/// An order after a change, together with any slip the change obliges the kitchen to be sent. +/// +/// +/// Returned as one object so the till cannot apply a change and forget to print: the response to +/// "add these items" already contains the ticket that has to go out (BR-POS-006). Kot is +/// null when nothing needs printing — an edit to a draft the kitchen has never seen. +/// +public sealed record OrderMutationDto(OrderDto Order, KotDocumentDto? Kot); + +/// An order's header for the dashboard and search results, without its lines. +public sealed record OrderSummaryDto( + Guid Id, + int? OrderNumber, + Guid TableId, + string TableNumber, + OrderStatus Status, + string CashierName, + DateTime CreatedAtUtc, + DateTime? ConfirmedAtUtc, + int ItemCount, + decimal Total, + KitchenTicketStatus? KitchenStatus); diff --git a/backend/src/RestaurantPOS.Application/Orders/Dtos/PrintDtos.cs b/backend/src/RestaurantPOS.Application/Orders/Dtos/PrintDtos.cs new file mode 100644 index 0000000..bb7e675 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Dtos/PrintDtos.cs @@ -0,0 +1,53 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Orders.Dtos; + +/// +/// Everything that goes on a printed slip, assembled server-side. +/// +/// +/// The API returns the content of a document rather than rendering it, because the printer lives +/// on the cashier's machine: the desktop shell lays this out as an 80mm page and sends it to the +/// Windows printer driver. Composing the numbers here keeps a receipt's arithmetic in one place +/// instead of trusting the till to add the bill up a second time. +/// +public sealed record KotDocumentDto( + Guid TicketId, + string RestaurantName, + int? OrderNumber, + string TableNumber, + int TicketNumber, + KitchenTicketKind Kind, + string CashierName, + DateTime PrintedAtUtc, + int PrintCount, + IReadOnlyCollection Lines); + +public sealed record KotDocumentLineDto( + string MenuItemName, int Quantity, string? SpecialInstructions, string? Note); + +/// A customer receipt as it should appear on 80mm paper (POS-027). +public sealed record ReceiptDocumentDto( + string ReceiptNumber, + string RestaurantName, + string AddressLine1, + string? AddressLine2, + string? City, + string? Phone, + int? OrderNumber, + string TableNumber, + string CashierName, + DateTime IssuedAtUtc, + int PrintCount, + IReadOnlyCollection Lines, + decimal Subtotal, + decimal DiscountAmount, + /// Always zero — the restaurant applies no VAT or GST (BR-POS-011). + decimal TaxAmount, + decimal Total, + decimal ChangeGiven, + IReadOnlyCollection Payments, + /// Encoded on the slip as a QR code so a bill can be looked up from paper (POS-028). + string QrPayload); + +public sealed record ReceiptLineDto(string MenuItemName, int Quantity, decimal UnitPrice, decimal LineTotal); diff --git a/backend/src/RestaurantPOS.Application/Orders/Dtos/TableDtos.cs b/backend/src/RestaurantPOS.Application/Orders/Dtos/TableDtos.cs new file mode 100644 index 0000000..defca96 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Dtos/TableDtos.cs @@ -0,0 +1,32 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Orders.Dtos; + +/// +/// What a table looks like on the floor plan. Occupancy and kitchen progress are derived from the +/// table's live order rather than stored, so this can never disagree with the order itself. +/// +public sealed record TableDto( + Guid Id, + string Number, + int Seats, + string? Notes, + bool IsActive, + /// Null when the table is free. + TableOrderSummaryDto? CurrentOrder); + +/// The live order sitting on a table, as much of it as the floor plan needs. +public sealed record TableOrderSummaryDto( + Guid OrderId, + int? OrderNumber, + OrderStatus Status, + int ItemCount, + decimal Total, + DateTime? ConfirmedAtUtc, + string CashierName, + /// + /// How far the kitchen has got overall: the least-advanced ticket still outstanding, so a + /// table with one dish plated and one still queued reads as preparing, not ready. Null while + /// the order is a draft and nothing has been sent. + /// + KitchenTicketStatus? KitchenStatus); diff --git a/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs b/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs new file mode 100644 index 0000000..168f2cd --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs @@ -0,0 +1,42 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Orders.Queries.GetOrderById; + +/// Loads one bill in full, itemised as the cashier sees it on screen (POS-037). +public sealed record GetOrderByIdQuery(Guid OrderId) : IRequest>; + +internal sealed class GetOrderByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) + { + var order = await OrderRepository.WithAggregate(db.Orders.AsNoTracking()) + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(OrderErrors.NotFound(request.OrderId)); + } + + var tableNumber = await db.RestaurantTables + .Where(t => t.Id == order.TableId) + .Select(t => t.Number) + .FirstAsync(cancellationToken); + + var cashierName = await db.Users + .Where(u => u.Id == order.CashierUserId) + .Select(u => u.FullName) + .FirstAsync(cancellationToken); + + return Result.Success(order.ToDto(tableNumber, cashierName)); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs b/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs new file mode 100644 index 0000000..1fc0183 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs @@ -0,0 +1,70 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Common; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Orders.Queries.GetOrders; + +/// +/// Lists orders for the dashboard and for searching (POS-034, POS-036). +/// +/// Restricts to orders still holding a table — the dashboard's default. +/// Matches a table number or an order number, however the cashier remembers it. +public sealed record GetOrdersQuery(bool OpenOnly, OrderStatus? Status, string? Search) + : IRequest>>; + +internal sealed class GetOrdersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private static readonly OrderStatus[] LiveStatuses = + [OrderStatus.Draft, OrderStatus.Open, OrderStatus.Checkout]; + + public async Task>> Handle( + GetOrdersQuery request, CancellationToken cancellationToken) + { + var query = OrderRepository.WithAggregate(db.Orders.AsNoTracking()); + + if (request.OpenOnly) + { + query = query.Where(o => LiveStatuses.Contains(o.Status)); + } + + if (request.Status.HasValue) + { + query = query.Where(o => o.Status == request.Status.Value); + } + + var orders = await query.ToListAsync(cancellationToken); + + var tableNumbers = await db.RestaurantTables.AsNoTracking() + .ToDictionaryAsync(t => t.Id, t => t.Number, cancellationToken); + + var cashierNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + var summaries = orders + .Select(o => o.ToSummaryDto( + tableNumbers.GetValueOrDefault(o.TableId, string.Empty), + cashierNames.GetValueOrDefault(o.CashierUserId, string.Empty))) + .ToList(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim(); + + summaries = [.. summaries.Where(s => + s.TableNumber.Contains(term, StringComparison.OrdinalIgnoreCase) + || (s.OrderNumber?.ToString().Contains(term, StringComparison.Ordinal) ?? false))]; + } + + // Newest first, but drafts and anything not yet numbered sort by when they were started. + return Result.Success>( + [.. summaries.OrderByDescending(s => s.CreatedAtUtc)]); + } +} diff --git a/backend/src/RestaurantPOS.Application/Orders/Queries/GetTables/GetTablesQuery.cs b/backend/src/RestaurantPOS.Application/Orders/Queries/GetTables/GetTablesQuery.cs new file mode 100644 index 0000000..331fcf2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Orders/Queries/GetTables/GetTablesQuery.cs @@ -0,0 +1,77 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Orders.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Orders.Queries.GetTables; + +/// +/// The floor plan: every table with whatever order is sitting on it (POS-034). This is the +/// cashier's home screen, so it carries enough of each live order — item count, total, kitchen +/// progress — to decide where to go next without opening anything. +/// +public sealed record GetTablesQuery(bool? IsActive) : IRequest>>; + +internal sealed class GetTablesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private static readonly OrderStatus[] LiveStatuses = + [OrderStatus.Draft, OrderStatus.Open, OrderStatus.Checkout]; + + public async Task>> Handle( + GetTablesQuery request, CancellationToken cancellationToken) + { + var tablesQuery = db.RestaurantTables.AsNoTracking(); + + if (request.IsActive.HasValue) + { + tablesQuery = tablesQuery.Where(t => t.IsActive == request.IsActive.Value); + } + + var tables = await tablesQuery.ToListAsync(cancellationToken); + + var liveOrders = await db.Orders.AsNoTracking() + .Include(o => o.Items) + .Include(o => o.Tickets) + .Where(o => LiveStatuses.Contains(o.Status)) + .ToListAsync(cancellationToken); + + var cashierNames = await db.Users.AsNoTracking() + .Where(u => liveOrders.Select(o => o.CashierUserId).Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + var ordersByTable = liveOrders.ToDictionary(o => o.TableId); + + var dtos = tables + .Select(table => + { + var order = ordersByTable.GetValueOrDefault(table.Id); + + var summary = order is null + ? null + : new TableOrderSummaryDto( + order.Id, + order.OrderNumber, + order.Status, + order.ActiveItems.Count(), + order.Total, + order.ConfirmedAtUtc, + cashierNames.GetValueOrDefault(order.CashierUserId, string.Empty), + OrderMappings.DeriveKitchenStatus(order.Tickets)); + + return new TableDto(table.Id, table.Number, table.Seats, table.Notes, table.IsActive, summary); + }) + // Numeric where the labels are numbers ("2" before "10"), alphabetical otherwise, so + // the floor plan reads in the order staff walk the room. + .OrderBy(t => int.TryParse(t.Number, out var n) ? n : int.MaxValue) + .ThenBy(t => t.Number, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Result.Success>(dtos); + } +} diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs new file mode 100644 index 0000000..be1e3a2 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/CreateMenuItem/CreateMenuItemCommand.cs @@ -0,0 +1,57 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.CreateMenuItem; + +public sealed record CreateMenuItemCommand(string Name, string Category, decimal Price) + : IRequest>; + +public sealed class CreateMenuItemCommandValidator : AbstractValidator +{ + public CreateMenuItemCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(MenuItem.NameMaxLength); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required.") + .MaximumLength(MenuItem.CategoryMaxLength); + + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class CreateMenuItemCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateMenuItemCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.MenuItems.AnyAsync(m => m.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(RecipeErrors.MenuItemNameTaken); + } + + var menuItem = MenuItem.Create(name, request.Category, request.Price); + + db.MenuItems.Add(menuItem); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + HasRecipe: false, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs new file mode 100644 index 0000000..3e8443c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/DeleteRecipe/DeleteRecipeCommand.cs @@ -0,0 +1,52 @@ +using System.Text.Json; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.DeleteRecipe; + +/// +/// Removes a menu item's recipe. The audit log keeps a full snapshot of what was deleted +/// (BR-REC-007), even though the live row is gone — a fresh recipe can be created afterwards. +/// +public sealed record DeleteRecipeCommand(Guid MenuItemId) : IRequest; + +internal sealed class DeleteRecipeCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(DeleteRecipeCommand request, CancellationToken cancellationToken) + { + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + if (recipe is null) + { + return Result.Failure(RecipeErrors.RecipeNotFound(request.MenuItemId)); + } + + var snapshot = recipe.Lines.Select(l => new { l.RawMaterialId, l.Quantity }).ToList(); + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: "Deleted", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: clock.UtcNow, + summary: $"Deleted recipe that had {snapshot.Count} ingredient(s).", + detailsJson: JsonSerializer.Serialize(snapshot)); + + db.Recipes.Remove(recipe); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs new file mode 100644 index 0000000..0248de0 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetMenuItemActive/SetMenuItemActiveCommand.cs @@ -0,0 +1,47 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.SetMenuItemActive; + +/// +/// Activates or deactivates a menu item. There is no delete: past orders (once Point of Sale +/// exists) will reference this row, so history must not disappear from under them. +/// +public sealed record SetMenuItemActiveCommand(Guid MenuItemId, bool IsActive) : IRequest>; + +internal sealed class SetMenuItemActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetMenuItemActiveCommand request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + if (request.IsActive) + { + menuItem.Activate(); + } + else + { + menuItem.Deactivate(); + } + + var hasRecipe = await db.Recipes.AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs new file mode 100644 index 0000000..16f9648 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/SetRecipeEnabled/SetRecipeEnabledCommand.cs @@ -0,0 +1,54 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.SetRecipeEnabled; + +/// Enables or disables a recipe (BR-REC-006). A disabled recipe cannot be used for a new sale. +public sealed record SetRecipeEnabledCommand(Guid MenuItemId, bool IsEnabled) : IRequest>; + +internal sealed class SetRecipeEnabledCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetRecipeEnabledCommand request, CancellationToken cancellationToken) + { + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + if (recipe is null) + { + return Result.Failure(RecipeErrors.RecipeNotFound(request.MenuItemId)); + } + + if (request.IsEnabled) + { + recipe.Enable(); + } + else + { + recipe.Disable(); + } + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: request.IsEnabled ? "Enabled" : "Disabled", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: clock.UtcNow, + summary: request.IsEnabled ? "Recipe enabled." : "Recipe disabled."); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs new file mode 100644 index 0000000..180f97f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpdateMenuItem/UpdateMenuItemCommand.cs @@ -0,0 +1,69 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.UpdateMenuItem; + +public sealed record UpdateMenuItemCommand(Guid MenuItemId, string Name, string Category, decimal Price) + : IRequest>; + +public sealed class UpdateMenuItemCommandValidator : AbstractValidator +{ + public UpdateMenuItemCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(MenuItem.NameMaxLength); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required.") + .MaximumLength(MenuItem.CategoryMaxLength); + + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class UpdateMenuItemCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateMenuItemCommand request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.MenuItems.AnyAsync( + m => m.Id != request.MenuItemId && m.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(RecipeErrors.MenuItemNameTaken); + } + + menuItem.UpdateDetails(name, request.Category, request.Price); + + var hasRecipe = await db.Recipes.AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs new file mode 100644 index 0000000..f0ef52b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Commands/UpsertRecipe/UpsertRecipeCommand.cs @@ -0,0 +1,110 @@ +using System.Text.Json; + +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Audit; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Commands.UpsertRecipe; + +/// One ingredient the caller wants on the recipe. +public sealed record RecipeLineInput(Guid RawMaterialId, decimal Quantity); + +/// +/// Creates a menu item's recipe if it has none, or replaces its lines wholesale if it already +/// does (REC-006). One endpoint for both, since "one recipe per menu item" (BR-REC-001) makes +/// create-vs-update a distinction the caller should not need to track. +/// +public sealed record UpsertRecipeCommand(Guid MenuItemId, IReadOnlyCollection Lines) + : IRequest>; + +public sealed class UpsertRecipeCommandValidator : AbstractValidator +{ + public UpsertRecipeCommandValidator() + { + RuleFor(x => x.MenuItemId).NotEmpty(); + + RuleFor(x => x.Lines) + .NotEmpty().WithMessage("A recipe must contain at least one raw material."); + + RuleForEach(x => x.Lines).ChildRules(line => + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero.")); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once in the same recipe.") + .When(x => x.Lines.Count > 0); + } +} + +internal sealed class UpsertRecipeCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(UpsertRecipeCommand request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + + var rawMaterials = await db.RawMaterials + .Where(r => rawMaterialIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + if (rawMaterials.Count != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(RecipeErrors.UnknownRawMaterial); + } + + if (rawMaterials.Values.Any(r => !r.IsActive)) + { + return Result.Failure(RecipeErrors.InactiveRawMaterial); + } + + var lines = request.Lines.Select(l => (l.RawMaterialId, l.Quantity)).ToList(); + + var recipe = await db.Recipes + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + var isNew = recipe is null; + + if (recipe is null) + { + recipe = Recipe.Create(request.MenuItemId, lines); + db.Recipes.Add(recipe); + } + else + { + recipe.ReplaceLines(lines); + } + + var now = clock.UtcNow; + + AuditLog.Record( + db, + entityType: "Recipe", + entityId: recipe.Id, + action: isNew ? "Created" : "Updated", + performedByUserId: currentUser.UserId!.Value, + performedByName: currentUser.Username ?? "unknown", + nowUtc: now, + summary: $"{(isNew ? "Created" : "Updated")} recipe with {lines.Count} ingredient(s).", + detailsJson: JsonSerializer.Serialize(lines)); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs b/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs new file mode 100644 index 0000000..6e5db96 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Dtos/MenuItemDto.cs @@ -0,0 +1,11 @@ +namespace RestaurantPOS.Application.Recipes.Dtos; + +/// A menu item as presented to the client. +public sealed record MenuItemDto( + Guid Id, + string Name, + string Category, + decimal Price, + bool IsActive, + bool HasRecipe, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs b/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs new file mode 100644 index 0000000..6c118f1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Dtos/RecipeDto.cs @@ -0,0 +1,18 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Recipes.Dtos; + +/// One ingredient line, with the raw material's own display details denormalised in. +public sealed record RecipeLineDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Quantity); + +public sealed record RecipeDto( + Guid Id, + Guid MenuItemId, + bool IsEnabled, + IReadOnlyCollection Lines, + DateTime CreatedAtUtc, + DateTime? UpdatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs new file mode 100644 index 0000000..e531506 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItemById/GetMenuItemByIdQuery.cs @@ -0,0 +1,33 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Queries.GetMenuItemById; + +public sealed record GetMenuItemByIdQuery(Guid MenuItemId) : IRequest>; + +internal sealed class GetMenuItemByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetMenuItemByIdQuery request, CancellationToken cancellationToken) + { + var menuItem = await db.MenuItems.AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == request.MenuItemId, cancellationToken); + + if (menuItem is null) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var hasRecipe = await db.Recipes.AsNoTracking().AnyAsync(r => r.MenuItemId == menuItem.Id, cancellationToken); + + return Result.Success(new MenuItemDto( + menuItem.Id, menuItem.Name, menuItem.Category, menuItem.Price, menuItem.IsActive, + hasRecipe, menuItem.CreatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs new file mode 100644 index 0000000..dd76ce6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetMenuItems/GetMenuItemsQuery.cs @@ -0,0 +1,59 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Recipes.Queries.GetMenuItems; + +/// Matches against name or category. +/// Restricts to a single category when supplied. +/// Restricts to active or inactive items when supplied. +public sealed record GetMenuItemsQuery(string? Search, string? Category, bool? IsActive) + : IRequest>>; + +internal sealed class GetMenuItemsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetMenuItemsQuery request, + CancellationToken cancellationToken) + { + var query = db.MenuItems.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(m => m.Name.ToLower().Contains(term) || m.Category.ToLower().Contains(term)); + } + + if (!string.IsNullOrWhiteSpace(request.Category)) + { + query = query.Where(m => m.Category == request.Category); + } + + if (request.IsActive is not null) + { + query = query.Where(m => m.IsActive == request.IsActive.Value); + } + + var recipeMenuItemIds = await db.Recipes.AsNoTracking().Select(r => r.MenuItemId).ToListAsync(cancellationToken); + var withRecipe = recipeMenuItemIds.ToHashSet(); + + var items = await query + .OrderByDescending(m => m.IsActive) + .ThenBy(m => m.Category) + .ThenBy(m => m.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = + [ + .. items.Select(m => new MenuItemDto( + m.Id, m.Name, m.Category, m.Price, m.IsActive, withRecipe.Contains(m.Id), m.CreatedAtUtc)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs new file mode 100644 index 0000000..338aead --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Recipes/Queries/GetRecipeByMenuItem/GetRecipeByMenuItemQuery.cs @@ -0,0 +1,36 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Recipes.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Recipes.Queries.GetRecipeByMenuItem; + +/// +/// Displays every ingredient associated with a menu item (REC-011). Not every menu item has a +/// recipe, so a missing one is reported clearly rather than treated as an error. +/// +public sealed record GetRecipeByMenuItemQuery(Guid MenuItemId) : IRequest>; + +internal sealed class GetRecipeByMenuItemQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetRecipeByMenuItemQuery request, CancellationToken cancellationToken) + { + var menuItemExists = await db.MenuItems.AnyAsync(m => m.Id == request.MenuItemId, cancellationToken); + if (!menuItemExists) + { + return Result.Failure(RecipeErrors.MenuItemNotFound(request.MenuItemId)); + } + + var recipe = await db.Recipes.AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.MenuItemId == request.MenuItemId, cancellationToken); + + return Result.Success(recipe is null ? null : await recipe.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj b/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj index 5dd7e0e..aa3bb65 100644 --- a/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj +++ b/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj @@ -12,8 +12,11 @@ + + + diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs new file mode 100644 index 0000000..a739df9 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CancelPurchaseOrder/CancelPurchaseOrderCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CancelPurchaseOrder; + +public sealed record CancelPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class CancelPurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + CancelPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status is PurchaseOrderStatus.Delivered or PurchaseOrderStatus.Cancelled) + { + return Result.Failure(SupplierErrors.NotCancellable); + } + + order.Cancel(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs new file mode 100644 index 0000000..b911fe5 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/ConfirmPurchaseOrder/ConfirmPurchaseOrderCommand.cs @@ -0,0 +1,41 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.ConfirmPurchaseOrder; + +/// Records that the supplier has agreed to fulfil the order as sent. +public sealed record ConfirmPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class ConfirmPurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ConfirmPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Submitted) + { + return Result.Failure(SupplierErrors.NotConfirmable); + } + + order.Confirm(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs new file mode 100644 index 0000000..e32af60 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreatePurchaseOrder/CreatePurchaseOrderCommand.cs @@ -0,0 +1,84 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CreatePurchaseOrder; + +public sealed record PurchaseOrderLineInput(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +/// Creates a new purchase order in Draft, ready to be edited further before being sent (INV/PO-001). +public sealed record CreatePurchaseOrderCommand( + Guid SupplierId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes) : IRequest>; + +public sealed class CreatePurchaseOrderCommandValidator : AbstractValidator +{ + public CreatePurchaseOrderCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + { + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative."); + }); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same purchase order.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(PurchaseOrder.NotesMaxLength); + } +} + +internal sealed class CreatePurchaseOrderCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle( + CreatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (!supplier.IsActive) + { + return Result.Failure(SupplierErrors.Inactive); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterialCount = await db.RawMaterials.CountAsync(r => rawMaterialIds.Contains(r.Id), cancellationToken); + if (rawMaterialCount != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds[0])); + } + + var order = PurchaseOrder.Create( + request.SupplierId, + currentUser.UserId!.Value, + request.Lines.Select(l => (l.RawMaterialId, l.Quantity, l.UnitPrice)), + request.ExpectedDeliveryDate, + request.Notes); + + db.PurchaseOrders.Add(order); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs new file mode 100644 index 0000000..3577e9f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/CreateSupplier/CreateSupplierCommand.cs @@ -0,0 +1,86 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.CreateSupplier; + +public sealed record CreateSupplierCommand( + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays) : IRequest>; + +public sealed class CreateSupplierCommandValidator : AbstractValidator +{ + public CreateSupplierCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(Supplier.NameMaxLength); + + RuleFor(x => x.ContactName).MaximumLength(Supplier.ContactNameMaxLength); + RuleFor(x => x.Phone).MaximumLength(Supplier.PhoneMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(Supplier.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Address).MaximumLength(Supplier.AddressMaxLength); + + RuleFor(x => x.PaymentTermsDays).GreaterThanOrEqualTo(0) + .WithMessage("Payment terms cannot be a negative number of days."); + + RuleFor(x => x.CreditLimit).GreaterThanOrEqualTo(0) + .WithMessage("Credit limit cannot be negative.") + .When(x => x.CreditLimit is not null); + + RuleFor(x => x.LeadTimeDays).GreaterThanOrEqualTo(0) + .WithMessage("Lead time cannot be a negative number of days.") + .When(x => x.LeadTimeDays is not null); + } +} + +internal sealed class CreateSupplierCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateSupplierCommand request, CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var exists = await db.Suppliers.AnyAsync(s => s.Name.ToLower() == normalised, cancellationToken); + if (exists) + { + return Result.Failure(SupplierErrors.NameTaken); + } + + var supplier = Supplier.Create( + name, + request.ContactName, + request.Phone, + request.Email, + request.Address, + request.PaymentTermsDays, + request.CreditLimit, + request.LeadTimeDays); + + db.Suppliers.Add(supplier); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs new file mode 100644 index 0000000..bf693ff --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/RecordSupplierPayment/RecordSupplierPaymentCommand.cs @@ -0,0 +1,81 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.RecordSupplierPayment; + +public sealed record RecordSupplierPaymentCommand( + Guid PurchaseOrderId, + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + string? Notes) : IRequest>; + +public sealed class RecordSupplierPaymentCommandValidator : AbstractValidator +{ + public RecordSupplierPaymentCommandValidator() + { + RuleFor(x => x.PurchaseOrderId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0).WithMessage("Payment amount must be greater than zero."); + RuleFor(x => x.Method).IsInEnum().WithMessage("Select a valid payment method."); + RuleFor(x => x.InvoiceReference).MaximumLength(SupplierPayment.InvoiceReferenceMaxLength); + RuleFor(x => x.Notes).MaximumLength(SupplierPayment.NotesMaxLength); + } +} + +internal sealed class RecordSupplierPaymentCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle( + RecordSupplierPaymentCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders + .Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + var alreadyPaid = await db.SupplierPayments + .Where(p => p.PurchaseOrderId == request.PurchaseOrderId) + .SumAsync(p => (decimal?)p.Amount, cancellationToken) ?? 0m; + + var balance = order.TotalAmount - alreadyPaid; + + if (request.Amount > balance) + { + return Result.Failure(SupplierErrors.PaymentExceedsBalance(balance)); + } + + var payment = SupplierPayment.Create( + request.PurchaseOrderId, + request.Amount, + request.PaymentDateUtc, + request.Method, + currentUser.UserId!.Value, + request.InvoiceReference, + request.Notes); + + db.SupplierPayments.Add(payment); + await db.SaveChangesAsync(cancellationToken); + + var recordedByName = await db.Users.Where(u => u.Id == payment.RecordedByUserId) + .Select(u => u.FullName).FirstAsync(cancellationToken); + + return Result.Success(new SupplierPaymentDto( + payment.Id, payment.PurchaseOrderId, payment.Amount, payment.PaymentDateUtc, payment.Method, + payment.InvoiceReference, payment.RecordedByUserId, recordedByName, payment.Notes)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs new file mode 100644 index 0000000..8241e99 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierActive/SetSupplierActiveCommand.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SetSupplierActive; + +public sealed record SetSupplierActiveCommand(Guid SupplierId, bool IsActive) : IRequest>; + +internal sealed class SetSupplierActiveCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(SetSupplierActiveCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + if (request.IsActive) + { + supplier.Activate(); + } + else + { + supplier.Deactivate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs new file mode 100644 index 0000000..a5d3c9e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SetSupplierPrice/SetSupplierPriceCommand.cs @@ -0,0 +1,74 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SetSupplierPrice; + +/// +/// Records the current price a supplier charges for a raw material. Every call appends a +/// history entry — prices are never overwritten in place — which is what lets a supplier's +/// price for an ingredient be tracked over time. +/// +public sealed record SetSupplierPriceCommand(Guid SupplierId, Guid RawMaterialId, decimal Price) + : IRequest>; + +public sealed class SetSupplierPriceCommandValidator : AbstractValidator +{ + public SetSupplierPriceCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + RuleFor(x => x.RawMaterialId).NotEmpty(); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative."); + } +} + +internal sealed class SetSupplierPriceCommandHandler(IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetSupplierPriceCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var rawMaterial = await db.RawMaterials.FirstOrDefaultAsync(r => r.Id == request.RawMaterialId, cancellationToken); + if (rawMaterial is null) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(request.RawMaterialId)); + } + + var now = clock.UtcNow; + + var current = await db.SupplierPrices.FirstOrDefaultAsync( + p => p.SupplierId == request.SupplierId && p.RawMaterialId == request.RawMaterialId, cancellationToken); + + if (current is null) + { + current = new SupplierPrice(request.SupplierId, request.RawMaterialId, request.Price, now); + db.SupplierPrices.Add(current); + } + else + { + current.UpdatePrice(request.Price, now); + } + + db.SupplierPriceHistoryEntries.Add(SupplierPriceHistoryEntry.Record( + request.SupplierId, request.RawMaterialId, request.Price, currentUser.UserId!.Value, now)); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new SupplierPriceDto( + supplier.Id, supplier.Name, rawMaterial.Id, rawMaterial.Name, rawMaterial.UnitOfMeasurement, + current.Price, current.UpdatedAtUtc)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs new file mode 100644 index 0000000..378bb4c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/SubmitPurchaseOrder/SubmitPurchaseOrderCommand.cs @@ -0,0 +1,41 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.SubmitPurchaseOrder; + +/// Sends a draft purchase order to its supplier. No further line edits after this point. +public sealed record SubmitPurchaseOrderCommand(Guid PurchaseOrderId) : IRequest>; + +internal sealed class SubmitPurchaseOrderCommandHandler(IAppDbContext db, IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + SubmitPurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Draft) + { + return Result.Failure(SupplierErrors.NotSubmittable); + } + + order.Submit(clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs new file mode 100644 index 0000000..821b12f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdatePurchaseOrder/UpdatePurchaseOrderCommand.cs @@ -0,0 +1,83 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.UpdatePurchaseOrder; + +public sealed record UpdatePurchaseOrderLineInput(Guid RawMaterialId, decimal Quantity, decimal UnitPrice); + +/// Replaces a draft purchase order's lines and details. Only possible while still a Draft. +public sealed record UpdatePurchaseOrderCommand( + Guid PurchaseOrderId, + IReadOnlyCollection Lines, + DateTime? ExpectedDeliveryDate, + string? Notes) : IRequest>; + +public sealed class UpdatePurchaseOrderCommandValidator : AbstractValidator +{ + public UpdatePurchaseOrderCommandValidator() + { + RuleFor(x => x.PurchaseOrderId).NotEmpty(); + + RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line is required."); + + RuleForEach(x => x.Lines).ChildRules(line => + { + line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than zero."); + line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative."); + }); + + RuleFor(x => x.Lines) + .Must(lines => lines.Select(l => l.RawMaterialId).Distinct().Count() == lines.Count) + .WithMessage("A raw material cannot appear more than once on the same purchase order.") + .When(x => x.Lines.Count > 0); + + RuleFor(x => x.Notes).MaximumLength(PurchaseOrder.NotesMaxLength); + } +} + +internal sealed class UpdatePurchaseOrderCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + UpdatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders + .Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + if (order.Status != PurchaseOrderStatus.Draft) + { + return Result.Failure(SupplierErrors.NotDraft); + } + + var rawMaterialIds = request.Lines.Select(l => l.RawMaterialId).ToList(); + var rawMaterialCount = await db.RawMaterials.CountAsync(r => rawMaterialIds.Contains(r.Id), cancellationToken); + if (rawMaterialCount != rawMaterialIds.Distinct().Count()) + { + return Result.Failure(InventoryErrors.RawMaterialNotFound(rawMaterialIds[0])); + } + + order.ReplaceLines(request.Lines.Select(l => (l.RawMaterialId, l.Quantity, l.UnitPrice))); + order.UpdateDetails(request.ExpectedDeliveryDate, request.Notes); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs new file mode 100644 index 0000000..c205009 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Commands/UpdateSupplier/UpdateSupplierCommand.cs @@ -0,0 +1,97 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Commands.UpdateSupplier; + +public sealed record UpdateSupplierCommand( + Guid SupplierId, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays) : IRequest>; + +public sealed class UpdateSupplierCommandValidator : AbstractValidator +{ + public UpdateSupplierCommandValidator() + { + RuleFor(x => x.SupplierId).NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .MaximumLength(Supplier.NameMaxLength); + + RuleFor(x => x.ContactName).MaximumLength(Supplier.ContactNameMaxLength); + RuleFor(x => x.Phone).MaximumLength(Supplier.PhoneMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(Supplier.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Address).MaximumLength(Supplier.AddressMaxLength); + + RuleFor(x => x.PaymentTermsDays).GreaterThanOrEqualTo(0) + .WithMessage("Payment terms cannot be a negative number of days."); + + RuleFor(x => x.CreditLimit).GreaterThanOrEqualTo(0) + .WithMessage("Credit limit cannot be negative.") + .When(x => x.CreditLimit is not null); + + RuleFor(x => x.LeadTimeDays).GreaterThanOrEqualTo(0) + .WithMessage("Lead time cannot be a negative number of days.") + .When(x => x.LeadTimeDays is not null); + } +} + +internal sealed class UpdateSupplierCommandHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateSupplierCommand request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var name = request.Name.Trim(); + var normalised = name.ToLowerInvariant(); + + var nameTaken = await db.Suppliers.AnyAsync( + s => s.Id != request.SupplierId && s.Name.ToLower() == normalised, cancellationToken); + + if (nameTaken) + { + return Result.Failure(SupplierErrors.NameTaken); + } + + supplier.UpdateDetails( + name, + request.ContactName, + request.Phone, + request.Email, + request.Address, + request.PaymentTermsDays, + request.CreditLimit, + request.LeadTimeDays); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(supplier.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs new file mode 100644 index 0000000..bac754e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/PurchaseOrderDto.cs @@ -0,0 +1,40 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record PurchaseOrderLineDto( + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Quantity, + decimal UnitPrice, + decimal LineTotal); + +/// A purchase order's header for a list screen, without its lines. +public sealed record PurchaseOrderSummaryDto( + Guid Id, + Guid SupplierId, + string SupplierName, + PurchaseOrderStatus Status, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + int LineCount); + +public sealed record PurchaseOrderDto( + Guid Id, + Guid SupplierId, + string SupplierName, + PurchaseOrderStatus Status, + Guid CreatedByUserId, + string CreatedByName, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + DateTime? SubmittedAtUtc, + string? Notes, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + IReadOnlyCollection Lines); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs new file mode 100644 index 0000000..acef700 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierDto.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierDto( + Guid Id, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays, + bool IsActive, + DateTime CreatedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs new file mode 100644 index 0000000..d0672a8 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPaymentDto.cs @@ -0,0 +1,14 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierPaymentDto( + Guid Id, + Guid PurchaseOrderId, + decimal Amount, + DateTime PaymentDateUtc, + PaymentMethod Method, + string? InvoiceReference, + Guid RecordedByUserId, + string RecordedByName, + string? Notes); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs new file mode 100644 index 0000000..ebc2eb6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPerformanceDto.cs @@ -0,0 +1,20 @@ +namespace RestaurantPOS.Application.Suppliers.Dtos; + +/// +/// Delivery and quality statistics for one supplier, derived entirely from their purchase +/// orders and the Goods Received Notes recorded against them — nothing here is separately +/// logged. +/// +public sealed record SupplierPerformanceDto( + Guid SupplierId, + string SupplierName, + int TotalOrders, + int DeliveredOrders, + int OnTimeDeliveries, + /// Null when no delivered order had an expected delivery date to compare against. + double? OnTimeDeliveryRate, + /// Average days from submission to the first GRN recorded against the order. Null with no data. + double? AverageDeliveryDays, + /// Average of every quality rating recorded on a GRN for this supplier. Null if none were given. + double? AverageQualityRating, + int IssueCount); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs new file mode 100644 index 0000000..92c25b7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Dtos/SupplierPriceDto.cs @@ -0,0 +1,15 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Dtos; + +public sealed record SupplierPriceDto( + Guid SupplierId, + string SupplierName, + Guid RawMaterialId, + string RawMaterialName, + UnitOfMeasurement UnitOfMeasurement, + decimal Price, + DateTime UpdatedAtUtc); + +public sealed record SupplierPriceHistoryEntryDto( + decimal Price, Guid RecordedByUserId, string RecordedByName, DateTime RecordedAtUtc); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs new file mode 100644 index 0000000..412e5ee --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrderById/GetPurchaseOrderByIdQuery.cs @@ -0,0 +1,31 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrderById; + +public sealed record GetPurchaseOrderByIdQuery(Guid PurchaseOrderId) : IRequest>; + +internal sealed class GetPurchaseOrderByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetPurchaseOrderByIdQuery request, CancellationToken cancellationToken) + { + var order = await db.PurchaseOrders.AsNoTracking().Include(o => o.Lines) + .FirstOrDefaultAsync(o => o.Id == request.PurchaseOrderId, cancellationToken); + + if (order is null) + { + return Result.Failure(SupplierErrors.PurchaseOrderNotFound(request.PurchaseOrderId)); + } + + return Result.Success(await order.ToDtoAsync(db, cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs new file mode 100644 index 0000000..d5132fe --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetPurchaseOrders/GetPurchaseOrdersQuery.cs @@ -0,0 +1,72 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetPurchaseOrders; + +public sealed record GetPurchaseOrdersQuery(Guid? SupplierId, PurchaseOrderStatus? Status) + : IRequest>>; + +internal sealed class GetPurchaseOrdersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + private const int MaxRows = 300; + + public async Task>> Handle( + GetPurchaseOrdersQuery request, CancellationToken cancellationToken) + { + var query = db.PurchaseOrders.AsNoTracking().Include(o => o.Lines).AsQueryable(); + + if (request.SupplierId is not null) + { + query = query.Where(o => o.SupplierId == request.SupplierId.Value); + } + + if (request.Status is not null) + { + query = query.Where(o => o.Status == request.Status.Value); + } + + var orders = await query + .OrderByDescending(o => o.CreatedAtUtc) + .Take(MaxRows) + .ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var orderIds = orders.Select(o => o.Id).ToList(); + var paidByOrder = await db.SupplierPayments.AsNoTracking() + .Where(p => orderIds.Contains(p.PurchaseOrderId)) + .GroupBy(p => p.PurchaseOrderId) + .Select(g => new { PurchaseOrderId = g.Key, Paid = g.Sum(p => p.Amount) }) + .ToDictionaryAsync(g => g.PurchaseOrderId, g => g.Paid, cancellationToken); + + IReadOnlyCollection result = + [ + .. orders.Select(o => + { + var paid = paidByOrder.GetValueOrDefault(o.Id, 0m); + + return new PurchaseOrderSummaryDto( + o.Id, + o.SupplierId, + supplierNames.GetValueOrDefault(o.SupplierId, "(unknown)"), + o.Status, + o.CreatedAtUtc, + o.ExpectedDeliveryDate, + o.TotalAmount, + paid, + o.TotalAmount - paid, + o.Lines.Count); + }), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs new file mode 100644 index 0000000..63535fe --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPayments/GetSupplierPaymentsQuery.cs @@ -0,0 +1,51 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPayments; + +/// Payments for one purchase order, or every payment for a supplier across all their orders. +public sealed record GetSupplierPaymentsQuery(Guid? PurchaseOrderId, Guid? SupplierId) + : IRequest>>; + +internal sealed class GetSupplierPaymentsQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPaymentsQuery request, CancellationToken cancellationToken) + { + var query = db.SupplierPayments.AsNoTracking().AsQueryable(); + + if (request.PurchaseOrderId is not null) + { + query = query.Where(p => p.PurchaseOrderId == request.PurchaseOrderId.Value); + } + else if (request.SupplierId is not null) + { + var orderIds = await db.PurchaseOrders.AsNoTracking() + .Where(o => o.SupplierId == request.SupplierId.Value) + .Select(o => o.Id) + .ToListAsync(cancellationToken); + + query = query.Where(p => orderIds.Contains(p.PurchaseOrderId)); + } + + var payments = await query.OrderByDescending(p => p.PaymentDateUtc).ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. payments.Select(p => new SupplierPaymentDto( + p.Id, p.PurchaseOrderId, p.Amount, p.PaymentDateUtc, p.Method, p.InvoiceReference, + p.RecordedByUserId, userNames.GetValueOrDefault(p.RecordedByUserId, "(unknown)"), p.Notes)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs new file mode 100644 index 0000000..414e66d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPerformance/GetSupplierPerformanceQuery.cs @@ -0,0 +1,81 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPerformance; + +/// +/// Delivery and quality statistics for a supplier. Everything here is derived from purchase +/// orders and the GRNs recorded against them — on-time delivery compares a GRN's received date +/// to its order's expected date, delivery time compares it to when the order was submitted, and +/// quality/issues come straight from what was recorded on each GRN. +/// +public sealed record GetSupplierPerformanceQuery(Guid SupplierId) : IRequest>; + +internal sealed class GetSupplierPerformanceQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetSupplierPerformanceQuery request, CancellationToken cancellationToken) + { + var supplier = await db.Suppliers.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == request.SupplierId, cancellationToken); + + if (supplier is null) + { + return Result.Failure(SupplierErrors.NotFound(request.SupplierId)); + } + + var orders = await db.PurchaseOrders.AsNoTracking() + .Where(o => o.SupplierId == request.SupplierId) + .Select(o => new { o.Id, o.Status, o.ExpectedDeliveryDate, o.SubmittedAtUtc }) + .ToListAsync(cancellationToken); + + var grns = await db.GoodsReceivedNotes.AsNoTracking() + .Where(g => g.SupplierId == request.SupplierId) + .Select(g => new { g.PurchaseOrderId, g.ReceivedAtUtc, g.QualityRating, g.HasIssue }) + .ToListAsync(cancellationToken); + + var deliveredOrders = orders.Where(o => o.Status == PurchaseOrderStatus.Delivered).ToList(); + + // A PO can be received across more than one GRN; the earliest is what "when did this + // order actually arrive" means for both the on-time and delivery-time calculations. + var earliestReceiptByOrder = grns + .Where(g => g.PurchaseOrderId is not null) + .GroupBy(g => g.PurchaseOrderId!.Value) + .ToDictionary(g => g.Key, g => g.Min(x => x.ReceivedAtUtc)); + + var onTimeEligible = deliveredOrders + .Where(o => o.ExpectedDeliveryDate is not null && earliestReceiptByOrder.ContainsKey(o.Id)) + .ToList(); + + var onTimeCount = onTimeEligible + .Count(o => earliestReceiptByOrder[o.Id].Date <= o.ExpectedDeliveryDate!.Value.Date); + + var deliveryDurations = deliveredOrders + .Where(o => o.SubmittedAtUtc is not null && earliestReceiptByOrder.ContainsKey(o.Id)) + .Select(o => (earliestReceiptByOrder[o.Id] - o.SubmittedAtUtc!.Value).TotalDays) + .ToList(); + + var ratings = grns.Where(g => g.QualityRating is not null).Select(g => (double)g.QualityRating!.Value).ToList(); + + var dto = new SupplierPerformanceDto( + supplier.Id, + supplier.Name, + orders.Count, + deliveredOrders.Count, + onTimeCount, + onTimeEligible.Count == 0 ? null : onTimeCount * 100.0 / onTimeEligible.Count, + deliveryDurations.Count == 0 ? null : deliveryDurations.Average(), + ratings.Count == 0 ? null : ratings.Average(), + grns.Count(g => g.HasIssue)); + + return Result.Success(dto); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs new file mode 100644 index 0000000..2df82ac --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPriceHistory/GetSupplierPriceHistoryQuery.cs @@ -0,0 +1,37 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPriceHistory; + +/// Every price a supplier has been recorded as charging for one raw material, newest first. +public sealed record GetSupplierPriceHistoryQuery(Guid SupplierId, Guid RawMaterialId) + : IRequest>>; + +internal sealed class GetSupplierPriceHistoryQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPriceHistoryQuery request, CancellationToken cancellationToken) + { + var entries = await db.SupplierPriceHistoryEntries.AsNoTracking() + .Where(e => e.SupplierId == request.SupplierId && e.RawMaterialId == request.RawMaterialId) + .OrderByDescending(e => e.RecordedAtUtc) + .ToListAsync(cancellationToken); + + var userNames = await db.Users.AsNoTracking() + .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken); + + IReadOnlyCollection result = + [ + .. entries.Select(e => new SupplierPriceHistoryEntryDto( + e.Price, e.RecordedByUserId, userNames.GetValueOrDefault(e.RecordedByUserId, "(unknown)"), e.RecordedAtUtc)), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs new file mode 100644 index 0000000..f09c185 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSupplierPrices/GetSupplierPricesQuery.cs @@ -0,0 +1,61 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSupplierPrices; + +/// +/// Current supplier prices, filterable by supplier (a supplier's whole price list) or by raw +/// material (every supplier's price for one ingredient, for side-by-side comparison) or both. +/// +public sealed record GetSupplierPricesQuery(Guid? SupplierId, Guid? RawMaterialId) + : IRequest>>; + +internal sealed class GetSupplierPricesQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSupplierPricesQuery request, CancellationToken cancellationToken) + { + var query = db.SupplierPrices.AsNoTracking().AsQueryable(); + + if (request.SupplierId is not null) + { + query = query.Where(p => p.SupplierId == request.SupplierId.Value); + } + + if (request.RawMaterialId is not null) + { + query = query.Where(p => p.RawMaterialId == request.RawMaterialId.Value); + } + + var prices = await query.ToListAsync(cancellationToken); + + var supplierNames = await db.Suppliers.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + var rawMaterials = await db.RawMaterials.AsNoTracking() + .ToDictionaryAsync(r => r.Id, cancellationToken); + + IReadOnlyCollection result = + [ + .. prices + .Select(p => new SupplierPriceDto( + p.SupplierId, + supplierNames.GetValueOrDefault(p.SupplierId, "(unknown)"), + p.RawMaterialId, + rawMaterials.TryGetValue(p.RawMaterialId, out var material) ? material.Name : "(unknown)", + rawMaterials.TryGetValue(p.RawMaterialId, out var m2) ? m2.UnitOfMeasurement : default, + p.Price, + p.UpdatedAtUtc)) + .OrderBy(p => p.RawMaterialName) + .ThenBy(p => p.Price), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs new file mode 100644 index 0000000..536a153 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Suppliers/Queries/GetSuppliers/GetSuppliersQuery.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Suppliers.Dtos; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Suppliers.Queries.GetSuppliers; + +public sealed record GetSuppliersQuery(string? Search, bool? IsActive) + : IRequest>>; + +internal sealed class GetSuppliersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSuppliersQuery request, CancellationToken cancellationToken) + { + var query = db.Suppliers.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(s => s.Name.ToLower().Contains(term)); + } + + if (request.IsActive is not null) + { + query = query.Where(s => s.IsActive == request.IsActive.Value); + } + + var suppliers = await query + .OrderByDescending(s => s.IsActive) + .ThenBy(s => s.Name) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. suppliers.Select(s => s.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs new file mode 100644 index 0000000..d740470 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs @@ -0,0 +1,87 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Application.Users.Common; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.CreateUser; + +/// +/// Creates a staff account. The new user is always flagged to change their password at first +/// sign-in, which is how the owner's administrator account gets its own password. +/// +/// Ignored when is Admin, who hold every module. +public sealed record CreateUserCommand( + string Username, + string FullName, + string? Email, + string Password, + UserRole Role, + IReadOnlyCollection? Modules) : IRequest>; + +public sealed class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Username) + .NotEmpty().WithMessage("Username is required.") + .Must(User.IsValidUsername) + .WithMessage( + $"Username must be {User.UsernameMinLength}-{User.UsernameMaxLength} characters and may " + + "contain only letters, digits, dots, hyphens and underscores."); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full name is required.") + .MaximumLength(User.FullNameMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(User.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Password).MustMeetPasswordPolicy(); + + RuleFor(x => x.Role).IsInEnum().WithMessage("Select a valid role."); + + RuleFor(x => x.Modules).MustBeAssignableModules(); + } +} + +internal sealed class CreateUserCommandHandler(IAppDbContext db, IPasswordHasher passwordHasher) + : IRequestHandler> +{ + public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var username = request.Username.Trim().ToLowerInvariant(); + + var exists = await db.Users.AnyAsync(u => u.Username == username, cancellationToken); + if (exists) + { + return Result.Failure(UserErrors.UsernameTaken); + } + + var user = User.Create( + username, + request.FullName, + request.Email, + passwordHasher.Hash(request.Password), + request.Role, + request.Modules, + mustChangePassword: true); + + db.Users.Add(user); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..d3b366d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs @@ -0,0 +1,55 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.ResetUserPassword; + +/// +/// Sets a temporary password on another user's behalf. The user must choose their own +/// password the next time they sign in. +/// +public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : IRequest; + +public sealed class ResetUserPasswordCommandValidator : AbstractValidator +{ + public ResetUserPasswordCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword).MustMeetPasswordPolicy(); + } +} + +internal sealed class ResetUserPasswordCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + user.ResetPassword(passwordHasher.Hash(request.NewPassword)); + + // Anyone signed in as this user is pushed back to the login screen. + user.RevokeAllRefreshTokens(clock.UtcNow); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs new file mode 100644 index 0000000..8a85ca7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs @@ -0,0 +1,82 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.SetUserActive; + +/// +/// Enables or disables a staff account. Accounts are deactivated rather than deleted so that +/// historical orders, bills and stock movements keep pointing at a real user. +/// +public sealed record SetUserActiveCommand(Guid UserId, bool IsActive) : IRequest>; + +internal sealed class SetUserActiveCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetUserActiveCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + if (user.IsActive == request.IsActive) + { + return Result.Success(user.ToDto()); + } + + if (!request.IsActive) + { + if (user.Id == currentUser.UserId) + { + return Result.Failure(UserErrors.CannotDeactivateSelf); + } + + if (user.IsSystemAdmin) + { + return Result.Failure(UserErrors.CannotModifySystemAdmin); + } + + if (user.Role == UserRole.Admin) + { + var anotherAdmin = await db.Users.AnyAsync( + u => u.Id != user.Id && u.IsActive && u.Role == UserRole.Admin, + cancellationToken); + + if (!anotherAdmin) + { + return Result.Failure(UserErrors.LastAdmin); + } + } + + user.Deactivate(); + + // Kill outstanding sessions immediately rather than waiting for the access token + // to expire on its own. + user.RevokeAllRefreshTokens(clock.UtcNow); + } + else + { + user.Activate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs new file mode 100644 index 0000000..e7bdf7b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs @@ -0,0 +1,101 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Common; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.UpdateUser; + +/// Updates a user's profile, role and module grants in one operation. +public sealed record UpdateUserCommand( + Guid UserId, + string FullName, + string? Email, + UserRole Role, + IReadOnlyCollection? Modules) : IRequest>; + +public sealed class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full name is required.") + .MaximumLength(User.FullNameMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(User.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Role).IsInEnum().WithMessage("Select a valid role."); + + RuleFor(x => x.Modules).MustBeAssignableModules(); + } +} + +internal sealed class UpdateUserCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle(UpdateUserCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + var roleIsChanging = user.Role != request.Role; + + if (roleIsChanging) + { + if (user.Id == currentUser.UserId) + { + return Result.Failure(UserErrors.CannotDemoteSelf); + } + + if (user.IsSystemAdmin) + { + return Result.Failure(UserErrors.CannotModifySystemAdmin); + } + + if (user.Role == UserRole.Admin && !await AnotherActiveAdminExistsAsync(user.Id, cancellationToken)) + { + return Result.Failure(UserErrors.LastAdmin); + } + } + + user.UpdateProfile(request.FullName, request.Email); + + if (roleIsChanging) + { + user.ChangeRole(request.Role); + } + + // ChangeRole clears grants, so modules are applied afterwards. The call is a no-op for + // administrators, whose access comes from the role itself. + user.ReplaceModuleGrants(request.Modules ?? []); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } + + private Task AnotherActiveAdminExistsAsync(Guid excludingUserId, CancellationToken cancellationToken) => + db.Users.AnyAsync( + u => u.Id != excludingUserId && u.IsActive && u.Role == UserRole.Admin, + cancellationToken); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs b/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs new file mode 100644 index 0000000..d27245c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Application.Users.Common; + +/// Validation shared by every command that assigns modules to a user. +internal static class ModuleRules +{ + /// + /// Rejects unknown modules and administrative modules, which are reserved for the + /// role rather than granted individually. + /// + public static IRuleBuilderOptions?> MustBeAssignableModules( + this IRuleBuilder?> ruleBuilder) => + ruleBuilder + .Must(modules => modules is null || modules.All(ModuleCatalog.IsDefined)) + .WithMessage("One or more of the selected modules is not recognised.") + .Must(modules => modules is null || modules.All(ModuleCatalog.IsAssignableToUser)) + .WithMessage( + "Administrative modules cannot be granted individually. Give the user the Admin role instead."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs b/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs new file mode 100644 index 0000000..185888c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs @@ -0,0 +1,36 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Users.Dtos; + +/// A staff account as presented to administrators in the user list and editor. +public sealed record UserDto +{ + public required Guid Id { get; init; } + + public required string Username { get; init; } + + public required string FullName { get; init; } + + public string? Email { get; init; } + + public required UserRole Role { get; init; } + + public required bool IsActive { get; init; } + + public required bool MustChangePassword { get; init; } + + /// True for the built-in administrator, which the UI protects from edits. + public required bool IsSystemAdmin { get; init; } + + public required bool HasApprovalPin { get; init; } + + public DateTime? LastLoginAtUtc { get; init; } + + public required DateTime CreatedAtUtc { get; init; } + + /// + /// Modules the user can actually open. For administrators this is the whole catalog, + /// even though no explicit grants are stored. + /// + public required IReadOnlyCollection Modules { get; init; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs new file mode 100644 index 0000000..a8dbe96 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs @@ -0,0 +1,47 @@ +using MediatR; + +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Application.Users.Queries.GetModules; + +/// +/// Returns the module catalog. The frontend builds both its navigation sidebar and the +/// per-user permission editor from this, so modules never have to be listed twice. +/// +public sealed record GetModulesQuery : IRequest>>; + +/// +/// A module as presented to the client. serialises to its name (for +/// example "PosBilling"), which is the same value that appears in a user's granted +/// module list — so the client can match the two directly. +/// +public sealed record ModuleDto( + AppModule Module, + string Name, + string Group, + string Description, + int SortOrder, + bool AdminOnly); + +internal sealed class GetModulesQueryHandler : IRequestHandler>> +{ + public Task>> Handle( + GetModulesQuery request, + CancellationToken cancellationToken) + { + IReadOnlyCollection modules = + [ + .. ModuleCatalog.All.Select(d => new ModuleDto( + d.Module, + d.Name, + d.Group, + d.Description, + d.SortOrder, + d.AdminOnly)), + ]; + + return Task.FromResult(Result.Success(modules)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs new file mode 100644 index 0000000..4fc5040 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs @@ -0,0 +1,30 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Queries.GetUserById; + +/// Loads a single staff account for the edit screen. +public sealed record GetUserByIdQuery(Guid UserId) : IRequest>; + +internal sealed class GetUserByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + return user is null + ? Result.Failure(UserErrors.NotFound(request.UserId)) + : Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs new file mode 100644 index 0000000..c438d60 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs @@ -0,0 +1,61 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Users.Queries.GetUsers; + +/// +/// Lists staff accounts for the administration screen, with optional filtering. +/// +/// Matches against username or full name. +/// Restricts to a single role when supplied. +/// Restricts to active or inactive accounts when supplied. +public sealed record GetUsersQuery(string? Search, UserRole? Role, bool? IsActive) + : IRequest>>; + +internal sealed class GetUsersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetUsersQuery request, + CancellationToken cancellationToken) + { + var query = db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(u => + u.Username.Contains(term) || + u.FullName.ToLower().Contains(term)); + } + + if (request.Role is not null) + { + query = query.Where(u => u.Role == request.Role.Value); + } + + if (request.IsActive is not null) + { + query = query.Where(u => u.IsActive == request.IsActive.Value); + } + + var users = await query + .OrderByDescending(u => u.IsActive) + .ThenBy(u => u.FullName) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. users.Select(u => u.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs b/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs index ad48015..a8723bf 100644 --- a/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs +++ b/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs @@ -12,4 +12,4 @@ public abstract class BaseEntity public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); public void ClearDomainEvents() => _domainEvents.Clear(); -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/Error.cs b/backend/src/RestaurantPOS.Domain/Common/Error.cs index 9d383f8..164930a 100644 --- a/backend/src/RestaurantPOS.Domain/Common/Error.cs +++ b/backend/src/RestaurantPOS.Domain/Common/Error.cs @@ -1,14 +1,28 @@ namespace RestaurantPOS.Domain.Common; +/// Classifies an so transport layers can pick a status code. +public enum ErrorType +{ + None = 0, + Failure = 1, + Validation = 2, + NotFound = 3, + Conflict = 4, + Unauthorized = 5, + Forbidden = 6, +} + #pragma warning disable CA1716 -public record Error(string Code, string Description) +public record Error(string Code, string Description, ErrorType Type = ErrorType.Failure) #pragma warning restore CA1716 { - public static readonly Error None = new(string.Empty, string.Empty); + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None); public static readonly Error NullValue = new("Error.NullValue", "Null value was provided."); - public static Error Failure(string code, string description) => new(code, description); - public static Error NotFound(string code, string description) => new(code, description); - public static Error Validation(string code, string description) => new(code, description); - public static Error Conflict(string code, string description) => new(code, description); -} + public static Error Failure(string code, string description) => new(code, description, ErrorType.Failure); + public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); + public static Error Validation(string code, string description) => new(code, description, ErrorType.Validation); + public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); + public static Error Unauthorized(string code, string description) => new(code, description, ErrorType.Unauthorized); + public static Error Forbidden(string code, string description) => new(code, description, ErrorType.Forbidden); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs b/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs index c336e17..d34c14c 100644 --- a/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs +++ b/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs @@ -2,4 +2,4 @@ namespace RestaurantPOS.Domain.Common; public interface IDomainEvent { -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/Result.cs b/backend/src/RestaurantPOS.Domain/Common/Result.cs index 873750f..152ccf4 100644 --- a/backend/src/RestaurantPOS.Domain/Common/Result.cs +++ b/backend/src/RestaurantPOS.Domain/Common/Result.cs @@ -45,4 +45,4 @@ protected internal Result(TValue? value, bool isSuccess, Error error) public static implicit operator Result(TValue? value) => value is not null ? Success(value) : Failure(Error.NullValue); -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs b/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs new file mode 100644 index 0000000..0763aee --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/AuditLogEntry.cs @@ -0,0 +1,90 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A record of one change to one entity, for screens that need to show "recent changes" or +/// prove what a deleted record used to contain (BR-REC-007). Deliberately generic — keyed by +/// and — so any module can write to it, though +/// only Recipe Management does so today (REC-010). +/// +public sealed class AuditLogEntry : BaseEntity +{ + public const int EntityTypeMaxLength = 100; + public const int ActionMaxLength = 50; + public const int SummaryMaxLength = 500; + + // EF Core materialisation. + private AuditLogEntry() + { + } + + private AuditLogEntry( + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime occurredAtUtc, + string summary, + string? detailsJson) + { + EntityType = entityType; + EntityId = entityId; + Action = action; + PerformedByUserId = performedByUserId; + PerformedByName = performedByName; + OccurredAtUtc = occurredAtUtc; + Summary = summary; + DetailsJson = detailsJson; + } + + /// The kind of entity changed, e.g. "Recipe". + public string EntityType { get; private set; } = string.Empty; + + public Guid EntityId { get; private set; } + + /// E.g. "Created", "Updated", "Deleted", "Enabled", "Disabled". + public string Action { get; private set; } = string.Empty; + + public Guid PerformedByUserId { get; private set; } + + /// Denormalised so history displays without joining back to Users. + public string PerformedByName { get; private set; } = string.Empty; + + public DateTime OccurredAtUtc { get; private set; } + + /// One-line human-readable description of what changed. + public string Summary { get; private set; } = string.Empty; + + /// Optional JSON snapshot of the entity's state, used to recover a deleted record's content. + public string? DetailsJson { get; private set; } + + public static AuditLogEntry Record( + string entityType, + Guid entityId, + string action, + Guid performedByUserId, + string performedByName, + DateTime occurredAtUtc, + string summary, + string? detailsJson = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entityType); + ArgumentException.ThrowIfNullOrWhiteSpace(action); + ArgumentException.ThrowIfNullOrWhiteSpace(summary); + + return new AuditLogEntry( + Truncate(entityType, EntityTypeMaxLength), + entityId, + Truncate(action, ActionMaxLength), + performedByUserId, + performedByName, + occurredAtUtc, + Truncate(summary, SummaryMaxLength), + detailsJson); + } + + private static string Truncate(string value, int maxLength) => + value.Length > maxLength ? value[..maxLength] : value; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs b/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs new file mode 100644 index 0000000..8d08a7b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/GoodsReceivedNote.cs @@ -0,0 +1,92 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A record of stock received from a supplier into the Main Store. Recording one immediately +/// increases Main Store stock — there is no separate draft/confirm step, since a GRN is only +/// ever entered once the goods have actually arrived and been counted. +/// +/// +/// Its lines are not stored here; they are the rows sharing this +/// note's as their . +/// +public sealed class GoodsReceivedNote : BaseEntity +{ + public const int NotesMaxLength = 500; + public const int MinQualityRating = 1; + public const int MaxQualityRating = 5; + + // EF Core materialisation. + private GoodsReceivedNote() + { + } + + private GoodsReceivedNote( + Guid supplierId, + Guid receivedByUserId, + DateTime receivedAtUtc, + string? notes, + Guid? purchaseOrderId, + int? qualityRating, + bool hasIssue) + { + SupplierId = supplierId; + ReceivedByUserId = receivedByUserId; + ReceivedAtUtc = receivedAtUtc; + Notes = NormaliseNotes(notes); + PurchaseOrderId = purchaseOrderId; + QualityRating = ValidateRating(qualityRating); + HasIssue = hasIssue; + } + + public Guid SupplierId { get; private set; } + + public Guid ReceivedByUserId { get; private set; } + + public DateTime ReceivedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + /// + /// The Purchase Order this delivery fulfils, if any — a GRN can also stand alone for a + /// delivery that never had a formal PO raised against it. + /// + public Guid? PurchaseOrderId { get; private set; } + + /// 1 (worst) to 5 (best), optional. + public int? QualityRating { get; private set; } + + /// Flags this delivery for the supplier performance view, e.g. a complaint was raised. + public bool HasIssue { get; private set; } + + public static GoodsReceivedNote Create( + Guid supplierId, + Guid receivedByUserId, + DateTime receivedAtUtc, + string? notes = null, + Guid? purchaseOrderId = null, + int? qualityRating = null, + bool hasIssue = false) => + new(supplierId, receivedByUserId, receivedAtUtc, notes, purchaseOrderId, qualityRating, hasIssue); + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } + + private static int? ValidateRating(int? rating) => + rating is null or >= MinQualityRating and <= MaxQualityRating + ? rating + : throw new ArgumentOutOfRangeException( + nameof(rating), rating, $"Quality rating must be between {MinQualityRating} and {MaxQualityRating}."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/KitchenTicket.cs b/backend/src/RestaurantPOS.Domain/Entities/KitchenTicket.cs new file mode 100644 index 0000000..b1a2943 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/KitchenTicket.cs @@ -0,0 +1,92 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One KOT: a slip printed for the kitchen and a card on the kitchen display. An order +/// accumulates several of these — the confirmation ticket plus one per amendment (POS-014, +/// POS-020) — because the kitchen has already started on the earlier ones and needs to be told +/// what changed, not handed a fresh copy of the whole order. +/// +public sealed class KitchenTicket : BaseEntity +{ + private readonly List _lines = []; + + // EF Core materialisation. + private KitchenTicket() + { + } + + internal KitchenTicket(Guid orderId, int ticketNumber, KitchenTicketKind kind, DateTime nowUtc) + { + OrderId = orderId; + TicketNumber = ticketNumber; + Kind = kind; + Status = KitchenTicketStatus.New; + PrintedAtUtc = nowUtc; + } + + public Guid OrderId { get; private set; } + + /// Position within its order, starting at 1 — the "KOT-2" on the printed slip. + public int TicketNumber { get; private set; } + + public KitchenTicketKind Kind { get; private set; } + + public KitchenTicketStatus Status { get; private set; } + + public DateTime PrintedAtUtc { get; private set; } + + public DateTime? StartedAtUtc { get; private set; } + + public DateTime? ReadyAtUtc { get; private set; } + + public DateTime? ServedAtUtc { get; private set; } + + /// How many times the slip has been sent to the printer, including the first. + public int PrintCount { get; private set; } = 1; + + public IReadOnlyCollection Lines => _lines.AsReadOnly(); + + /// + /// True when this ticket represents food to cook. Cancellation slips are notices — they are + /// shown to the kitchen but must not drag a table's progress backwards. + /// + public bool IsWorkable => Kind != KitchenTicketKind.Cancellation; + + internal void AddLine(Guid orderItemId, string menuItemName, int quantity, string? specialInstructions, string? note) => + _lines.Add(new KitchenTicketLine(Id, orderItemId, menuItemName, quantity, specialInstructions, note)); + + /// + /// Moves the ticket on to . Forward-only: prep timings are read back + /// as kitchen performance, so a ticket cannot be walked backwards to manufacture a better one. + /// + public void Advance(KitchenTicketStatus status, DateTime nowUtc) + { + if (status <= Status) + { + throw new InvalidOperationException( + $"A kitchen ticket cannot move from {Status} back to {status}."); + } + + Status = status; + + switch (status) + { + case KitchenTicketStatus.Preparing: + StartedAtUtc = nowUtc; + break; + case KitchenTicketStatus.Ready: + ReadyAtUtc = nowUtc; + break; + case KitchenTicketStatus.Served: + ServedAtUtc = nowUtc; + break; + default: + break; + } + } + + public void RecordReprint() => PrintCount++; +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/KitchenTicketLine.cs b/backend/src/RestaurantPOS.Domain/Entities/KitchenTicketLine.cs new file mode 100644 index 0000000..8d65e14 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/KitchenTicketLine.cs @@ -0,0 +1,48 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One line as it was printed on a kitchen ticket. This is a snapshot, not a live view of the +/// order: a reprint has to show what the kitchen was actually handed, including the quantity that +/// was correct at print time even though it has since been amended. +/// +public sealed class KitchenTicketLine : BaseEntity +{ + public const int NoteMaxLength = 120; + + // EF Core materialisation. + private KitchenTicketLine() + { + } + + internal KitchenTicketLine( + Guid kitchenTicketId, + Guid orderItemId, + string menuItemName, + int quantity, + string? specialInstructions, + string? note) + { + KitchenTicketId = kitchenTicketId; + OrderItemId = orderItemId; + MenuItemName = menuItemName; + Quantity = quantity; + SpecialInstructions = specialInstructions; + Note = note; + } + + public Guid KitchenTicketId { get; private set; } + + /// The bill line this was printed for, so the display can group amendments with it. + public Guid OrderItemId { get; private set; } + + public string MenuItemName { get; private set; } = string.Empty; + + public int Quantity { get; private set; } + + public string? SpecialInstructions { get; private set; } + + /// Why this line is on an amendment ticket, e.g. "Was 1" or "CANCELLED". + public string? Note { get; private set; } +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs b/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs new file mode 100644 index 0000000..7cb39c3 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/MenuItem.cs @@ -0,0 +1,77 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A sellable dish or drink. Recipe Management attaches an optional to +/// this, which is how a sale eventually deducts kitchen stock — but not every menu item needs +/// one (a bottled drink with no preparation, for example). +/// +public sealed class MenuItem : BaseEntity +{ + public const int NameMaxLength = 150; + public const int CategoryMaxLength = 80; + + // EF Core materialisation. + private MenuItem() + { + } + + private MenuItem(string name, string category, decimal price) + { + Name = NormaliseName(name); + Category = NormaliseCategory(category); + Price = ValidatePrice(price); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + /// Free-text grouping for the menu, e.g. "Rice & Curry", "Beverages". + public string Category { get; private set; } = string.Empty; + + public decimal Price { get; private set; } + + public bool IsActive { get; private set; } + + public static MenuItem Create(string name, string category, decimal price) => + new(name, category, price); + + public void UpdateDetails(string name, string category, decimal price) + { + Name = NormaliseName(name); + Category = NormaliseCategory(category); + Price = ValidatePrice(price); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static string NormaliseCategory(string category) + { + ArgumentException.ThrowIfNullOrWhiteSpace(category); + + var trimmed = category.Trim(); + + return trimmed.Length > CategoryMaxLength + ? throw new ArgumentException($"Category cannot exceed {CategoryMaxLength} characters.", nameof(category)) + : trimmed; + } + + private static decimal ValidatePrice(decimal price) => + price >= 0 + ? price + : throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/Order.cs b/backend/src/RestaurantPOS.Domain/Entities/Order.cs new file mode 100644 index 0000000..fe0d534 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Order.cs @@ -0,0 +1,372 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// A dish being added to a bill, priced from the menu at the moment it is ordered. +public sealed record NewOrderItem( + Guid MenuItemId, string MenuItemName, decimal UnitPrice, int Quantity, string? SpecialInstructions); + +/// +/// A table's bill, from the first dish keyed in to the receipt handed over. +/// +/// +/// The aggregate owns kitchen ticket creation rather than leaving it to the caller, because "the +/// kitchen is told whenever a confirmed order changes" (BR-POS-004, BR-POS-006, POS-020) is the +/// rule that stops the pass cooking one thing while the bill says another. Every mutator that can +/// change what the kitchen should be cooking returns the ticket it raised — or null while the +/// order is still a and nothing has been sent — so a caller +/// cannot change the order without being handed the slip that has to go out. +/// +public sealed class Order : BaseEntity +{ + private readonly List _items = []; + private readonly List _tickets = []; + private readonly List _payments = []; + + // EF Core materialisation. + private Order() + { + } + + private Order(Guid tableId, Guid cashierUserId) + { + TableId = tableId; + CashierUserId = cashierUserId; + Status = OrderStatus.Draft; + DiscountType = DiscountType.None; + } + + /// Sequence within , assigned on confirmation (POS-009). + public int? OrderNumber { get; private set; } + + /// Business day the number belongs to; the sequence restarts each day. + public DateOnly? OrderDate { get; private set; } + + public Guid TableId { get; private set; } + + /// Who keyed the order in (POS-012). + public Guid CashierUserId { get; private set; } + + public OrderStatus Status { get; private set; } + + public DiscountType DiscountType { get; private set; } + + /// A percentage when is Percentage, otherwise an amount. + public decimal DiscountValue { get; private set; } + + public DateTime? ConfirmedAtUtc { get; private set; } + + public DateTime? CompletedAtUtc { get; private set; } + + public DateTime? CancelledAtUtc { get; private set; } + + /// The administrator whose PIN authorised cancelling the whole order (BR-POS-009). + public Guid? CancelledByUserId { get; private set; } + + public Receipt? Receipt { get; private set; } + + public IReadOnlyCollection Items => _items.AsReadOnly(); + + public IReadOnlyCollection Tickets => _tickets.AsReadOnly(); + + public IReadOnlyCollection Payments => _payments.AsReadOnly(); + + /// Lines still on the bill — everything not voided. + public IEnumerable ActiveItems => _items.Where(i => !i.IsCancelled); + + public decimal Subtotal => ActiveItems.Sum(i => i.LineTotal); + + /// + /// Money off the bill. Always recomputed from the subtotal and capped by it, so a fixed + /// discount set against a larger bill can never exceed a bill that has since shrunk + /// (BR-POS-010). + /// + public decimal DiscountAmount => DiscountType switch + { + DiscountType.Percentage => Math.Round(Subtotal * DiscountValue / 100m, 2, MidpointRounding.AwayFromZero), + DiscountType.Fixed => Math.Min(DiscountValue, Subtotal), + _ => 0m, + }; + + /// What the customer owes. No tax or service charge is applied (BR-POS-011, BR-POS-012). + public decimal Total => Subtotal - DiscountAmount; + + public decimal AmountPaid => _payments.Sum(p => p.Amount); + + /// Cash to hand back across all tenders (POS-024). + public decimal ChangeDue => _payments.Sum(p => p.ChangeGiven); + + /// True while the order still holds its table (BR-POS-017). + public bool IsLive => Status is OrderStatus.Draft or OrderStatus.Open or OrderStatus.Checkout; + + public static Order Create(Guid tableId, Guid cashierUserId) => new(tableId, cashierUserId); + + /// + /// Adds dishes to the bill. Free at any time on an open order and never needs approval + /// (BR-POS-005); prints an addition ticket once the kitchen already has the order. + /// + /// The ticket raised, or null while the order is still a draft. + public KitchenTicket? AddItems(IEnumerable items, DateTime nowUtc) + { + ArgumentNullException.ThrowIfNull(items); + EnsureEditable(); + + var added = items + .Select(i => new OrderItem(Id, i.MenuItemId, i.MenuItemName, i.UnitPrice, i.Quantity, i.SpecialInstructions)) + .ToList(); + + if (added.Count == 0) + { + throw new ArgumentException("At least one item is required.", nameof(items)); + } + + _items.AddRange(added); + + if (Status != OrderStatus.Open) + { + return null; + } + + var ticket = CreateTicket(KitchenTicketKind.Addition, nowUtc); + foreach (var item in added) + { + ticket.AddLine(item.Id, item.MenuItemName, item.Quantity, item.SpecialInstructions, null); + } + + return ticket; + } + + /// + /// Saves the order and sends it to the kitchen (BR-POS-003, BR-POS-004). The order number is + /// supplied by the caller, which is the only place that can see the rest of the day's orders. + /// + public KitchenTicket Confirm(int orderNumber, DateOnly orderDate, DateTime nowUtc) + { + if (Status != OrderStatus.Draft) + { + throw new InvalidOperationException("Only a draft order can be confirmed."); + } + + if (!ActiveItems.Any()) + { + throw new InvalidOperationException("An order must have at least one item before it can be confirmed."); + } + + OrderNumber = orderNumber; + OrderDate = orderDate; + Status = OrderStatus.Open; + ConfirmedAtUtc = nowUtc; + + var ticket = CreateTicket(KitchenTicketKind.New, nowUtc); + foreach (var item in ActiveItems) + { + ticket.AddLine(item.Id, item.MenuItemName, item.Quantity, item.SpecialInstructions, null); + } + + return ticket; + } + + /// + /// Changes how many of a dish were ordered. Callers gate this behind an approval PIN once the + /// order is open (BR-POS-007); the kitchen is told what the quantity used to be so the line + /// cook can tell an amendment from a fresh order. + /// + public KitchenTicket? ChangeItemQuantity(Guid orderItemId, int quantity, DateTime nowUtc) + { + EnsureEditable(); + + var item = FindItem(orderItemId); + var previousQuantity = item.Quantity; + + if (previousQuantity == quantity) + { + return null; + } + + item.ChangeQuantity(quantity); + + if (Status != OrderStatus.Open) + { + return null; + } + + var ticket = CreateTicket(KitchenTicketKind.Modification, nowUtc); + ticket.AddLine(item.Id, item.MenuItemName, item.Quantity, item.SpecialInstructions, $"Was {previousQuantity}"); + + return ticket; + } + + /// + /// Takes a dish off the bill. While the order is a draft the line simply disappears — the + /// kitchen never heard about it. Once open the line is kept and voided instead, so the bill + /// still shows what was taken off and why the kitchen got a cancellation slip (POS-020). + /// + public KitchenTicket? RemoveItem(Guid orderItemId, DateTime nowUtc) + { + EnsureEditable(); + + var item = FindItem(orderItemId); + + if (Status != OrderStatus.Open) + { + _items.Remove(item); + return null; + } + + item.Cancel(nowUtc); + + var ticket = CreateTicket(KitchenTicketKind.Cancellation, nowUtc); + ticket.AddLine(item.Id, item.MenuItemName, item.Quantity, item.SpecialInstructions, "CANCELLED"); + + return ticket; + } + + /// Applies money off the bill (POS-007). + public void SetDiscount(DiscountType type, decimal value) + { + EnsureEditable(); + + switch (type) + { + case DiscountType.None: + DiscountType = DiscountType.None; + DiscountValue = 0m; + return; + + case DiscountType.Percentage when value is < 0m or > 100m: + throw new ArgumentOutOfRangeException(nameof(value), value, "A percentage discount must be between 0 and 100."); + + case DiscountType.Fixed when value < 0m: + throw new ArgumentOutOfRangeException(nameof(value), value, "A discount cannot be negative."); + + case DiscountType.Fixed when value > Subtotal: + throw new ArgumentOutOfRangeException(nameof(value), value, "A discount cannot exceed the order subtotal."); + + default: + break; + } + + DiscountType = type; + DiscountValue = value; + } + + /// Freezes the bill so it cannot move while the customer is paying. + public void StartCheckout() + { + if (Status != OrderStatus.Open) + { + throw new InvalidOperationException("Only an open order can be checked out."); + } + + Status = OrderStatus.Checkout; + } + + /// Backs out of the payment screen, reopening the bill for more items. + public void ReturnToOpen() + { + if (Status != OrderStatus.Checkout) + { + throw new InvalidOperationException("Only an order in checkout can be reopened."); + } + + _payments.Clear(); + Status = OrderStatus.Open; + } + + /// Records one tender against the bill (POS-023). + public OrderPayment AddPayment( + OrderPaymentMethod method, decimal amount, decimal? tenderedAmount, string? reference) + { + if (Status != OrderStatus.Checkout) + { + throw new InvalidOperationException("Payments can only be taken during checkout."); + } + + var payment = new OrderPayment(Id, method, amount, tenderedAmount, reference); + _payments.Add(payment); + + return payment; + } + + /// + /// Settles the bill and issues the receipt (POS-025), which releases the table (POS-032). The + /// tenders must come to the bill exactly (BR-POS-013). + /// + public Receipt Complete(string receiptNumber, DateTime nowUtc) + { + if (Status != OrderStatus.Checkout) + { + throw new InvalidOperationException("Only an order in checkout can be completed."); + } + + if (AmountPaid != Total) + { + throw new InvalidOperationException("The payments taken must equal the order total."); + } + + Status = OrderStatus.Completed; + CompletedAtUtc = nowUtc; + Receipt = new Receipt(Id, receiptNumber, nowUtc); + + return Receipt; + } + + /// + /// Abandons the order without payment, releasing the table. Requires an approval PIN + /// (BR-POS-009); the kitchen gets one cancellation slip covering everything still live. + /// + public KitchenTicket? Cancel(Guid cancelledByUserId, DateTime nowUtc) + { + if (Status is OrderStatus.Completed or OrderStatus.Cancelled) + { + throw new InvalidOperationException("A completed or already-cancelled order cannot be cancelled."); + } + + var wasSentToKitchen = Status is OrderStatus.Open or OrderStatus.Checkout; + var liveItems = ActiveItems.ToList(); + + foreach (var item in liveItems) + { + item.Cancel(nowUtc); + } + + Status = OrderStatus.Cancelled; + CancelledAtUtc = nowUtc; + CancelledByUserId = cancelledByUserId; + _payments.Clear(); + + if (!wasSentToKitchen || liveItems.Count == 0) + { + return null; + } + + var ticket = CreateTicket(KitchenTicketKind.Cancellation, nowUtc); + foreach (var item in liveItems) + { + ticket.AddLine(item.Id, item.MenuItemName, item.Quantity, item.SpecialInstructions, "ORDER CANCELLED"); + } + + return ticket; + } + + private KitchenTicket CreateTicket(KitchenTicketKind kind, DateTime nowUtc) + { + var ticket = new KitchenTicket(Id, _tickets.Count + 1, kind, nowUtc); + _tickets.Add(ticket); + + return ticket; + } + + private OrderItem FindItem(Guid orderItemId) => + _items.FirstOrDefault(i => i.Id == orderItemId) + ?? throw new InvalidOperationException("That item is not on this order."); + + private void EnsureEditable() + { + if (Status is not (OrderStatus.Draft or OrderStatus.Open)) + { + throw new InvalidOperationException($"An order that is {Status} can no longer be edited."); + } + } +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/OrderItem.cs b/backend/src/RestaurantPOS.Domain/Entities/OrderItem.cs new file mode 100644 index 0000000..93a02ae --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/OrderItem.cs @@ -0,0 +1,100 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One line on a bill. Name and price are copied from the at the moment it +/// was ordered rather than read through a relation, so re-pricing the menu tonight never rewrites +/// what a customer was charged last week — a printed receipt has to stay reproducible (POS-029). +/// +public sealed class OrderItem : BaseEntity +{ + public const int SpecialInstructionsMaxLength = 250; + + // EF Core materialisation. + private OrderItem() + { + } + + internal OrderItem( + Guid orderId, + Guid menuItemId, + string menuItemName, + decimal unitPrice, + int quantity, + string? specialInstructions) + { + OrderId = orderId; + MenuItemId = menuItemId; + MenuItemName = menuItemName; + UnitPrice = unitPrice >= 0 + ? unitPrice + : throw new ArgumentOutOfRangeException(nameof(unitPrice), unitPrice, "Unit price cannot be negative."); + Quantity = ValidateQuantity(quantity); + SpecialInstructions = NormaliseInstructions(specialInstructions); + } + + public Guid OrderId { get; private set; } + + public Guid MenuItemId { get; private set; } + + /// The dish name as it was when ordered. + public string MenuItemName { get; private set; } = string.Empty; + + /// The price charged per unit, fixed at the time of ordering. + public decimal UnitPrice { get; private set; } + + public int Quantity { get; private set; } + + public string? SpecialInstructions { get; private set; } + + public bool IsCancelled { get; private set; } + + public DateTime? CancelledAtUtc { get; private set; } + + /// What this line contributes to the bill. A cancelled line contributes nothing. + public decimal LineTotal => IsCancelled ? 0m : UnitPrice * Quantity; + + /// Changes how many were ordered. Callers gate this behind an approval PIN (BR-POS-007). + public void ChangeQuantity(int quantity) + { + EnsureNotCancelled(); + Quantity = ValidateQuantity(quantity); + } + + /// Voids the line, keeping it on the order as a record of what was taken off. + public void Cancel(DateTime nowUtc) + { + EnsureNotCancelled(); + IsCancelled = true; + CancelledAtUtc = nowUtc; + } + + private void EnsureNotCancelled() + { + if (IsCancelled) + { + throw new InvalidOperationException("A cancelled order item cannot be changed."); + } + } + + private static int ValidateQuantity(int quantity) => + quantity > 0 + ? quantity + : throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be greater than zero."); + + private static string? NormaliseInstructions(string? instructions) + { + if (string.IsNullOrWhiteSpace(instructions)) + { + return null; + } + + var trimmed = instructions.Trim(); + + return trimmed.Length > SpecialInstructionsMaxLength + ? throw new ArgumentException( + $"Special instructions cannot exceed {SpecialInstructionsMaxLength} characters.", nameof(instructions)) + : trimmed; + } +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/OrderPayment.cs b/backend/src/RestaurantPOS.Domain/Entities/OrderPayment.cs new file mode 100644 index 0000000..dffc1a6 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/OrderPayment.cs @@ -0,0 +1,54 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One tender against a bill. A bill can carry several of these so a table can settle part in +/// cash and the rest on a card (POS-023); together they must come to exactly the bill total +/// (BR-POS-013). +/// +public sealed class OrderPayment : BaseEntity +{ + public const int ReferenceMaxLength = 100; + + // EF Core materialisation. + private OrderPayment() + { + } + + internal OrderPayment( + Guid orderId, + OrderPaymentMethod method, + decimal amount, + decimal? tenderedAmount, + string? reference) + { + OrderId = orderId; + Method = method; + Amount = amount > 0 + ? amount + : throw new ArgumentOutOfRangeException(nameof(amount), amount, "A payment must be greater than zero."); + TenderedAmount = tenderedAmount; + Reference = reference; + } + + public Guid OrderId { get; private set; } + + public OrderPaymentMethod Method { get; private set; } + + /// What this tender contributes to the bill. + public decimal Amount { get; private set; } + + /// + /// Cash handed over, when it exceeds . Kept so the receipt can show what + /// was given and what came back (POS-024); null for every non-cash method. + /// + public decimal? TenderedAmount { get; private set; } + + /// Card approval code, transfer reference, or similar. + public string? Reference { get; private set; } + + /// Change handed back, or zero when the exact amount was tendered. + public decimal ChangeGiven => TenderedAmount is null ? 0m : Math.Max(0m, TenderedAmount.Value - Amount); +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..35e459b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrder.cs @@ -0,0 +1,169 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// An order placed with a supplier for one or more raw materials. Lines are editable only while +/// the order is still a — once submitted, it represents +/// a real commitment sent to the supplier and its lines are frozen. +/// +public sealed class PurchaseOrder : BaseEntity +{ + public const int NotesMaxLength = 500; + + private readonly List _lines = []; + + // EF Core materialisation. + private PurchaseOrder() + { + } + + private PurchaseOrder( + Guid supplierId, + Guid createdByUserId, + IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines, + DateTime? expectedDeliveryDate, + string? notes) + { + SupplierId = supplierId; + CreatedByUserId = createdByUserId; + Status = PurchaseOrderStatus.Draft; + ExpectedDeliveryDate = expectedDeliveryDate; + Notes = NormaliseNotes(notes); + SetLines(lines); + } + + public Guid SupplierId { get; private set; } + + public Guid CreatedByUserId { get; private set; } + + public PurchaseOrderStatus Status { get; private set; } + + public DateTime? ExpectedDeliveryDate { get; private set; } + + /// When the order left Draft. Null until then; used to measure delivery time. + public DateTime? SubmittedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + public IReadOnlyCollection Lines => _lines.AsReadOnly(); + + public decimal TotalAmount => _lines.Sum(l => l.LineTotal); + + public static PurchaseOrder Create( + Guid supplierId, + Guid createdByUserId, + IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines, + DateTime? expectedDeliveryDate = null, + string? notes = null) => + new(supplierId, createdByUserId, lines, expectedDeliveryDate, notes); + + /// Replaces every line wholesale. Only while still a Draft. + public void ReplaceLines(IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines) + { + EnsureDraft(); + SetLines(lines); + } + + public void UpdateDetails(DateTime? expectedDeliveryDate, string? notes) + { + EnsureDraft(); + ExpectedDeliveryDate = expectedDeliveryDate; + Notes = NormaliseNotes(notes); + } + + /// Sends the order to the supplier. No further line edits after this point. + public void Submit(DateTime nowUtc) + { + if (Status != PurchaseOrderStatus.Draft) + { + throw new InvalidOperationException("Only a draft purchase order can be submitted."); + } + + Status = PurchaseOrderStatus.Submitted; + SubmittedAtUtc = nowUtc; + } + + /// Records that the supplier has agreed to fulfil the order as sent. + public void Confirm() + { + if (Status != PurchaseOrderStatus.Submitted) + { + throw new InvalidOperationException("Only a submitted purchase order can be confirmed."); + } + + Status = PurchaseOrderStatus.Confirmed; + } + + /// + /// Marks the order delivered. Called when a Goods Received Note is recorded against it; + /// idempotent because a single order can be received across more than one GRN. + /// + public void MarkDelivered() + { + if (Status is PurchaseOrderStatus.Delivered) + { + return; + } + + if (Status is PurchaseOrderStatus.Cancelled) + { + throw new InvalidOperationException("A cancelled purchase order cannot be marked delivered."); + } + + Status = PurchaseOrderStatus.Delivered; + } + + public void Cancel() + { + if (Status is PurchaseOrderStatus.Delivered or PurchaseOrderStatus.Cancelled) + { + throw new InvalidOperationException("A delivered or already-cancelled purchase order cannot be cancelled."); + } + + Status = PurchaseOrderStatus.Cancelled; + } + + private void EnsureDraft() + { + if (Status != PurchaseOrderStatus.Draft) + { + throw new InvalidOperationException("Only a draft purchase order can be edited."); + } + } + + private void SetLines(IEnumerable<(Guid RawMaterialId, decimal Quantity, decimal UnitPrice)> lines) + { + ArgumentNullException.ThrowIfNull(lines); + + var materialised = lines.ToList(); + + if (materialised.Count == 0) + { + throw new ArgumentException("A purchase order must contain at least one line.", nameof(lines)); + } + + if (materialised.Select(l => l.RawMaterialId).Distinct().Count() != materialised.Count) + { + throw new ArgumentException("A raw material cannot appear more than once on the same purchase order.", nameof(lines)); + } + + _lines.Clear(); + _lines.AddRange(materialised.Select(l => new PurchaseOrderLine(Id, l.RawMaterialId, l.Quantity, l.UnitPrice))); + } + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs new file mode 100644 index 0000000..dcb6480 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/PurchaseOrderLine.cs @@ -0,0 +1,32 @@ +namespace RestaurantPOS.Domain.Entities; + +/// One raw material, quantity and unit price ordered on a Purchase Order. +public sealed class PurchaseOrderLine +{ + // EF Core materialisation. + private PurchaseOrderLine() + { + } + + internal PurchaseOrderLine(Guid purchaseOrderId, Guid rawMaterialId, decimal quantity, decimal unitPrice) + { + PurchaseOrderId = purchaseOrderId; + RawMaterialId = rawMaterialId; + Quantity = quantity > 0 + ? quantity + : throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be greater than zero."); + UnitPrice = unitPrice >= 0 + ? unitPrice + : throw new ArgumentOutOfRangeException(nameof(unitPrice), unitPrice, "Unit price cannot be negative."); + } + + public Guid PurchaseOrderId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Quantity { get; private set; } + + public decimal UnitPrice { get; private set; } + + public decimal LineTotal => Quantity * UnitPrice; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs b/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs new file mode 100644 index 0000000..749c020 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RawMaterial.cs @@ -0,0 +1,89 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// An ingredient tracked in stock. Its is fixed for the +/// material's lifetime — recipes and every stock movement referencing it are always expressed +/// in this same unit, so the system never needs to convert between units. +/// +public sealed class RawMaterial : BaseEntity +{ + public const int NameMaxLength = 150; + + // EF Core materialisation. + private RawMaterial() + { + } + + private RawMaterial( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel, + decimal? kitchenParLevel) + { + Name = NormaliseName(name); + UnitOfMeasurement = unitOfMeasurement; + MainStoreReorderLevel = ValidateThreshold(mainStoreReorderLevel, nameof(mainStoreReorderLevel)); + KitchenParLevel = ValidateThreshold(kitchenParLevel, nameof(kitchenParLevel)); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + public UnitOfMeasurement UnitOfMeasurement { get; private set; } + + /// + /// When Main Store stock falls to or below this, the store is flagged low so staff know to + /// call the supplier. Null means no threshold is configured for this material. + /// + public decimal? MainStoreReorderLevel { get; private set; } + + /// + /// When Kitchen stock falls to or below this, the kitchen is flagged low so staff know to + /// request a release from the Main Store. Null means no threshold is configured. + /// + public decimal? KitchenParLevel { get; private set; } + + public bool IsActive { get; private set; } + + public static RawMaterial Create( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel = null, + decimal? kitchenParLevel = null) => + new(name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel); + + public void UpdateDetails( + string name, + UnitOfMeasurement unitOfMeasurement, + decimal? mainStoreReorderLevel, + decimal? kitchenParLevel) + { + Name = NormaliseName(name); + UnitOfMeasurement = unitOfMeasurement; + MainStoreReorderLevel = ValidateThreshold(mainStoreReorderLevel, nameof(mainStoreReorderLevel)); + KitchenParLevel = ValidateThreshold(kitchenParLevel, nameof(kitchenParLevel)); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static decimal? ValidateThreshold(decimal? threshold, string paramName) => + threshold is null or >= 0 + ? threshold + : throw new ArgumentOutOfRangeException(paramName, threshold, "A threshold cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/Receipt.cs b/backend/src/RestaurantPOS.Domain/Entities/Receipt.cs new file mode 100644 index 0000000..8877277 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Receipt.cs @@ -0,0 +1,44 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// The record that a bill was settled and a slip printed (POS-030). It carries the receipt number +/// and print history only — the printed content is rebuilt from the order, which is frozen once +/// completed, so a reprint (POS-029) can never drift from the original. +/// +public sealed class Receipt : BaseEntity +{ + public const int NumberMaxLength = 30; + + // EF Core materialisation. + private Receipt() + { + } + + internal Receipt(Guid orderId, string number, DateTime issuedAtUtc) + { + OrderId = orderId; + Number = number; + IssuedAtUtc = issuedAtUtc; + LastPrintedAtUtc = issuedAtUtc; + } + + public Guid OrderId { get; private set; } + + /// Customer-facing reference, e.g. "REC-001-2026". + public string Number { get; private set; } = string.Empty; + + public DateTime IssuedAtUtc { get; private set; } + + /// Times the slip has been printed, including the one handed over at payment. + public int PrintCount { get; private set; } = 1; + + public DateTime LastPrintedAtUtc { get; private set; } + + public void RecordReprint(DateTime nowUtc) + { + PrintCount++; + LastPrintedAtUtc = nowUtc; + } +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs b/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs new file mode 100644 index 0000000..6ca3657 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Recipe.cs @@ -0,0 +1,63 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// The bill of materials for one menu item: which raw materials a single sale of it consumes, +/// and how much of each. At most one recipe exists per menu item (enforced by a unique index on +/// — see BR-REC-001), and not every menu item has one at all. +/// +public sealed class Recipe : BaseEntity +{ + private readonly List _lines = []; + + // EF Core materialisation. + private Recipe() + { + } + + private Recipe(Guid menuItemId, IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) + { + MenuItemId = menuItemId; + IsEnabled = true; + SetLines(lines); + } + + public Guid MenuItemId { get; private set; } + + /// Disabled recipes cannot be used for new sales (BR-REC-006) but stay editable. + public bool IsEnabled { get; private set; } + + public IReadOnlyCollection Lines => _lines.AsReadOnly(); + + /// Creates a recipe. Requires at least one line (BR-REC-002). + public static Recipe Create(Guid menuItemId, IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) => + new(menuItemId, lines); + + /// Replaces every ingredient line wholesale (REC-006: recipes are editable at any time). + public void ReplaceLines(IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) => SetLines(lines); + + public void Enable() => IsEnabled = true; + + public void Disable() => IsEnabled = false; + + private void SetLines(IEnumerable<(Guid RawMaterialId, decimal Quantity)> lines) + { + ArgumentNullException.ThrowIfNull(lines); + + var materialised = lines.ToList(); + + if (materialised.Count == 0) + { + throw new ArgumentException("A recipe must contain at least one raw material.", nameof(lines)); + } + + if (materialised.Select(l => l.RawMaterialId).Distinct().Count() != materialised.Count) + { + throw new ArgumentException("A raw material cannot appear more than once in the same recipe.", nameof(lines)); + } + + _lines.Clear(); + _lines.AddRange(materialised.Select(l => new RecipeLine(Id, l.RawMaterialId, l.Quantity))); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs b/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs new file mode 100644 index 0000000..85d3a9c --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RecipeLine.cs @@ -0,0 +1,29 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// One raw material and the quantity of it a single unit of the parent recipe's menu item +/// consumes, expressed in that raw material's own unit of measurement. +/// +public sealed class RecipeLine +{ + // EF Core materialisation. + private RecipeLine() + { + } + + internal RecipeLine(Guid recipeId, Guid rawMaterialId, decimal quantity) + { + RecipeId = recipeId; + RawMaterialId = rawMaterialId; + Quantity = quantity > 0 + ? quantity + : throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be greater than zero."); + } + + public Guid RecipeId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + /// Always greater than zero (BR-REC-003); fractional amounts are allowed (REC-012). + public decimal Quantity { get; private set; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs b/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..6e357f6 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs @@ -0,0 +1,40 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// A single issued refresh token. Only a hash of the token is stored, so a leak of the +/// database does not hand an attacker usable sessions. +/// +public sealed class RefreshToken +{ + // EF Core materialisation. + private RefreshToken() + { + } + + internal RefreshToken(Guid userId, string tokenHash, DateTime expiresAtUtc, DateTime nowUtc) + { + Id = Guid.NewGuid(); + UserId = userId; + TokenHash = tokenHash; + ExpiresAtUtc = expiresAtUtc; + CreatedAtUtc = nowUtc; + } + + public Guid Id { get; private set; } + + public Guid UserId { get; private set; } + + /// SHA-256 hash of the opaque token handed to the client. + public string TokenHash { get; private set; } = string.Empty; + + public DateTime ExpiresAtUtc { get; private set; } + + public DateTime CreatedAtUtc { get; private set; } + + public DateTime? RevokedAtUtc { get; private set; } + + /// True when the token has neither been revoked nor expired. + public bool IsActive(DateTime nowUtc) => RevokedAtUtc is null && ExpiresAtUtc > nowUtc; + + internal void Revoke(DateTime nowUtc) => RevokedAtUtc ??= nowUtc; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RestaurantTable.cs b/backend/src/RestaurantPOS.Domain/Entities/RestaurantTable.cs new file mode 100644 index 0000000..206aaad --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RestaurantTable.cs @@ -0,0 +1,82 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A table customers sit at, identified by the number printed on it. Occupancy is deliberately +/// not stored here: a table is occupied exactly when it has a live order, so deriving it +/// at read time means the two can never disagree — no repair job for a table left "occupied" +/// after its order was completed by some path that forgot to clear the flag (POS-031, POS-032). +/// +public sealed class RestaurantTable : BaseEntity +{ + public const int NumberMaxLength = 20; + public const int NotesMaxLength = 200; + + // EF Core materialisation. + private RestaurantTable() + { + } + + private RestaurantTable(string number, int seats, string? notes) + { + Number = NormaliseNumber(number); + Seats = ValidateSeats(seats); + Notes = NormaliseNotes(notes); + IsActive = true; + } + + /// The table's label as the staff say it out loud — "4", "A2", "Terrace 1". + public string Number { get; private set; } = string.Empty; + + /// How many covers it seats. Zero means unrecorded. + public int Seats { get; private set; } + + public string? Notes { get; private set; } + + public bool IsActive { get; private set; } + + public static RestaurantTable Create(string number, int seats = 0, string? notes = null) => + new(number, seats, notes); + + public void UpdateDetails(string number, int seats, string? notes) + { + Number = NormaliseNumber(number); + Seats = ValidateSeats(seats); + Notes = NormaliseNotes(notes); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseNumber(string number) + { + ArgumentException.ThrowIfNullOrWhiteSpace(number); + + var trimmed = number.Trim(); + + return trimmed.Length > NumberMaxLength + ? throw new ArgumentException($"Table number cannot exceed {NumberMaxLength} characters.", nameof(number)) + : trimmed; + } + + private static int ValidateSeats(int seats) => + seats >= 0 + ? seats + : throw new ArgumentOutOfRangeException(nameof(seats), seats, "Seats cannot be negative."); + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs b/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs new file mode 100644 index 0000000..ec608d1 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockLevel.cs @@ -0,0 +1,57 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// The current on-hand quantity of one raw material in one store. This is a derived balance, +/// never edited directly (BR-INV-006) — it only changes through , called +/// by the application layer alongside writing the that explains why. +/// +public sealed class StockLevel +{ + // EF Core materialisation. + private StockLevel() + { + } + + public StockLevel(Guid rawMaterialId, StoreType store) + { + RawMaterialId = rawMaterialId; + Store = store; + QuantityOnHand = 0; + } + + public Guid RawMaterialId { get; private set; } + + public StoreType Store { get; private set; } + + public decimal QuantityOnHand { get; private set; } + + public DateTime? UpdatedAtUtc { get; private set; } + + /// + /// Applies a signed change to the balance. This is a last-resort guard, not the primary + /// safety check — callers are expected to have already confirmed the result would not go + /// negative (the application layer blocks that with a friendly error) before ever reaching + /// here. + /// + /// + /// Permits the balance to fall below zero. Reserved for recording something that has already + /// physically happened: a dish sold at the till is deducted whatever the ledger says, because + /// refusing the deduction would not un-cook the food, it would only hide that the kitchen is + /// running on unrecorded stock (BR-POS-015). + /// + public void ApplyDelta(decimal delta, DateTime nowUtc, bool allowNegative = false) + { + var updated = QuantityOnHand + delta; + + if (updated < 0 && !allowNegative) + { + throw new InvalidOperationException( + $"Applying this change would take the balance negative ({updated})."); + } + + QuantityOnHand = updated; + UpdatedAtUtc = nowUtc; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs b/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs new file mode 100644 index 0000000..ca74168 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockMovement.cs @@ -0,0 +1,83 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// One line in the permanent stock ledger: every quantity change to every raw material, in +/// either store, for any reason, is one row here (BR-INV-005, BR-INV-007). This is the single +/// source of truth movements are reconstructed from — a GRN's or release's "lines" are simply +/// the movements sharing its , rather than being duplicated into their +/// own line tables. +/// +public sealed class StockMovement : BaseEntity +{ + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private StockMovement() + { + } + + public StockMovement( + Guid rawMaterialId, + StoreType store, + decimal quantityDelta, + StockMovementType type, + Guid? referenceId, + Guid performedByUserId, + DateTime occurredAtUtc, + string? notes) + { + if (quantityDelta == 0) + { + throw new ArgumentOutOfRangeException(nameof(quantityDelta), quantityDelta, "A movement must change the balance."); + } + + RawMaterialId = rawMaterialId; + Store = store; + QuantityDelta = quantityDelta; + Type = type; + ReferenceId = referenceId; + PerformedByUserId = performedByUserId; + OccurredAtUtc = occurredAtUtc; + Notes = NormaliseNotes(notes); + } + + public Guid RawMaterialId { get; private set; } + + public StoreType Store { get; private set; } + + /// Signed change to the balance: positive increases stock, negative decreases it. + public decimal QuantityDelta { get; private set; } + + public StockMovementType Type { get; private set; } + + /// + /// The id of the record this movement traces back to — a or + /// . Null for an or + /// , which have no separate header record. + /// + public Guid? ReferenceId { get; private set; } + + public Guid PerformedByUserId { get; private set; } + + public DateTime OccurredAtUtc { get; private set; } + + /// Required for an adjustment (the reason for the correction); optional otherwise. + public string? Notes { get; private set; } + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs b/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs new file mode 100644 index 0000000..30e0867 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/StockRelease.cs @@ -0,0 +1,72 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A transfer of stock from the Main Store to the Kitchen. Approval is immediate rather than a +/// separate pending state: the releasing staff member and an administrator's approval PIN are +/// both supplied in the same request, so a release exists only once it is already approved and +/// its stock movements have already been applied (INV-008 through INV-012). +/// +/// +/// Its lines are not stored here; they are the rows (one +/// and one +/// per raw material) sharing this release's +/// as their . +/// +public sealed class StockRelease : BaseEntity +{ + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private StockRelease() + { + } + + private StockRelease( + Guid requestedByUserId, + DateTime requestedAtUtc, + Guid approvedByUserId, + DateTime approvedAtUtc, + string? notes) + { + RequestedByUserId = requestedByUserId; + RequestedAtUtc = requestedAtUtc; + ApprovedByUserId = approvedByUserId; + ApprovedAtUtc = approvedAtUtc; + Notes = NormaliseNotes(notes); + } + + public Guid RequestedByUserId { get; private set; } + + public DateTime RequestedAtUtc { get; private set; } + + /// The administrator whose approval PIN authorised this release. + public Guid ApprovedByUserId { get; private set; } + + public DateTime ApprovedAtUtc { get; private set; } + + public string? Notes { get; private set; } + + public static StockRelease Create( + Guid requestedByUserId, + DateTime requestedAtUtc, + Guid approvedByUserId, + DateTime approvedAtUtc, + string? notes = null) => + new(requestedByUserId, requestedAtUtc, approvedByUserId, approvedAtUtc, notes); + + private static string? NormaliseNotes(string? notes) + { + if (string.IsNullOrWhiteSpace(notes)) + { + return null; + } + + var trimmed = notes.Trim(); + + return trimmed.Length > NotesMaxLength + ? throw new ArgumentException($"Notes cannot exceed {NotesMaxLength} characters.", nameof(notes)) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs b/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs new file mode 100644 index 0000000..525c986 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/Supplier.cs @@ -0,0 +1,129 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A goods supplier: who a Goods Received Note credits stock to, who a Purchase Order is sent +/// to, and whose prices and payment history the Supplier Management module tracks. +/// +public sealed class Supplier : BaseEntity +{ + public const int NameMaxLength = 150; + public const int ContactNameMaxLength = 150; + public const int PhoneMaxLength = 30; + public const int EmailMaxLength = 200; + public const int AddressMaxLength = 300; + + // EF Core materialisation. + private Supplier() + { + } + + private Supplier( + string name, + string? contactName, + string? phone, + string? email, + string? address, + int paymentTermsDays, + decimal? creditLimit, + int? leadTimeDays) + { + Name = NormaliseName(name); + ContactName = NormaliseOptional(contactName, ContactNameMaxLength, nameof(contactName)); + Phone = NormaliseOptional(phone, PhoneMaxLength, nameof(phone)); + Email = NormaliseOptional(email, EmailMaxLength, nameof(email))?.ToLowerInvariant(); + Address = NormaliseOptional(address, AddressMaxLength, nameof(address)); + PaymentTermsDays = ValidateNonNegative(paymentTermsDays, nameof(paymentTermsDays)); + CreditLimit = creditLimit is null ? null : ValidateNonNegative(creditLimit.Value, nameof(creditLimit)); + LeadTimeDays = leadTimeDays is null ? null : ValidateNonNegative(leadTimeDays.Value, nameof(leadTimeDays)); + IsActive = true; + } + + public string Name { get; private set; } = string.Empty; + + public string? ContactName { get; private set; } + + public string? Phone { get; private set; } + + public string? Email { get; private set; } + + public string? Address { get; private set; } + + /// Days after delivery payment is due. 0 means cash on delivery. + public int PaymentTermsDays { get; private set; } + + /// Maximum outstanding balance this supplier extends. Null means no limit is tracked. + public decimal? CreditLimit { get; private set; } + + /// Typical days between placing an order and delivery. Null means not yet known. + public int? LeadTimeDays { get; private set; } + + public bool IsActive { get; private set; } + + public static Supplier Create( + string name, + string? contactName = null, + string? phone = null, + string? email = null, + string? address = null, + int paymentTermsDays = 0, + decimal? creditLimit = null, + int? leadTimeDays = null) => + new(name, contactName, phone, email, address, paymentTermsDays, creditLimit, leadTimeDays); + + public void UpdateDetails( + string name, + string? contactName, + string? phone, + string? email, + string? address, + int paymentTermsDays, + decimal? creditLimit, + int? leadTimeDays) + { + Name = NormaliseName(name); + ContactName = NormaliseOptional(contactName, ContactNameMaxLength, nameof(contactName)); + Phone = NormaliseOptional(phone, PhoneMaxLength, nameof(phone)); + Email = NormaliseOptional(email, EmailMaxLength, nameof(email))?.ToLowerInvariant(); + Address = NormaliseOptional(address, AddressMaxLength, nameof(address)); + PaymentTermsDays = ValidateNonNegative(paymentTermsDays, nameof(paymentTermsDays)); + CreditLimit = creditLimit is null ? null : ValidateNonNegative(creditLimit.Value, nameof(creditLimit)); + LeadTimeDays = leadTimeDays is null ? null : ValidateNonNegative(leadTimeDays.Value, nameof(leadTimeDays)); + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + private static string NormaliseName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var trimmed = name.Trim(); + + return trimmed.Length > NameMaxLength + ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name)) + : trimmed; + } + + private static string? NormaliseOptional(string? value, int maxLength, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + return trimmed.Length > maxLength + ? throw new ArgumentException($"'{paramName}' cannot exceed {maxLength} characters.", paramName) + : trimmed; + } + + private static int ValidateNonNegative(int value, string paramName) => + value >= 0 ? value : throw new ArgumentOutOfRangeException(paramName, value, "Cannot be negative."); + + private static decimal ValidateNonNegative(decimal value, string paramName) => + value >= 0 ? value : throw new ArgumentOutOfRangeException(paramName, value, "Cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs new file mode 100644 index 0000000..21b1d13 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPayment.cs @@ -0,0 +1,78 @@ +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A payment made toward a Purchase Order. A PO's paid/pending status is derived by comparing +/// the sum of its payments to its total, rather than stored as its own field — so it can never +/// go stale relative to the payments actually recorded. +/// +public sealed class SupplierPayment : BaseEntity +{ + public const int InvoiceReferenceMaxLength = 100; + public const int NotesMaxLength = 500; + + // EF Core materialisation. + private SupplierPayment() + { + } + + private SupplierPayment( + Guid purchaseOrderId, + decimal amount, + DateTime paymentDateUtc, + PaymentMethod method, + string? invoiceReference, + Guid recordedByUserId, + string? notes) + { + PurchaseOrderId = purchaseOrderId; + Amount = amount > 0 + ? amount + : throw new ArgumentOutOfRangeException(nameof(amount), amount, "Payment amount must be greater than zero."); + PaymentDateUtc = paymentDateUtc; + Method = method; + InvoiceReference = NormaliseOptional(invoiceReference, InvoiceReferenceMaxLength, nameof(invoiceReference)); + RecordedByUserId = recordedByUserId; + Notes = NormaliseOptional(notes, NotesMaxLength, nameof(notes)); + } + + public Guid PurchaseOrderId { get; private set; } + + public decimal Amount { get; private set; } + + public DateTime PaymentDateUtc { get; private set; } + + public PaymentMethod Method { get; private set; } + + public string? InvoiceReference { get; private set; } + + public Guid RecordedByUserId { get; private set; } + + public string? Notes { get; private set; } + + public static SupplierPayment Create( + Guid purchaseOrderId, + decimal amount, + DateTime paymentDateUtc, + PaymentMethod method, + Guid recordedByUserId, + string? invoiceReference = null, + string? notes = null) => + new(purchaseOrderId, amount, paymentDateUtc, method, invoiceReference, recordedByUserId, notes); + + private static string? NormaliseOptional(string? value, int maxLength, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + return trimmed.Length > maxLength + ? throw new ArgumentException($"'{paramName}' cannot exceed {maxLength} characters.", paramName) + : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs new file mode 100644 index 0000000..45f3dd8 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPrice.cs @@ -0,0 +1,40 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// The current price a supplier charges for a raw material. This is a derived "latest" view, +/// kept in step by the application layer alongside a that +/// records every change — the same current-value-plus-ledger shape as +/// /. +/// +public sealed class SupplierPrice +{ + // EF Core materialisation. + private SupplierPrice() + { + } + + public SupplierPrice(Guid supplierId, Guid rawMaterialId, decimal price, DateTime updatedAtUtc) + { + SupplierId = supplierId; + RawMaterialId = rawMaterialId; + Price = ValidatePrice(price); + UpdatedAtUtc = updatedAtUtc; + } + + public Guid SupplierId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Price { get; private set; } + + public DateTime UpdatedAtUtc { get; private set; } + + public void UpdatePrice(decimal price, DateTime nowUtc) + { + Price = ValidatePrice(price); + UpdatedAtUtc = nowUtc; + } + + private static decimal ValidatePrice(decimal price) => + price >= 0 ? price : throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs b/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs new file mode 100644 index 0000000..b9144c0 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/SupplierPriceHistoryEntry.cs @@ -0,0 +1,36 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Entities; + +/// One recorded price for a raw material from a supplier, kept forever for historical tracking. +public sealed class SupplierPriceHistoryEntry : BaseEntity +{ + // EF Core materialisation. + private SupplierPriceHistoryEntry() + { + } + + private SupplierPriceHistoryEntry( + Guid supplierId, Guid rawMaterialId, decimal price, Guid recordedByUserId, DateTime recordedAtUtc) + { + SupplierId = supplierId; + RawMaterialId = rawMaterialId; + Price = price; + RecordedByUserId = recordedByUserId; + RecordedAtUtc = recordedAtUtc; + } + + public Guid SupplierId { get; private set; } + + public Guid RawMaterialId { get; private set; } + + public decimal Price { get; private set; } + + public Guid RecordedByUserId { get; private set; } + + public DateTime RecordedAtUtc { get; private set; } + + public static SupplierPriceHistoryEntry Record( + Guid supplierId, Guid rawMaterialId, decimal price, Guid recordedByUserId, DateTime recordedAtUtc) => + new(supplierId, rawMaterialId, price, recordedByUserId, recordedAtUtc); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/User.cs b/backend/src/RestaurantPOS.Domain/Entities/User.cs new file mode 100644 index 0000000..97dc419 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/User.cs @@ -0,0 +1,301 @@ +using System.Text.RegularExpressions; + +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A staff account. Aggregate root owning the user's module grants and refresh tokens. +/// +/// +/// The aggregate never sees plaintext secrets: callers pass in already-hashed passwords and +/// PINs. Hashing lives in the infrastructure layer behind IPasswordHasher. +/// +public sealed partial class User : BaseEntity +{ + public const int UsernameMinLength = 3; + public const int UsernameMaxLength = 32; + public const int FullNameMaxLength = 120; + public const int EmailMaxLength = 200; + + /// Length of the numeric approval PIN used to authorise privileged actions. + public const int ApprovalPinLength = 4; + + private readonly List _modulePermissions = []; + private readonly List _refreshTokens = []; + + // EF Core materialisation. + private User() + { + } + + private User( + string username, + string fullName, + string? email, + string passwordHash, + UserRole role, + bool mustChangePassword, + bool isSystemAdmin) + { + Username = NormaliseUsername(username); + FullName = NormaliseFullName(fullName); + Email = NormaliseEmail(email); + PasswordHash = passwordHash; + Role = role; + MustChangePassword = mustChangePassword; + IsSystemAdmin = isSystemAdmin; + IsActive = true; + } + + /// Login identifier. Always stored lower-cased so lookups are case-insensitive. + public string Username { get; private set; } = string.Empty; + + public string FullName { get; private set; } = string.Empty; + + /// Optional — floor staff are not required to have an email address. + public string? Email { get; private set; } + + public string PasswordHash { get; private set; } = string.Empty; + + public UserRole Role { get; private set; } + + public bool IsActive { get; private set; } + + /// Forces the password-reset screen on next sign-in. + public bool MustChangePassword { get; private set; } + + /// Hash of the 4-digit approval PIN. Administrators only; null when unset. + public string? ApprovalPinHash { get; private set; } + + public DateTime? ApprovalPinSetAtUtc { get; private set; } + + public DateTime? LastLoginAtUtc { get; private set; } + + /// + /// True for the account created by database seeding. It cannot be deactivated or demoted, + /// which guarantees the system can always be administered. + /// + public bool IsSystemAdmin { get; private set; } + + public IReadOnlyCollection ModulePermissions => _modulePermissions.AsReadOnly(); + + public IReadOnlyCollection RefreshTokens => _refreshTokens.AsReadOnly(); + + /// Creates a staff account. Administrators implicitly hold every module. + public static User Create( + string username, + string fullName, + string? email, + string passwordHash, + UserRole role, + IEnumerable? modules = null, + bool mustChangePassword = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + var user = new User(username, fullName, email, passwordHash, role, mustChangePassword, isSystemAdmin: false); + + if (role == UserRole.User && modules is not null) + { + user.ReplaceModuleGrants(modules); + } + + return user; + } + + /// Creates the built-in administrator used to bootstrap a fresh installation. + public static User CreateSystemAdmin(string username, string fullName, string passwordHash) => + new(username, fullName, email: null, passwordHash, UserRole.Admin, + mustChangePassword: true, isSystemAdmin: true); + + /// + /// True when the user may open . Administrators always can. + /// + public bool HasAccessTo(AppModule module) => + Role == UserRole.Admin || _modulePermissions.Exists(p => p.Module == module); + + /// Modules the user may open, expanding an administrator to the full catalog. + public IReadOnlyCollection EffectiveModules() => + Role == UserRole.Admin + ? [.. ModuleCatalog.All.Select(d => d.Module)] + : [.. _modulePermissions.Select(p => p.Module).Order()]; + + public void UpdateProfile(string fullName, string? email) + { + FullName = NormaliseFullName(fullName); + Email = NormaliseEmail(email); + } + + /// Replaces the user's module grants wholesale. No-op for administrators. + public void ReplaceModuleGrants(IEnumerable modules) + { + ArgumentNullException.ThrowIfNull(modules); + + _modulePermissions.Clear(); + + if (Role == UserRole.Admin) + { + // Administrators derive access from their role, so explicit grants are redundant. + return; + } + + foreach (var module in modules.Distinct().Order()) + { + _modulePermissions.Add(new UserModulePermission(Id, module)); + } + } + + /// + /// Changes the user's role. Promoting to administrator drops the now-redundant module + /// grants; demoting leaves the user with no modules until an administrator assigns some. + /// + public void ChangeRole(UserRole role) + { + if (Role == role) + { + return; + } + + Role = role; + _modulePermissions.Clear(); + + if (role == UserRole.User) + { + // A demoted administrator also loses the approval PIN, which is admin-only. + ClearApprovalPin(); + } + } + + /// Sets a new password chosen by the user, clearing the forced-reset flag. + public void SetPassword(string passwordHash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + PasswordHash = passwordHash; + MustChangePassword = false; + } + + /// + /// Sets a password on the user's behalf (administrator reset). The user is forced to + /// choose their own password at next sign-in. + /// + public void ResetPassword(string passwordHash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + PasswordHash = passwordHash; + MustChangePassword = true; + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + /// Stores the hash of a newly issued approval PIN. + public void SetApprovalPin(string pinHash, DateTime nowUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pinHash); + + ApprovalPinHash = pinHash; + ApprovalPinSetAtUtc = nowUtc; + } + + public void ClearApprovalPin() + { + ApprovalPinHash = null; + ApprovalPinSetAtUtc = null; + } + + public bool HasApprovalPin => ApprovalPinHash is not null; + + public void RecordSuccessfulLogin(DateTime nowUtc) => LastLoginAtUtc = nowUtc; + + /// Records a newly issued refresh token and prunes ones that are no longer usable. + public RefreshToken IssueRefreshToken(string tokenHash, DateTime expiresAtUtc, DateTime nowUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tokenHash); + + _refreshTokens.RemoveAll(t => !t.IsActive(nowUtc)); + + var token = new RefreshToken(Id, tokenHash, expiresAtUtc, nowUtc); + _refreshTokens.Add(token); + return token; + } + + /// Finds a usable refresh token by its hash. + public RefreshToken? FindActiveRefreshToken(string tokenHash, DateTime nowUtc) => + _refreshTokens.Find(t => t.TokenHash == tokenHash && t.IsActive(nowUtc)); + + public void RevokeRefreshToken(RefreshToken token, DateTime nowUtc) + { + ArgumentNullException.ThrowIfNull(token); + token.Revoke(nowUtc); + } + + /// Revokes every outstanding session, e.g. on sign-out or password change. + public void RevokeAllRefreshTokens(DateTime nowUtc) + { + foreach (var token in _refreshTokens) + { + token.Revoke(nowUtc); + } + } + + /// + /// True when is a syntactically valid login name. Applies the + /// same trim-and-lower-case normalisation as , so anything this accepts + /// the aggregate will too — otherwise validators would reject names the domain allows. + /// + public static bool IsValidUsername(string? username) => + !string.IsNullOrWhiteSpace(username) && UsernamePattern().IsMatch(username.Trim().ToLowerInvariant()); + + private static string NormaliseUsername(string username) + { + ArgumentException.ThrowIfNullOrWhiteSpace(username); + + var normalised = username.Trim().ToLowerInvariant(); + + if (!UsernamePattern().IsMatch(normalised)) + { + throw new ArgumentException( + $"'{username}' is not a valid username. Use {UsernameMinLength}-{UsernameMaxLength} " + + "letters, digits, dots, hyphens or underscores.", + nameof(username)); + } + + return normalised; + } + + private static string NormaliseFullName(string fullName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fullName); + + var trimmed = fullName.Trim(); + + return trimmed.Length > FullNameMaxLength + ? throw new ArgumentException($"Full name cannot exceed {FullNameMaxLength} characters.", nameof(fullName)) + : trimmed; + } + + private static string? NormaliseEmail(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return null; + } + + var trimmed = email.Trim().ToLowerInvariant(); + + return trimmed.Length > EmailMaxLength + ? throw new ArgumentException($"Email cannot exceed {EmailMaxLength} characters.", nameof(email)) + : trimmed; + } + + // Attribute arguments must be compile-time literals, so the {3,32} bound is spelled out + // here rather than interpolated. Keep it in step with UsernameMinLength/UsernameMaxLength. + [GeneratedRegex("^[a-z0-9._-]{3,32}$", RegexOptions.CultureInvariant)] + private static partial Regex UsernamePattern(); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs b/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs new file mode 100644 index 0000000..8f2f4a2 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs @@ -0,0 +1,33 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A grant of one module to one user. Access is currently all-or-nothing per module: +/// the presence of a row means the user may open that module. +/// +/// +/// Modelled as its own row (rather than, say, a bit mask on ) so that +/// action-level flags such as CanCreate or CanApprove can later be added as +/// nullable columns without restructuring existing data. +/// +public sealed class UserModulePermission +{ + // EF Core materialisation. + private UserModulePermission() + { + } + + internal UserModulePermission(Guid userId, AppModule module) + { + UserId = userId; + Module = module; + GrantedAtUtc = DateTime.UtcNow; + } + + public Guid UserId { get; private set; } + + public AppModule Module { get; private set; } + + public DateTime GrantedAtUtc { get; private set; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs b/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs new file mode 100644 index 0000000..7601017 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs @@ -0,0 +1,21 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Assignable functional areas of the POS. Values are persisted, so they must remain stable: +/// append new members with new numbers, never renumber existing ones. +/// +public enum AppModule +{ + PosBilling = 1, + RecipeManagement = 2, + StoreStockManagement = 3, + KitchenStockRelease = 4, + KitchenStockTracking = 5, + KitchenOperations = 6, + ReportsAnalytics = 7, + UserManagement = 8, + Notifications = 9, + SupplierManagement = 10, + ExpensesManagement = 11, + SystemSettings = 12, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/DiscountType.cs b/backend/src/RestaurantPOS.Domain/Enums/DiscountType.cs new file mode 100644 index 0000000..c9fc5be --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/DiscountType.cs @@ -0,0 +1,13 @@ +namespace RestaurantPOS.Domain.Enums; + +/// How a bill discount is expressed (POS-007). +public enum DiscountType +{ + None = 0, + + /// A percentage of the subtotal, 0-100. + Percentage = 1, + + /// A flat amount off, never more than the subtotal (BR-POS-010). + Fixed = 2, +} diff --git a/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketKind.cs b/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketKind.cs new file mode 100644 index 0000000..d3ab8c1 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketKind.cs @@ -0,0 +1,21 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Why a ticket was printed. The kitchen needs to tell "cook these" apart from "you are already +/// cooking this, the numbers changed" and "stop, this is off" — so amendments print as their own +/// tickets (POS-020) rather than silently reprinting the original. +/// +public enum KitchenTicketKind +{ + /// The first ticket for an order, printed on confirmation (POS-010). + New = 1, + + /// Items added to an already-open order (POS-014). + Addition = 2, + + /// A quantity changed on an item already sent (POS-015). + Modification = 3, + + /// An item or the whole order was cancelled (POS-020, POS-021). + Cancellation = 4, +} diff --git a/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketStatus.cs b/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketStatus.cs new file mode 100644 index 0000000..f6bd04b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/KitchenTicketStatus.cs @@ -0,0 +1,20 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// How far the kitchen has got with one printed ticket. A table shows its least-advanced ticket, +/// so a table where one dish is still queued reads as preparing rather than ready. +/// +public enum KitchenTicketStatus +{ + /// Printed and queued. Nobody has started cooking it. + New = 1, + + /// Being cooked. + Preparing = 2, + + /// Cooked and waiting to go out. + Ready = 3, + + /// Carried to the table. + Served = 4, +} diff --git a/backend/src/RestaurantPOS.Domain/Enums/OrderPaymentMethod.cs b/backend/src/RestaurantPOS.Domain/Enums/OrderPaymentMethod.cs new file mode 100644 index 0000000..fdc4121 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/OrderPaymentMethod.cs @@ -0,0 +1,15 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// How a customer settles a bill (POS-022). Deliberately separate from the supplier-side +/// : money going out to a supplier and money coming in at the till are +/// different transactions with different accepted methods, and conflating them would force one +/// enum to carry values that are meaningless on the other side. +/// +public enum OrderPaymentMethod +{ + Cash = 1, + Card = 2, + Qr = 3, + BankTransfer = 4, +} diff --git a/backend/src/RestaurantPOS.Domain/Enums/OrderStatus.cs b/backend/src/RestaurantPOS.Domain/Enums/OrderStatus.cs new file mode 100644 index 0000000..27a7298 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/OrderStatus.cs @@ -0,0 +1,24 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Where an order sits in its lifecycle (POS-033). Confirming a draft prints the first KOT and +/// opens the order; it then stays — accepting further items without approval +/// (BR-POS-005) — until the cashier starts a checkout. +/// +public enum OrderStatus +{ + /// Being built at the till. Nothing has reached the kitchen and no table is held. + Draft = 1, + + /// Confirmed and sent to the kitchen. Still accepting new items. + Open = 2, + + /// Payment in progress. Items are frozen so the bill cannot move under the cashier. + Checkout = 3, + + /// Paid in full, receipt issued, table released (POS-032). + Completed = 4, + + /// Abandoned before payment. Requires an approval PIN (BR-POS-009). + Cancelled = 5, +} diff --git a/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs b/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs new file mode 100644 index 0000000..605a260 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/PaymentMethod.cs @@ -0,0 +1,10 @@ +namespace RestaurantPOS.Domain.Enums; + +public enum PaymentMethod +{ + Cash = 1, + BankTransfer = 2, + Cheque = 3, + Card = 4, + Other = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs b/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs new file mode 100644 index 0000000..0dc4f3d --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/PurchaseOrderStatus.cs @@ -0,0 +1,15 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Lifecycle of a Purchase Order. Lines are editable only in ; every other +/// status represents a commitment that has already left the building (sent to the supplier, +/// confirmed by them, or delivered) and so is no longer freely editable. +/// +public enum PurchaseOrderStatus +{ + Draft = 1, + Submitted = 2, + Confirmed = 3, + Delivered = 4, + Cancelled = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs b/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs new file mode 100644 index 0000000..30849c2 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/StockMovementType.cs @@ -0,0 +1,27 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// What caused a . Doubles as the movement's implied +/// direction and the kind of record (if any) it traces back to via +/// : +/// → a , +/// / → a , +/// and stand alone. +/// +public enum StockMovementType +{ + /// Stock received from a supplier into the Main Store. Always a positive delta. + GoodsReceived = 1, + + /// The Main Store side of a release to the kitchen. Always a negative delta. + StockReleaseOut = 2, + + /// The Kitchen side of a release from the main store. Always a positive delta. + StockReleaseIn = 3, + + /// A manual correction to match a physical stock count. Delta may be either sign. + Adjustment = 4, + + /// Kitchen stock deducted per a menu item's recipe when a sale completes. + Consumption = 5, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs b/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs new file mode 100644 index 0000000..5779352 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/StoreType.cs @@ -0,0 +1,12 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// The two fixed stock locations the restaurant operates. There are exactly two by design — +/// suppliers replenish only , and is replenished +/// only by an approved release from the main store (see BR-INV-001 through BR-INV-003). +/// +public enum StoreType +{ + MainStore = 1, + Kitchen = 2, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs b/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs new file mode 100644 index 0000000..58eec1b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/UnitOfMeasurement.cs @@ -0,0 +1,17 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// The unit a raw material's stock is counted in. A raw material has exactly one unit for its +/// entire lifetime — recipes consuming it must specify quantities in this same unit, so no +/// conversion table is needed anywhere in the system. +/// +public enum UnitOfMeasurement +{ + Kilogram = 1, + Gram = 2, + Liter = 3, + Milliliter = 4, + Piece = 5, + Bottle = 6, + Packet = 7, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs b/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs new file mode 100644 index 0000000..d95962b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Coarse-grained role. Fine-grained access is driven by per-user module grants; +/// see and UserModulePermission. +/// +public enum UserRole +{ + /// Full access to every module regardless of explicit grants. + Admin = 1, + + /// Access limited to the modules explicitly granted by an administrator. + User = 2, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs new file mode 100644 index 0000000..47ce820 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs @@ -0,0 +1,60 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by authentication and approval-PIN use cases. +public static class AuthErrors +{ + /// + /// Deliberately identical for an unknown username and a wrong password so the API + /// does not reveal which usernames exist. + /// + public static readonly Error InvalidCredentials = + Error.Unauthorized("Auth.InvalidCredentials", "The username or password is incorrect."); + + public static readonly Error AccountDeactivated = + Error.Forbidden("Auth.AccountDeactivated", "This account has been deactivated. Contact an administrator."); + + public static readonly Error InvalidRefreshToken = + Error.Unauthorized("Auth.InvalidRefreshToken", "Your session has expired. Please sign in again."); + + public static readonly Error PasswordMismatch = + Error.Validation("Auth.PasswordMismatch", "The current password is incorrect."); + + public static readonly Error PasswordReused = + Error.Validation("Auth.PasswordReused", "The new password must be different from the current password."); + + public static readonly Error PinNotSet = + Error.NotFound("Auth.PinNotSet", "No approval PIN has been set for this account."); + + public static readonly Error InvalidPin = + Error.Validation("Auth.InvalidPin", "That approval PIN is not valid."); + + public static readonly Error PinRequiresAdmin = + Error.Validation("Auth.PinRequiresAdmin", "Only administrators can hold an approval PIN."); + + public static readonly Error NoAdminPinConfigured = + Error.Conflict( + "Auth.NoAdminPinConfigured", + "No administrator has configured an approval PIN yet."); + + /// + /// A wrong PIN, told to the person at the till how many tries are left. Counting down out + /// loud is deliberate: the cashier is usually mistyping a PIN they were told correctly, and + /// a silent lockout mid-service reads as the till breaking. + /// + public static Error InvalidPinWithAttemptsLeft(int attemptsRemaining) => + Error.Validation( + "Auth.InvalidPin", + $"That approval PIN is not valid. {attemptsRemaining} attempt{(attemptsRemaining == 1 ? "" : "s")} remaining."); + + /// + /// Too many wrong PINs from one terminal. This pauses PIN entry on that terminal only — + /// administrator accounts are never disabled, because locking out the sole administrator + /// mid-service would leave the restaurant unable to approve anything at all. + /// + public static Error PinAttemptsExhausted(TimeSpan retryAfter) => + Error.Forbidden( + "Auth.PinAttemptsExhausted", + $"Too many incorrect PIN attempts. Try again in {retryAfter.Minutes}:{retryAfter.Seconds:00}."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs new file mode 100644 index 0000000..d46afba --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/InventoryErrors.cs @@ -0,0 +1,41 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by raw material, supplier and stock movement use cases. +public static class InventoryErrors +{ + public static Error RawMaterialNotFound(Guid id) => + Error.NotFound("RawMaterial.NotFound", $"No raw material was found with id '{id}'."); + + public static readonly Error RawMaterialNameTaken = + Error.Conflict("RawMaterial.NameTaken", "A raw material with that name already exists."); + + public static readonly Error RawMaterialInactive = + Error.Validation("RawMaterial.Inactive", "This raw material is inactive and cannot be used in a new transaction."); + + public static readonly Error EmptyLines = + Error.Validation("Inventory.EmptyLines", "At least one raw material line is required."); + + public static readonly Error DuplicateRawMaterialLine = + Error.Validation("Inventory.DuplicateRawMaterialLine", "A raw material cannot appear more than once in the same transaction."); + + public static readonly Error InsufficientStock = + Error.Conflict( + "Inventory.InsufficientStock", + "This would take a raw material's stock below zero. Check the quantities and current stock levels."); + + public static Error MenuItemHasNoRecipe(Guid menuItemId) => + Error.Conflict( + "Inventory.MenuItemHasNoRecipe", + $"Menu item '{menuItemId}' has no recipe, so no stock was deducted."); + + public static Error GoodsReceivedNoteNotFound(Guid id) => + Error.NotFound("GoodsReceivedNote.NotFound", $"No GRN was found with id '{id}'."); + + public static Error StockReleaseNotFound(Guid id) => + Error.NotFound("StockRelease.NotFound", $"No stock release was found with id '{id}'."); + + public static readonly Error RecipeDisabled = + Error.Conflict("Inventory.RecipeDisabled", "This menu item's recipe is disabled and cannot be used for a new sale."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/OrderErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/OrderErrors.cs new file mode 100644 index 0000000..3d61536 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/OrderErrors.cs @@ -0,0 +1,70 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by table, order, payment and kitchen ticket use cases. +public static class OrderErrors +{ + public static Error TableNotFound(Guid id) => + Error.NotFound("Table.NotFound", $"No table was found with id '{id}'."); + + public static readonly Error TableNumberTaken = + Error.Conflict("Table.NumberTaken", "A table with that number already exists."); + + public static readonly Error TableInactive = + Error.Validation("Table.Inactive", "This table is out of service and cannot take an order."); + + public static readonly Error TableOccupied = + Error.Conflict("Table.Occupied", "This table already has an order in progress."); + + public static readonly Error TableInUse = + Error.Conflict("Table.InUse", "This table has an order in progress and cannot be taken out of service."); + + public static Error NotFound(Guid id) => + Error.NotFound("Order.NotFound", $"No order was found with id '{id}'."); + + public static Error ItemNotFound(Guid id) => + Error.NotFound("Order.ItemNotFound", $"No item was found on this order with id '{id}'."); + + public static readonly Error NotEditable = + Error.Conflict("Order.NotEditable", "This order can no longer be changed."); + + public static readonly Error NotDraft = + Error.Conflict("Order.NotDraft", "Only a draft order can be confirmed."); + + public static readonly Error NoItems = + Error.Validation("Order.NoItems", "Add at least one item before confirming the order."); + + public static readonly Error NotOpen = + Error.Conflict("Order.NotOpen", "Only an open order can be checked out."); + + public static readonly Error NotInCheckout = + Error.Conflict("Order.NotInCheckout", "This order is not being paid for."); + + public static readonly Error NotCancellable = + Error.Conflict("Order.NotCancellable", "A completed or already-cancelled order cannot be cancelled."); + + public static Error DiscountExceedsSubtotal(decimal subtotal) => + Error.Validation( + "Order.DiscountExceedsSubtotal", $"A discount cannot exceed the order subtotal of {subtotal:0.00}."); + + public static Error PaymentMismatch(decimal total, decimal paid) => + Error.Validation( + "Order.PaymentMismatch", + $"The payments taken come to {paid:0.00}, but the bill is {total:0.00}. They must match exactly."); + + public static Error MenuItemNotFound(Guid id) => + Error.NotFound("Order.MenuItemNotFound", $"No menu item was found with id '{id}'."); + + public static readonly Error MenuItemInactive = + Error.Validation("Order.MenuItemInactive", "This menu item is not currently available."); + + public static readonly Error NoReceipt = + Error.NotFound("Order.NoReceipt", "This order has no receipt because it has not been paid."); + + public static Error TicketNotFound(Guid id) => + Error.NotFound("KitchenTicket.NotFound", $"No kitchen ticket was found with id '{id}'."); + + public static Error TicketCannotGoBack(string from, string to) => + Error.Conflict("KitchenTicket.CannotGoBack", $"A kitchen ticket cannot move from {from} back to {to}."); +} diff --git a/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs new file mode 100644 index 0000000..5feb742 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/RecipeErrors.cs @@ -0,0 +1,28 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by menu item and recipe use cases. +public static class RecipeErrors +{ + public static Error MenuItemNotFound(Guid id) => + Error.NotFound("MenuItem.NotFound", $"No menu item was found with id '{id}'."); + + public static readonly Error MenuItemNameTaken = + Error.Conflict("MenuItem.NameTaken", "A menu item with that name already exists."); + + public static Error RecipeNotFound(Guid menuItemId) => + Error.NotFound("Recipe.NotFound", $"Menu item '{menuItemId}' does not have a recipe."); + + public static readonly Error EmptyRecipe = + Error.Validation("Recipe.Empty", "A recipe must contain at least one raw material."); + + public static readonly Error DuplicateRawMaterial = + Error.Validation("Recipe.DuplicateRawMaterial", "A raw material cannot appear more than once in the same recipe."); + + public static readonly Error UnknownRawMaterial = + Error.Validation("Recipe.UnknownRawMaterial", "One or more selected raw materials could not be found."); + + public static readonly Error InactiveRawMaterial = + Error.Validation("Recipe.InactiveRawMaterial", "One or more selected raw materials are inactive."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs new file mode 100644 index 0000000..a34bc4f --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/SupplierErrors.cs @@ -0,0 +1,45 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by supplier, purchase order, pricing and payment use cases. +public static class SupplierErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound("Supplier.NotFound", $"No supplier was found with id '{id}'."); + + public static readonly Error NameTaken = + Error.Conflict("Supplier.NameTaken", "A supplier with that name already exists."); + + public static readonly Error Inactive = + Error.Validation("Supplier.Inactive", "This supplier is inactive and cannot be used on a new transaction."); + + public static Error PurchaseOrderNotFound(Guid id) => + Error.NotFound("PurchaseOrder.NotFound", $"No purchase order was found with id '{id}'."); + + public static readonly Error NotDraft = + Error.Conflict("PurchaseOrder.NotDraft", "Only a draft purchase order can be edited."); + + public static readonly Error NotSubmittable = + Error.Conflict("PurchaseOrder.NotSubmittable", "Only a draft purchase order can be submitted."); + + public static readonly Error NotConfirmable = + Error.Conflict("PurchaseOrder.NotConfirmable", "Only a submitted purchase order can be confirmed."); + + public static readonly Error NotCancellable = + Error.Conflict( + "PurchaseOrder.NotCancellable", "A delivered or already-cancelled purchase order cannot be cancelled."); + + public static readonly Error SupplierMismatch = + Error.Validation( + "GoodsReceivedNote.SupplierMismatch", "The selected purchase order was not placed with this supplier."); + + public static readonly Error PurchaseOrderCancelled = + Error.Conflict( + "GoodsReceivedNote.PurchaseOrderCancelled", "This purchase order was cancelled and cannot receive goods."); + + public static Error PaymentExceedsBalance(decimal balance) => + Error.Validation( + "SupplierPayment.ExceedsBalance", + $"This payment exceeds the outstanding balance of {balance:0.00} on the purchase order."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs new file mode 100644 index 0000000..ce39741 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs @@ -0,0 +1,37 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by user administration use cases. +public static class UserErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound("User.NotFound", $"No user was found with id '{id}'."); + + public static readonly Error UsernameTaken = + Error.Conflict("User.UsernameTaken", "That username is already in use."); + + public static readonly Error CannotDeactivateSelf = + Error.Conflict("User.CannotDeactivateSelf", "You cannot deactivate your own account."); + + public static readonly Error CannotDemoteSelf = + Error.Conflict("User.CannotDemoteSelf", "You cannot change your own role."); + + public static readonly Error CannotModifySystemAdmin = + Error.Conflict( + "User.CannotModifySystemAdmin", + "The built-in administrator account cannot be deactivated or have its role changed."); + + public static readonly Error LastAdmin = + Error.Conflict( + "User.LastAdmin", + "At least one active administrator must remain. Promote another user first."); + + public static readonly Error ModulesNotApplicableToAdmin = + Error.Validation( + "User.ModulesNotApplicableToAdmin", + "Administrators already have access to every module, so module grants cannot be set for them."); + + public static readonly Error UnknownModule = + Error.Validation("User.UnknownModule", "One or more of the selected modules is not recognised."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs b/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs new file mode 100644 index 0000000..0e05ae8 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs @@ -0,0 +1,80 @@ +using System.Collections.ObjectModel; + +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Modules; + +/// +/// The authoritative list of assignable modules. The frontend renders its navigation and +/// permission editor from this catalog, so adding a module here is all that is needed to +/// make it grantable. +/// +public static class ModuleCatalog +{ + private const string InventoryGroup = "Inventory Management"; + private const string OperationsGroup = "Operations"; + private const string AdministrationGroup = "Administration"; + + private static readonly ModuleDescriptor[] Descriptors = + [ + new(AppModule.PosBilling, "POS & Billing", OperationsGroup, + "Take orders, split and settle bills, and print receipts.", 10), + + new(AppModule.RecipeManagement, "Recipe Management", OperationsGroup, + "Define dishes and the ingredient quantities each one consumes.", 20), + + new(AppModule.StoreStockManagement, "Store Stock Management", InventoryGroup, + "Receive goods into the main store and maintain stock levels.", 30), + + new(AppModule.KitchenStockRelease, "Kitchen Stock Release", InventoryGroup, + "Issue stock from the main store to the kitchen.", 40), + + new(AppModule.KitchenStockTracking, "Kitchen Stock Tracking", InventoryGroup, + "Track consumption and wastage of stock held by the kitchen.", 50), + + new(AppModule.KitchenOperations, "Kitchen Operations", OperationsGroup, + "Kitchen display, ticket queue and preparation status.", 60), + + new(AppModule.SupplierManagement, "Supplier Management", OperationsGroup, + "Maintain suppliers, purchase orders and goods received notes.", 70), + + new(AppModule.ExpensesManagement, "Expenses Management", OperationsGroup, + "Record and categorise day-to-day operating expenses.", 80), + + new(AppModule.ReportsAnalytics, "Reports & Analytics", AdministrationGroup, + "Sales, inventory, expense and staff performance reporting.", 90), + + new(AppModule.Notifications, "Notifications", AdministrationGroup, + "Low-stock, approval and operational alerts.", 100), + + new(AppModule.UserManagement, "User Management & Roles", AdministrationGroup, + "Create staff accounts and control which modules they can open.", 110, AdminOnly: true), + + new(AppModule.SystemSettings, "System Settings & Backup", AdministrationGroup, + "Restaurant details, tax and printer settings, and database backups.", 120, AdminOnly: true), + ]; + + /// All assignable modules in display order. + public static IReadOnlyList All { get; } = + new ReadOnlyCollection( + [.. Descriptors.OrderBy(d => d.SortOrder)]); + + /// Modules an administrator may grant to a non-admin user. + public static IReadOnlyList Assignable { get; } = + new ReadOnlyCollection( + [.. Descriptors.Where(d => !d.AdminOnly).OrderBy(d => d.SortOrder)]); + + private static readonly Dictionary ByModule = + Descriptors.ToDictionary(d => d.Module); + + /// Returns true when the value maps to a module in the catalog. + public static bool IsDefined(AppModule module) => ByModule.ContainsKey(module); + + /// True when the module may be granted to a non-admin user. + public static bool IsAssignableToUser(AppModule module) => + ByModule.TryGetValue(module, out var descriptor) && !descriptor.AdminOnly; + + /// Looks up display metadata for a module. + /// The module is not in the catalog. + public static ModuleDescriptor Describe(AppModule module) => ByModule[module]; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs b/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs new file mode 100644 index 0000000..8176d77 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs @@ -0,0 +1,24 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Modules; + +/// +/// Display metadata for an . Kept in the domain so the API, the +/// permission editor and the navigation sidebar all describe modules identically. +/// +/// The module being described. +/// Human readable module name. +/// Grouping label used to nest related modules in the UI. +/// Short explanation of what the module allows. +/// Stable ordering for menus and permission lists. +/// +/// When true the module carries administrative authority and is reserved for +/// ; it is never offered in the per-user permission editor. +/// +public sealed record ModuleDescriptor( + AppModule Module, + string Name, + string Group, + string Description, + int SortOrder, + bool AdminOnly = false); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs b/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs new file mode 100644 index 0000000..088b129 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs @@ -0,0 +1,10 @@ +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Clock; + +internal sealed class SystemDateTimeProvider : IDateTimeProvider +{ + public DateTime UtcNow => DateTime.UtcNow; + + public DateOnly Today => DateOnly.FromDateTime(DateTime.Now); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs index 3fc01c1..7d06e20 100644 --- a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs +++ b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs @@ -1,7 +1,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Infrastructure.Clock; +using RestaurantPOS.Infrastructure.Identity; using RestaurantPOS.Infrastructure.Persistence; +using RestaurantPOS.Infrastructure.Persistence.Interceptors; +using RestaurantPOS.Infrastructure.Persistence.Seeding; +using RestaurantPOS.Infrastructure.Settings; namespace RestaurantPOS.Infrastructure; @@ -9,9 +16,42 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - services.AddDbContext(options => - options.UseSqlite(configuration.GetConnectionString("DefaultConnection"))); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection(SeedAdminOptions.SectionName)); + + var restaurantProfile = new RestaurantProfileOptions(); + configuration.GetSection(RestaurantProfileOptions.SectionName).Bind(restaurantProfile); + services.AddSingleton(restaurantProfile); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(); + + services.AddDbContext((serviceProvider, options) => + options + .UseSqlite( + configuration.GetConnectionString("DefaultConnection"), + // The collections hanging off a user are tiny, so one round trip beats the + // extra queries splitting would cost. + sqlite => sqlite.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery)) + .AddInterceptors(serviceProvider.GetRequiredService())); + + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/ApprovalPinThrottle.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/ApprovalPinThrottle.cs new file mode 100644 index 0000000..95d7339 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/ApprovalPinThrottle.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; + +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// +/// In-memory approval-PIN rate limiter. +/// +/// +/// Memory is the right store here rather than the database: the restaurant runs a single POS +/// process against a local SQLite file, so there is nothing to share state with, and a lockout +/// that evaporates if the machine is restarted is the correct behaviour anyway — a manager +/// restarting the till to get back in is a perfectly good escape hatch from a mistyped PIN. +/// +internal sealed class ApprovalPinThrottle(IDateTimeProvider clock) : IApprovalPinThrottle +{ + /// Tries allowed before entry pauses, matching the 3 attempts the POS spec calls for. + private const int MaxAttempts = 3; + + private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _attempts = new(StringComparer.Ordinal); + + public ApprovalPinAttemptState Check(string terminalKey) + { + if (!_attempts.TryGetValue(terminalKey, out var record)) + { + return new ApprovalPinAttemptState(IsLocked: false, TimeSpan.Zero, MaxAttempts); + } + + var remainingLock = record.LockedUntilUtc - clock.UtcNow; + + if (remainingLock > TimeSpan.Zero) + { + return new ApprovalPinAttemptState(IsLocked: true, remainingLock, AttemptsRemaining: 0); + } + + // The lockout has elapsed, so the slate is wiped rather than leaving the terminal one + // failure away from an immediate re-lock. + if (record.LockedUntilUtc != default) + { + _attempts.TryRemove(terminalKey, out _); + return new ApprovalPinAttemptState(IsLocked: false, TimeSpan.Zero, MaxAttempts); + } + + return new ApprovalPinAttemptState(IsLocked: false, TimeSpan.Zero, MaxAttempts - record.Failures); + } + + public ApprovalPinAttemptState RecordFailure(string terminalKey) + { + var updated = _attempts.AddOrUpdate( + terminalKey, + _ => new Attempts(1, default), + (_, existing) => existing with { Failures = existing.Failures + 1 }); + + if (updated.Failures < MaxAttempts) + { + return new ApprovalPinAttemptState(IsLocked: false, TimeSpan.Zero, MaxAttempts - updated.Failures); + } + + var lockedUntil = clock.UtcNow + LockoutDuration; + _attempts[terminalKey] = new Attempts(updated.Failures, lockedUntil); + + return new ApprovalPinAttemptState(IsLocked: true, LockoutDuration, AttemptsRemaining: 0); + } + + public void Reset(string terminalKey) => _attempts.TryRemove(terminalKey, out _); + + private sealed record Attempts(int Failures, DateTime LockedUntilUtc); +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs new file mode 100644 index 0000000..6dc3b56 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs @@ -0,0 +1,41 @@ +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// +/// BCrypt-based hashing for passwords and approval PINs. The work factor makes offline +/// guessing expensive, which matters most for the 4-digit PIN with its tiny key space. +/// +internal sealed class BCryptPasswordHasher : IPasswordHasher +{ + /// + /// Cost 12 is roughly 250ms per hash on typical till hardware — slow enough to blunt + /// brute force, fast enough to keep sign-in snappy. + /// + private const int WorkFactor = 12; + + public string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + + return BCrypt.Net.BCrypt.HashPassword(plaintext, WorkFactor); + } + + public bool Verify(string plaintext, string hash) + { + if (string.IsNullOrEmpty(plaintext) || string.IsNullOrEmpty(hash)) + { + return false; + } + + try + { + return BCrypt.Net.BCrypt.Verify(plaintext, hash); + } + catch (BCrypt.Net.SaltParseException) + { + // A malformed stored hash must read as "wrong password", never as an unhandled 500. + return false; + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..1d2bc33 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// Token issuing and validation settings, bound from the Jwt configuration section. +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; set; } = "RestaurantPOS"; + + public string Audience { get; set; } = "RestaurantPOS.Client"; + + /// + /// Base64 signing key. Left unset for a local install, in which case a random key is + /// generated on first run and kept in . + /// + public string? SigningKey { get; set; } + + /// + /// Where the auto-generated signing key is stored. Relative paths resolve against the + /// application directory. Deleting this file signs everyone out. + /// + public string KeyFilePath { get; set; } = Path.Combine("keys", "jwt-signing.key"); + + /// + /// Access token lifetime. Short by design — the client silently refreshes, and a shorter + /// window limits how long a revoked user keeps working. + /// + [Range(5, 720)] + public int AccessTokenMinutes { get; set; } = 60; + + /// How long a till stays signed in without re-entering a password. + [Range(1, 90)] + public int RefreshTokenDays { get; set; } = 14; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs new file mode 100644 index 0000000..490e025 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// +/// Supplies the symmetric key used to sign and validate access tokens. +/// +/// +/// This system is installed on a single machine in the restaurant, so requiring the installer +/// to invent and configure a secret would be friction that ends in a weak shared default. +/// Instead a strong key is generated on first run and persisted next to the application. An +/// explicitly configured always wins, which is what a +/// multi-machine or containerised deployment would use. +/// +public sealed class JwtSigningKeyProvider +{ + private const int KeySizeBytes = 64; + + public JwtSigningKeyProvider(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var settings = options.Value; + + var keyMaterial = !string.IsNullOrWhiteSpace(settings.SigningKey) + ? Convert.FromBase64String(settings.SigningKey) + : LoadOrCreatePersistedKey(settings.KeyFilePath); + + if (keyMaterial.Length < 32) + { + throw new InvalidOperationException( + "The configured JWT signing key is too short. Supply at least 32 bytes of base64-encoded material."); + } + + SecurityKey = new SymmetricSecurityKey(keyMaterial); + } + + public SymmetricSecurityKey SecurityKey { get; } + + private static byte[] LoadOrCreatePersistedKey(string keyFilePath) + { + var path = Path.IsPathRooted(keyFilePath) + ? keyFilePath + : Path.Combine(AppContext.BaseDirectory, keyFilePath); + + if (File.Exists(path)) + { + return Convert.FromBase64String(File.ReadAllText(path).Trim()); + } + + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var generated = RandomNumberGenerator.GetBytes(KeySizeBytes); + File.WriteAllText(path, Convert.ToBase64String(generated)); + + return generated; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..04d54fa --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,93 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// Issues signed access tokens and opaque refresh tokens. +internal sealed class JwtTokenService( + IOptions options, + JwtSigningKeyProvider keyProvider, + IDateTimeProvider clock) : ITokenService +{ + private const int RefreshTokenSizeBytes = 32; + + private readonly JwtOptions _options = options.Value; + + public AccessToken CreateAccessToken(User user) + { + ArgumentNullException.ThrowIfNull(user); + + var now = clock.UtcNow; + var expires = now.AddMinutes(_options.AccessTokenMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.UniqueName, user.Username), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new(ClaimTypes.Role, user.Role.ToString()), + }; + + if (user.MustChangePassword) + { + claims.Add(new Claim(AppClaimTypes.MustChangePassword, "true")); + } + + // Administrators are authorised by role, so listing every module would only bloat the + // token. Regular users carry one claim per granted module. + if (user.Role != UserRole.Admin) + { + claims.AddRange(user + .EffectiveModules() + .Select(m => new Claim(AppClaimTypes.Module, m.ToString()))); + } + + var credentials = new SigningCredentials(keyProvider.SecurityKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: now, + expires: expires, + signingCredentials: credentials); + + return new AccessToken(new JwtSecurityTokenHandler().WriteToken(token), expires); + } + + public RefreshTokenPair CreateRefreshToken() + { + var value = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(RefreshTokenSizeBytes)); + + return new RefreshTokenPair( + value, + HashRefreshToken(value), + clock.UtcNow.AddDays(_options.RefreshTokenDays)); + } + + /// + /// A plain SHA-256 digest is enough here: the token is 256 bits of cryptographic randomness, + /// so there is no low-entropy input for an attacker to brute force the way there is with a + /// password. Using BCrypt instead would only slow every request down. + /// + public string HashRefreshToken(string token) + { + ArgumentException.ThrowIfNullOrEmpty(token); + + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + + return Convert.ToHexString(digest).ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs index b8cea10..82324df 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs @@ -1,10 +1,14 @@ using MediatR; + using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; namespace RestaurantPOS.Infrastructure.Persistence; -public class AppDbContext : DbContext +public class AppDbContext : DbContext, IAppDbContext { private readonly IPublisher? _publisher; @@ -13,6 +17,50 @@ public AppDbContext(DbContextOptions options, IPublisher? publishe _publisher = publisher; } + public DbSet Users => Set(); + + public DbSet UserModulePermissions => Set(); + + public DbSet RefreshTokens => Set(); + + public DbSet MenuItems => Set(); + + public DbSet RawMaterials => Set(); + + public DbSet Recipes => Set(); + + public DbSet Suppliers => Set(); + + public DbSet StockLevels => Set(); + + public DbSet StockMovements => Set(); + + public DbSet GoodsReceivedNotes => Set(); + + public DbSet StockReleases => Set(); + + public DbSet AuditLogEntries => Set(); + + public DbSet PurchaseOrders => Set(); + + public DbSet SupplierPrices => Set(); + + public DbSet SupplierPriceHistoryEntries => Set(); + + public DbSet SupplierPayments => Set(); + + public DbSet RestaurantTables => Set(); + + public DbSet Orders => Set(); + + public DbSet OrderItems => Set(); + + public DbSet KitchenTickets => Set(); + + public DbSet OrderPayments => Set(); + + public DbSet Receipts => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs new file mode 100644 index 0000000..daee880 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/AuditLogEntryConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class AuditLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("AuditLogEntries"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).ValueGeneratedNever(); + + builder.Property(a => a.EntityType) + .IsRequired() + .HasMaxLength(AuditLogEntry.EntityTypeMaxLength); + + builder.Property(a => a.Action) + .IsRequired() + .HasMaxLength(AuditLogEntry.ActionMaxLength); + + builder.Property(a => a.PerformedByName) + .IsRequired() + .HasMaxLength(120); + + builder.Property(a => a.Summary) + .IsRequired() + .HasMaxLength(AuditLogEntry.SummaryMaxLength); + + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs new file mode 100644 index 0000000..e18d0a0 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/GoodsReceivedNoteConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class GoodsReceivedNoteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("GoodsReceivedNotes"); + + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).ValueGeneratedNever(); + + builder.Property(n => n.ReceivedAtUtc).IsRequired(); + builder.Property(n => n.Notes).HasMaxLength(GoodsReceivedNote.NotesMaxLength); + builder.Property(n => n.HasIssue).IsRequired(); + + builder.HasIndex(n => n.ReceivedAtUtc); + builder.HasIndex(n => n.PurchaseOrderId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/KitchenTicketConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/KitchenTicketConfiguration.cs new file mode 100644 index 0000000..bdc4b59 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/KitchenTicketConfiguration.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class KitchenTicketConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("KitchenTickets"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).ValueGeneratedNever(); + + builder.Property(t => t.Kind).IsRequired().HasConversion(); + builder.Property(t => t.Status).IsRequired().HasConversion(); + builder.Property(t => t.TicketNumber).IsRequired(); + + // The kitchen display reads outstanding tickets constantly, so it gets its own index. + builder.HasIndex(t => t.Status); + builder.HasIndex(t => new { t.OrderId, t.TicketNumber }); + + builder.Metadata + .FindNavigation(nameof(KitchenTicket.Lines))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(t => t.Lines) + .WithOne() + .HasForeignKey(l => l.KitchenTicketId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Ignore(t => t.IsWorkable); + } +} + +internal sealed class KitchenTicketLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("KitchenTicketLines"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).ValueGeneratedNever(); + + builder.Property(l => l.MenuItemName).IsRequired().HasMaxLength(MenuItem.NameMaxLength); + builder.Property(l => l.Quantity).IsRequired(); + + builder.Property(l => l.SpecialInstructions) + .HasMaxLength(OrderItem.SpecialInstructionsMaxLength); + + builder.Property(l => l.Note).HasMaxLength(KitchenTicketLine.NoteMaxLength); + + builder.HasIndex(l => l.KitchenTicketId); + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs new file mode 100644 index 0000000..95fea12 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/MenuItemConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class MenuItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("MenuItems"); + + builder.HasKey(m => m.Id); + + // Ids are generated by the domain, not the database. + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.Name) + .IsRequired() + .HasMaxLength(MenuItem.NameMaxLength); + + builder.HasIndex(m => m.Name).IsUnique(); + + builder.Property(m => m.Category) + .IsRequired() + .HasMaxLength(MenuItem.CategoryMaxLength); + + builder.Property(m => m.Price).IsRequired(); + + builder.Property(m => m.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/OrderConfiguration.cs new file mode 100644 index 0000000..eebeea2 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/OrderConfiguration.cs @@ -0,0 +1,135 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Orders"); + + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedNever(); + + builder.Property(o => o.Status).IsRequired().HasConversion(); + builder.Property(o => o.DiscountType).IsRequired().HasConversion(); + + // The daily sequence is only meaningful together with the day it belongs to. + builder.HasIndex(o => new { o.OrderDate, o.OrderNumber }); + builder.HasIndex(o => new { o.TableId, o.Status }); + + builder.HasOne() + .WithMany() + .HasForeignKey(o => o.TableId) + .OnDelete(DeleteBehavior.Restrict); + + foreach (var navigation in new[] { nameof(Order.Items), nameof(Order.Tickets), nameof(Order.Payments) }) + { + builder.Metadata.FindNavigation(navigation)!.SetPropertyAccessMode(PropertyAccessMode.Field); + } + + builder.HasMany(o => o.Items) + .WithOne() + .HasForeignKey(i => i.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(o => o.Tickets) + .WithOne() + .HasForeignKey(t => t.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(o => o.Payments) + .WithOne() + .HasForeignKey(p => p.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(o => o.Receipt) + .WithOne() + .HasForeignKey(r => r.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + // Every money figure is derived from the lines and the discount, so none of them are + // stored: a persisted total is a second source of truth that drifts the moment a line + // changes without it. + builder.Ignore(o => o.Subtotal); + builder.Ignore(o => o.DiscountAmount); + builder.Ignore(o => o.Total); + builder.Ignore(o => o.AmountPaid); + builder.Ignore(o => o.ChangeDue); + builder.Ignore(o => o.ActiveItems); + builder.Ignore(o => o.IsLive); + } +} + +internal sealed class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("OrderItems"); + + builder.HasKey(i => i.Id); + builder.Property(i => i.Id).ValueGeneratedNever(); + + builder.Property(i => i.MenuItemName).IsRequired().HasMaxLength(MenuItem.NameMaxLength); + builder.Property(i => i.UnitPrice).IsRequired(); + builder.Property(i => i.Quantity).IsRequired(); + + builder.Property(i => i.SpecialInstructions) + .HasMaxLength(OrderItem.SpecialInstructionsMaxLength); + + builder.HasIndex(i => i.OrderId); + + builder.HasOne() + .WithMany() + .HasForeignKey(i => i.MenuItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Ignore(i => i.LineTotal); + } +} + +internal sealed class OrderPaymentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("OrderPayments"); + + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedNever(); + + builder.Property(p => p.Method).IsRequired().HasConversion(); + builder.Property(p => p.Amount).IsRequired(); + builder.Property(p => p.Reference).HasMaxLength(OrderPayment.ReferenceMaxLength); + + builder.HasIndex(p => p.OrderId); + + builder.Ignore(p => p.ChangeGiven); + } +} + +internal sealed class ReceiptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Receipts"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + builder.Property(r => r.Number).IsRequired().HasMaxLength(Receipt.NumberMaxLength); + + builder.HasIndex(r => r.Number).IsUnique(); + builder.HasIndex(r => r.OrderId).IsUnique(); + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..5b9efd1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/PurchaseOrderConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("PurchaseOrders"); + + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedNever(); + + builder.Property(o => o.Status) + .IsRequired() + .HasConversion(); + + builder.Property(o => o.Notes).HasMaxLength(PurchaseOrder.NotesMaxLength); + + builder.HasIndex(o => new { o.SupplierId, o.Status }); + + builder.Metadata + .FindNavigation(nameof(PurchaseOrder.Lines))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(o => o.Lines) + .WithOne() + .HasForeignKey(l => l.PurchaseOrderId) + .OnDelete(DeleteBehavior.Cascade); + + // TotalAmount is computed in memory from the lines, not persisted. + builder.Ignore(o => o.TotalAmount); + } +} + +internal sealed class PurchaseOrderLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("PurchaseOrderLines"); + + // A raw material cannot appear more than once on the same purchase order. + builder.HasKey(l => new { l.PurchaseOrderId, l.RawMaterialId }); + + builder.Property(l => l.Quantity).IsRequired(); + builder.Property(l => l.UnitPrice).IsRequired(); + + builder.Ignore(l => l.LineTotal); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs new file mode 100644 index 0000000..aff2ab1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RawMaterialConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RawMaterialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RawMaterials"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + builder.Property(r => r.Name) + .IsRequired() + .HasMaxLength(RawMaterial.NameMaxLength); + + builder.HasIndex(r => r.Name).IsUnique(); + + builder.Property(r => r.UnitOfMeasurement) + .IsRequired() + .HasConversion(); + + builder.Property(r => r.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs new file mode 100644 index 0000000..2f05c7f --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RecipeConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RecipeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Recipes"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + // At most one recipe per menu item (BR-REC-001). + builder.HasIndex(r => r.MenuItemId).IsUnique(); + + builder.Property(r => r.IsEnabled).IsRequired(); + + builder.Metadata + .FindNavigation(nameof(Recipe.Lines))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(r => r.Lines) + .WithOne() + .HasForeignKey(l => l.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal sealed class RecipeLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RecipeLines"); + + // A raw material cannot appear more than once in the same recipe. + builder.HasKey(l => new { l.RecipeId, l.RawMaterialId }); + + builder.Property(l => l.Quantity).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..f67c64c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RefreshTokens"); + + builder.HasKey(t => t.Id); + + // The aggregate assigns the id, so the key is already populated by the time EF sees a + // new token. Saying so explicitly stops EF from assuming a set key means an existing + // row and issuing an UPDATE for a token that was only just created. + builder.Property(t => t.Id).ValueGeneratedNever(); + + builder.Property(t => t.TokenHash) + .IsRequired() + .HasMaxLength(128); + + // Token lookup on refresh goes through this index. + builder.HasIndex(t => t.TokenHash).IsUnique(); + + builder.Property(t => t.ExpiresAtUtc).IsRequired(); + builder.Property(t => t.CreatedAtUtc).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RestaurantTableConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RestaurantTableConfiguration.cs new file mode 100644 index 0000000..83fc47d --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RestaurantTableConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RestaurantTableConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RestaurantTables"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).ValueGeneratedNever(); + + builder.Property(t => t.Number) + .IsRequired() + .HasMaxLength(RestaurantTable.NumberMaxLength); + + builder.Property(t => t.Notes).HasMaxLength(RestaurantTable.NotesMaxLength); + + // Two tables labelled "4" would make the floor plan ambiguous for staff and for search. + builder.HasIndex(t => t.Number).IsUnique(); + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs new file mode 100644 index 0000000..cbe0d27 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockLevelConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockLevelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockLevels"); + + // One balance row per raw material, per store. + builder.HasKey(l => new { l.RawMaterialId, l.Store }); + + builder.Property(l => l.Store) + .IsRequired() + .HasConversion(); + + builder.Property(l => l.QuantityOnHand).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs new file mode 100644 index 0000000..caf274c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockMovements"); + + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.Store) + .IsRequired() + .HasConversion(); + + builder.Property(m => m.QuantityDelta).IsRequired(); + + builder.Property(m => m.Type) + .IsRequired() + .HasConversion(); + + builder.Property(m => m.Notes).HasMaxLength(StockMovement.NotesMaxLength); + + // The stock history screen filters by store and raw material and sorts by date, and a + // GRN/release detail screen looks its lines up by ReferenceId. + builder.HasIndex(m => new { m.Store, m.RawMaterialId, m.OccurredAtUtc }); + builder.HasIndex(m => m.ReferenceId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs new file mode 100644 index 0000000..f0cc0c0 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/StockReleaseConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class StockReleaseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("StockReleases"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever(); + + builder.Property(r => r.RequestedAtUtc).IsRequired(); + builder.Property(r => r.ApprovedAtUtc).IsRequired(); + builder.Property(r => r.Notes).HasMaxLength(StockRelease.NotesMaxLength); + + builder.HasIndex(r => r.RequestedAtUtc); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs new file mode 100644 index 0000000..2c80f8d --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Suppliers"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + + builder.Property(s => s.Name) + .IsRequired() + .HasMaxLength(Supplier.NameMaxLength); + + builder.HasIndex(s => s.Name).IsUnique(); + + builder.Property(s => s.ContactName).HasMaxLength(Supplier.ContactNameMaxLength); + builder.Property(s => s.Phone).HasMaxLength(Supplier.PhoneMaxLength); + builder.Property(s => s.Email).HasMaxLength(Supplier.EmailMaxLength); + builder.Property(s => s.Address).HasMaxLength(Supplier.AddressMaxLength); + builder.Property(s => s.PaymentTermsDays).IsRequired(); + + builder.Property(s => s.IsActive).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs new file mode 100644 index 0000000..0db0ce1 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPaymentConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierPaymentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPayments"); + + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedNever(); + + builder.Property(p => p.Amount).IsRequired(); + builder.Property(p => p.PaymentDateUtc).IsRequired(); + + builder.Property(p => p.Method) + .IsRequired() + .HasConversion(); + + builder.Property(p => p.InvoiceReference).HasMaxLength(SupplierPayment.InvoiceReferenceMaxLength); + builder.Property(p => p.Notes).HasMaxLength(SupplierPayment.NotesMaxLength); + + builder.HasIndex(p => p.PurchaseOrderId); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs new file mode 100644 index 0000000..6b97a9e --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/SupplierPriceConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class SupplierPriceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPrices"); + + // One current price per supplier, per raw material. + builder.HasKey(p => new { p.SupplierId, p.RawMaterialId }); + + builder.Property(p => p.Price).IsRequired(); + builder.Property(p => p.UpdatedAtUtc).IsRequired(); + } +} + +internal sealed class SupplierPriceHistoryEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("SupplierPriceHistoryEntries"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + + builder.Property(e => e.Price).IsRequired(); + builder.Property(e => e.RecordedAtUtc).IsRequired(); + + builder.HasIndex(e => new { e.SupplierId, e.RawMaterialId, e.RecordedAtUtc }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..e7e8d63 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Users"); + + builder.HasKey(u => u.Id); + + // Ids are generated by the domain, not the database. + builder.Property(u => u.Id).ValueGeneratedNever(); + + builder.Property(u => u.Username) + .IsRequired() + .HasMaxLength(User.UsernameMaxLength); + + // Usernames are normalised to lower case by the aggregate, so a plain unique index is + // enough to make lookups case-insensitive without relying on collation. + builder.HasIndex(u => u.Username).IsUnique(); + + builder.Property(u => u.FullName) + .IsRequired() + .HasMaxLength(User.FullNameMaxLength); + + builder.Property(u => u.Email) + .HasMaxLength(User.EmailMaxLength); + + builder.Property(u => u.PasswordHash) + .IsRequired() + .HasMaxLength(200); + + builder.Property(u => u.ApprovalPinHash) + .HasMaxLength(200); + + builder.Property(u => u.Role) + .IsRequired() + .HasConversion(); + + builder.Property(u => u.IsActive).IsRequired(); + builder.Property(u => u.MustChangePassword).IsRequired(); + builder.Property(u => u.IsSystemAdmin).IsRequired(); + + // The aggregate exposes read-only views over private lists, so EF must read and write + // the backing fields rather than the properties. + builder.Metadata + .FindNavigation(nameof(User.ModulePermissions))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.Metadata + .FindNavigation(nameof(User.RefreshTokens))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(u => u.ModulePermissions) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(u => u.RefreshTokens) + .WithOne() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs new file mode 100644 index 0000000..21703be --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class UserModulePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("UserModulePermissions"); + + // Composite key: a user can hold a given module at most once. + builder.HasKey(p => new { p.UserId, p.Module }); + + builder.Property(p => p.Module) + .IsRequired() + .HasConversion(); + + builder.Property(p => p.GrantedAtUtc).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..33f6de4 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Infrastructure.Persistence.Interceptors; + +/// +/// Stamps and so +/// individual handlers never have to remember to. +/// +internal sealed class AuditableEntityInterceptor(IDateTimeProvider clock) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + ArgumentNullException.ThrowIfNull(eventData); + + Stamp(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(eventData); + + Stamp(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void Stamp(DbContext? context) + { + if (context is null) + { + return; + } + + var now = clock.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAtUtc = now; + break; + + case EntityState.Modified: + entry.Entity.UpdatedAtUtc = now; + break; + + default: + break; + } + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs new file mode 100644 index 0000000..884d6a9 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs @@ -0,0 +1,158 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260802081447_InitialUserManagement")] + partial class InitialUserManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs new file mode 100644 index 0000000..988a5ed --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs @@ -0,0 +1,111 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialUserManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", maxLength: 32, nullable: false), + FullName = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: true), + PasswordHash = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + MustChangePassword = table.Column(type: "INTEGER", nullable: false), + ApprovalPinHash = table.Column(type: "TEXT", maxLength: 200, nullable: true), + ApprovalPinSetAtUtc = table.Column(type: "TEXT", nullable: true), + LastLoginAtUtc = table.Column(type: "TEXT", nullable: true), + IsSystemAdmin = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + RevokedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserModulePermissions", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + Module = table.Column(type: "INTEGER", nullable: false), + GrantedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserModulePermissions", x => new { x.UserId, x.Module }); + table.ForeignKey( + name: "FK_UserModulePermissions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "UserModulePermissions"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs new file mode 100644 index 0000000..55c2b80 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.Designer.cs @@ -0,0 +1,490 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804063947_AddRecipeAndInventoryManagement")] + partial class AddRecipeAndInventoryManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs new file mode 100644 index 0000000..8a980da --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804063947_AddRecipeAndInventoryManagement.cs @@ -0,0 +1,274 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRecipeAndInventoryManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + EntityId = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", maxLength: 50, nullable: false), + PerformedByUserId = table.Column(type: "TEXT", nullable: false), + PerformedByName = table.Column(type: "TEXT", maxLength: 120, nullable: false), + OccurredAtUtc = table.Column(type: "TEXT", nullable: false), + Summary = table.Column(type: "TEXT", maxLength: 500, nullable: false), + DetailsJson = table.Column(type: "TEXT", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GoodsReceivedNotes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + ReceivedByUserId = table.Column(type: "TEXT", nullable: false), + ReceivedAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GoodsReceivedNotes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MenuItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MenuItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RawMaterials", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + UnitOfMeasurement = table.Column(type: "INTEGER", nullable: false), + MainStoreReorderLevel = table.Column(type: "TEXT", nullable: true), + KitchenParLevel = table.Column(type: "TEXT", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RawMaterials", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Recipes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MenuItemId = table.Column(type: "TEXT", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Recipes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StockLevels", + columns: table => new + { + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Store = table.Column(type: "INTEGER", nullable: false), + QuantityOnHand = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockLevels", x => new { x.RawMaterialId, x.Store }); + }); + + migrationBuilder.CreateTable( + name: "StockMovements", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Store = table.Column(type: "INTEGER", nullable: false), + QuantityDelta = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + ReferenceId = table.Column(type: "TEXT", nullable: true), + PerformedByUserId = table.Column(type: "TEXT", nullable: false), + OccurredAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockMovements", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StockReleases", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RequestedByUserId = table.Column(type: "TEXT", nullable: false), + RequestedAtUtc = table.Column(type: "TEXT", nullable: false), + ApprovedByUserId = table.Column(type: "TEXT", nullable: false), + ApprovedAtUtc = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StockReleases", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Suppliers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 150, nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Suppliers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RecipeLines", + columns: table => new + { + RecipeId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RecipeLines", x => new { x.RecipeId, x.RawMaterialId }); + table.ForeignKey( + name: "FK_RecipeLines_Recipes_RecipeId", + column: x => x.RecipeId, + principalTable: "Recipes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogEntries_EntityType_EntityId", + table: "AuditLogEntries", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_GoodsReceivedNotes_ReceivedAtUtc", + table: "GoodsReceivedNotes", + column: "ReceivedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_MenuItems_Name", + table: "MenuItems", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RawMaterials_Name", + table: "RawMaterials", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Recipes_MenuItemId", + table: "Recipes", + column: "MenuItemId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StockMovements_ReferenceId", + table: "StockMovements", + column: "ReferenceId"); + + migrationBuilder.CreateIndex( + name: "IX_StockMovements_Store_RawMaterialId_OccurredAtUtc", + table: "StockMovements", + columns: new[] { "Store", "RawMaterialId", "OccurredAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_StockReleases_RequestedAtUtc", + table: "StockReleases", + column: "RequestedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_Suppliers_Name", + table: "Suppliers", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogEntries"); + + migrationBuilder.DropTable( + name: "GoodsReceivedNotes"); + + migrationBuilder.DropTable( + name: "MenuItems"); + + migrationBuilder.DropTable( + name: "RawMaterials"); + + migrationBuilder.DropTable( + name: "RecipeLines"); + + migrationBuilder.DropTable( + name: "StockLevels"); + + migrationBuilder.DropTable( + name: "StockMovements"); + + migrationBuilder.DropTable( + name: "StockReleases"); + + migrationBuilder.DropTable( + name: "Suppliers"); + + migrationBuilder.DropTable( + name: "Recipes"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs new file mode 100644 index 0000000..f31e707 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.Designer.cs @@ -0,0 +1,689 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804135447_AddSupplierManagement")] + partial class AddSupplierManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs new file mode 100644 index 0000000..9247c5c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260804135447_AddSupplierManagement.cs @@ -0,0 +1,256 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSupplierManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Address", + table: "Suppliers", + type: "TEXT", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "ContactName", + table: "Suppliers", + type: "TEXT", + maxLength: 150, + nullable: true); + + migrationBuilder.AddColumn( + name: "CreditLimit", + table: "Suppliers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + table: "Suppliers", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "LeadTimeDays", + table: "Suppliers", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PaymentTermsDays", + table: "Suppliers", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Phone", + table: "Suppliers", + type: "TEXT", + maxLength: 30, + nullable: true); + + migrationBuilder.AddColumn( + name: "HasIssue", + table: "GoodsReceivedNotes", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PurchaseOrderId", + table: "GoodsReceivedNotes", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "QualityRating", + table: "GoodsReceivedNotes", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "PurchaseOrders", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + ExpectedDeliveryDate = table.Column(type: "TEXT", nullable: true), + SubmittedAtUtc = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPayments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PurchaseOrderId = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + PaymentDateUtc = table.Column(type: "TEXT", nullable: false), + Method = table.Column(type: "INTEGER", nullable: false), + InvoiceReference = table.Column(type: "TEXT", maxLength: 100, nullable: true), + RecordedByUserId = table.Column(type: "TEXT", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPayments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPriceHistoryEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SupplierId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + RecordedByUserId = table.Column(type: "TEXT", nullable: false), + RecordedAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPriceHistoryEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierPrices", + columns: table => new + { + SupplierId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierPrices", x => new { x.SupplierId, x.RawMaterialId }); + }); + + migrationBuilder.CreateTable( + name: "PurchaseOrderLines", + columns: table => new + { + PurchaseOrderId = table.Column(type: "TEXT", nullable: false), + RawMaterialId = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "TEXT", nullable: false), + UnitPrice = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseOrderLines", x => new { x.PurchaseOrderId, x.RawMaterialId }); + table.ForeignKey( + name: "FK_PurchaseOrderLines_PurchaseOrders_PurchaseOrderId", + column: x => x.PurchaseOrderId, + principalTable: "PurchaseOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GoodsReceivedNotes_PurchaseOrderId", + table: "GoodsReceivedNotes", + column: "PurchaseOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseOrders_SupplierId_Status", + table: "PurchaseOrders", + columns: new[] { "SupplierId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_SupplierPayments_PurchaseOrderId", + table: "SupplierPayments", + column: "PurchaseOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_SupplierPriceHistoryEntries_SupplierId_RawMaterialId_RecordedAtUtc", + table: "SupplierPriceHistoryEntries", + columns: new[] { "SupplierId", "RawMaterialId", "RecordedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PurchaseOrderLines"); + + migrationBuilder.DropTable( + name: "SupplierPayments"); + + migrationBuilder.DropTable( + name: "SupplierPriceHistoryEntries"); + + migrationBuilder.DropTable( + name: "SupplierPrices"); + + migrationBuilder.DropTable( + name: "PurchaseOrders"); + + migrationBuilder.DropIndex( + name: "IX_GoodsReceivedNotes_PurchaseOrderId", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "Address", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "ContactName", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "CreditLimit", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "Email", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "LeadTimeDays", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "PaymentTermsDays", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "Phone", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "HasIssue", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "PurchaseOrderId", + table: "GoodsReceivedNotes"); + + migrationBuilder.DropColumn( + name: "QualityRating", + table: "GoodsReceivedNotes"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.Designer.cs new file mode 100644 index 0000000..ef0f9cd --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.Designer.cs @@ -0,0 +1,1059 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260805135418_AddPosBillingAndKitchenOperations")] + partial class AddPosBillingAndKitchenOperations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("PrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReadyAtUtc") + .HasColumnType("TEXT"); + + b.Property("ServedAtUtc") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TicketNumber") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrderId", "TicketNumber"); + + b.ToTable("KitchenTickets", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("KitchenTicketId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("OrderItemId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KitchenTicketId"); + + b.ToTable("KitchenTicketLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CancelledByUserId") + .HasColumnType("TEXT"); + + b.Property("CashierUserId") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DiscountType") + .HasColumnType("INTEGER"); + + b.Property("DiscountValue") + .HasColumnType("TEXT"); + + b.Property("OrderDate") + .HasColumnType("TEXT"); + + b.Property("OrderNumber") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TableId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderDate", "OrderNumber"); + + b.HasIndex("TableId", "Status"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsCancelled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenderedAmount") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IssuedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastPrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("Receipts", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RestaurantTable", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("RestaurantTables", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Tickets") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.KitchenTicket", null) + .WithMany("Lines") + .HasForeignKey("KitchenTicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.RestaurantTable", null) + .WithMany() + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.MenuItem", null) + .WithMany() + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Payments") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithOne("Receipt") + .HasForeignKey("RestaurantPOS.Domain.Entities.Receipt", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Navigation("Items"); + + b.Navigation("Payments"); + + b.Navigation("Receipt"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.cs new file mode 100644 index 0000000..4b68f33 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260805135418_AddPosBillingAndKitchenOperations.cs @@ -0,0 +1,279 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPosBillingAndKitchenOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RestaurantTables", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Number = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Seats = table.Column(type: "INTEGER", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 200, nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RestaurantTables", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderNumber = table.Column(type: "INTEGER", nullable: true), + OrderDate = table.Column(type: "TEXT", nullable: true), + TableId = table.Column(type: "TEXT", nullable: false), + CashierUserId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + DiscountType = table.Column(type: "INTEGER", nullable: false), + DiscountValue = table.Column(type: "TEXT", nullable: false), + ConfirmedAtUtc = table.Column(type: "TEXT", nullable: true), + CompletedAtUtc = table.Column(type: "TEXT", nullable: true), + CancelledAtUtc = table.Column(type: "TEXT", nullable: true), + CancelledByUserId = table.Column(type: "TEXT", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + table.ForeignKey( + name: "FK_Orders_RestaurantTables_TableId", + column: x => x.TableId, + principalTable: "RestaurantTables", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "KitchenTickets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderId = table.Column(type: "TEXT", nullable: false), + TicketNumber = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + PrintedAtUtc = table.Column(type: "TEXT", nullable: false), + StartedAtUtc = table.Column(type: "TEXT", nullable: true), + ReadyAtUtc = table.Column(type: "TEXT", nullable: true), + ServedAtUtc = table.Column(type: "TEXT", nullable: true), + PrintCount = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_KitchenTickets", x => x.Id); + table.ForeignKey( + name: "FK_KitchenTickets_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OrderItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderId = table.Column(type: "TEXT", nullable: false), + MenuItemId = table.Column(type: "TEXT", nullable: false), + MenuItemName = table.Column(type: "TEXT", maxLength: 150, nullable: false), + UnitPrice = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + SpecialInstructions = table.Column(type: "TEXT", maxLength: 250, nullable: true), + IsCancelled = table.Column(type: "INTEGER", nullable: false), + CancelledAtUtc = table.Column(type: "TEXT", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderItems", x => x.Id); + table.ForeignKey( + name: "FK_OrderItems_MenuItems_MenuItemId", + column: x => x.MenuItemId, + principalTable: "MenuItems", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OrderItems_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OrderPayments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderId = table.Column(type: "TEXT", nullable: false), + Method = table.Column(type: "INTEGER", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + TenderedAmount = table.Column(type: "TEXT", nullable: true), + Reference = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderPayments", x => x.Id); + table.ForeignKey( + name: "FK_OrderPayments_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Receipts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderId = table.Column(type: "TEXT", nullable: false), + Number = table.Column(type: "TEXT", maxLength: 30, nullable: false), + IssuedAtUtc = table.Column(type: "TEXT", nullable: false), + PrintCount = table.Column(type: "INTEGER", nullable: false), + LastPrintedAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Receipts", x => x.Id); + table.ForeignKey( + name: "FK_Receipts_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "KitchenTicketLines", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + KitchenTicketId = table.Column(type: "TEXT", nullable: false), + OrderItemId = table.Column(type: "TEXT", nullable: false), + MenuItemName = table.Column(type: "TEXT", maxLength: 150, nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + SpecialInstructions = table.Column(type: "TEXT", maxLength: 250, nullable: true), + Note = table.Column(type: "TEXT", maxLength: 120, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_KitchenTicketLines", x => x.Id); + table.ForeignKey( + name: "FK_KitchenTicketLines_KitchenTickets_KitchenTicketId", + column: x => x.KitchenTicketId, + principalTable: "KitchenTickets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_KitchenTicketLines_KitchenTicketId", + table: "KitchenTicketLines", + column: "KitchenTicketId"); + + migrationBuilder.CreateIndex( + name: "IX_KitchenTickets_OrderId_TicketNumber", + table: "KitchenTickets", + columns: new[] { "OrderId", "TicketNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_KitchenTickets_Status", + table: "KitchenTickets", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_OrderItems_MenuItemId", + table: "OrderItems", + column: "MenuItemId"); + + migrationBuilder.CreateIndex( + name: "IX_OrderItems_OrderId", + table: "OrderItems", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_OrderPayments_OrderId", + table: "OrderPayments", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_OrderDate_OrderNumber", + table: "Orders", + columns: new[] { "OrderDate", "OrderNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_Orders_TableId_Status", + table: "Orders", + columns: new[] { "TableId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_Number", + table: "Receipts", + column: "Number", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_OrderId", + table: "Receipts", + column: "OrderId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RestaurantTables_Number", + table: "RestaurantTables", + column: "Number", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KitchenTicketLines"); + + migrationBuilder.DropTable( + name: "OrderItems"); + + migrationBuilder.DropTable( + name: "OrderPayments"); + + migrationBuilder.DropTable( + name: "Receipts"); + + migrationBuilder.DropTable( + name: "KitchenTickets"); + + migrationBuilder.DropTable( + name: "Orders"); + + migrationBuilder.DropTable( + name: "RestaurantTables"); + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..a14ceac --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,1056 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("PrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReadyAtUtc") + .HasColumnType("TEXT"); + + b.Property("ServedAtUtc") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TicketNumber") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrderId", "TicketNumber"); + + b.ToTable("KitchenTickets", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("KitchenTicketId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("OrderItemId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KitchenTicketId"); + + b.ToTable("KitchenTicketLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CancelledByUserId") + .HasColumnType("TEXT"); + + b.Property("CashierUserId") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DiscountType") + .HasColumnType("INTEGER"); + + b.Property("DiscountValue") + .HasColumnType("TEXT"); + + b.Property("OrderDate") + .HasColumnType("TEXT"); + + b.Property("OrderNumber") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TableId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderDate", "OrderNumber"); + + b.HasIndex("TableId", "Status"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsCancelled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenderedAmount") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IssuedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastPrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("Receipts", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RestaurantTable", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("RestaurantTables", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Tickets") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.KitchenTicket", null) + .WithMany("Lines") + .HasForeignKey("KitchenTicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.RestaurantTable", null) + .WithMany() + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.MenuItem", null) + .WithMany() + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Payments") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithOne("Receipt") + .HasForeignKey("RestaurantPOS.Domain.Entities.Receipt", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Navigation("Items"); + + b.Navigation("Payments"); + + b.Navigation("Receipt"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs new file mode 100644 index 0000000..b72050f --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Infrastructure.Persistence.Seeding; + +/// +/// Brings a fresh database up to a usable state: applies migrations and guarantees that at +/// least one administrator exists. +/// +public sealed partial class DatabaseSeeder( + AppDbContext db, + IPasswordHasher passwordHasher, + IOptions options, + ILogger logger) +{ + private readonly SeedAdminOptions _options = options.Value; + + /// + /// Applies pending migrations, then creates the built-in administrator if no administrator + /// account exists. Safe to run on every start-up. + /// + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await db.Database.MigrateAsync(cancellationToken); + + var anyAdmin = await db.Users.AnyAsync(u => u.Role == UserRole.Admin, cancellationToken); + if (anyAdmin) + { + return; + } + + var username = _options.Username.Trim().ToLowerInvariant(); + + // Guard against a non-admin already holding the configured name, which would otherwise + // fail the unique index and stop the application from starting. + var nameTaken = await db.Users.AnyAsync(u => u.Username == username, cancellationToken); + if (nameTaken) + { + SeedUsernameTaken(logger, username); + return; + } + + var admin = User.CreateSystemAdmin( + username, + _options.FullName, + passwordHasher.Hash(_options.Password)); + + db.Users.Add(admin); + await db.SaveChangesAsync(cancellationToken); + + SeededAdmin(logger, username); + } + + [LoggerMessage( + EventId = 2000, + Level = LogLevel.Warning, + Message = "Seeded the built-in administrator '{Username}'. " + + "It must change its password at first sign-in.")] + private static partial void SeededAdmin(ILogger logger, string username); + + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Error, + Message = "Cannot seed an administrator: the username '{Username}' is already taken by a " + + "non-administrator. Set SeedAdmin:Username to a free name.")] + private static partial void SeedUsernameTaken(ILogger logger, string username); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs new file mode 100644 index 0000000..9d63587 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs @@ -0,0 +1,24 @@ +namespace RestaurantPOS.Infrastructure.Persistence.Seeding; + +/// +/// Credentials for the built-in administrator created on a fresh database, bound from the +/// SeedAdmin configuration section. +/// +/// +/// The seeded account is always flagged to change its password at first sign-in, so the value +/// configured here is a one-time bootstrap credential rather than a lasting password. +/// +public sealed class SeedAdminOptions +{ + public const string SectionName = "SeedAdmin"; + + public string Username { get; set; } = "admin"; + + public string FullName { get; set; } = "System Administrator"; + + /// + /// Bootstrap password. Override it per installation via configuration or the + /// SeedAdmin__Password environment variable. + /// + public string Password { get; set; } = "ChangeMe!123"; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj b/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj index 8a5aa3f..177e4f7 100644 --- a/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj +++ b/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/backend/src/RestaurantPOS.Infrastructure/Settings/RestaurantProfileOptions.cs b/backend/src/RestaurantPOS.Infrastructure/Settings/RestaurantProfileOptions.cs new file mode 100644 index 0000000..7948ca8 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Settings/RestaurantProfileOptions.cs @@ -0,0 +1,19 @@ +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Settings; + +/// Receipt letterhead details, bound from the Restaurant configuration section. +public sealed class RestaurantProfileOptions : IRestaurantProfile +{ + public const string SectionName = "Restaurant"; + + public string Name { get; set; } = "Sri Lakshmi Family Restaurant"; + + public string AddressLine1 { get; set; } = "Jaffna Road, Sandamalgama"; + + public string? AddressLine2 { get; set; } + + public string? City { get; set; } = "Anuradhapura"; + + public string? Phone { get; set; } = "077 7273794"; +} diff --git a/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs b/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs index 00fd8c8..4e30278 100644 --- a/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs +++ b/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; + using NetArchTest.Rules; + using Xunit; namespace RestaurantPOS.ArchitectureTests; diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs new file mode 100644 index 0000000..5cbe180 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs @@ -0,0 +1,154 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Authentication; + +/// +/// Covers the approval PIN: the mechanism other modules will call when a member of staff needs +/// an administrator to authorise something at the till, such as cancelling an order. +/// +public class ApprovalPinTests : IntegrationTestBase +{ + [Fact] + public async Task AdminCanGenerateAPin_AndItIsReturnedExactlyOnce() + { + var session = await SignInAsAdminAsync(); + session.User.HasApprovalPin.Should().BeFalse(); + + var response = await Client.SetApprovalPinAsync(AdminPassword); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var pin = (await PosApiClient.ReadAsync(response)).Pin; + pin.Should().HaveLength(4).And.MatchRegex("^[0-9]{4}$"); + + // The PIN itself is never readable again — only the fact that one exists. + var me = await PosApiClient.ReadAsync(await Client.GetMeAsync()); + me.HasApprovalPin.Should().BeTrue(); + } + + [Fact] + public async Task AdminCanChooseTheirOwnPin() + { + await SignInAsAdminAsync(); + + var response = await Client.SetApprovalPinAsync(AdminPassword, "4821"); + + (await PosApiClient.ReadAsync(response)).Pin.Should().Be("4821"); + } + + [Fact] + public async Task SettingAPinRequiresTheCurrentPassword() + { + await SignInAsAdminAsync(); + + var response = await Client.SetApprovalPinAsync("WrongPassword1", "4821"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.PasswordMismatch"); + } + + [Theory] + [InlineData("123")] + [InlineData("12345")] + [InlineData("abcd")] + public async Task APinMustBeFourDigits(string pin) + { + await SignInAsAdminAsync(); + + (await Client.SetApprovalPinAsync(AdminPassword, pin)) + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task StaffCanPresentAnAdminPinToAuthoriseAnAction() + { + var admin = await SignInAsAdminAsync(); + var pin = (await PosApiClient.ReadAsync( + await Client.SetApprovalPinAsync(AdminPassword, "4821"))).Pin; + + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + var response = await staff.VerifyApprovalPinAsync(pin, "Cancel order #1042"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var approval = await PosApiClient.ReadAsync(response); + approval.ApprovedByUserId.Should().Be(admin.User.Id); + approval.ApprovedByName.Should().Be(admin.User.FullName); + } + + [Fact] + public async Task AWrongPinIsRejected() + { + await SignInAsAdminAsync(); + await Client.SetApprovalPinAsync(AdminPassword, "4821"); + var (staff, _) = await CreateAndSignInStaffAsync(); + + var response = await staff.VerifyApprovalPinAsync("1111"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidPin"); + } + + [Fact] + public async Task VerifyingReportsClearlyWhenNoAdminHasConfiguredAPin() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(); + + var response = await staff.VerifyApprovalPinAsync("4821"); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.NoAdminPinConfigured"); + } + + [Fact] + public async Task StaffCannotHoldAPinOfTheirOwn() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(); + + (await staff.SetApprovalPinAsync("Ravi@2026x", "4821")) + .StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task APinCanBeCleared() + { + await SignInAsAdminAsync(); + await Client.SetApprovalPinAsync(AdminPassword, "4821"); + + (await Client.ClearApprovalPinAsync()).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var me = await PosApiClient.ReadAsync(await Client.GetMeAsync()); + me.HasApprovalPin.Should().BeFalse(); + + // Clearing a PIN that is not set is reported rather than silently succeeding. + (await Client.ClearApprovalPinAsync()).StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DemotingAnAdministratorRemovesTheirApprovalPin() + { + await SignInAsAdminAsync(); + + var created = await Client.CreateUserAsync("owner", "Owner@2026", "Admin", "Restaurant Owner"); + var owner = await PosApiClient.ReadAsync(created); + + var ownerClient = NewClient(); + var login = await ownerClient.LoginAsync("owner", "Owner@2026"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + var changed = await ownerClient.ChangePasswordAsync("Owner@2026", "Owner@2026New"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + await ownerClient.SetApprovalPinAsync("Owner@2026New", "7777"); + + await Client.UpdateUserAsync(owner.Id, "Restaurant Owner", "User", ["PosBilling"]); + + var demoted = await PosApiClient.ReadAsync(await Client.GetUserAsync(owner.Id)); + demoted.HasApprovalPin.Should().BeFalse("an approval PIN carries administrator authority"); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs new file mode 100644 index 0000000..7f9247a --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs @@ -0,0 +1,164 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Authentication; + +public class AuthenticationTests : IntegrationTestBase +{ + [Fact] + public async Task SeededAdmin_CanSignIn_ButIsFlaggedToChangeItsPassword() + { + var response = await Client.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var session = await PosApiClient.ReadAsync(response); + session.AccessToken.Should().NotBeNullOrWhiteSpace(); + session.RefreshToken.Should().NotBeNullOrWhiteSpace(); + session.User.MustChangePassword.Should().BeTrue(); + session.User.IsSystemAdmin.Should().BeTrue(); + session.User.Role.Should().Be("Admin"); + } + + [Fact] + public async Task Administrators_HoldEveryModule() + { + var session = await SignInAsAdminAsync(); + + var modules = await PosApiClient.ReadAsync>(await Client.GetModulesAsync()); + + session.User.Modules.Should().BeEquivalentTo(modules.Select(m => m.Module)); + } + + [Theory] + [InlineData("admin", "WrongPassword1")] + [InlineData("does-not-exist", "AnyPassword1")] + public async Task BadCredentials_AreRejectedIndistinguishably(string username, string password) + { + var response = await Client.LoginAsync(username, password); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidCredentials"); + } + + [Fact] + public async Task PendingPasswordChange_ConfinesTheSessionToTheResetFlow() + { + var login = await Client.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + Client.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + + // Ordinary work is refused with a code the client uses to route to the reset screen... + var blocked = await Client.GetUsersAsync(); + blocked.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await PosApiClient.ReadErrorCodeAsync(blocked)).Should().Be("Auth.PasswordChangeRequired"); + + // ...while the endpoints the reset screen itself needs stay reachable. + (await Client.GetMeAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangingThePassword_LiftsTheRestriction() + { + var session = await SignInAsAdminAsync(); + + session.User.MustChangePassword.Should().BeFalse(); + (await Client.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_RejectsAWrongCurrentPasswordAndAReusedOne() + { + await SignInAsAdminAsync(); + + var wrongCurrent = await Client.ChangePasswordAsync("NotMyPassword1", "Another@2026"); + wrongCurrent.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(wrongCurrent)).Should().Be("Auth.PasswordMismatch"); + + var reused = await Client.ChangePasswordAsync(AdminPassword, AdminPassword); + reused.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(reused)).Should().Be("Auth.PasswordReused"); + } + + [Fact] + public async Task ChangePassword_EnforcesThePasswordPolicy() + { + await SignInAsAdminAsync(); + + var response = await Client.ChangePasswordAsync(AdminPassword, "weak"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task RefreshToken_IsRotatedAndTheOldOneStopsWorking() + { + var first = await SignInAsAdminAsync(); + + var refreshed = await Client.RefreshAsync(first.RefreshToken); + refreshed.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await PosApiClient.ReadAsync(refreshed); + second.RefreshToken.Should().NotBe(first.RefreshToken); + + // Replaying the consumed token must fail, so a stolen copy has a short useful life. + (await Client.RefreshAsync(first.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.RefreshAsync(second.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Refresh_RejectsATokenItNeverIssued() + { + var response = await Client.RefreshAsync("not-a-real-token"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidRefreshToken"); + } + + [Fact] + public async Task Logout_RevokesTheRefreshTokenAndIsSafeToRepeat() + { + var session = await SignInAsAdminAsync(); + + (await Client.LogoutAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.RefreshAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Signing out twice, or with nothing at all, must never fail. + (await Client.LogoutAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.LogoutAsync(null)).StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task ChangingPassword_EndsEveryOtherSession() + { + await SignInAsAdminAsync(); + + // A second till signed in as the same account. + var otherTill = NewClient(); + var otherLogin = await otherTill.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, AdminPassword); + var otherSession = await PosApiClient.ReadAsync(otherLogin); + + await Client.ChangePasswordAsync(AdminPassword, "Rotated@2026"); + + (await otherTill.RefreshAsync(otherSession.RefreshToken)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ProtectedEndpoints_RejectAnonymousCallers() + { + (await Client.GetMeAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs new file mode 100644 index 0000000..484613e --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs @@ -0,0 +1,86 @@ +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// +/// Gives every test its own application instance and database. +/// +/// +/// These tests deliberately mutate global state — the administrator's password, whether an +/// account is active, whether a PIN exists — so sharing one instance across a class would make +/// them order-dependent. xUnit constructs a fresh test class instance per test, so +/// gives each one a clean install to work against. +/// +public abstract class IntegrationTestBase : IAsyncLifetime, IDisposable +{ + /// Password the seeded administrator is moved to during provisioning. + protected const string AdminPassword = "Lakshmi@2026"; + + private CustomWebApplicationFactory _factory = null!; + + /// An unauthenticated client against this test's own application instance. + protected PosApiClient Client { get; private set; } = null!; + + public Task InitializeAsync() + { + _factory = new CustomWebApplicationFactory(); + Client = new PosApiClient(_factory.CreateClient()); + + return Task.CompletedTask; + } + + public Task DisposeAsync() => Task.CompletedTask; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _factory?.Dispose(); + } + } + + /// Creates a second, independent client against the same application instance. + protected PosApiClient NewClient() => new(_factory.CreateClient()); + + /// + /// Signs in as a fully provisioned administrator: seeded credentials + /// exchanged for a real password, leaving an unrestricted session. + /// + protected Task SignInAsAdminAsync() => + Client.SignInAsProvisionedAdminAsync(AdminPassword); + + /// + /// Creates a staff account and signs a separate client in as it, completing the mandatory + /// first password change. Returns that client and the account's id. + /// + protected async Task<(PosApiClient StaffClient, Guid UserId)> CreateAndSignInStaffAsync( + string username = "cashier01", + string finalPassword = "Ravi@2026x", + params string[] modules) + { + const string temporaryPassword = "Temp@2026aa"; + + var created = await Client.CreateUserAsync( + username, temporaryPassword, "User", "Ravi Kumar", null, modules); + + created.EnsureSuccessStatusCode(); + var user = await PosApiClient.ReadAsync(created); + + var staff = NewClient(); + var login = await staff.LoginAsync(username, temporaryPassword); + login.EnsureSuccessStatusCode(); + staff.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + + var changed = await staff.ChangePasswordAsync(temporaryPassword, finalPassword); + changed.EnsureSuccessStatusCode(); + staff.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + + return (staff, user.Id); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs new file mode 100644 index 0000000..e0f641f --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Inventory.cs @@ -0,0 +1,173 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// A menu item as the API returns it. +public sealed record MenuItemResponse( + Guid Id, string Name, string Category, decimal Price, bool IsActive, bool HasRecipe); + +public sealed record RecipeLineResponse(Guid RawMaterialId, string RawMaterialName, string UnitOfMeasurement, decimal Quantity); + +public sealed record RecipeResponse(Guid Id, Guid MenuItemId, bool IsEnabled, IReadOnlyCollection Lines); + +public sealed record RawMaterialResponse( + Guid Id, + string Name, + string UnitOfMeasurement, + decimal? MainStoreReorderLevel, + decimal? KitchenParLevel, + bool IsActive); + +public sealed record StockLevelResponse( + Guid RawMaterialId, string RawMaterialName, string UnitOfMeasurement, string Store, decimal QuantityOnHand, bool IsLowStock); + +public sealed record StockMovementResponse( + Guid Id, + Guid RawMaterialId, + string RawMaterialName, + string Store, + decimal QuantityDelta, + string Type, + Guid? ReferenceId, + string PerformedByName, + string? Notes); + +public sealed record StockMovementLineResponse(Guid RawMaterialId, string RawMaterialName, decimal Quantity); + +public sealed record GoodsReceivedNoteResponse( + Guid Id, + Guid SupplierId, + string SupplierName, + string? Notes, + Guid? PurchaseOrderId, + int? QualityRating, + bool HasIssue, + IReadOnlyCollection Lines); + +public sealed record StockReleaseResponse( + Guid Id, string ApprovedByName, string? Notes, IReadOnlyCollection Lines); + +public sealed record ConsumedLineResponse(Guid RawMaterialId, string RawMaterialName, decimal QuantityDeducted); + +public sealed record ConsumptionResultResponse(bool Deducted, IReadOnlyCollection Lines); + +/// Recipe Management and Inventory Management calls, split out from the auth/user API surface. +public sealed partial class PosApiClient +{ + // ----- Menu items ----- + + public Task CreateMenuItemAsync(string name, string category, decimal price) => + Http.PostAsJsonAsync($"{BaseUrl}/menu-items", new { name, category, price }, Json); + + public Task UpdateMenuItemAsync(Guid id, string name, string category, decimal price) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{id}", new { name, category, price }, Json); + + public Task SetMenuItemActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{id}/status", new { isActive }, Json); + + public Task GetMenuItemsAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/menu-items{query}"); + + public Task GetMenuItemAsync(Guid id) => Http.GetAsync($"{BaseUrl}/menu-items/{id}"); + + // ----- Recipes ----- + + public Task GetRecipeAsync(Guid menuItemId) => + Http.GetAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe"); + + public Task UpsertRecipeAsync(Guid menuItemId, params (Guid RawMaterialId, decimal Quantity)[] lines) => + Http.PutAsJsonAsync( + $"{BaseUrl}/menu-items/{menuItemId}/recipe", + new { lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }) }, + Json); + + public Task SetRecipeEnabledAsync(Guid menuItemId, bool isEnabled) => + Http.PutAsJsonAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe/status", new { isEnabled }, Json); + + public Task DeleteRecipeAsync(Guid menuItemId) => + Http.DeleteAsync($"{BaseUrl}/menu-items/{menuItemId}/recipe"); + + // ----- Raw materials ----- + + public Task CreateRawMaterialAsync( + string name, string unitOfMeasurement = "Kilogram", decimal? mainStoreReorderLevel = null, decimal? kitchenParLevel = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/raw-materials", + new { name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel }, + Json); + + public Task UpdateRawMaterialAsync( + Guid id, string name, string unitOfMeasurement, decimal? mainStoreReorderLevel, decimal? kitchenParLevel) => + Http.PutAsJsonAsync( + $"{BaseUrl}/raw-materials/{id}", + new { name, unitOfMeasurement, mainStoreReorderLevel, kitchenParLevel }, + Json); + + public Task SetRawMaterialActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/raw-materials/{id}/status", new { isActive }, Json); + + public Task GetRawMaterialsAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/raw-materials{query}"); + + // ----- Main Store ----- + + public Task GetMainStoreStockAsync(bool lowStockOnly = false) => + Http.GetAsync($"{BaseUrl}/inventory/main-store/stock?lowStockOnly={lowStockOnly}"); + + public Task GetMainStoreMovementsAsync() => + Http.GetAsync($"{BaseUrl}/inventory/main-store/movements"); + + public Task CreateGoodsReceivedNoteAsync( + Guid supplierId, + (Guid RawMaterialId, decimal Quantity)[] lines, + string? notes = null, + Guid? purchaseOrderId = null, + int? qualityRating = null, + bool hasIssue = false) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/main-store/goods-received", + new + { + supplierId, + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }), + notes, + purchaseOrderId, + qualityRating, + hasIssue, + }, + Json); + + public Task CreateMainStoreAdjustmentAsync(Guid rawMaterialId, decimal quantityDelta, string reason) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/main-store/adjustments", + new { rawMaterialId, store = "MainStore", quantityDelta, reason }, + Json); + + // ----- Kitchen ----- + + public Task GetKitchenStockAsync(bool lowStockOnly = false) => + Http.GetAsync($"{BaseUrl}/inventory/kitchen/stock?lowStockOnly={lowStockOnly}"); + + public Task GetKitchenMovementsAsync() => + Http.GetAsync($"{BaseUrl}/inventory/kitchen/movements"); + + public Task CreateKitchenAdjustmentAsync(Guid rawMaterialId, decimal quantityDelta, string reason) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/kitchen/adjustments", + new { rawMaterialId, store = "Kitchen", quantityDelta, reason }, + Json); + + public Task ConsumeStockAsync(Guid menuItemId, decimal quantitySold) => + Http.PostAsJsonAsync($"{BaseUrl}/inventory/kitchen/consumption/{menuItemId}", new { quantitySold }, Json); + + // ----- Releases ----- + + public Task CreateStockReleaseAsync( + (Guid RawMaterialId, decimal Quantity)[] lines, string pin, string? notes = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/inventory/releases", + new { lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity }), pin, notes }, + Json); + + public Task GetStockReleasesAsync() => Http.GetAsync($"{BaseUrl}/inventory/releases"); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Orders.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Orders.cs new file mode 100644 index 0000000..67ab5d3 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Orders.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +public sealed record TableResponse( + Guid Id, string Number, int Seats, string? Notes, bool IsActive, TableOrderSummaryResponse? CurrentOrder); + +public sealed record TableOrderSummaryResponse( + Guid OrderId, int? OrderNumber, string Status, int ItemCount, decimal Total, string? KitchenStatus); + +public sealed record OrderResponse( + Guid Id, + int? OrderNumber, + Guid TableId, + string TableNumber, + string Status, + string CashierName, + string DiscountType, + decimal DiscountValue, + decimal Subtotal, + decimal DiscountAmount, + decimal Total, + decimal AmountPaid, + decimal ChangeDue, + string? KitchenStatus, + string? ReceiptNumber, + IReadOnlyCollection Items, + IReadOnlyCollection Payments); + +public sealed record OrderItemResponse( + Guid Id, Guid MenuItemId, string MenuItemName, decimal UnitPrice, int Quantity, + string? SpecialInstructions, bool IsCancelled, decimal LineTotal); + +public sealed record OrderPaymentResponse( + Guid Id, string Method, decimal Amount, decimal? TenderedAmount, decimal ChangeGiven, string? Reference); + +public sealed record KotDocumentResponse( + Guid TicketId, string RestaurantName, int? OrderNumber, string TableNumber, int TicketNumber, + string Kind, string CashierName, int PrintCount, IReadOnlyCollection Lines); + +public sealed record KotLineResponse(string MenuItemName, int Quantity, string? SpecialInstructions, string? Note); + +public sealed record OrderMutationResponse(OrderResponse Order, KotDocumentResponse? Kot); + +public sealed record ReceiptDocumentResponse( + string ReceiptNumber, + string RestaurantName, + string AddressLine1, + string? City, + string? Phone, + int? OrderNumber, + string TableNumber, + string CashierName, + int PrintCount, + IReadOnlyCollection Lines, + decimal Subtotal, + decimal DiscountAmount, + decimal TaxAmount, + decimal Total, + decimal ChangeGiven, + IReadOnlyCollection Payments, + string QrPayload); + +public sealed record ReceiptLineResponse(string MenuItemName, int Quantity, decimal UnitPrice, decimal LineTotal); + +public sealed record OrderSummaryResponse( + Guid Id, int? OrderNumber, string TableNumber, string Status, int ItemCount, decimal Total); + +public sealed record KitchenTicketResponse( + Guid Id, Guid OrderId, int? OrderNumber, string TableNumber, int TicketNumber, + string Kind, string Status, int PrintCount, int WaitingMinutes, + IReadOnlyCollection Lines); + +public sealed partial class PosApiClient +{ + public Task GetTablesAsync(bool? isActive = null) => + Http.GetAsync($"{BaseUrl}/tables{(isActive.HasValue ? $"?isActive={isActive}" : string.Empty)}"); + + public Task CreateTableAsync(string number, int seats = 4, string? notes = null) => + Http.PostAsJsonAsync($"{BaseUrl}/tables", new { number, seats, notes }, Json); + + public Task UpdateTableAsync(Guid id, string number, int seats, string? notes = null) => + Http.PutAsJsonAsync($"{BaseUrl}/tables/{id}", new { number, seats, notes }, Json); + + public Task SetTableActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/tables/{id}/status", new { isActive }, Json); + + public Task CreateOrderAsync(Guid tableId) => + Http.PostAsJsonAsync($"{BaseUrl}/orders", new { tableId }, Json); + + public Task GetOrdersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/orders{query}"); + + public Task GetOrderAsync(Guid id) => Http.GetAsync($"{BaseUrl}/orders/{id}"); + + public Task AddOrderItemsAsync( + Guid orderId, params (Guid MenuItemId, int Quantity, string? Instructions)[] items) => + Http.PostAsJsonAsync( + $"{BaseUrl}/orders/{orderId}/items", + new { items = items.Select(i => new { menuItemId = i.MenuItemId, quantity = i.Quantity, specialInstructions = i.Instructions }) }, + Json); + + public Task ConfirmOrderAsync(Guid orderId) => + Http.PostAsync($"{BaseUrl}/orders/{orderId}/confirm", null); + + public Task ChangeOrderItemQuantityAsync( + Guid orderId, Guid itemId, int quantity, string? pin = null) => + Http.PutAsJsonAsync($"{BaseUrl}/orders/{orderId}/items/{itemId}/quantity", new { quantity, pin }, Json); + + public Task VoidOrderItemAsync(Guid orderId, Guid itemId, string? pin = null) => + Http.PostAsJsonAsync($"{BaseUrl}/orders/{orderId}/items/{itemId}/void", new { pin }, Json); + + public Task CancelOrderAsync(Guid orderId, string? pin = null, string? reason = null) => + Http.PostAsJsonAsync($"{BaseUrl}/orders/{orderId}/cancel", new { pin, reason }, Json); + + public Task SetOrderDiscountAsync(Guid orderId, string type, decimal value) => + Http.PutAsJsonAsync($"{BaseUrl}/orders/{orderId}/discount", new { type, value }, Json); + + public Task StartCheckoutAsync(Guid orderId) => + Http.PostAsync($"{BaseUrl}/orders/{orderId}/checkout", null); + + public Task ReopenOrderAsync(Guid orderId) => + Http.PostAsync($"{BaseUrl}/orders/{orderId}/reopen", null); + + public Task PayOrderAsync( + Guid orderId, params (string Method, decimal Amount, decimal? Tendered)[] payments) => + Http.PostAsJsonAsync( + $"{BaseUrl}/orders/{orderId}/payments", + new { payments = payments.Select(p => new { method = p.Method, amount = p.Amount, tenderedAmount = p.Tendered, reference = (string?)null }) }, + Json); + + public Task ReprintReceiptAsync(Guid orderId) => + Http.PostAsync($"{BaseUrl}/orders/{orderId}/receipt/reprint", null); + + public Task GetKitchenTicketsAsync(bool includeServed = false) => + Http.GetAsync($"{BaseUrl}/kitchen/tickets?includeServed={includeServed}"); + + public Task AdvanceKitchenTicketAsync(Guid ticketId, string status) => + Http.PutAsJsonAsync($"{BaseUrl}/kitchen/tickets/{ticketId}/status", new { status }, Json); + + public Task ReprintKitchenTicketAsync(Guid ticketId) => + Http.PostAsync($"{BaseUrl}/kitchen/tickets/{ticketId}/reprint", null); +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs new file mode 100644 index 0000000..8cbb611 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Suppliers.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +public sealed record SupplierResponse( + Guid Id, + string Name, + string? ContactName, + string? Phone, + string? Email, + string? Address, + int PaymentTermsDays, + decimal? CreditLimit, + int? LeadTimeDays, + bool IsActive); + +public sealed record PurchaseOrderLineResponse(Guid RawMaterialId, string RawMaterialName, decimal Quantity, decimal UnitPrice, decimal LineTotal); + +public sealed record PurchaseOrderResponse( + Guid Id, + Guid SupplierId, + string SupplierName, + string Status, + DateTime CreatedAtUtc, + DateTime? ExpectedDeliveryDate, + DateTime? SubmittedAtUtc, + string? Notes, + decimal TotalAmount, + decimal AmountPaid, + decimal Balance, + IReadOnlyCollection Lines); + +public sealed record SupplierPriceResponse(Guid SupplierId, string SupplierName, Guid RawMaterialId, string RawMaterialName, decimal Price); + +public sealed record SupplierPriceHistoryEntryResponse(decimal Price, string RecordedByName, DateTime RecordedAtUtc); + +public sealed record SupplierPaymentResponse( + Guid Id, Guid PurchaseOrderId, decimal Amount, DateTime PaymentDateUtc, string Method, string? InvoiceReference); + +public sealed record SupplierPerformanceResponse( + Guid SupplierId, + int TotalOrders, + int DeliveredOrders, + int OnTimeDeliveries, + double? OnTimeDeliveryRate, + double? AverageDeliveryDays, + double? AverageQualityRating, + int IssueCount); + +/// Supplier Management calls: suppliers, purchase orders, pricing, payments and performance. +public sealed partial class PosApiClient +{ + public Task CreateSupplierAsync( + string name, + string? contactName = null, + string? phone = null, + string? email = null, + string? address = null, + int paymentTermsDays = 0, + decimal? creditLimit = null, + int? leadTimeDays = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/suppliers", + new { name, contactName, phone, email, address, paymentTermsDays, creditLimit, leadTimeDays }, + Json); + + public Task UpdateSupplierAsync( + Guid id, string name, int paymentTermsDays = 0, decimal? creditLimit = null, int? leadTimeDays = null) => + Http.PutAsJsonAsync( + $"{BaseUrl}/suppliers/{id}", + new { name, contactName = (string?)null, phone = (string?)null, email = (string?)null, address = (string?)null, paymentTermsDays, creditLimit, leadTimeDays }, + Json); + + public Task SetSupplierActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/suppliers/{id}/status", new { isActive }, Json); + + public Task GetSuppliersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/suppliers{query}"); + + public Task CreatePurchaseOrderAsync( + Guid supplierId, + (Guid RawMaterialId, decimal Quantity, decimal UnitPrice)[] lines, + DateTime? expectedDeliveryDate = null, + string? notes = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/purchase-orders", + new + { + supplierId, + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity, unitPrice = l.UnitPrice }), + expectedDeliveryDate, + notes, + }, + Json); + + public Task UpdatePurchaseOrderAsync( + Guid id, (Guid RawMaterialId, decimal Quantity, decimal UnitPrice)[] lines, DateTime? expectedDeliveryDate = null, string? notes = null) => + Http.PutAsJsonAsync( + $"{BaseUrl}/purchase-orders/{id}", + new + { + lines = lines.Select(l => new { rawMaterialId = l.RawMaterialId, quantity = l.Quantity, unitPrice = l.UnitPrice }), + expectedDeliveryDate, + notes, + }, + Json); + + public Task SubmitPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/submit", null); + + public Task ConfirmPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/confirm", null); + + public Task CancelPurchaseOrderAsync(Guid id) => + Http.PostAsync($"{BaseUrl}/purchase-orders/{id}/cancel", null); + + public Task GetPurchaseOrdersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/purchase-orders{query}"); + + public Task GetPurchaseOrderAsync(Guid id) => Http.GetAsync($"{BaseUrl}/purchase-orders/{id}"); + + public Task SetSupplierPriceAsync(Guid supplierId, Guid rawMaterialId, decimal price) => + Http.PutAsJsonAsync($"{BaseUrl}/suppliers/{supplierId}/prices", new { rawMaterialId, price }, Json); + + public Task GetSupplierPricesAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/suppliers/prices{query}"); + + public Task GetSupplierPriceHistoryAsync(Guid supplierId, Guid rawMaterialId) => + Http.GetAsync($"{BaseUrl}/suppliers/{supplierId}/prices/{rawMaterialId}/history"); + + public Task RecordSupplierPaymentAsync( + Guid purchaseOrderId, decimal amount, DateTime paymentDateUtc, string method = "Cash", string? invoiceReference = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/purchase-orders/{purchaseOrderId}/payments", + new { amount, paymentDateUtc, method, invoiceReference, notes = (string?)null }, + Json); + + public Task GetPurchaseOrderPaymentsAsync(Guid purchaseOrderId) => + Http.GetAsync($"{BaseUrl}/purchase-orders/{purchaseOrderId}/payments"); + + public Task GetSupplierPerformanceAsync(Guid supplierId) => + Http.GetAsync($"{BaseUrl}/suppliers/{supplierId}/performance"); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs new file mode 100644 index 0000000..0452ce8 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs @@ -0,0 +1,161 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// Session payload returned by sign-in, refresh and password change. +public sealed record SessionResponse( + string AccessToken, + string RefreshToken, + UserResponse User); + +/// A staff account as the API returns it. +public sealed record UserResponse( + Guid Id, + string Username, + string FullName, + string? Email, + string Role, + bool IsActive, + bool MustChangePassword, + bool IsSystemAdmin, + bool HasApprovalPin, + IReadOnlyCollection Modules); + +/// A module catalog entry. +public sealed record ModuleResponse(string Module, string Name, string Group, bool AdminOnly); + +/// Who authorised a PIN-gated action. +public sealed record ApprovalResponse(Guid ApprovedByUserId, string ApprovedByName); + +/// The plaintext PIN, returned once when it is set. +public sealed record PinResponse(string Pin); + +/// +/// Thin wrapper over that keeps the tests focused on behaviour rather +/// than on URL and JSON plumbing. +/// +public sealed partial class PosApiClient(HttpClient http) +{ + internal const string BaseUrl = "/api/v1"; + + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() }, + }; + + public HttpClient Http { get; } = http; + + /// Attaches a bearer token to every subsequent request, or clears it when null. + public PosApiClient Authenticate(string? accessToken) + { + Http.DefaultRequestHeaders.Authorization = accessToken is null + ? null + : new AuthenticationHeaderValue("Bearer", accessToken); + + return this; + } + + public Task LoginAsync(string username, string password) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/login", new { username, password }, Json); + + public Task RefreshAsync(string refreshToken) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/refresh", new { refreshToken }, Json); + + public Task LogoutAsync(string? refreshToken) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/logout", new { refreshToken }, Json); + + public Task ChangePasswordAsync(string currentPassword, string newPassword) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/change-password", new { currentPassword, newPassword }, Json); + + public Task GetMeAsync() => Http.GetAsync($"{BaseUrl}/auth/me"); + + public Task GetModulesAsync() => Http.GetAsync($"{BaseUrl}/modules"); + + public Task GetUsersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/users{query}"); + + public Task GetUserAsync(Guid id) => Http.GetAsync($"{BaseUrl}/users/{id}"); + + public Task CreateUserAsync( + string username, + string password, + string role = "User", + string fullName = "Test Staff", + string? email = null, + params string[] modules) => + Http.PostAsJsonAsync( + $"{BaseUrl}/users", + new { username, fullName, email, password, role, modules }, + Json); + + public Task UpdateUserAsync( + Guid id, + string fullName, + string role, + string[] modules, + string? email = null) => + Http.PutAsJsonAsync($"{BaseUrl}/users/{id}", new { fullName, email, role, modules }, Json); + + public Task SetUserActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/users/{id}/status", new { isActive }, Json); + + public Task ResetUserPasswordAsync(Guid id, string newPassword) => + Http.PostAsJsonAsync($"{BaseUrl}/users/{id}/password", new { newPassword }, Json); + + public Task SetApprovalPinAsync(string currentPassword, string? pin = null) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/pin", new { currentPassword, pin }, Json); + + public Task ClearApprovalPinAsync() => Http.DeleteAsync($"{BaseUrl}/auth/pin"); + + public Task VerifyApprovalPinAsync(string pin, string? reason = null) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/pin/verify", new { pin, reason }, Json); + + /// Deserialises a response body, failing loudly if it is empty. + public static async Task ReadAsync(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + var value = await response.Content.ReadFromJsonAsync(Json); + + return value ?? throw new InvalidOperationException( + $"Expected a {typeof(T).Name} body but the response was empty. Status: {response.StatusCode}."); + } + + /// Reads the machine-readable code out of an RFC 7807 problem response. + public static async Task ReadErrorCodeAsync(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + return document.RootElement.TryGetProperty("code", out var code) + ? code.GetString() + : null; + } + + /// + /// Signs in as the seeded administrator and completes the mandatory first password change, + /// leaving the client authenticated with a fully privileged session. + /// + public async Task SignInAsProvisionedAdminAsync(string newPassword) + { + var login = await LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + login.EnsureSuccessStatusCode(); + Authenticate((await ReadAsync(login)).AccessToken); + + var changed = await ChangePasswordAsync( + CustomWebApplicationFactory.SeedAdminPassword, newPassword); + + changed.EnsureSuccessStatusCode(); + var session = await ReadAsync(changed); + Authenticate(session.AccessToken); + + return session; + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs b/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs index a3bafaa..6192d9d 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs +++ b/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,52 +1,71 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using RestaurantPOS.Infrastructure.Persistence; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; namespace RestaurantPOS.IntegrationTests; +/// +/// Boots the real API against a throwaway SQLite file. +/// +/// +/// The application's own start-up path runs the migrations and seeds the administrator, so +/// these tests exercise the same bootstrap a fresh install goes through rather than a +/// test-only shortcut. +/// public class CustomWebApplicationFactory : WebApplicationFactory { - private readonly string _dbFilePath = Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.db"); + public const string SeedAdminUsername = "admin"; + + /// The bootstrap password the seeded administrator is created with. + public const string SeedAdminPassword = "Bootstrap@2026"; + + private readonly string _dbFilePath = + Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.db"); + + private readonly string _keyFilePath = + Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.key"); protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); + ArgumentNullException.ThrowIfNull(builder); - if (descriptor is not null) - { - services.Remove(descriptor); - } + builder.UseEnvironment(Environments.Development); - services.AddDbContext(options => - options.UseSqlite($"Data Source={_dbFilePath}")); - - var sp = services.BuildServiceProvider(); - using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - }); + builder.ConfigureAppConfiguration((_, config) => + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbFilePath}", + ["SeedAdmin:Username"] = SeedAdminUsername, + ["SeedAdmin:Password"] = SeedAdminPassword, + ["SeedAdmin:FullName"] = "System Administrator", + // Each factory gets its own signing key so tokens never leak between test classes. + ["Jwt:KeyFilePath"] = _keyFilePath, + })); } protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (disposing) + + if (!disposing) + { + return; + } + + foreach (var path in new[] { _dbFilePath, _keyFilePath }) { - if (File.Exists(_dbFilePath)) + try + { + File.Delete(path); + } + catch (IOException) + { + // Transient file locks on Windows are not worth failing a test run over. + } + catch (UnauthorizedAccessException) { - try - { - File.Delete(_dbFilePath); - } - catch - { - // Ignore transient lock cleanup errors - } + // As above. } } } diff --git a/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs index a4a4109..4ce354e 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs +++ b/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs @@ -1,6 +1,9 @@ using System.Net; + using FluentAssertions; + using Microsoft.AspNetCore.Mvc.Testing; + using Xunit; namespace RestaurantPOS.IntegrationTests; diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs new file mode 100644 index 0000000..c62bd6b --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Inventory/InventoryTests.cs @@ -0,0 +1,327 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Inventory; + +public class InventoryTests : IntegrationTestBase +{ + [Fact] + public async Task ANewRawMaterial_StartsWithZeroStockInBothStores() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 0); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 0); + } + + [Fact] + public async Task RawMaterialNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateRawMaterialAsync("Rice"); + + var duplicate = await Client.CreateRawMaterialAsync("RICE"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("RawMaterial.NameTaken"); + } + + [Fact] + public async Task ReceivingGoods_IncreasesMainStoreStockImmediately() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)], "Weekly delivery"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 20m); + } + + [Fact] + public async Task ReceivingGoods_RejectsAnInactiveSupplier() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.SetSupplierActiveAsync(supplierId, false); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Supplier.Inactive"); + } + + [Fact] + public async Task LowStockIsFlaggedOnceTheReorderLevelIsReached() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice", mainStoreReorderLevel: 10m); + var supplierId = await CreateSupplierAsync(); + + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)]); + + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.IsLowStock); + } + + [Fact] + public async Task ReleasingStock_RequiresAValidApprovalPin() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidPin"); + } + + [Fact] + public async Task ReleasingStock_WithNoAdminPinConfiguredAnywhere_FailsClearly() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.NoAdminPinConfigured"); + } + + [Fact] + public async Task ReleasingStock_MovesItFromMainStoreToKitchen() + { + var admin = await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + var pin = await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 5m)], pin, "Morning prep"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var release = await PosApiClient.ReadAsync(response); + release.ApprovedByName.Should().Be(admin.User.FullName); + release.Lines.Should().ContainSingle(l => l.RawMaterialId == riceId && l.Quantity == 5m); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 15m); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 5m); + } + + [Fact] + public async Task ReleasingMoreThanIsInMainStock_IsBlockedAndChangesNothing() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + var pin = await SetApprovalPinAsync(); + + var response = await Client.CreateStockReleaseAsync([(riceId, 10m)], pin); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Inventory.InsufficientStock"); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 5m, "a rejected release must not partially apply"); + } + + [Fact] + public async Task ReleasingTwoRawMaterialsWhereOnlyOneHasEnoughStock_AppliesNeitherOfThem() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m), (chickenId, 2m)]); + var pin = await SetApprovalPinAsync(); + + // Rice has plenty; chicken does not — the whole release must fail atomically. + var response = await Client.CreateStockReleaseAsync([(riceId, 5m), (chickenId, 10m)], pin); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var mainStore = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + mainStore.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 20m); + mainStore.Should().Contain(s => s.RawMaterialId == chickenId && s.QuantityOnHand == 2m); + } + + [Fact] + public async Task ConsumingStock_DeductsKitchenStockAccordingToTheRecipe() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m), (chickenId, 10m)]); + var pin = await SetApprovalPinAsync(); + await Client.CreateStockReleaseAsync([(riceId, 5m), (chickenId, 3m)], pin); + + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m), (chickenId, 0.15m)); + + var response = await Client.ConsumeStockAsync(itemId, 2); + + var result = await PosApiClient.ReadAsync(response); + result.Deducted.Should().BeTrue(); + result.Lines.Should().Contain(l => l.RawMaterialId == riceId && l.QuantityDeducted == 0.5m); + result.Lines.Should().Contain(l => l.RawMaterialId == chickenId && l.QuantityDeducted == 0.3m); + + var kitchen = await ReadStockAsync(await Client.GetKitchenStockAsync()); + kitchen.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 4.5m); + kitchen.Should().Contain(s => s.RawMaterialId == chickenId && s.QuantityOnHand == 2.7m); + } + + [Fact] + public async Task ConsumingStock_ForAMenuItemWithNoRecipe_SucceedsAsANoOp() + { + await SignInAsAdminAsync(); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Bottled Water", "Beverages", 100m))).Id; + + var response = await Client.ConsumeStockAsync(itemId, 5); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await PosApiClient.ReadAsync(response)).Deducted.Should().BeFalse(); + } + + [Fact] + public async Task ConsumingStock_ForADisabledRecipe_SucceedsAsANoOpRatherThanFailing() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Fried Rice", "Rice", 700m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + await Client.SetRecipeEnabledAsync(itemId, false); + + var response = await Client.ConsumeStockAsync(itemId, 1); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await PosApiClient.ReadAsync(response)).Deducted.Should().BeFalse(); + } + + [Fact] + public async Task ConsumingMoreThanKitchenHasInStock_IsRecordedRatherThanBlocked() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var itemId = (await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Fried Rice", "Rice", 700m))).Id; + await Client.UpsertRecipeAsync(itemId, (riceId, 1m)); + // No stock has been released to the kitchen at all. + + var response = await Client.ConsumeStockAsync(itemId, 1); + + // A sale is never refused for want of stock (BR-POS-015). The dish has already been + // cooked and served by the time consumption is recorded, so rejecting the deduction + // would not un-sell it — it would only leave the ledger claiming the rice is still on + // the shelf. The balance going negative is the true reading: it says the kitchen has + // been cooking from stock nobody booked in, which is what the manager needs to see. + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await PosApiClient.ReadAsync(response)).Deducted.Should().BeTrue(); + + var stock = await PosApiClient.ReadAsync>(await Client.GetKitchenStockAsync()); + stock.Single(s => s.RawMaterialId == riceId).QuantityOnHand.Should().Be(-1m); + } + + [Fact] + public async Task AnAdjustmentRequiresAReason() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.CreateMainStoreAdjustmentAsync(riceId, 5m, ""); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task AnAdjustmentCorrectsTheBalanceAndIsRecordedInHistory() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 20m)]); + + var response = await Client.CreateMainStoreAdjustmentAsync(riceId, -2m, "Spillage during counting"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var stock = await ReadStockAsync(await Client.GetMainStoreStockAsync()); + stock.Should().Contain(s => s.RawMaterialId == riceId && s.QuantityOnHand == 18m); + + var history = await ReadMovementsAsync(await Client.GetMainStoreMovementsAsync()); + history.Should().Contain(m => m.Type == "Adjustment" && m.Notes == "Spillage during counting"); + } + + [Fact] + public async Task DeactivatingARawMaterial_BlocksItFromANewGoodsReceivedNote() + { + await SignInAsAdminAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var supplierId = await CreateSupplierAsync(); + await Client.SetRawMaterialActiveAsync(riceId, false); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)]); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("RawMaterial.Inactive"); + } + + [Fact] + public async Task StaffGrantedOnlyKitchenTracking_CannotReceiveGoodsIntoMainStore() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "KitchenStockTracking"); + + (await staff.GetMainStoreStockAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetKitchenStockAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StaffGrantedOnlyStoreStockManagement_CannotCreateAStockRelease() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + var response = await staff.CreateStockReleaseAsync([(Guid.NewGuid(), 1m)], "0000"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + private async Task CreateRawMaterialAsync(string name, string unit = "Kilogram", decimal? mainStoreReorderLevel = null) => + (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync(name, unit, mainStoreReorderLevel))).Id; + + private async Task CreateSupplierAsync(string name = "ABC Wholesale") => + (await PosApiClient.ReadAsync(await Client.CreateSupplierAsync(name))).Id; + + private async Task SetApprovalPinAsync() => + (await PosApiClient.ReadAsync(await Client.SetApprovalPinAsync(AdminPassword, "4821"))).Pin; + + private static Task> ReadStockAsync(HttpResponseMessage response) => + PosApiClient.ReadAsync>(response); + + private static Task> ReadMovementsAsync(HttpResponseMessage response) => + PosApiClient.ReadAsync>(response); +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Orders/KitchenOperationsTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Orders/KitchenOperationsTests.cs new file mode 100644 index 0000000..5865ba4 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Orders/KitchenOperationsTests.cs @@ -0,0 +1,151 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Orders; + +/// The kitchen display: the ticket queue, its statuses, and how those reach the till. +public class KitchenOperationsTests : IntegrationTestBase +{ + private async Task<(Guid TableId, Guid MenuItemId)> SeedAsync(string tableNumber = "2") + { + var table = await PosApiClient.ReadAsync(await Client.CreateTableAsync(tableNumber)); + var menuItem = await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync("Fried Rice", "Mains", 250m)); + + return (table.Id, menuItem.Id); + } + + private async Task OpenOrderAsync(Guid tableId, Guid menuItemId, int quantity = 1) + { + var order = await PosApiClient.ReadAsync(await Client.CreateOrderAsync(tableId)); + await Client.AddOrderItemsAsync(order.Id, (menuItemId, quantity, null)); + var confirmed = await Client.ConfirmOrderAsync(order.Id); + + return (await PosApiClient.ReadAsync(confirmed)).Order; + } + + [Fact] + public async Task AConfirmedOrder_AppearsOnTheKitchenDisplayAsANewTicket() + { + await SignInAsAdminAsync(); + var (tableId, menuItemId) = await SeedAsync(); + await OpenOrderAsync(tableId, menuItemId); + + var tickets = await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync()); + + tickets.Should().ContainSingle(); + tickets[0].Status.Should().Be("New"); + tickets[0].Kind.Should().Be("New"); + tickets[0].TableNumber.Should().Be("2"); + tickets[0].Lines.Single().MenuItemName.Should().Be("Fried Rice"); + } + + [Fact] + public async Task ATicketMovesThroughPreparingReadyAndServed() + { + await SignInAsAdminAsync(); + var (tableId, menuItemId) = await SeedAsync(); + await OpenOrderAsync(tableId, menuItemId); + var ticket = (await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync())).Single(); + + foreach (var status in new[] { "Preparing", "Ready", "Served" }) + { + var advanced = await Client.AdvanceKitchenTicketAsync(ticket.Id, status); + advanced.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(advanced)).Status.Should().Be(status); + } + + var queue = await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync()); + + queue.Should().BeEmpty("a served ticket drops off the work queue"); + } + + [Fact] + public async Task ATicketCannotBeMovedBackwards() + { + await SignInAsAdminAsync(); + var (tableId, menuItemId) = await SeedAsync(); + await OpenOrderAsync(tableId, menuItemId); + var ticket = (await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync())).Single(); + + await Client.AdvanceKitchenTicketAsync(ticket.Id, "Ready"); + var backwards = await Client.AdvanceKitchenTicketAsync(ticket.Id, "Preparing"); + + backwards.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(backwards)).Should().Be("KitchenTicket.CannotGoBack"); + } + + [Fact] + public async Task TheTillSeesTheLeastAdvancedTicketForATable() + { + await SignInAsAdminAsync(); + var (tableId, menuItemId) = await SeedAsync(); + var order = await OpenOrderAsync(tableId, menuItemId); + + var firstTicket = (await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync())).Single(); + await Client.AdvanceKitchenTicketAsync(firstTicket.Id, "Ready"); + + // A second round is ordered, so the table is not ready after all. + await Client.AddOrderItemsAsync(order.Id, (menuItemId, 1, null)); + + var tables = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + + tables.Single(t => t.Id == tableId).CurrentOrder!.KitchenStatus + .Should().Be("New", "one dish is plated but another has only just been ordered"); + } + + [Fact] + public async Task ReprintingATicket_ReproducesItAndCountsThePrint() + { + await SignInAsAdminAsync(); + var (tableId, menuItemId) = await SeedAsync(); + await OpenOrderAsync(tableId, menuItemId, quantity: 2); + var ticket = (await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync())).Single(); + + var reprint = await Client.ReprintKitchenTicketAsync(ticket.Id); + + reprint.EnsureSuccessStatusCode(); + var slip = await PosApiClient.ReadAsync(reprint); + slip.TicketId.Should().Be(ticket.Id); + slip.PrintCount.Should().Be(2); + slip.Lines.Single().Quantity.Should().Be(2); + } + + [Fact] + public async Task AVoidedItemPutsACancellationSlipOnTheDisplay() + { + await SignInAsAdminAsync(); + await Client.SetApprovalPinAsync(AdminPassword, "4417"); + var (tableId, menuItemId) = await SeedAsync(); + var order = await OpenOrderAsync(tableId, menuItemId); + + await Client.VoidOrderItemAsync(order.Id, order.Items.Single().Id, "4417"); + + var tickets = await PosApiClient.ReadAsync>( + await Client.GetKitchenTicketsAsync()); + + tickets.Should().HaveCount(2); + tickets.Should().ContainSingle(t => t.Kind == "Cancellation") + .Which.Lines.Single().Note.Should().Be("CANCELLED"); + } + + [Fact] + public async Task StaffWithoutKitchenOperations_CannotReachTheDisplay() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + (await staff.GetKitchenTicketsAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Orders/PosBillingTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Orders/PosBillingTests.cs new file mode 100644 index 0000000..11a99fe --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Orders/PosBillingTests.cs @@ -0,0 +1,456 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Orders; + +/// +/// Covers the till end to end, including the two-table scenario the requirements describe: two +/// bills open at once, items added to each after confirmation, then each settled on its own. +/// +public class PosBillingTests : IntegrationTestBase +{ + private const string Pin = "4417"; + + private async Task CreateTableAsync(string number) + { + var response = await Client.CreateTableAsync(number); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + return (await PosApiClient.ReadAsync(response)).Id; + } + + private async Task CreateMenuItemAsync(string name, decimal price) + { + var response = await Client.CreateMenuItemAsync(name, "Mains", price); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + return (await PosApiClient.ReadAsync(response)).Id; + } + + /// Sets the administrator's approval PIN so PIN-gated actions can be exercised. + private async Task ConfigurePinAsync() + { + var response = await Client.SetApprovalPinAsync(AdminPassword, Pin); + response.EnsureSuccessStatusCode(); + } + + private async Task OpenOrderAsync(Guid tableId, params (Guid Id, int Qty)[] items) + { + var created = await Client.CreateOrderAsync(tableId); + created.StatusCode.Should().Be(HttpStatusCode.Created); + var order = await PosApiClient.ReadAsync(created); + + var added = await Client.AddOrderItemsAsync( + order.Id, [.. items.Select(i => (i.Id, i.Qty, (string?)null))]); + added.EnsureSuccessStatusCode(); + + var confirmed = await Client.ConfirmOrderAsync(order.Id); + confirmed.EnsureSuccessStatusCode(); + + return (await PosApiClient.ReadAsync(confirmed)).Order; + } + + [Fact] + public async Task ConfirmingAnOrder_NumbersItPrintsAKotAndOccupiesTheTable() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + + var created = await Client.CreateOrderAsync(tableId); + var order = await PosApiClient.ReadAsync(created); + order.Status.Should().Be("Draft"); + order.OrderNumber.Should().BeNull(); + + await Client.AddOrderItemsAsync(order.Id, (friedRice, 1, null)); + + var confirmed = await Client.ConfirmOrderAsync(order.Id); + confirmed.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(confirmed); + + result.Order.Status.Should().Be("Open"); + result.Order.OrderNumber.Should().Be(1); + result.Order.Total.Should().Be(250m); + + result.Kot.Should().NotBeNull("confirming an order must produce a slip for the kitchen"); + result.Kot!.Kind.Should().Be("New"); + result.Kot.TableNumber.Should().Be("2"); + result.Kot.Lines.Should().ContainSingle().Which.MenuItemName.Should().Be("Fried Rice"); + + var tables = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + tables.Single(t => t.Id == tableId).CurrentOrder!.Status.Should().Be("Open"); + } + + [Fact] + public async Task ConfirmingAnOrderWithNoItems_IsRejected() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + + var created = await Client.CreateOrderAsync(tableId); + var order = await PosApiClient.ReadAsync(created); + + var confirmed = await Client.ConfirmOrderAsync(order.Id); + + confirmed.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(confirmed)).Should().Be("Order.NoItems"); + } + + [Fact] + public async Task ATableCannotHoldTwoOrdersAtOnce() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + await OpenOrderAsync(tableId, (friedRice, 1)); + + var second = await Client.CreateOrderAsync(tableId); + + second.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(second)).Should().Be("Table.Occupied"); + } + + [Fact] + public async Task AddingItemsToAConfirmedOrder_NeedsNoPinAndPrintsAnotherKot() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var sandwich = await CreateMenuItemAsync("Sandwich", 150m); + + var order = await OpenOrderAsync(tableId, (friedRice, 1)); + + var added = await Client.AddOrderItemsAsync(order.Id, (sandwich, 1, null)); + + added.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(added); + + result.Order.Total.Should().Be(400m); + result.Order.Status.Should().Be("Open"); + result.Kot!.Kind.Should().Be("Addition"); + result.Kot.TicketNumber.Should().Be(2); + result.Kot.Lines.Should().ContainSingle() + .Which.MenuItemName.Should().Be("Sandwich", "the kitchen is already cooking the rice"); + } + + [Fact] + public async Task ChangingAQuantityOnAnOpenOrder_RequiresTheApprovalPin() + { + await SignInAsAdminAsync(); + await ConfigurePinAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 1)); + var itemId = order.Items.Single().Id; + + var withoutPin = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 3); + withoutPin.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var wrongPin = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 3, "0000"); + wrongPin.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var withPin = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 3, Pin); + + withPin.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(withPin); + result.Order.Total.Should().Be(750m); + result.Kot!.Kind.Should().Be("Modification"); + result.Kot.Lines.Single().Note.Should().Be("Was 1", "the kitchen needs to know what changed"); + } + + [Fact] + public async Task VoidingAnItemOnAnOpenOrder_RequiresThePinAndPrintsACancellationKot() + { + await SignInAsAdminAsync(); + await ConfigurePinAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var sandwich = await CreateMenuItemAsync("Sandwich", 150m); + var order = await OpenOrderAsync(tableId, (friedRice, 1), (sandwich, 1)); + var sandwichLine = order.Items.Single(i => i.MenuItemName == "Sandwich"); + + var withoutPin = await Client.VoidOrderItemAsync(order.Id, sandwichLine.Id); + withoutPin.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var withPin = await Client.VoidOrderItemAsync(order.Id, sandwichLine.Id, Pin); + + withPin.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(withPin); + result.Order.Total.Should().Be(250m); + result.Order.Items.Should().HaveCount(2, "a voided line stays on the bill as a record"); + result.Order.Items.Single(i => i.MenuItemName == "Sandwich").IsCancelled.Should().BeTrue(); + result.Kot!.Kind.Should().Be("Cancellation"); + result.Kot.Lines.Single().Note.Should().Be("CANCELLED"); + } + + [Fact] + public async Task EditingADraft_NeedsNoPinBecauseNothingHasBeenSentToTheKitchen() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + + var created = await Client.CreateOrderAsync(tableId); + var order = await PosApiClient.ReadAsync(created); + var added = await Client.AddOrderItemsAsync(order.Id, (friedRice, 1, null)); + var itemId = (await PosApiClient.ReadAsync(added)).Order.Items.Single().Id; + + var changed = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 5); + changed.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(changed)).Kot + .Should().BeNull("a draft has never reached the kitchen"); + + var voided = await Client.VoidOrderItemAsync(order.Id, itemId); + voided.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(voided)).Order.Items + .Should().BeEmpty("a draft line is simply deleted"); + } + + [Fact] + public async Task PayingABill_IssuesAReceiptAndReleasesTheTable() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var sandwich = await CreateMenuItemAsync("Sandwich", 150m); + var order = await OpenOrderAsync(tableId, (friedRice, 1), (sandwich, 1)); + + (await Client.StartCheckoutAsync(order.Id)).EnsureSuccessStatusCode(); + + var paid = await Client.PayOrderAsync(order.Id, ("Cash", 400m, 500m)); + + paid.EnsureSuccessStatusCode(); + var receipt = await PosApiClient.ReadAsync(paid); + + receipt.ReceiptNumber.Should().Be($"REC-001-{DateTime.Now.Year}"); + receipt.RestaurantName.Should().Be("Sri Lakshmi Family Restaurant"); + receipt.Total.Should().Be(400m); + receipt.TaxAmount.Should().Be(0m, "no VAT or GST is applied (BR-POS-011)"); + receipt.ChangeGiven.Should().Be(100m); + receipt.Lines.Should().HaveCount(2); + receipt.QrPayload.Should().NotBeNullOrWhiteSpace("the slip carries a QR code (POS-028)"); + + var tables = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + tables.Single(t => t.Id == tableId).CurrentOrder + .Should().BeNull("the table is free again once the bill is paid (POS-032)"); + } + + [Fact] + public async Task PaymentsMustAddUpToTheBillExactly() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 1)); + await Client.StartCheckoutAsync(order.Id); + + var short_ = await Client.PayOrderAsync(order.Id, ("Cash", 200m, null)); + + short_.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(short_)).Should().Be("Order.PaymentMismatch"); + } + + [Fact] + public async Task ABillCanBeSplitAcrossSeveralPaymentMethods() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var sandwich = await CreateMenuItemAsync("Sandwich", 150m); + var order = await OpenOrderAsync(tableId, (friedRice, 1), (sandwich, 1)); + await Client.StartCheckoutAsync(order.Id); + + var paid = await Client.PayOrderAsync(order.Id, ("Cash", 100m, null), ("Card", 300m, null)); + + paid.EnsureSuccessStatusCode(); + var receipt = await PosApiClient.ReadAsync(paid); + receipt.Payments.Should().HaveCount(2); + receipt.Payments.Sum(p => p.Amount).Should().Be(400m); + } + + [Fact] + public async Task ADiscountComesOffTheBillAndCannotExceedIt() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 4)); + + var tooBig = await Client.SetOrderDiscountAsync(order.Id, "Fixed", 5000m); + tooBig.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(tooBig)).Should().Be("Order.DiscountExceedsSubtotal"); + + var applied = await Client.SetOrderDiscountAsync(order.Id, "Percentage", 10m); + + applied.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(applied); + result.Order.Subtotal.Should().Be(1000m); + result.Order.DiscountAmount.Should().Be(100m); + result.Order.Total.Should().Be(900m); + } + + [Fact] + public async Task ReprintingAReceipt_ReproducesItAndCountsThePrint() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 1)); + await Client.StartCheckoutAsync(order.Id); + var original = await PosApiClient.ReadAsync( + await Client.PayOrderAsync(order.Id, ("Cash", 250m, null))); + + var reprint = await Client.ReprintReceiptAsync(order.Id); + + reprint.EnsureSuccessStatusCode(); + var copy = await PosApiClient.ReadAsync(reprint); + copy.ReceiptNumber.Should().Be(original.ReceiptNumber); + copy.Total.Should().Be(original.Total); + copy.PrintCount.Should().Be(2, "the original print counts as the first"); + } + + [Fact] + public async Task CancellingAConfirmedOrder_RequiresThePinAndFreesTheTable() + { + await SignInAsAdminAsync(); + await ConfigurePinAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 2)); + + var withoutPin = await Client.CancelOrderAsync(order.Id); + withoutPin.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var cancelled = await Client.CancelOrderAsync(order.Id, Pin, "Customer left"); + + cancelled.EnsureSuccessStatusCode(); + var result = await PosApiClient.ReadAsync(cancelled); + result.Order.Status.Should().Be("Cancelled"); + result.Kot!.Kind.Should().Be("Cancellation"); + result.Kot.Lines.Single().Note.Should().Be("ORDER CANCELLED"); + + var tables = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + tables.Single(t => t.Id == tableId).CurrentOrder.Should().BeNull(); + } + + [Fact] + public async Task ThreeWrongPins_PauseFurtherAttemptsOnThatTerminal() + { + await SignInAsAdminAsync(); + await ConfigurePinAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var order = await OpenOrderAsync(tableId, (friedRice, 1)); + var itemId = order.Items.Single().Id; + + for (var attempt = 1; attempt <= 3; attempt++) + { + var failed = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 2, "0000"); + failed.IsSuccessStatusCode.Should().BeFalse(); + } + + // Even the correct PIN is refused during the cooldown. + var lockedOut = await Client.ChangeOrderItemQuantityAsync(order.Id, itemId, 2, Pin); + + lockedOut.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await PosApiClient.ReadErrorCodeAsync(lockedOut)).Should().Be("Auth.PinAttemptsExhausted"); + } + + [Fact] + public async Task TwoTablesAreServedAndSettledIndependently() + { + await SignInAsAdminAsync(); + var table2 = await CreateTableAsync("2"); + var table3 = await CreateTableAsync("3"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + var sandwich = await CreateMenuItemAsync("Sandwich", 150m); + var kottu = await CreateMenuItemAsync("Kottu Roti", 300m); + var lassi = await CreateMenuItemAsync("Mango Lassi", 50m); + + var order1 = await OpenOrderAsync(table2, (friedRice, 1)); + var order2 = await OpenOrderAsync(table3, (kottu, 1)); + + order1.OrderNumber.Should().Be(1); + order2.OrderNumber.Should().Be(2, "each order gets its own number (BR-POS-016)"); + + await Client.AddOrderItemsAsync(order1.Id, (sandwich, 1, null)); + await Client.AddOrderItemsAsync(order2.Id, (lassi, 2, null)); + + var openOrders = await PosApiClient.ReadAsync>( + await Client.GetOrdersAsync("?openOnly=true")); + openOrders.Should().HaveCount(2); + openOrders.Single(o => o.OrderNumber == 1).Total.Should().Be(400m); + openOrders.Single(o => o.OrderNumber == 2).Total.Should().Be(400m); + + // Settling table 2 must leave table 3 exactly as it was (BR-POS-019). + await Client.StartCheckoutAsync(order1.Id); + (await Client.PayOrderAsync(order1.Id, ("Cash", 400m, 400m))).EnsureSuccessStatusCode(); + + var tables = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + tables.Single(t => t.Id == table2).CurrentOrder.Should().BeNull(); + tables.Single(t => t.Id == table3).CurrentOrder!.Total.Should().Be(400m); + + await Client.StartCheckoutAsync(order2.Id); + (await Client.PayOrderAsync(order2.Id, ("Card", 400m, null))).EnsureSuccessStatusCode(); + + var settled = await PosApiClient.ReadAsync>(await Client.GetTablesAsync()); + settled.Should().OnlyContain(t => t.CurrentOrder == null, "every table is free again"); + } + + [Fact] + public async Task OrdersCanBeSearchedByTableNumber() + { + await SignInAsAdminAsync(); + var table2 = await CreateTableAsync("2"); + var table7 = await CreateTableAsync("7"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + await OpenOrderAsync(table2, (friedRice, 1)); + await OpenOrderAsync(table7, (friedRice, 1)); + + var found = await PosApiClient.ReadAsync>( + await Client.GetOrdersAsync("?search=7")); + + found.Should().ContainSingle().Which.TableNumber.Should().Be("7"); + } + + [Fact] + public async Task ATableWithALiveOrderCannotBeTakenOutOfService() + { + await SignInAsAdminAsync(); + var tableId = await CreateTableAsync("2"); + var friedRice = await CreateMenuItemAsync("Fried Rice", 250m); + await OpenOrderAsync(tableId, (friedRice, 1)); + + var response = await Client.SetTableActiveAsync(tableId, false); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Table.InUse"); + } + + [Fact] + public async Task TableNumbersMustBeUnique() + { + await SignInAsAdminAsync(); + await CreateTableAsync("2"); + + var duplicate = await Client.CreateTableAsync("2"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("Table.NumberTaken"); + } + + [Fact] + public async Task StaffWithoutPosBilling_CannotReachTheTill() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "KitchenOperations"); + + (await staff.GetTablesAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetOrdersAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs new file mode 100644 index 0000000..d23affb --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Recipes/RecipeManagementTests.cs @@ -0,0 +1,203 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Recipes; + +public class RecipeManagementTests : IntegrationTestBase +{ + [Fact] + public async Task CreatingAMenuItem_StartsWithNoRecipe() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var item = await PosApiClient.ReadAsync(response); + item.HasRecipe.Should().BeFalse(); + item.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task MenuItemNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateMenuItemAsync("Chicken Fried Rice", "Rice & Curry", 850m); + + var duplicate = await Client.CreateMenuItemAsync("CHICKEN FRIED RICE", "Rice & Curry", 900m); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("MenuItem.NameTaken"); + } + + [Fact] + public async Task ANegativePriceIsRejected() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateMenuItemAsync("Fried Rice", "Rice", -1m); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RequiresAtLeastOneLine() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + + var response = await Client.UpsertRecipeAsync(itemId); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsAZeroQuantity() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 0m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsTheSameRawMaterialTwice() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 1m), (riceId, 2m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatingARecipe_RejectsAnUnknownRawMaterial() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + + var response = await Client.UpsertRecipeAsync(itemId, (Guid.NewGuid(), 1m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Recipe.UnknownRawMaterial"); + } + + [Fact] + public async Task CreatingARecipe_RejectsAnInactiveRawMaterial() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.SetRawMaterialActiveAsync(riceId, false); + + var response = await Client.UpsertRecipeAsync(itemId, (riceId, 1m)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Recipe.InactiveRawMaterial"); + } + + [Fact] + public async Task UpsertingASecondTime_ReplacesTheLinesRatherThanMergingThem() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + var chickenId = await CreateRawMaterialAsync("Chicken"); + var oilId = await CreateRawMaterialAsync("Cooking Oil", "Liter"); + + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m), (chickenId, 0.15m)); + var replaced = await Client.UpsertRecipeAsync(itemId, (riceId, 0.3m), (oilId, 0.05m)); + + var recipe = await PosApiClient.ReadAsync(replaced); + recipe.Lines.Should().HaveCount(2); + recipe.Lines.Should().Contain(l => l.RawMaterialId == riceId && l.Quantity == 0.3m); + recipe.Lines.Should().Contain(l => l.RawMaterialId == oilId); + recipe.Lines.Should().NotContain(l => l.RawMaterialId == chickenId); + + // The replacement must be durable, not just reflected in the handler's in-memory response. + var reFetched = await PosApiClient.ReadAsync(await Client.GetRecipeAsync(itemId)); + reFetched.Lines.Should().HaveCount(2); + reFetched.Lines.Should().NotContain(l => l.RawMaterialId == chickenId); + } + + [Fact] + public async Task ANewMenuItem_ReportsHasRecipeOnceOneExists() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + var item = await PosApiClient.ReadAsync(await Client.GetMenuItemAsync(itemId)); + item.HasRecipe.Should().BeTrue(); + } + + [Fact] + public async Task DisablingARecipe_KeepsItsLinesVisible() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + var disabled = await PosApiClient.ReadAsync( + await Client.SetRecipeEnabledAsync(itemId, false)); + + disabled.IsEnabled.Should().BeFalse(); + disabled.Lines.Should().ContainSingle(); + } + + [Fact] + public async Task DeletingARecipe_AllowsANewOneToBeCreatedAfterwards() + { + await SignInAsAdminAsync(); + var itemId = await CreateMenuItemAsync(); + var riceId = await CreateRawMaterialAsync("Rice"); + await Client.UpsertRecipeAsync(itemId, (riceId, 0.25m)); + + (await Client.DeleteRecipeAsync(itemId)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterDelete = await Client.GetRecipeAsync(itemId); + afterDelete.StatusCode.Should().Be(HttpStatusCode.OK); + (await afterDelete.Content.ReadAsStringAsync()).Should().BeEmpty("no recipe exists for this menu item any more"); + + var recreated = await Client.UpsertRecipeAsync(itemId, (riceId, 0.5m)); + recreated.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StaffWithoutTheModuleCannotReachMenuItems() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + (await staff.GetMenuItemsAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task StaffGrantedRecipeManagement_CanManageMenuItems() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "RecipeManagement"); + + (await staff.CreateMenuItemAsync("Fried Rice", "Rice", 700m)) + .StatusCode.Should().Be(HttpStatusCode.Created); + } + + private async Task CreateMenuItemAsync(string name = "Chicken Fried Rice") => + (await PosApiClient.ReadAsync(await Client.CreateMenuItemAsync(name, "Rice & Curry", 850m))).Id; + + private async Task CreateRawMaterialAsync(string name, string unit = "Kilogram") => + (await PosApiClient.ReadAsync(await Client.CreateRawMaterialAsync(name, unit))).Id; +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj b/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj index ad94696..0239633 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj +++ b/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj @@ -12,7 +12,6 @@ - diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs new file mode 100644 index 0000000..072d309 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Suppliers/SupplierManagementTests.cs @@ -0,0 +1,413 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Suppliers; + +public class SupplierManagementTests : IntegrationTestBase +{ + [Fact] + public async Task CreatingASupplier_StoresContactDetailsAndTerms() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateSupplierAsync( + "ABC Wholesale", "Mr. Perera", "0771234567", "ABC@Wholesale.LK", "123 Galle Rd", + paymentTermsDays: 30, creditLimit: 100000m, leadTimeDays: 3); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var supplier = await PosApiClient.ReadAsync(response); + supplier.Email.Should().Be("abc@wholesale.lk"); + supplier.PaymentTermsDays.Should().Be(30); + supplier.CreditLimit.Should().Be(100000m); + supplier.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task SupplierNamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateSupplierAsync("ABC Wholesale"); + + var duplicate = await Client.CreateSupplierAsync("ABC WHOLESALE"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("Supplier.NameTaken"); + } + + [Fact] + public async Task StaffWithOnlyStoreStockManagement_CanReadSuppliersForTheGrnDropdownButNotManageThem() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "StoreStockManagement"); + + // Reading the list is needed to pick a supplier when recording a GRN, so it is allowed... + (await staff.GetSuppliersAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + + // ...but actually managing suppliers, pricing or purchase orders stays SupplierManagement-only. + (await staff.CreateSupplierAsync("Sneaky Co")).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetPurchaseOrdersAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task StaffGrantedSupplierManagement_CanManageSuppliers() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "SupplierManagement"); + + (await staff.CreateSupplierAsync("ABC Wholesale")).StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ----- Purchase order lifecycle ----- + + [Fact] + public async Task CreatingAPurchaseOrder_StartsAsDraftWithComputedTotal() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, chickenId) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreatePurchaseOrderAsync( + supplierId, [(riceId, 50m, 210m), (chickenId, 20m, 950m)]); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var order = await PosApiClient.ReadAsync(response); + order.Status.Should().Be("Draft"); + order.TotalAmount.Should().Be(50m * 210m + 20m * 950m); + order.Balance.Should().Be(order.TotalAmount); + } + + [Fact] + public async Task APurchaseOrder_RequiresAtLeastOneLine() + { + await SignInAsAdminAsync(); + var (supplierId, _, _) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreatePurchaseOrderAsync(supplierId, []); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UpdatingADraftOrder_ReplacesItsLines() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, chickenId) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var updated = await PosApiClient.ReadAsync( + await Client.UpdatePurchaseOrderAsync(order.Id, [(chickenId, 5m, 900m)])); + + updated.Lines.Should().ContainSingle(l => l.RawMaterialId == chickenId); + updated.TotalAmount.Should().Be(5m * 900m); + } + + [Fact] + public async Task OnceSubmitted_TheOrderCanNoLongerBeEdited() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + (await Client.SubmitPurchaseOrderAsync(order.Id)).StatusCode.Should().Be(HttpStatusCode.OK); + + var editAttempt = await Client.UpdatePurchaseOrderAsync(order.Id, [(riceId, 99m, 1m)]); + editAttempt.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(editAttempt)).Should().Be("PurchaseOrder.NotDraft"); + } + + [Fact] + public async Task ConfirmingBeforeSubmitting_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var response = await Client.ConfirmPurchaseOrderAsync(order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("PurchaseOrder.NotConfirmable"); + } + + [Fact] + public async Task FullLifecycle_DraftToSubmittedToConfirmed() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + await Client.SubmitPurchaseOrderAsync(order.Id); + var confirmed = await PosApiClient.ReadAsync( + await Client.ConfirmPurchaseOrderAsync(order.Id)); + + confirmed.Status.Should().Be("Confirmed"); + } + + [Fact] + public async Task CancellingADraftOrder_Succeeds() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var cancelled = await PosApiClient.ReadAsync( + await Client.CancelPurchaseOrderAsync(order.Id)); + + cancelled.Status.Should().Be("Cancelled"); + } + + [Fact] + public async Task CancellingAnAlreadyDeliveredOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var orderId = await CreateSubmittedConfirmedOrderAsync(supplierId, riceId); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], purchaseOrderId: orderId); + + var response = await Client.CancelPurchaseOrderAsync(orderId); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("PurchaseOrder.NotCancellable"); + } + + // ----- GRN <-> PO reconciliation ----- + + [Fact] + public async Task RecordingAGrnAgainstAConfirmedOrder_MarksItDelivered() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var orderId = await CreateSubmittedConfirmedOrderAsync(supplierId, riceId); + + var grnResponse = await Client.CreateGoodsReceivedNoteAsync( + supplierId, [(riceId, 10m)], purchaseOrderId: orderId, qualityRating: 4); + + var grn = await PosApiClient.ReadAsync(grnResponse); + grn.PurchaseOrderId.Should().Be(orderId); + grn.QualityRating.Should().Be(4); + + var order = await PosApiClient.ReadAsync(await Client.GetPurchaseOrderAsync(orderId)); + order.Status.Should().Be("Delivered"); + } + + [Fact] + public async Task AGrn_CanStandAloneWithoutAPurchaseOrder() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)]); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + (await PosApiClient.ReadAsync(response)).PurchaseOrderId.Should().BeNull(); + } + + [Fact] + public async Task AGrnAgainstACancelledOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + await Client.CancelPurchaseOrderAsync(order.Id); + + var response = await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], purchaseOrderId: order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("GoodsReceivedNote.PurchaseOrderCancelled"); + } + + [Fact] + public async Task AGrnAgainstAnotherSuppliersOrder_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var otherSupplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("XYZ Traders"))).Id; + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 200m)])); + + var response = await Client.CreateGoodsReceivedNoteAsync( + otherSupplierId, [(riceId, 10m)], purchaseOrderId: order.Id); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("GoodsReceivedNote.SupplierMismatch"); + } + + // ----- Payments ----- + + [Fact] + public async Task RecordingAPayment_ReducesTheOutstandingBalance() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + + var response = await Client.RecordSupplierPaymentAsync(order.Id, 4000m, DateTime.UtcNow, "BankTransfer", "INV-001"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var refreshed = await PosApiClient.ReadAsync(await Client.GetPurchaseOrderAsync(order.Id)); + refreshed.AmountPaid.Should().Be(4000m); + refreshed.Balance.Should().Be(6000m); + } + + [Fact] + public async Task APaymentExceedingTheOutstandingBalance_IsRejected() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + + var response = await Client.RecordSupplierPaymentAsync(order.Id, 999999m, DateTime.UtcNow); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("SupplierPayment.ExceedsBalance"); + } + + [Fact] + public async Task PaymentsListedForAnOrder_ReflectWhatWasRecorded() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(riceId, 10m, 1000m)])); + await Client.RecordSupplierPaymentAsync(order.Id, 3000m, DateTime.UtcNow, "Cash"); + await Client.RecordSupplierPaymentAsync(order.Id, 2000m, DateTime.UtcNow, "Cheque"); + + var payments = await PosApiClient.ReadAsync>( + await Client.GetPurchaseOrderPaymentsAsync(order.Id)); + + payments.Should().HaveCount(2); + payments.Sum(p => p.Amount).Should().Be(5000m); + } + + // ----- Pricing ----- + + [Fact] + public async Task SettingAPrice_CanBeUpdatedAndKeepsHistory() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + await Client.SetSupplierPriceAsync(supplierId, riceId, 210m); + var updated = await PosApiClient.ReadAsync( + await Client.SetSupplierPriceAsync(supplierId, riceId, 215m)); + + updated.Price.Should().Be(215m); + + var history = await PosApiClient.ReadAsync>( + await Client.GetSupplierPriceHistoryAsync(supplierId, riceId)); + + history.Should().HaveCount(2); + history.Select(h => h.Price).Should().Contain([210m, 215m]); + } + + [Fact] + public async Task PricesForARawMaterial_CanBeComparedAcrossSuppliers() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + var otherSupplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("XYZ Traders"))).Id; + + await Client.SetSupplierPriceAsync(supplierId, riceId, 215m); + await Client.SetSupplierPriceAsync(otherSupplierId, riceId, 205m); + + var prices = await PosApiClient.ReadAsync>( + await Client.GetSupplierPricesAsync($"?rawMaterialId={riceId}")); + + prices.Should().HaveCount(2); + prices.Should().Contain(p => p.SupplierId == supplierId && p.Price == 215m); + prices.Should().Contain(p => p.SupplierId == otherSupplierId && p.Price == 205m); + } + + // ----- Performance ----- + + [Fact] + public async Task Performance_ReflectsOnTimeDeliveryAndQualityRatings() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync( + supplierId, [(riceId, 10m, 200m)], expectedDeliveryDate: DateTime.UtcNow.AddDays(5))); + await Client.SubmitPurchaseOrderAsync(order.Id); + await Client.ConfirmPurchaseOrderAsync(order.Id); + await Client.CreateGoodsReceivedNoteAsync( + supplierId, [(riceId, 10m)], purchaseOrderId: order.Id, qualityRating: 5); + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.TotalOrders.Should().Be(1); + performance.DeliveredOrders.Should().Be(1); + performance.OnTimeDeliveries.Should().Be(1); + performance.OnTimeDeliveryRate.Should().Be(100); + performance.AverageQualityRating.Should().Be(5); + performance.IssueCount.Should().Be(0); + } + + [Fact] + public async Task Performance_CountsFlaggedIssues() + { + await SignInAsAdminAsync(); + var (supplierId, riceId, _) = await SeedSupplierAndMaterialsAsync(); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 10m)], hasIssue: true); + await Client.CreateGoodsReceivedNoteAsync(supplierId, [(riceId, 5m)], hasIssue: false); + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.IssueCount.Should().Be(1); + } + + [Fact] + public async Task Performance_ForASupplierWithNoOrders_ReportsNullsRatherThanErrors() + { + await SignInAsAdminAsync(); + var supplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("Brand New Supplier"))).Id; + + var performance = await PosApiClient.ReadAsync( + await Client.GetSupplierPerformanceAsync(supplierId)); + + performance.TotalOrders.Should().Be(0); + performance.OnTimeDeliveryRate.Should().BeNull(); + performance.AverageQualityRating.Should().BeNull(); + } + + // ----- Helpers ----- + + private async Task<(Guid SupplierId, Guid RiceId, Guid ChickenId)> SeedSupplierAndMaterialsAsync() + { + var supplierId = (await PosApiClient.ReadAsync( + await Client.CreateSupplierAsync("ABC Wholesale"))).Id; + var riceId = (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync("Rice"))).Id; + var chickenId = (await PosApiClient.ReadAsync( + await Client.CreateRawMaterialAsync("Chicken"))).Id; + + return (supplierId, riceId, chickenId); + } + + private async Task CreateSubmittedConfirmedOrderAsync(Guid supplierId, Guid rawMaterialId) + { + var order = await PosApiClient.ReadAsync( + await Client.CreatePurchaseOrderAsync(supplierId, [(rawMaterialId, 10m, 200m)])); + await Client.SubmitPurchaseOrderAsync(order.Id); + await Client.ConfirmPurchaseOrderAsync(order.Id); + + return order.Id; + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs new file mode 100644 index 0000000..1a7083b --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs @@ -0,0 +1,235 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Users; + +public class UserManagementTests : IntegrationTestBase +{ + [Fact] + public async Task Admin_CreatesAStaffAccountWithTheChosenModules() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateUserAsync( + "cashier01", "Cashier@2026", "User", "Ravi Kumar", "Ravi@SriLakshmi.LK", + "PosBilling", "KitchenOperations"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var user = await PosApiClient.ReadAsync(response); + user.Username.Should().Be("cashier01"); + user.Email.Should().Be("ravi@srilakshmi.lk", "emails are normalised to lower case"); + user.Role.Should().Be("User"); + user.Modules.Should().BeEquivalentTo(["PosBilling", "KitchenOperations"]); + user.MustChangePassword.Should().BeTrue(); + user.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task UsernamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateUserAsync("cashier01", "Cashier@2026"); + + var duplicate = await Client.CreateUserAsync("CASHIER01", "Another@2026"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("User.UsernameTaken"); + } + + [Fact] + public async Task AdministrativeModulesCannotBeGrantedToAStaffAccount() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateUserAsync( + "sneaky", "Sneaky@2026", "User", "Test", null, "UserManagement"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task StaffAreConfinedToTheirGrantedModules() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + var me = await PosApiClient.ReadAsync(await staff.GetMeAsync()); + me.Modules.Should().BeEquivalentTo(["PosBilling"]); + + // User administration is an admin power, not a grantable module. + (await staff.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + + // The catalog itself stays readable, because the client renders its sidebar from it. + (await staff.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpdatingAUserReplacesItsModuleGrants() + { + await SignInAsAdminAsync(); + var created = await Client.CreateUserAsync( + "cashier01", "Cashier@2026", "User", "Ravi Kumar", null, "PosBilling"); + var user = await PosApiClient.ReadAsync(created); + + var updated = await Client.UpdateUserAsync( + user.Id, "Ravi K. Kumar", "User", ["ReportsAnalytics", "ExpensesManagement"]); + + updated.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await PosApiClient.ReadAsync(updated); + result.FullName.Should().Be("Ravi K. Kumar"); + result.Modules.Should().BeEquivalentTo(["ReportsAnalytics", "ExpensesManagement"]); + } + + [Fact] + public async Task PromotingToAdmin_GrantsEveryModule() + { + await SignInAsAdminAsync(); + var created = await Client.CreateUserAsync( + "manager", "Manager@2026", "User", "Priya", null, "PosBilling"); + var user = await PosApiClient.ReadAsync(created); + + var promoted = await PosApiClient.ReadAsync( + await Client.UpdateUserAsync(user.Id, "Priya", "Admin", [])); + + var catalog = await PosApiClient.ReadAsync>(await Client.GetModulesAsync()); + promoted.Role.Should().Be("Admin"); + promoted.Modules.Should().BeEquivalentTo(catalog.Select(m => m.Module)); + } + + [Fact] + public async Task DeactivatingAUser_BlocksSignInAndKillsExistingSessions() + { + var admin = await SignInAsAdminAsync(); + var (staff, staffId) = await CreateAndSignInStaffAsync(); + + var staffSession = await PosApiClient.ReadAsync( + await NewClient().LoginAsync("cashier01", "Ravi@2026x")); + + (await Client.SetUserActiveAsync(staffId, false)).StatusCode.Should().Be(HttpStatusCode.OK); + + // Outstanding sessions are revoked rather than left to expire on their own. + (await staff.RefreshAsync(staffSession.RefreshToken)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var blockedLogin = await NewClient().LoginAsync("cashier01", "Ravi@2026x"); + blockedLogin.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await PosApiClient.ReadErrorCodeAsync(blockedLogin)).Should().Be("Auth.AccountDeactivated"); + + admin.User.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ReactivatingAUser_RestoresSignIn() + { + await SignInAsAdminAsync(); + var (_, staffId) = await CreateAndSignInStaffAsync(); + + await Client.SetUserActiveAsync(staffId, false); + await Client.SetUserActiveAsync(staffId, true); + + (await NewClient().LoginAsync("cashier01", "Ravi@2026x")) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task AnAdminCannotDeactivateOrDemoteThemselves() + { + var session = await SignInAsAdminAsync(); + + var deactivate = await Client.SetUserActiveAsync(session.User.Id, false); + deactivate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(deactivate)).Should().Be("User.CannotDeactivateSelf"); + + var demote = await Client.UpdateUserAsync(session.User.Id, session.User.FullName, "User", []); + demote.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(demote)).Should().Be("User.CannotDemoteSelf"); + } + + [Fact] + public async Task TheBuiltInAdministratorCannotBeDeactivatedByAnotherAdmin() + { + var seeded = await SignInAsAdminAsync(); + + // Promote a second administrator and sign in as them. + var created = await Client.CreateUserAsync("owner", "Owner@2026", "Admin", "Restaurant Owner"); + var owner = await PosApiClient.ReadAsync(created); + + var ownerClient = NewClient(); + var login = await ownerClient.LoginAsync("owner", "Owner@2026"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + var changed = await ownerClient.ChangePasswordAsync("Owner@2026", "Owner@2026New"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + + var response = await ownerClient.SetUserActiveAsync(seeded.User.Id, false); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("User.CannotModifySystemAdmin"); + owner.Role.Should().Be("Admin"); + } + + [Fact] + public async Task ResettingAPassword_ForcesTheUserToChooseANewOneAndEndsTheirSessions() + { + await SignInAsAdminAsync(); + var (staff, staffId) = await CreateAndSignInStaffAsync(); + + (await Client.ResetUserPasswordAsync(staffId, "Reset@2026x")) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // The old password no longer works. + (await NewClient().LoginAsync("cashier01", "Ravi@2026x")) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // The temporary one does, but lands the user straight back on the reset screen. + var relogin = NewClient(); + var session = await PosApiClient.ReadAsync( + await relogin.LoginAsync("cashier01", "Reset@2026x")); + session.User.MustChangePassword.Should().BeTrue(); + + relogin.Authenticate(session.AccessToken); + (await relogin.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + + staff.Should().NotBeNull(); + } + + [Fact] + public async Task UsersCanBeFilteredBySearchRoleAndStatus() + { + await SignInAsAdminAsync(); + await Client.CreateUserAsync("cashier01", "Cashier@2026", "User", "Ravi Kumar"); + await Client.CreateUserAsync("chef01", "Chef@2026aa", "User", "Nimal Perera"); + + var byName = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?search=Nimal")); + byName.Should().ContainSingle().Which.Username.Should().Be("chef01"); + + var byUsername = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?search=cashier")); + byUsername.Should().ContainSingle().Which.Username.Should().Be("cashier01"); + + var admins = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?role=Admin")); + admins.Should().OnlyContain(u => u.Role == "Admin"); + + var active = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?isActive=true")); + active.Should().OnlyContain(u => u.IsActive); + } + + [Fact] + public async Task RequestingAnUnknownUserReturnsNotFound() + { + await SignInAsAdminAsync(); + + var response = await Client.GetUserAsync(Guid.NewGuid()); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs b/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs new file mode 100644 index 0000000..143de4c --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; + +using RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Application; + +public class ApprovalPinValidationTests +{ + private readonly SetApprovalPinCommandValidator _setValidator = new(); + private readonly VerifyApprovalPinCommandValidator _verifyValidator = new(); + + [Fact] + public void ANullPinIsAllowedOnSet_MeaningGenerateOneForMe() + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", null)).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("123")] // too short + [InlineData("12345")] // too long + [InlineData("12a4")] // not all digits + [InlineData("")] + public void RejectsPinsThatAreNotFourDigits(string pin) + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", pin)).IsValid.Should().BeFalse(); + _verifyValidator.Validate(new VerifyApprovalPinCommand(pin, null)).IsValid.Should().BeFalse(); + } + + [Theory] + [InlineData("0000")] + [InlineData("4821")] + [InlineData("9999")] + public void AcceptsAnyFourDigitPin(string pin) + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", pin)).IsValid.Should().BeTrue(); + _verifyValidator.Validate(new VerifyApprovalPinCommand(pin, null)).IsValid.Should().BeTrue(); + } + + [Fact] + public void SettingAPinRequiresTheCurrentPassword() + { + _setValidator.Validate(new SetApprovalPinCommand("", "1234")).IsValid.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs b/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs new file mode 100644 index 0000000..446bf8f --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; + +using RestaurantPOS.Application.Users.Commands.CreateUser; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Application; + +public class CreateUserCommandValidatorTests +{ + private readonly CreateUserCommandValidator _validator = new(); + + private static CreateUserCommand Valid( + string username = "cashier01", + string password = "Cashier@2026", + UserRole role = UserRole.User, + string? email = null, + IReadOnlyCollection? modules = null) => + new(username, "Ravi Kumar", email, password, role, modules ?? [AppModule.PosBilling]); + + [Fact] + public void AcceptsAWellFormedCommand() + { + _validator.Validate(Valid()).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("short1A")] // under 8 characters + [InlineData("alllowercase1")] // no upper-case letter + [InlineData("ALLUPPERCASE1")] // no lower-case letter + [InlineData("NoDigitsHere")] // no digit + public void RejectsPasswordsThatFailThePolicy(string password) + { + var result = _validator.Validate(Valid(password: password)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Password)); + } + + [Theory] + [InlineData("ab")] + [InlineData("has spaces")] + [InlineData("bad!char")] + public void RejectsMalformedUsernames(string username) + { + var result = _validator.Validate(Valid(username: username)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Username)); + } + + [Fact] + public void RejectsAMalformedEmailButAllowsNone() + { + _validator.Validate(Valid(email: "not-an-email")).IsValid.Should().BeFalse(); + _validator.Validate(Valid(email: null)).IsValid.Should().BeTrue(); + _validator.Validate(Valid(email: "ravi@srilakshmi.lk")).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData(AppModule.UserManagement)] + [InlineData(AppModule.SystemSettings)] + public void RejectsGrantingAdministrativeModulesDirectly(AppModule module) + { + var result = _validator.Validate(Valid(modules: [module])); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Modules)); + } + + [Fact] + public void RejectsModuleValuesOutsideTheCatalog() + { + var result = _validator.Validate(Valid(modules: [(AppModule)999])); + + result.IsValid.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/KitchenTicketTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/KitchenTicketTests.cs new file mode 100644 index 0000000..54ce459 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/KitchenTicketTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; + +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class KitchenTicketTests +{ + private static readonly DateTime Now = new(2026, 8, 5, 19, 45, 0, DateTimeKind.Utc); + private static readonly DateOnly Today = new(2026, 8, 5); + + private static Order OpenOrder() + { + var order = Order.Create(Guid.NewGuid(), Guid.NewGuid()); + order.AddItems([new NewOrderItem(Guid.NewGuid(), "Fried Rice", 250m, 1, null)], Now); + order.Confirm(1, Today, Now); + + return order; + } + + [Fact] + public void Advance_MovesForwardAndStampsTheTime() + { + var ticket = OpenOrder().Tickets.Single(); + + ticket.Advance(KitchenTicketStatus.Preparing, Now); + ticket.Advance(KitchenTicketStatus.Ready, Now.AddMinutes(8)); + ticket.Advance(KitchenTicketStatus.Served, Now.AddMinutes(10)); + + ticket.Status.Should().Be(KitchenTicketStatus.Served); + ticket.StartedAtUtc.Should().Be(Now); + ticket.ReadyAtUtc.Should().Be(Now.AddMinutes(8)); + ticket.ServedAtUtc.Should().Be(Now.AddMinutes(10)); + } + + [Fact] + public void Advance_CanSkipAheadWhenAPlateGoesStraightOut() + { + var ticket = OpenOrder().Tickets.Single(); + + ticket.Advance(KitchenTicketStatus.Ready, Now); + + ticket.Status.Should().Be(KitchenTicketStatus.Ready); + ticket.StartedAtUtc.Should().BeNull("it was never marked as started"); + } + + [Fact] + public void Advance_RefusesToGoBackwards() + { + var ticket = OpenOrder().Tickets.Single(); + ticket.Advance(KitchenTicketStatus.Ready, Now); + + var act = () => ticket.Advance(KitchenTicketStatus.Preparing, Now); + + act.Should().Throw("prep timings are read back as kitchen performance"); + } + + [Fact] + public void Advance_RefusesToRepeatTheCurrentStatus() + { + var ticket = OpenOrder().Tickets.Single(); + ticket.Advance(KitchenTicketStatus.Preparing, Now); + + var act = () => ticket.Advance(KitchenTicketStatus.Preparing, Now); + + act.Should().Throw(); + } + + [Fact] + public void CancellationTickets_AreNotWorkable() + { + var order = OpenOrder(); + var ticket = order.RemoveItem(order.Items.Single().Id, Now); + + ticket!.IsWorkable.Should().BeFalse("a cancellation slip is a notice, not food to cook"); + } + + [Fact] + public void DeriveKitchenStatus_ReportsTheLeastAdvancedTicket() + { + var order = OpenOrder(); + order.Tickets.Single().Advance(KitchenTicketStatus.Ready, Now); + order.AddItems([new NewOrderItem(Guid.NewGuid(), "Sandwich", 150m, 1, null)], Now); + + var status = OrderMappings.DeriveKitchenStatus(order.Tickets); + + status.Should().Be(KitchenTicketStatus.New, + "one dish is plated but another has only just been ordered, so the table is not ready"); + } + + [Fact] + public void DeriveKitchenStatus_IgnoresCancellationSlips() + { + var order = OpenOrder(); + order.AddItems([new NewOrderItem(Guid.NewGuid(), "Sandwich", 150m, 1, null)], Now); + + foreach (var ticket in order.Tickets) + { + ticket.Advance(KitchenTicketStatus.Ready, Now); + } + + order.RemoveItem(order.Items.First().Id, Now); + + OrderMappings.DeriveKitchenStatus(order.Tickets).Should().Be(KitchenTicketStatus.Ready, + "voiding a line must not drag a plated table back to the start of the queue"); + } + + [Fact] + public void DeriveKitchenStatus_IsNullBeforeAnythingReachesTheKitchen() + { + var order = Order.Create(Guid.NewGuid(), Guid.NewGuid()); + order.AddItems([new NewOrderItem(Guid.NewGuid(), "Fried Rice", 250m, 1, null)], Now); + + OrderMappings.DeriveKitchenStatus(order.Tickets).Should().BeNull(); + } +} diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs new file mode 100644 index 0000000..d2a9bf3 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/MenuItemTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class MenuItemTests +{ + [Fact] + public void Create_StartsActive() + { + var item = MenuItem.Create("Chicken Fried Rice", "Rice & Curry", 850m); + + item.IsActive.Should().BeTrue(); + } + + [Fact] + public void Create_RejectsANegativePrice() + { + var act = () => MenuItem.Create("Water", "Beverages", -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_AllowsAZeroPrice() + { + var item = MenuItem.Create("Complimentary Water", "Beverages", 0m); + + item.Price.Should().Be(0m); + } + + [Fact] + public void UpdateDetails_ChangesNameCategoryAndPrice() + { + var item = MenuItem.Create("Fried Rice", "Rice", 800m); + + item.UpdateDetails("Chicken Fried Rice", "Rice & Curry", 850m); + + item.Name.Should().Be("Chicken Fried Rice"); + item.Category.Should().Be("Rice & Curry"); + item.Price.Should().Be(850m); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var item = MenuItem.Create("Fried Rice", "Rice", 800m); + + item.Deactivate(); + item.IsActive.Should().BeFalse(); + + item.Activate(); + item.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs new file mode 100644 index 0000000..37366eb --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class ModuleCatalogTests +{ + [Fact] + public void EveryDeclaredModuleIsDescribed() + { + var declared = Enum.GetValues(); + + declared.Should().OnlyContain(m => ModuleCatalog.IsDefined(m), + "the catalog drives navigation and the permission editor, so a module missing from " + + "it would be unreachable in the UI"); + + ModuleCatalog.All.Should().HaveCount(declared.Length); + } + + [Fact] + public void AdministrativeModulesAreNotIndividuallyGrantable() + { + ModuleCatalog.IsAssignableToUser(AppModule.UserManagement).Should().BeFalse(); + ModuleCatalog.IsAssignableToUser(AppModule.SystemSettings).Should().BeFalse(); + ModuleCatalog.IsAssignableToUser(AppModule.PosBilling).Should().BeTrue(); + } + + [Fact] + public void AssignableExcludesExactlyTheAdminOnlyModules() + { + ModuleCatalog.Assignable.Should().BeEquivalentTo(ModuleCatalog.All.Where(d => !d.AdminOnly)); + } + + [Fact] + public void ModulesAreOrderedAndCarryDisplayMetadata() + { + ModuleCatalog.All.Should().BeInAscendingOrder(d => d.SortOrder); + ModuleCatalog.All.Should().OnlyContain(d => + !string.IsNullOrWhiteSpace(d.Name) && + !string.IsNullOrWhiteSpace(d.Group) && + !string.IsNullOrWhiteSpace(d.Description)); + } + + [Fact] + public void EnumValuesAreStableBecauseTheyArePersisted() + { + // Renumbering these would silently repoint every stored permission at a different + // module, so the expected values are pinned here deliberately. + ((int)AppModule.PosBilling).Should().Be(1); + ((int)AppModule.RecipeManagement).Should().Be(2); + ((int)AppModule.StoreStockManagement).Should().Be(3); + ((int)AppModule.KitchenStockRelease).Should().Be(4); + ((int)AppModule.KitchenStockTracking).Should().Be(5); + ((int)AppModule.KitchenOperations).Should().Be(6); + ((int)AppModule.ReportsAnalytics).Should().Be(7); + ((int)AppModule.UserManagement).Should().Be(8); + ((int)AppModule.Notifications).Should().Be(9); + ((int)AppModule.SupplierManagement).Should().Be(10); + ((int)AppModule.ExpensesManagement).Should().Be(11); + ((int)AppModule.SystemSettings).Should().Be(12); + + ((int)UserRole.Admin).Should().Be(1); + ((int)UserRole.User).Should().Be(2); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/OrderTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/OrderTests.cs new file mode 100644 index 0000000..3bac4c8 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/OrderTests.cs @@ -0,0 +1,337 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class OrderTests +{ + private static readonly DateTime Now = new(2026, 8, 5, 19, 45, 0, DateTimeKind.Utc); + private static readonly DateOnly Today = new(2026, 8, 5); + + private static Order NewOrder() => Order.Create(Guid.NewGuid(), Guid.NewGuid()); + + private static NewOrderItem Dish(string name = "Fried Rice", decimal price = 250m, int quantity = 1) => + new(Guid.NewGuid(), name, price, quantity, null); + + private static Order OpenOrderWith(params NewOrderItem[] items) + { + var order = NewOrder(); + order.AddItems(items.Length == 0 ? [Dish()] : items, Now); + order.Confirm(1, Today, Now); + + return order; + } + + [Fact] + public void Create_StartsAsADraftHoldingItsTable() + { + var order = NewOrder(); + + order.Status.Should().Be(OrderStatus.Draft); + order.OrderNumber.Should().BeNull("a draft has not been numbered yet"); + order.IsLive.Should().BeTrue("a draft already holds its table"); + } + + [Fact] + public void AddItems_OnADraft_RaisesNoKitchenTicket() + { + var order = NewOrder(); + + var ticket = order.AddItems([Dish()], Now); + + ticket.Should().BeNull("the kitchen has not been told about this order yet"); + order.Tickets.Should().BeEmpty(); + order.Subtotal.Should().Be(250m); + } + + [Fact] + public void Confirm_NumbersTheOrderAndPrintsTheFirstKot() + { + var order = NewOrder(); + order.AddItems([Dish("Fried Rice", 250m), Dish("Sandwich", 150m)], Now); + + var ticket = order.Confirm(7, Today, Now); + + order.Status.Should().Be(OrderStatus.Open); + order.OrderNumber.Should().Be(7); + order.OrderDate.Should().Be(Today); + ticket.Kind.Should().Be(KitchenTicketKind.New); + ticket.TicketNumber.Should().Be(1); + ticket.Lines.Should().HaveCount(2); + } + + [Fact] + public void Confirm_RejectsAnOrderWithNoItems() + { + var order = NewOrder(); + + var act = () => order.Confirm(1, Today, Now); + + act.Should().Throw("an order must have at least one item (BR-POS-002)"); + } + + [Fact] + public void Confirm_RejectsAnOrderThatIsNotADraft() + { + var order = OpenOrderWith(); + + var act = () => order.Confirm(2, Today, Now); + + act.Should().Throw(); + } + + [Fact] + public void AddItems_OnAnOpenOrder_PrintsAnAdditionKotWithOnlyTheNewItems() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + + var ticket = order.AddItems([Dish("Sandwich", 150m)], Now); + + ticket.Should().NotBeNull(); + ticket!.Kind.Should().Be(KitchenTicketKind.Addition); + ticket.TicketNumber.Should().Be(2); + ticket.Lines.Should().ContainSingle() + .Which.MenuItemName.Should().Be("Sandwich", "the kitchen is already cooking the rice"); + order.Total.Should().Be(400m); + } + + [Fact] + public void ChangeItemQuantity_OnAnOpenOrder_TellsTheKitchenWhatTheQuantityWas() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m, quantity: 1)); + var item = order.Items.Single(); + + var ticket = order.ChangeItemQuantity(item.Id, 3, Now); + + ticket.Should().NotBeNull(); + ticket!.Kind.Should().Be(KitchenTicketKind.Modification); + ticket.Lines.Single().Quantity.Should().Be(3); + ticket.Lines.Single().Note.Should().Be("Was 1"); + order.Total.Should().Be(750m); + } + + [Fact] + public void ChangeItemQuantity_ToTheSameNumber_ChangesNothingAndPrintsNothing() + { + var order = OpenOrderWith(Dish(quantity: 2)); + var item = order.Items.Single(); + + var ticket = order.ChangeItemQuantity(item.Id, 2, Now); + + ticket.Should().BeNull("nothing changed, so the kitchen has nothing to be told"); + order.Tickets.Should().ContainSingle(); + } + + [Fact] + public void RemoveItem_OnADraft_DeletesTheLineOutright() + { + var order = NewOrder(); + order.AddItems([Dish()], Now); + var item = order.Items.Single(); + + var ticket = order.RemoveItem(item.Id, Now); + + ticket.Should().BeNull(); + order.Items.Should().BeEmpty("the kitchen never heard about it, so there is nothing to record"); + } + + [Fact] + public void RemoveItem_OnAnOpenOrder_VoidsTheLineAndPrintsACancellationKot() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m), Dish("Sandwich", 150m)); + var sandwich = order.Items.Single(i => i.MenuItemName == "Sandwich"); + + var ticket = order.RemoveItem(sandwich.Id, Now); + + ticket.Should().NotBeNull(); + ticket!.Kind.Should().Be(KitchenTicketKind.Cancellation); + ticket.Lines.Single().Note.Should().Be("CANCELLED"); + + order.Items.Should().HaveCount(2, "a voided line stays on the bill as a record"); + sandwich.IsCancelled.Should().BeTrue(); + sandwich.LineTotal.Should().Be(0m); + order.Total.Should().Be(250m, "a voided line contributes nothing"); + } + + [Fact] + public void SetDiscount_AsAPercentage_ComesOffTheSubtotal() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m, quantity: 4)); + + order.SetDiscount(DiscountType.Percentage, 10m); + + order.Subtotal.Should().Be(1000m); + order.DiscountAmount.Should().Be(100m); + order.Total.Should().Be(900m); + } + + [Fact] + public void SetDiscount_Fixed_CannotExceedTheSubtotal() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + + var act = () => order.SetDiscount(DiscountType.Fixed, 400m); + + act.Should().Throw("a discount cannot exceed the subtotal (BR-POS-010)"); + } + + [Fact] + public void DiscountAmount_IsCappedWhenTheBillShrinksBelowAFixedDiscount() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m), Dish("Sandwich", 150m)); + order.SetDiscount(DiscountType.Fixed, 400m); + + var sandwich = order.Items.Single(i => i.MenuItemName == "Sandwich"); + order.RemoveItem(sandwich.Id, Now); + + order.Subtotal.Should().Be(250m); + order.DiscountAmount.Should().Be(250m, "the discount is capped by what is left on the bill"); + order.Total.Should().Be(0m, "and can never drive the bill negative"); + } + + [Fact] + public void SetDiscount_RejectsAPercentageAboveOneHundred() + { + var order = OpenOrderWith(); + + var act = () => order.SetDiscount(DiscountType.Percentage, 101m); + + act.Should().Throw(); + } + + [Fact] + public void Complete_SettlesTheBillAndIssuesAReceipt() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + order.StartCheckout(); + order.AddPayment(OrderPaymentMethod.Cash, 250m, tenderedAmount: 500m, reference: null); + + var receipt = order.Complete("REC-001-2026", Now); + + order.Status.Should().Be(OrderStatus.Completed); + order.IsLive.Should().BeFalse("the table is released once the bill is paid"); + order.ChangeDue.Should().Be(250m); + receipt.Number.Should().Be("REC-001-2026"); + } + + [Fact] + public void Complete_RejectsPaymentsThatDoNotAddUpToTheBill() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + order.StartCheckout(); + order.AddPayment(OrderPaymentMethod.Cash, 200m, null, null); + + var act = () => order.Complete("REC-001-2026", Now); + + act.Should().Throw("the tenders must equal the bill exactly (BR-POS-013)"); + } + + [Fact] + public void Complete_AcceptsASplitAcrossSeveralMethods() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m), Dish("Sandwich", 150m)); + order.StartCheckout(); + order.AddPayment(OrderPaymentMethod.Cash, 100m, null, null); + order.AddPayment(OrderPaymentMethod.Card, 300m, null, "AUTH-88"); + + var act = () => order.Complete("REC-002-2026", Now); + + act.Should().NotThrow(); + order.AmountPaid.Should().Be(400m); + } + + [Fact] + public void AddPayment_IsRejectedBeforeCheckoutHasStarted() + { + var order = OpenOrderWith(); + + var act = () => order.AddPayment(OrderPaymentMethod.Cash, 250m, null, null); + + act.Should().Throw(); + } + + [Fact] + public void ReturnToOpen_DiscardsTendersKeyedAgainstTheOldBill() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + order.StartCheckout(); + order.AddPayment(OrderPaymentMethod.Cash, 250m, null, null); + + order.ReturnToOpen(); + + order.Status.Should().Be(OrderStatus.Open); + order.Payments.Should().BeEmpty("the bill they were counted against is about to change"); + } + + [Fact] + public void AddItems_IsRejectedOnceCheckoutHasStarted() + { + var order = OpenOrderWith(); + order.StartCheckout(); + + var act = () => order.AddItems([Dish("Sandwich", 150m)], Now); + + act.Should().Throw("the bill is frozen while the customer pays"); + } + + [Fact] + public void Cancel_VoidsEveryLiveLineAndPrintsOneCancellationKot() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m), Dish("Sandwich", 150m)); + var approver = Guid.NewGuid(); + + var ticket = order.Cancel(approver, Now); + + order.Status.Should().Be(OrderStatus.Cancelled); + order.CancelledByUserId.Should().Be(approver); + order.Total.Should().Be(0m); + order.IsLive.Should().BeFalse("a cancelled order releases its table"); + + ticket.Should().NotBeNull(); + ticket!.Kind.Should().Be(KitchenTicketKind.Cancellation); + ticket.Lines.Should().HaveCount(2); + ticket.Lines.Should().OnlyContain(l => l.Note == "ORDER CANCELLED"); + } + + [Fact] + public void Cancel_OnADraft_PrintsNothingBecauseTheKitchenNeverSawIt() + { + var order = NewOrder(); + order.AddItems([Dish()], Now); + + var ticket = order.Cancel(Guid.NewGuid(), Now); + + ticket.Should().BeNull(); + order.Status.Should().Be(OrderStatus.Cancelled); + } + + [Fact] + public void Cancel_RejectsAnOrderThatIsAlreadyPaid() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + order.StartCheckout(); + order.AddPayment(OrderPaymentMethod.Cash, 250m, null, null); + order.Complete("REC-001-2026", Now); + + var act = () => order.Cancel(Guid.NewGuid(), Now); + + act.Should().Throw(); + } + + [Fact] + public void TicketNumbers_RunInSequenceAcrossEveryAmendment() + { + var order = OpenOrderWith(Dish("Fried Rice", 250m)); + order.AddItems([Dish("Sandwich", 150m)], Now); + var item = order.Items.First(); + order.ChangeItemQuantity(item.Id, 2, Now); + + order.Tickets.Select(t => t.TicketNumber).Should().Equal(1, 2, 3); + order.Tickets.Select(t => t.Kind).Should().Equal( + KitchenTicketKind.New, KitchenTicketKind.Addition, KitchenTicketKind.Modification); + } +} diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs new file mode 100644 index 0000000..9ce6331 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/PurchaseOrderTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class PurchaseOrderTests +{ + private static readonly Guid SupplierId = Guid.NewGuid(); + private static readonly Guid CreatedByUserId = Guid.NewGuid(); + private static readonly Guid RawMaterialA = Guid.NewGuid(); + private static readonly Guid RawMaterialB = Guid.NewGuid(); + + private static PurchaseOrder NewOrder(params (Guid, decimal, decimal)[] lines) => + PurchaseOrder.Create(SupplierId, CreatedByUserId, lines.Length == 0 ? [(RawMaterialA, 10m, 200m)] : lines); + + [Fact] + public void Create_StartsAsDraft() + { + var order = NewOrder(); + + order.Status.Should().Be(PurchaseOrderStatus.Draft); + order.SubmittedAtUtc.Should().BeNull(); + } + + [Fact] + public void Create_RequiresAtLeastOneLine() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, []); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsTheSameRawMaterialTwice() + { + var act = () => PurchaseOrder.Create( + SupplierId, CreatedByUserId, [(RawMaterialA, 1m, 100m), (RawMaterialA, 2m, 100m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsAZeroQuantity() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, [(RawMaterialA, 0m, 100m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsANegativeUnitPrice() + { + var act = () => PurchaseOrder.Create(SupplierId, CreatedByUserId, [(RawMaterialA, 1m, -1m)]); + + act.Should().Throw(); + } + + [Fact] + public void TotalAmount_SumsQuantityTimesUnitPriceAcrossLines() + { + var order = NewOrder((RawMaterialA, 10m, 200m), (RawMaterialB, 5m, 900m)); + + order.TotalAmount.Should().Be(10m * 200m + 5m * 900m); + } + + [Fact] + public void Submit_SetsSubmittedAtAndMovesToSubmitted() + { + var order = NewOrder(); + var now = DateTime.UtcNow; + + order.Submit(now); + + order.Status.Should().Be(PurchaseOrderStatus.Submitted); + order.SubmittedAtUtc.Should().Be(now); + } + + [Fact] + public void Submit_FailsWhenNotADraft() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + var act = () => order.Submit(DateTime.UtcNow); + + act.Should().Throw(); + } + + [Fact] + public void Confirm_RequiresSubmittedFirst() + { + var order = NewOrder(); + + var act = () => order.Confirm(); + + act.Should().Throw(); + } + + [Fact] + public void Confirm_MovesSubmittedToConfirmed() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + order.Confirm(); + + order.Status.Should().Be(PurchaseOrderStatus.Confirmed); + } + + [Fact] + public void ReplaceLines_OnlyWorksWhileDraft() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + + var act = () => order.ReplaceLines([(RawMaterialB, 1m, 1m)]); + + act.Should().Throw(); + } + + [Fact] + public void ReplaceLines_OverwritesRatherThanMerges() + { + var order = NewOrder((RawMaterialA, 1m, 100m)); + + order.ReplaceLines([(RawMaterialB, 2m, 200m)]); + + order.Lines.Should().ContainSingle().Which.RawMaterialId.Should().Be(RawMaterialB); + } + + [Fact] + public void MarkDelivered_IsIdempotentSinceAnOrderCanBeReceivedAcrossMultipleGrns() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + order.Confirm(); + + order.MarkDelivered(); + var act = () => order.MarkDelivered(); + + act.Should().NotThrow(); + order.Status.Should().Be(PurchaseOrderStatus.Delivered); + } + + [Fact] + public void MarkDelivered_RefusesACancelledOrder() + { + var order = NewOrder(); + order.Cancel(); + + var act = () => order.MarkDelivered(); + + act.Should().Throw(); + } + + [Theory] + [InlineData(PurchaseOrderStatus.Draft)] + [InlineData(PurchaseOrderStatus.Submitted)] + [InlineData(PurchaseOrderStatus.Confirmed)] + public void Cancel_WorksFromAnyStateBeforeDelivery(PurchaseOrderStatus startingStatus) + { + var order = NewOrder(); + if (startingStatus >= PurchaseOrderStatus.Submitted) order.Submit(DateTime.UtcNow); + if (startingStatus >= PurchaseOrderStatus.Confirmed) order.Confirm(); + + order.Cancel(); + + order.Status.Should().Be(PurchaseOrderStatus.Cancelled); + } + + [Fact] + public void Cancel_RefusesADeliveredOrder() + { + var order = NewOrder(); + order.Submit(DateTime.UtcNow); + order.Confirm(); + order.MarkDelivered(); + + var act = () => order.Cancel(); + + act.Should().Throw(); + } + + [Fact] + public void Cancel_RefusesAnAlreadyCancelledOrder() + { + var order = NewOrder(); + order.Cancel(); + + var act = () => order.Cancel(); + + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs new file mode 100644 index 0000000..5856589 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/RawMaterialTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class RawMaterialTests +{ + [Fact] + public void Create_StartsActiveWithNoThresholdsByDefault() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.IsActive.Should().BeTrue(); + rice.MainStoreReorderLevel.Should().BeNull(); + rice.KitchenParLevel.Should().BeNull(); + } + + [Fact] + public void Create_RejectsANegativeReorderLevel() + { + var act = () => RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram, mainStoreReorderLevel: -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsANegativeParLevel() + { + var act = () => RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram, kitchenParLevel: -1m); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_CanChangeTheUnitOfMeasurement() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.UpdateDetails("Rice", UnitOfMeasurement.Gram, null, null); + + rice.UnitOfMeasurement.Should().Be(UnitOfMeasurement.Gram); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var rice = RawMaterial.Create("Rice", UnitOfMeasurement.Kilogram); + + rice.Deactivate(); + rice.IsActive.Should().BeFalse(); + + rice.Activate(); + rice.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs new file mode 100644 index 0000000..d2cd276 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/RecipeTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class RecipeTests +{ + private static readonly Guid MenuItemId = Guid.NewGuid(); + private static readonly Guid RawMaterialA = Guid.NewGuid(); + private static readonly Guid RawMaterialB = Guid.NewGuid(); + + [Fact] + public void Create_RequiresAtLeastOneLine() + { + var act = () => Recipe.Create(MenuItemId, []); + + act.Should().Throw("BR-REC-002: a recipe must contain at least one raw material"); + } + + [Fact] + public void Create_RejectsAZeroOrNegativeQuantity() + { + var act = () => Recipe.Create(MenuItemId, [(RawMaterialA, 0m)]); + + act.Should().Throw("BR-REC-003: quantities must be greater than zero"); + } + + [Fact] + public void Create_AcceptsAFractionalQuantity() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 0.25m)]); + + recipe.Lines.Should().ContainSingle().Which.Quantity.Should().Be(0.25m); + } + + [Fact] + public void Create_RejectsTheSameRawMaterialTwice() + { + var act = () => Recipe.Create(MenuItemId, [(RawMaterialA, 1m), (RawMaterialA, 2m)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_StartsEnabled() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.IsEnabled.Should().BeTrue(); + } + + [Fact] + public void ReplaceLines_OverwritesRatherThanMerges() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.ReplaceLines([(RawMaterialB, 2m)]); + + recipe.Lines.Should().ContainSingle().Which.RawMaterialId.Should().Be(RawMaterialB); + } + + [Fact] + public void ReplaceLines_StillEnforcesAtLeastOneLine() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + var act = () => recipe.ReplaceLines([]); + + act.Should().Throw(); + } + + [Fact] + public void EnableAndDisable_ToggleIsEnabled() + { + var recipe = Recipe.Create(MenuItemId, [(RawMaterialA, 1m)]); + + recipe.Disable(); + recipe.IsEnabled.Should().BeFalse(); + + recipe.Enable(); + recipe.IsEnabled.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs new file mode 100644 index 0000000..76e82b7 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/StockLevelTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class StockLevelTests +{ + [Fact] + public void NewLevel_StartsAtZero() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + + level.QuantityOnHand.Should().Be(0); + } + + [Fact] + public void ApplyDelta_IncreasesTheBalance() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + + level.ApplyDelta(10m, now); + + level.QuantityOnHand.Should().Be(10m); + level.UpdatedAtUtc.Should().Be(now); + } + + [Fact] + public void ApplyDelta_AccumulatesAcrossMultipleCalls() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + + level.ApplyDelta(10m, now); + level.ApplyDelta(-3m, now); + + level.QuantityOnHand.Should().Be(7m); + } + + [Fact] + public void ApplyDelta_RefusesToGoNegative() + { + var level = new StockLevel(Guid.NewGuid(), StoreType.MainStore); + var now = DateTime.UtcNow; + level.ApplyDelta(5m, now); + + var act = () => level.ApplyDelta(-10m, now); + + act.Should().Throw( + "this is a last-resort guard; the application layer is expected to check first"); + level.QuantityOnHand.Should().Be(5m, "a rejected delta must not partially apply"); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs new file mode 100644 index 0000000..48bf539 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/SupplierTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class SupplierTests +{ + [Fact] + public void Create_StartsActiveWithZeroPaymentTermsByDefault() + { + var supplier = Supplier.Create("ABC Wholesale"); + + supplier.IsActive.Should().BeTrue(); + supplier.PaymentTermsDays.Should().Be(0); + supplier.CreditLimit.Should().BeNull(); + supplier.LeadTimeDays.Should().BeNull(); + } + + [Fact] + public void Create_StoresContactDetailsAndTerms() + { + var supplier = Supplier.Create( + "ABC Wholesale", + contactName: "Mr. Perera", + phone: "0771234567", + email: " ABC@Wholesale.LK ", + address: "123 Galle Rd", + paymentTermsDays: 30, + creditLimit: 100000m, + leadTimeDays: 3); + + supplier.ContactName.Should().Be("Mr. Perera"); + supplier.Phone.Should().Be("0771234567"); + supplier.Email.Should().Be("abc@wholesale.lk", "email is normalised to lower case like elsewhere in the domain"); + supplier.PaymentTermsDays.Should().Be(30); + supplier.CreditLimit.Should().Be(100000m); + supplier.LeadTimeDays.Should().Be(3); + } + + [Fact] + public void Create_RejectsNegativePaymentTerms() + { + var act = () => Supplier.Create("ABC", paymentTermsDays: -1); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNegativeCreditLimit() + { + var act = () => Supplier.Create("ABC", creditLimit: -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNegativeLeadTime() + { + var act = () => Supplier.Create("ABC", leadTimeDays: -1); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_ReplacesEveryField() + { + var supplier = Supplier.Create("ABC Wholesale", paymentTermsDays: 30); + + supplier.UpdateDetails("XYZ Traders", "New Contact", "011", "new@x.lk", "New Address", 0, null, null); + + supplier.Name.Should().Be("XYZ Traders"); + supplier.PaymentTermsDays.Should().Be(0); + supplier.CreditLimit.Should().BeNull(); + } + + [Fact] + public void ActivateAndDeactivate_ToggleIsActive() + { + var supplier = Supplier.Create("ABC Wholesale"); + + supplier.Deactivate(); + supplier.IsActive.Should().BeFalse(); + + supplier.Activate(); + supplier.IsActive.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs new file mode 100644 index 0000000..b2726fc --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs @@ -0,0 +1,193 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class UserTests +{ + private const string AnyHash = "hashed-password"; + + private static User NewStaffUser(params AppModule[] modules) => + User.Create("cashier01", "Ravi Kumar", null, AnyHash, UserRole.User, modules); + + [Theory] + [InlineData("Cashier01", "cashier01")] + [InlineData(" ADMIN ", "admin")] + [InlineData("front.desk_2", "front.desk_2")] + public void Create_NormalisesUsernameToLowerCase(string input, string expected) + { + var user = User.Create(input, "Someone", null, AnyHash, UserRole.User); + + user.Username.Should().Be(expected); + } + + [Theory] + [InlineData("ab")] // shorter than the minimum + [InlineData("has spaces")] + [InlineData("bad!char")] + [InlineData("thisusernameisfartoolongtobeacceptedbythedomain")] + public void Create_RejectsMalformedUsernames(string username) + { + var act = () => User.Create(username, "Someone", null, AnyHash, UserRole.User); + + act.Should().Throw(); + } + + [Fact] + public void Create_NormalisesEmailAndTreatsBlankAsAbsent() + { + var withEmail = User.Create("a.user", "A User", " Ravi@Example.COM ", AnyHash, UserRole.User); + var withBlank = User.Create("b.user", "B User", " ", AnyHash, UserRole.User); + + withEmail.Email.Should().Be("ravi@example.com"); + withBlank.Email.Should().BeNull(); + } + + [Fact] + public void Create_FlagsNewAccountsForPasswordChange() + { + var user = NewStaffUser(); + + user.MustChangePassword.Should().BeTrue(); + user.IsActive.Should().BeTrue(); + user.IsSystemAdmin.Should().BeFalse(); + } + + [Fact] + public void HasAccessTo_IsLimitedToGrantedModulesForStaff() + { + var user = NewStaffUser(AppModule.PosBilling); + + user.HasAccessTo(AppModule.PosBilling).Should().BeTrue(); + user.HasAccessTo(AppModule.ReportsAnalytics).Should().BeFalse(); + } + + [Fact] + public void HasAccessTo_IsUnconditionalForAdministrators() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin); + + admin.ModulePermissions.Should().BeEmpty("administrators derive access from their role"); + ModuleCatalog.All.Should().OnlyContain(d => admin.HasAccessTo(d.Module)); + admin.EffectiveModules().Should().HaveCount(ModuleCatalog.All.Count); + } + + [Fact] + public void Create_IgnoresModuleGrantsForAdministrators() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin, [AppModule.PosBilling]); + + admin.ModulePermissions.Should().BeEmpty(); + } + + [Fact] + public void ReplaceModuleGrants_OverwritesAndDeduplicates() + { + var user = NewStaffUser(AppModule.PosBilling, AppModule.KitchenOperations); + + user.ReplaceModuleGrants([AppModule.ReportsAnalytics, AppModule.ReportsAnalytics]); + + user.EffectiveModules().Should().ContainSingle().Which.Should().Be(AppModule.ReportsAnalytics); + } + + [Fact] + public void ChangeRole_ToUser_DropsTheApprovalPin() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin); + admin.SetApprovalPin("pin-hash", DateTime.UtcNow); + + admin.ChangeRole(UserRole.User); + + admin.HasApprovalPin.Should().BeFalse("an approval PIN is an administrator's authority"); + admin.EffectiveModules().Should().BeEmpty(); + } + + [Fact] + public void ChangeRole_ToAdmin_ClearsNowRedundantGrants() + { + var user = NewStaffUser(AppModule.PosBilling); + + user.ChangeRole(UserRole.Admin); + + user.ModulePermissions.Should().BeEmpty(); + user.HasAccessTo(AppModule.SystemSettings).Should().BeTrue(); + } + + [Fact] + public void SetPassword_ClearsTheForcedChangeFlag() + { + var user = NewStaffUser(); + + user.SetPassword("new-hash"); + + user.PasswordHash.Should().Be("new-hash"); + user.MustChangePassword.Should().BeFalse(); + } + + [Fact] + public void ResetPassword_ForcesTheUserToChooseTheirOwn() + { + var user = NewStaffUser(); + user.SetPassword("chosen-by-user"); + + user.ResetPassword("temporary-hash"); + + user.PasswordHash.Should().Be("temporary-hash"); + user.MustChangePassword.Should().BeTrue(); + } + + [Fact] + public void IssueRefreshToken_PrunesTokensThatAreNoLongerUsable() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + + user.IssueRefreshToken("expired", now.AddMinutes(-1), now); + user.IssueRefreshToken("live", now.AddDays(7), now); + + user.RefreshTokens.Should().ContainSingle().Which.TokenHash.Should().Be("live"); + } + + [Fact] + public void FindActiveRefreshToken_IgnoresRevokedAndExpiredTokens() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + var token = user.IssueRefreshToken("abc", now.AddDays(7), now); + + user.FindActiveRefreshToken("abc", now).Should().NotBeNull(); + + user.RevokeRefreshToken(token, now); + + user.FindActiveRefreshToken("abc", now).Should().BeNull(); + user.FindActiveRefreshToken("never-issued", now).Should().BeNull(); + } + + [Fact] + public void RevokeAllRefreshTokens_EndsEverySession() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + user.IssueRefreshToken("a", now.AddDays(7), now); + user.IssueRefreshToken("b", now.AddDays(7), now); + + user.RevokeAllRefreshTokens(now); + + user.RefreshTokens.Should().OnlyContain(t => !t.IsActive(now)); + } + + [Fact] + public void CreateSystemAdmin_IsProtectedAndMustChangeItsPassword() + { + var admin = User.CreateSystemAdmin("admin", "System Administrator", AnyHash); + + admin.IsSystemAdmin.Should().BeTrue(); + admin.Role.Should().Be(UserRole.Admin); + admin.MustChangePassword.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs b/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs index 194510e..9527431 100644 --- a/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs +++ b/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; + using Xunit; namespace RestaurantPOS.UnitTests; diff --git a/docs/user-management.md b/docs/user-management.md new file mode 100644 index 0000000..3bb735a --- /dev/null +++ b/docs/user-management.md @@ -0,0 +1,163 @@ +# User Management & Roles + +The first module of the Sri Lakshmi Family Restaurant POS. It owns sign-in, staff accounts, +per-user module access, and the administrator approval PIN that other modules will call when a +privileged action needs authorising. + +## Roles and access + +There are two roles: + +| Role | Access | +| ------- | ------------------------------------------------------------------------- | +| `Admin` | Every module, implicitly. Can administer staff and hold an approval PIN. | +| `User` | Only the modules an administrator has explicitly granted. | + +Module access is all-or-nothing per module. Grants are stored one row per (user, module) in +`UserModulePermissions`, so action-level flags (`CanCreate`, `CanApprove`, …) can be added later +as columns with defaults rather than as a restructuring migration. + +Two modules are marked `AdminOnly` in the catalog and are never offered in the permission +editor, because granting them would be granting administrative authority itself: + +- `UserManagement` +- `SystemSettings` + +The catalog is served by `GET /api/v1/modules`. The frontend builds both its navigation sidebar +and the permission editor from it, so adding a module to `ModuleCatalog` on the backend is all +that is needed to make it appear and become grantable. + +## First run + +On start-up the API applies migrations and, if no administrator exists, seeds one from the +`SeedAdmin` configuration section: + +```json +"SeedAdmin": { + "Username": "admin", + "FullName": "System Administrator", + "Password": "ChangeMe!123" +} +``` + +Override per installation with configuration or environment variables +(`SeedAdmin__Username`, `SeedAdmin__Password`). + +The seeded account is flagged `MustChangePassword`, so the first thing anyone signing in as it +must do is choose a real password. + +### Adding the owner + +Sign in as the seeded administrator, then create the owner's account with the `Admin` role and a +temporary password. They will be forced to set their own password at first sign-in — the same +mechanism, no special case. + +## Forced password change + +Any account with `MustChangePassword` set — the seeded admin, anyone just created, anyone whose +password an admin has reset — receives a normal session, but that session is confined to the +password-change flow. + +This is enforced **server-side**, not just in the UI: `PasswordChangeRequiredMiddleware` rejects +every endpoint except those marked `[AllowPendingPasswordChange]` with: + +``` +403 { "code": "Auth.PasswordChangeRequired" } +``` + +so a temporary password cannot be used to do real work by calling the API directly. The client +keys on that code to route to the reset screen. + +## Approval PIN + +An administrator can set or generate a 4-digit PIN (`POST /api/v1/auth/pin`, re-authenticated +with their current password). Only the BCrypt hash is stored — the plaintext is returned exactly +once, at the moment it is set, and cannot be read back. + +`POST /api/v1/auth/pin/verify` is the shared approval gate any module can call. It is callable +by **any signed-in user**, which is the point: a cashier attempting to void an order calls it +while an administrator types their PIN, and the response records who authorised it. + +```jsonc +// POST /api/v1/auth/pin/verify { "pin": "4821", "reason": "Cancel order #1042" } +{ + "approvedByUserId": "…", + "approvedByName": "System Administrator", + "approvedAtUtc": "2026-08-02T08:17:34Z" +} +``` + +Because 4 digits is only ten thousand combinations, this endpoint sits behind a fixed-window +rate limiter (10 attempts/minute, partitioned per user), and PINs are hashed with the same cost +factor as passwords. + +## Sessions + +- **Access token** — JWT, 60 minutes by default, carrying the user's id, role and granted + modules. Administrators carry no module claims; their role covers everything. +- **Refresh token** — opaque, 14 days, **rotated on every use**. Only a SHA-256 hash is stored. + Presenting a token that has already been consumed fails, so a stolen copy has a short life. + +Sessions are revoked immediately — not left to expire — when a user is deactivated, has their +password reset by an admin, or changes their own password (all sessions but the current one). + +### Signing key + +If `Jwt:SigningKey` is empty, a 64-byte key is generated on first run and stored at +`Jwt:KeyFilePath` (default `keys/jwt-signing.key`, alongside the application). This keeps a +single-machine install zero-configuration without shipping a weak shared default. The file is +gitignored; deleting it signs everyone out. A multi-machine deployment should set +`Jwt:SigningKey` explicitly instead. + +## Accounts are deactivated, never deleted + +There is no delete endpoint. Orders, bills and stock movements will reference the staff member +who performed them, so accounts are deactivated to preserve that history. Guards prevent +administering the system into a corner: + +| Guard | Error code | +| ------------------------------ | ------------------------------- | +| Cannot deactivate yourself | `User.CannotDeactivateSelf` | +| Cannot change your own role | `User.CannotDemoteSelf` | +| Built-in admin is protected | `User.CannotModifySystemAdmin` | +| Cannot remove the last admin | `User.LastAdmin` | + +## API reference + +All paths are prefixed `/api/v1`. + +| Method | Path | Who | +| -------- | ----------------------- | ------------------------ | +| `POST` | `/auth/login` | anonymous | +| `POST` | `/auth/refresh` | anonymous | +| `POST` | `/auth/logout` | anonymous | +| `GET` | `/auth/me` | any signed-in user | +| `POST` | `/auth/change-password` | any signed-in user | +| `POST` | `/auth/pin` | admin | +| `DELETE` | `/auth/pin` | admin | +| `POST` | `/auth/pin/verify` | any signed-in user | +| `GET` | `/modules` | any signed-in user | +| `GET` | `/users` | admin | +| `GET` | `/users/{id}` | admin | +| `POST` | `/users` | admin | +| `PUT` | `/users/{id}` | admin | +| `PUT` | `/users/{id}/status` | admin | +| `POST` | `/users/{id}/password` | admin | + +Failures are RFC 7807 problem responses carrying a stable `code` (for example +`Auth.InvalidCredentials`, `User.UsernameTaken`) so clients branch on the code, never on prose. + +## Running it + +```bash +# Backend — migrates, seeds and serves on http://localhost:5207 +cd backend/src/RestaurantPOS.API +dotnet run + +# Backend tests (89: 48 unit, 39 integration, 2 architecture) +cd backend +dotnet test +``` + +Integration tests boot the real API against a throwaway SQLite file and go through the same +migrate-and-seed path a fresh install does, so the bootstrap itself is covered. diff --git a/frontend/.husky/commit-msg b/frontend/.husky/commit-msg deleted file mode 100644 index e81b051..0000000 --- a/frontend/.husky/commit-msg +++ /dev/null @@ -1 +0,0 @@ -npx commitlint --edit "$1" diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit deleted file mode 100644 index 2312dc5..0000000 --- a/frontend/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npx lint-staged diff --git a/frontend/commitlint.config.cjs b/frontend/commitlint.config.cjs deleted file mode 100644 index d42f351..0000000 --- a/frontend/commitlint.config.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module = { - extends: ["@commitlint/config-conventional"], -}; diff --git a/frontend/electron/ipc/printer.ipc.ts b/frontend/electron/ipc/printer.ipc.ts index 72bdbd8..af55b82 100644 --- a/frontend/electron/ipc/printer.ipc.ts +++ b/frontend/electron/ipc/printer.ipc.ts @@ -1,8 +1,12 @@ import { ipcMain } from 'electron'; -import { printerService } from '../services/printer.service'; +import { printerService, type PrintHtmlOptions } from '../services/printer.service'; export function registerPrinterIPC() { - ipcMain.handle('printer:print', async (_event, data) => { - return await printerService.printReceipt(data); + ipcMain.handle('printer:printHtml', async (_event, html: string, options?: PrintHtmlOptions) => { + return await printerService.printHtml(html, options); + }); + + ipcMain.handle('printer:list', async () => { + return await printerService.listPrinters(); }); } diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 0c20832..17f7d84 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -2,7 +2,8 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electronAPI', { ping: () => ipcRenderer.invoke('system:ping'), - printReceipt: (data: unknown) => ipcRenderer.invoke('printer:print', data), + printHtml: (html: string, options?: unknown) => ipcRenderer.invoke('printer:printHtml', html, options), + listPrinters: () => ipcRenderer.invoke('printer:list'), onMainProcessMessage: (callback: (message: string) => void) => { ipcRenderer.on('main-process-message', (_event, message) => callback(message)); }, diff --git a/frontend/electron/services/printer.service.ts b/frontend/electron/services/printer.service.ts index fe957ab..b4b0e71 100644 --- a/frontend/electron/services/printer.service.ts +++ b/frontend/electron/services/printer.service.ts @@ -1,8 +1,80 @@ +import { BrowserWindow } from 'electron'; + +export interface PrintHtmlOptions { + silent?: boolean; + deviceName?: string; + widthMm?: number; +} + +export interface PrintResult { + success: boolean; + message: string; +} + +/** Microns per millimetre — Electron states page size in microns. */ +const MICRONS_PER_MM = 1000; + +/** + * Sends rendered documents to a printer. + * + * The renderer hands over a finished HTML document and this loads it offscreen to print. Going + * through the installed printer driver rather than emitting raw ESC/POS is what lets the same + * code drive whatever thermal printer the restaurant actually owns, and lets a receipt carry a + * QR code without encoding one for a specific print head. + */ export class PrinterService { - public async printReceipt(data: unknown): Promise<{ success: boolean; message: string }> { - // ESC/POS USB & Serial Hardware Print Logic - console.log('[Electron PrinterService] Printing receipt payload:', data); - return { success: true, message: 'Receipt printed successfully' }; + public async printHtml(html: string, options: PrintHtmlOptions = {}): Promise { + const { silent = true, deviceName, widthMm = 80 } = options; + + // Hidden, so printing never steals focus from the till in the middle of service. + const printWindow = new BrowserWindow({ + show: false, + webPreferences: { javascript: false }, + }); + + try { + await printWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + + return await new Promise((resolve) => { + printWindow.webContents.print( + { + silent, + printBackground: true, + ...(deviceName ? { deviceName } : {}), + margins: { marginType: 'none' }, + // Roll paper has no fixed page length, so the height is left generous and the + // printer cuts at the end of the content. + pageSize: { width: widthMm * MICRONS_PER_MM, height: 297 * MICRONS_PER_MM }, + }, + (success, failureReason) => { + resolve( + success + ? { success: true, message: 'Printed' } + : { success: false, message: failureReason || 'Printing was cancelled' }, + ); + }, + ); + }); + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : 'Printing failed' }; + } finally { + if (!printWindow.isDestroyed()) { + printWindow.destroy(); + } + } + } + + /** Printers installed on this machine, so the operator can choose one. */ + public async listPrinters(): Promise { + const window = BrowserWindow.getAllWindows()[0]; + + if (!window) { + return []; + } + + const printers = await window.webContents.getPrintersAsync(); + + return printers.map((printer) => printer.name); } } diff --git a/frontend/lint-staged.config.mjs b/frontend/lint-staged.config.mjs deleted file mode 100644 index 1ff0b6a..0000000 --- a/frontend/lint-staged.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -export default { - "*.{js,jsx,ts,tsx}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,css,md}": [ - "prettier --write" - ] -}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec3eeff..de6b0bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,22 +8,35 @@ "name": "restaurant-pos-frontend", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.64.2", + "@types/qrcode": "^1.5.6", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8", "react-hook-form": "^7.54.2", "react-redux": "^9.2.0", + "react-router-dom": "^7.18.2", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { - "@commitlint/cli": "^19.6.1", - "@commitlint/config-conventional": "^19.6.0", "@eslint/eslintrc": "^3.2.0", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", @@ -37,7 +50,7 @@ "autoprefixer": "^10.4.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", - "electron": "^43.2.0", + "electron": "^42.0.0", "electron-builder": "^26.15.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -46,9 +59,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-unused-imports": "^4.1.4", - "husky": "^9.1.7", "jsdom": "^26.0.0", - "lint-staged": "^15.4.2", "msw": "^2.7.0", "postcss": "^8.5.1", "prettier": "^3.4.2", @@ -184,14 +195,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -324,13 +335,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -397,18 +408,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -416,9 +427,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -456,323 +467,6 @@ "node": ">=18.18" } }, - "node_modules/@boundaries/elements/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@boundaries/elements/node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/@boundaries/elements/node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@boundaries/elements/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@commitlint/cli": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", - "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/format": "^19.8.1", - "@commitlint/lint": "^19.8.1", - "@commitlint/load": "^19.8.1", - "@commitlint/read": "^19.8.1", - "@commitlint/types": "^19.8.1", - "tinyexec": "^1.0.0", - "yargs": "^17.0.0" - }, - "bin": { - "commitlint": "cli.js" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-conventional": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", - "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "conventional-changelog-conventionalcommits": "^7.0.2" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-validator": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", - "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "ajv": "^8.11.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/ensure": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", - "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/execute-rule": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", - "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/format": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", - "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/is-ignored": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", - "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "semver": "^7.6.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/lint": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", - "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/is-ignored": "^19.8.1", - "@commitlint/parse": "^19.8.1", - "@commitlint/rules": "^19.8.1", - "@commitlint/types": "^19.8.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/load": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", - "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.1", - "@commitlint/execute-rule": "^19.8.1", - "@commitlint/resolve-extends": "^19.8.1", - "@commitlint/types": "^19.8.1", - "chalk": "^5.3.0", - "cosmiconfig": "^9.0.0", - "cosmiconfig-typescript-loader": "^6.1.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/message": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", - "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/parse": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", - "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "conventional-changelog-angular": "^7.0.0", - "conventional-commits-parser": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/read": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", - "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/top-level": "^19.8.1", - "@commitlint/types": "^19.8.1", - "git-raw-commits": "^4.0.0", - "minimist": "^1.2.8", - "tinyexec": "^1.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/resolve-extends": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", - "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.1", - "@commitlint/types": "^19.8.1", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/rules": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", - "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/ensure": "^19.8.1", - "@commitlint/message": "^19.8.1", - "@commitlint/to-lines": "^19.8.1", - "@commitlint/types": "^19.8.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/to-lines": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", - "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/top-level": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", - "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^7.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/types": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", - "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/conventional-commits-parser": "^5.0.0", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -916,38 +610,6 @@ "node": ">=10.12.0" } }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/asar/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@electron/fuses": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", @@ -1012,6 +674,19 @@ "node": ">=10" } }, + "node_modules/@electron/fuses/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/get": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", @@ -1245,13 +920,12 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1884,6 +1558,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1897,6 +1609,13 @@ "node": ">=14.0.0" } }, + "node_modules/@hapi/address/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@hapi/formula": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", @@ -1904,13 +1623,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@hapi/pinpoint": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", @@ -1928,14 +1640,114 @@ "node": ">=14.0.0" } }, - "node_modules/@hapi/topo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@hookform/resolvers": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz", + "integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==", + "license": "MIT", "dependencies": { - "@hapi/hoek": "^11.0.2" + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@humanfs/core": { @@ -2122,6 +1934,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2311,10 +2130,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", - "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, @@ -2339,165 +2175,953 @@ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 20.19.0" + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", - "engines": { - "node": ">=12.4.0" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/deferred-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", - "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=8.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "tslib": "^2.8.1" + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/webcrypto": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", - "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/json-schema": "^1.1.12", - "@peculiar/utils": "^2.0.2", - "tslib": "^2.8.1", - "webcrypto-core": "^1.9.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, - "engines": { - "node": ">=14.18.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "@radix-ui/react-primitive": "2.1.10" }, - "bin": { - "playwright": "cli.js" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -2532,9 +3156,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", - "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -2546,9 +3170,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", - "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -2560,9 +3184,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", - "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -2574,9 +3198,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", - "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -2588,9 +3212,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", - "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -2602,9 +3226,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", - "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -2616,9 +3240,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", - "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], @@ -2630,9 +3254,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", - "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], @@ -2644,9 +3268,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", - "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], @@ -2658,9 +3282,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", - "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], @@ -2672,9 +3296,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", - "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], @@ -2686,9 +3310,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", - "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], @@ -2700,9 +3324,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", - "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], @@ -2714,9 +3338,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", - "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], @@ -2728,9 +3352,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", - "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], @@ -2742,9 +3366,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", - "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], @@ -2756,9 +3380,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", - "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], @@ -2770,9 +3394,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", - "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], @@ -2784,9 +3408,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", - "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], @@ -2798,9 +3422,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", - "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -2812,9 +3436,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", - "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -2826,9 +3450,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", - "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -2840,9 +3464,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", - "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -2854,9 +3478,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", - "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -2868,9 +3492,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -3136,16 +3760,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/conventional-commits-parser": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", - "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3222,16 +3836,24 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3239,10 +3861,10 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3552,17 +4174,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3819,22 +4430,20 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3847,27 +4456,10 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4140,6 +4732,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4167,13 +4771,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -4467,9 +5064,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", - "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4613,16 +5210,6 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/builder-util/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4670,6 +5257,19 @@ "node": ">= 14" } }, + "node_modules/builder-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -4794,6 +5394,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4936,37 +5545,16 @@ "node": ">=8" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" + "clsx": "^2.1.1" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://polar.sh/cva" } }, "node_modules/cli-width": { @@ -5017,16 +5605,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5099,7 +5677,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5112,14 +5689,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5135,24 +5704,13 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - } - }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" + "node": ">= 6" } }, "node_modules/compare-version": { @@ -5212,19 +5770,6 @@ "node": ">=20" } }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/concurrently/node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -5260,51 +5805,6 @@ "dev": true, "license": "MIT" }, - "node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", - "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "conventional-commits-parser": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5316,7 +5816,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5333,51 +5832,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", - "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jiti": "2.6.1" - }, - "engines": { - "node": ">=v18" - }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" - } - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -5461,19 +5915,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/dargs": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -5559,6 +6000,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5685,6 +6135,12 @@ "license": "MIT", "optional": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5692,6 +6148,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -5703,35 +6165,6 @@ "p-limit": "^3.1.0 " } }, - "node_modules/dir-compare/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dir-compare/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5773,19 +6206,6 @@ "license": "MIT", "peer": true }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5863,9 +6283,9 @@ } }, "node_modules/electron": { - "version": "43.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", - "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "version": "42.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.8.0.tgz", + "integrity": "sha512-lgeUDjUuUzUSBchBudmjCZ8ApeYdVneMi17nLRMdfxGw7FyLFligsLtIF+dL3UoNnOsupAwdqVNJCK5MFE82kQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5953,6 +6373,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/electron-builder/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/electron-publish": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", @@ -6004,10 +6437,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/electron-publish/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.398", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", - "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -6089,9 +6535,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -6140,27 +6586,14 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/err-code": { @@ -6170,16 +6603,6 @@ "dev": true, "license": "MIT" }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -6504,15 +6927,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -6641,48 +7064,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint-plugin-boundaries/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-boundaries/node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-plugin-boundaries/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "node_modules/eslint-plugin-boundaries/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/eslint-plugin-import": { @@ -6888,22 +7280,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", @@ -6920,29 +7296,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/eslint/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -7017,37 +7383,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7076,9 +7411,39 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7111,10 +7476,10 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "dev": true, + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "devOptional": true, "funding": [ { "type": "github", @@ -7207,21 +7572,16 @@ } }, "node_modules/find-up": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { - "locate-path": "^7.2.0", - "path-exists": "^5.0.0", - "unicorn-magic": "^0.1.0" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/flat-cache": { @@ -7432,7 +7792,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7475,6 +7834,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -7488,19 +7856,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7520,9 +7875,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "dev": true, "license": "MIT", "dependencies": { @@ -7532,42 +7887,23 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/git-raw-commits": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "dargs": "^8.0.0", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "git-raw-commits": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7586,32 +7922,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -7631,22 +7941,6 @@ "node": ">=10.0" } }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -7930,16 +8224,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -7962,35 +8246,21 @@ "dependencies": { "agent-base": "6", "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", + }, "engines": { - "node": ">=16.17.0" + "node": ">= 6" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "bin": { - "husky": "bin.js" + "dependencies": { + "debug": "4" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">= 6.0.0" } }, "node_modules/iconv-lite": { @@ -8053,17 +8323,6 @@ "node": ">=4" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8103,16 +8362,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8146,13 +8395,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -8243,13 +8485,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -8336,16 +8578,12 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-generator-function": { @@ -8441,16 +8679,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8506,19 +8734,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -8554,19 +8769,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-text-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", - "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "text-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -8681,6 +8883,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", @@ -8754,25 +8969,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/joi": { - "version": "18.2.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", - "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/address": "^5.1.1", - "@hapi/formula": "^3.0.2", - "@hapi/hoek": "^11.0.7", - "@hapi/pinpoint": "^2.0.1", - "@hapi/tlds": "^1.1.1", - "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8781,9 +8977,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -8843,16 +9039,6 @@ } } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/jsdom/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -8887,18 +9073,11 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -8942,33 +9121,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9020,52 +9172,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lint-staged": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", - "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^13.1.0", - "debug": "^4.4.0", - "execa": "^8.0.1", - "lilconfig": "^3.1.3", - "listr2": "^8.2.5", - "micromatch": "^4.0.8", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.7.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/listr2": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", - "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -9085,19 +9191,15 @@ } }, "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { - "p-locate": "^6.0.0" + "p-locate": "^4.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/lodash": { @@ -9107,27 +9209,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9135,107 +9216,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -9342,27 +9322,7 @@ "license": "MIT", "engines": { "node": ">= 0.4" - } - }, - "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" + } }, "node_modules/merge2": { "version": "1.4.1", @@ -9422,32 +9382,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -9612,22 +9546,22 @@ } }, "node_modules/msw/node_modules/tldts": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", - "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.9" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/msw/node_modules/tldts-core": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", - "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "dev": true, "license": "MIT" }, @@ -9738,35 +9672,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-exports-info": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", - "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -9884,35 +9789,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nwsapi": { "version": "2.2.24", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", @@ -9984,22 +9860,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", @@ -10063,22 +9923,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10134,37 +9978,57 @@ } }, "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^1.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { - "p-limit": "^4.0.0" + "p-limit": "^2.2.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -10185,25 +10049,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -10218,13 +10063,12 @@ } }, "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=8" } }, "node_modules/path-is-absolute": { @@ -10337,19 +10181,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", - "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -10460,6 +10291,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10517,28 +10357,6 @@ "postcss": "^8.0.0" } }, - "node_modules/postcss-import/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/postcss-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", @@ -10882,38 +10700,168 @@ "dev": true, "license": "MIT", "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", "dependencies": { - "tslib": "^2.8.1" + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" } }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, - "license": "MIT", + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, "engines": { - "node": ">=16.0.0" + "node": ">=6" } }, "node_modules/quansync": { @@ -10989,9 +10937,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.83.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", - "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "version": "7.84.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.84.0.tgz", + "integrity": "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11045,6 +10993,119 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -11181,7 +11242,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11191,12 +11251,18 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -11222,16 +11288,14 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", - "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.2", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -11252,16 +11316,6 @@ "dev": true, "license": "MIT" }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -11285,39 +11339,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -11346,13 +11367,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -11368,29 +11382,6 @@ "rimraf": "bin.js" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -11411,9 +11402,9 @@ } }, "node_modules/rollup": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", - "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { @@ -11427,31 +11418,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.3", - "@rollup/rollup-android-arm64": "4.62.3", - "@rollup/rollup-darwin-arm64": "4.62.3", - "@rollup/rollup-darwin-x64": "4.62.3", - "@rollup/rollup-freebsd-arm64": "4.62.3", - "@rollup/rollup-freebsd-x64": "4.62.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", - "@rollup/rollup-linux-arm-musleabihf": "4.62.3", - "@rollup/rollup-linux-arm64-gnu": "4.62.3", - "@rollup/rollup-linux-arm64-musl": "4.62.3", - "@rollup/rollup-linux-loong64-gnu": "4.62.3", - "@rollup/rollup-linux-loong64-musl": "4.62.3", - "@rollup/rollup-linux-ppc64-gnu": "4.62.3", - "@rollup/rollup-linux-ppc64-musl": "4.62.3", - "@rollup/rollup-linux-riscv64-gnu": "4.62.3", - "@rollup/rollup-linux-riscv64-musl": "4.62.3", - "@rollup/rollup-linux-s390x-gnu": "4.62.3", - "@rollup/rollup-linux-x64-gnu": "4.62.3", - "@rollup/rollup-linux-x64-musl": "4.62.3", - "@rollup/rollup-openbsd-x64": "4.62.3", - "@rollup/rollup-openharmony-arm64": "4.62.3", - "@rollup/rollup-win32-arm64-msvc": "4.62.3", - "@rollup/rollup-win32-ia32-msvc": "4.62.3", - "@rollup/rollup-win32-x64-gnu": "4.62.3", - "@rollup/rollup-win32-x64-msvc": "4.62.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -11656,6 +11648,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", @@ -11857,36 +11855,6 @@ "node": ">=10" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -11915,6 +11883,16 @@ "node": ">=10.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11946,16 +11924,6 @@ "source-map": "^0.6.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -12036,16 +12004,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -12087,16 +12045,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -12110,13 +12058,6 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/string.prototype.trim": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", @@ -12217,30 +12158,17 @@ "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/strip-final-newline": { + "node_modules/strip-bom": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, "node_modules/strip-indent": { @@ -12336,16 +12264,16 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -12429,36 +12357,6 @@ "node": ">=14.0.0" } }, - "node_modules/tailwindcss/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tailwindcss/node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -12469,28 +12367,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/tailwindcss/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -12582,6 +12458,61 @@ "node": "20 || >=22" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -12598,19 +12529,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-extensions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", - "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -12634,13 +12552,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -12668,16 +12579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -12892,7 +12793,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -13071,22 +12971,8 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13215,6 +13101,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -13533,6 +13462,42 @@ "node": ">=20.0.0" } }, + "node_modules/wait-on/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/wait-on/node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/wait-on/node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -13678,6 +13643,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -13794,16 +13765,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -13925,22 +13886,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -13977,16 +13922,6 @@ "dev": true, "license": "MIT" }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -14016,13 +13951,13 @@ } }, "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/frontend/package.json b/frontend/package.json index 34dff36..521f1e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,6 @@ "name": "restaurant-pos-frontend", "version": "0.1.0", "private": true, - "type": "module", "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -18,28 +17,40 @@ "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "coverage": "vitest run --coverage", - "prepare": "husky", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"", "sonar": "sonar-scanner" }, "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.64.2", + "@types/qrcode": "^1.5.6", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8", "react-hook-form": "^7.54.2", "react-redux": "^9.2.0", + "react-router-dom": "^7.18.2", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { - "@commitlint/cli": "^19.6.1", - "@commitlint/config-conventional": "^19.6.0", "@eslint/eslintrc": "^3.2.0", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", @@ -53,7 +64,7 @@ "autoprefixer": "^10.4.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", - "electron": "^43.2.0", + "electron": "^42.0.0", "electron-builder": "^26.15.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -62,9 +73,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-unused-imports": "^4.1.4", - "husky": "^9.1.7", "jsdom": "^26.0.0", - "lint-staged": "^15.4.2", "msw": "^2.7.0", "postcss": "^8.5.1", "prettier": "^3.4.2", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index a8b99a8..de90fa7 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,19 +1,24 @@ +import { useEffect } from "react"; +import { RouterProvider } from "react-router-dom"; +import { Toaster } from "sonner"; +import { sessionEnded, useRestoreSession } from "@/features/auth"; +import { onSessionExpired } from "@/shared/api/axiosClient"; +import { useAppDispatch } from "@/shared/store"; +import { router } from "./routing/router"; + export default function App() { + useRestoreSession(); + + const dispatch = useAppDispatch(); + + // When a refresh token can no longer be renewed, the interceptor calls this to clear the + // session; RequireAuth then redirects to /login on its own next render. + useEffect(() => onSessionExpired(() => dispatch(sessionEnded())), [dispatch]); + return ( -
-
-

- Restaurant POS System -

-

- Enterprise Desktop POS Terminal (Electron + React 19 + Vite) -

-
-

- Status: Architecture Scaffolded & Ready -

-
-
-
+ <> + + + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 40091be..3ac69d0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,18 +2,109 @@ @tailwind components; @tailwind utilities; -:root { - --background: #ffffff; - --foreground: #09090b; - --primary: #18181b; - --primary-foreground: #fafafa; +/* + * Colours are declared as raw HSL channels so Tailwind can compose them with an alpha value + * (e.g. `bg-primary/10`). Every colour the app uses is defined here once, in both themes, so + * no component hard-codes a hex value. + */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222 24% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 24% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 24% 11%; + + /* Deep teal-green: calm and professional on a till screen being stared at all day. */ + --primary: 168 72% 22%; + --primary-foreground: 0 0% 100%; + + --secondary: 210 20% 96%; + --secondary-foreground: 222 24% 20%; + + --muted: 210 20% 96%; + --muted-foreground: 215 16% 44%; + + --accent: 210 20% 94%; + --accent-foreground: 222 24% 20%; + + --destructive: 0 72% 45%; + --destructive-foreground: 0 0% 100%; + + --success: 142 62% 32%; + --success-foreground: 0 0% 100%; + + --warning: 35 92% 42%; + --warning-foreground: 0 0% 100%; + + --border: 214 22% 90%; + --input: 214 22% 88%; + --ring: 168 62% 30%; + + --radius: 0.625rem; + } + + .dark { + --background: 222 26% 8%; + --foreground: 210 20% 96%; + + --card: 222 22% 11%; + --card-foreground: 210 20% 96%; + + --popover: 222 22% 11%; + --popover-foreground: 210 20% 96%; + + --primary: 168 58% 44%; + --primary-foreground: 222 32% 7%; + + --secondary: 217 19% 17%; + --secondary-foreground: 210 20% 92%; + + --muted: 217 19% 16%; + --muted-foreground: 215 16% 64%; + + --accent: 217 19% 19%; + --accent-foreground: 210 20% 92%; + + --destructive: 0 62% 52%; + --destructive-foreground: 0 0% 100%; + + --success: 142 52% 46%; + --success-foreground: 222 32% 7%; + + --warning: 35 84% 54%; + --warning-foreground: 222 32% 7%; + + --border: 217 19% 21%; + --input: 217 19% 24%; + --ring: 168 58% 44%; + } } -@media (prefers-color-scheme: dark) { - :root { - --background: #09090b; - --foreground: #fafafa; - --primary: #fafafa; - --primary-foreground: #18181b; +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: + 'rlig' 1, + 'calt' 1; + } + + /* A POS is driven by touch and keyboard, so focus must always be clearly visible. */ + :focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; + } +} + +@layer utilities { + /* Tabular figures stop money and counts from shifting as their values change. */ + .tabular { + font-variant-numeric: tabular-nums; } } diff --git a/frontend/src/app/routing/RequireAuth.tsx b/frontend/src/app/routing/RequireAuth.tsx new file mode 100644 index 0000000..d44fed9 --- /dev/null +++ b/frontend/src/app/routing/RequireAuth.tsx @@ -0,0 +1,29 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; + +/** + * Gate for every screen that requires a signed-in session. + * + * Also enforces the forced-password-change flow client-side: this mirrors the server's + * `PasswordChangeRequiredMiddleware`, which is the real enforcement point, but redirecting here + * too means a user with a pending change never even sees a screen the API would reject. + */ +export function RequireAuth() { + const { isAuthenticated, isResolving, mustChangePassword } = useAuth(); + const location = useLocation(); + + if (isResolving) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (mustChangePassword && location.pathname !== "/change-password") { + return ; + } + + return ; +} diff --git a/frontend/src/app/routing/RequireGuest.tsx b/frontend/src/app/routing/RequireGuest.tsx new file mode 100644 index 0000000..04d00d8 --- /dev/null +++ b/frontend/src/app/routing/RequireGuest.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; + +/** + * Gate for screens meant only for a signed-out visitor, namely `/login`. + * + * A signed-in user who lands here (back button, stale bookmark) is sent to the right place + * instead of seeing the login form again. + */ +export function RequireGuest() { + const { isAuthenticated, isResolving, mustChangePassword } = useAuth(); + + if (isResolving) { + return ; + } + + if (isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/app/routing/RequireModule.tsx b/frontend/src/app/routing/RequireModule.tsx new file mode 100644 index 0000000..6ac17af --- /dev/null +++ b/frontend/src/app/routing/RequireModule.tsx @@ -0,0 +1,20 @@ +import { Navigate, Outlet } from "react-router-dom"; +import type { ModuleKey } from "@/entities/user"; +import { useAuth } from "@/features/auth"; + +export interface RequireModuleProps { + module: ModuleKey; +} + +/** + * Gate for a screen belonging to a specific module. + * + * This is a UX convenience, not the security boundary — every endpoint the page calls enforces + * the same module grant server-side (`AuthorizationPolicies.ForModule`), so at worst a user + * without access sees an empty screen's requests fail, never real data. + */ +export function RequireModule({ module }: RequireModuleProps) { + const { can } = useAuth(); + + return can(module) ? : ; +} diff --git a/frontend/src/app/routing/router.tsx b/frontend/src/app/routing/router.tsx new file mode 100644 index 0000000..9d9d2a1 --- /dev/null +++ b/frontend/src/app/routing/router.tsx @@ -0,0 +1,94 @@ +import { lazy, ReactNode, Suspense } from "react"; +import { createBrowserRouter, Navigate } from "react-router-dom"; +import { LoadingState } from "@/shared/ui"; +import { AppShell } from "@/widgets/app-shell/AppShell"; +import { RequireAuth } from "./RequireAuth"; +import { RequireGuest } from "./RequireGuest"; +import { RequireModule } from "./RequireModule"; + +// Lazy-loaded so the initial bundle only carries what the login screen needs; everything else +// loads once a session is established. +const LoginPage = lazy(() => import("@/pages/login")); +const ChangePasswordPage = lazy(() => import("@/pages/change-password")); +const DashboardPage = lazy(() => import("@/pages/dashboard")); +const AccountPage = lazy(() => import("@/pages/account")); +const UsersPage = lazy(() => import("@/pages/users")); +const ReportsPage = lazy(() => import("@/pages/reports")); +const RecipesPage = lazy(() => import("@/pages/recipes")); +const MainStorePage = lazy(() => import("@/pages/inventory/main-store")); +const KitchenPage = lazy(() => import("@/pages/inventory/kitchen")); +const ReleasesPage = lazy(() => import("@/pages/inventory/releases")); +const SuppliersPage = lazy(() => import("@/pages/suppliers")); +const PosDashboardPage = lazy(() => import("@/pages/pos")); +const TableManagementPage = lazy(() => import("@/pages/pos/tables")); +const OrderScreen = lazy(() => import("@/pages/pos/order")); +const CheckoutScreen = lazy(() => import("@/pages/pos/checkout")); +const KitchenDisplayPage = lazy(() => import("@/pages/kitchen")); + +function withSuspense(element: ReactNode) { + return }>{element}; +} + +export const router = createBrowserRouter([ + { + element: , + children: [{ path: "/login", element: withSuspense() }], + }, + { + element: , + children: [ + // Reachable the instant a session exists, even mid forced-password-change. + { path: "/change-password", element: withSuspense() }, + { + element: , + children: [ + { index: true, element: withSuspense() }, + { path: "account", element: withSuspense() }, + { + element: , + children: [ + { path: "pos", element: withSuspense() }, + { path: "pos/tables", element: withSuspense() }, + { path: "pos/orders/:orderId", element: withSuspense() }, + { path: "pos/orders/:orderId/checkout", element: withSuspense() }, + { path: "checkout", element: }, + ], + }, + { + element: , + children: [{ path: "kitchen", element: withSuspense() }], + }, + { + element: , + children: [{ path: "reports", element: withSuspense() }], + }, + { + element: , + children: [{ path: "users", element: withSuspense() }], + }, + { + element: , + children: [{ path: "recipes", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/main-store", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/kitchen", element: withSuspense() }], + }, + { + element: , + children: [{ path: "inventory/releases", element: withSuspense() }], + }, + { + element: , + children: [{ path: "suppliers", element: withSuspense() }], + }, + ], + }, + ], + }, + { path: "*", element: }, +]); diff --git a/frontend/src/entities/inventory/index.ts b/frontend/src/entities/inventory/index.ts new file mode 100644 index 0000000..1b2111a --- /dev/null +++ b/frontend/src/entities/inventory/index.ts @@ -0,0 +1,18 @@ +export type { + StoreType, + StockMovementType, + StockLevel, + StockMovement, + StockMovementLine, + GoodsReceivedNote, + GoodsReceivedNoteSummary, + StockRelease, + StockReleaseSummary, + StockLineInput, + CreateGoodsReceivedNotePayload, + CreateStockReleasePayload, + CreateStockAdjustmentPayload, + StockMovementFilters, + ConsumedLine, + ConsumptionResult, +} from "./model/types"; diff --git a/frontend/src/entities/inventory/model/types.ts b/frontend/src/entities/inventory/model/types.ts new file mode 100644 index 0000000..e3f0b70 --- /dev/null +++ b/frontend/src/entities/inventory/model/types.ts @@ -0,0 +1,138 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +export type StoreType = "MainStore" | "Kitchen"; + +export type StockMovementType = + | "GoodsReceived" + | "StockReleaseOut" + | "StockReleaseIn" + | "Adjustment" + | "Consumption"; + +/** A raw material's current balance in one store. */ +export interface StockLevel { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + store: StoreType; + quantityOnHand: number; + isLowStock: boolean; +} + +/** One row in the permanent stock ledger. */ +export interface StockMovement { + id: string; + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + store: StoreType; + quantityDelta: number; + type: StockMovementType; + referenceId: string | null; + performedByUserId: string; + performedByName: string; + occurredAtUtc: string; + notes: string | null; +} + +export interface StockMovementLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; +} + +export interface GoodsReceivedNote { + id: string; + supplierId: string; + supplierName: string; + receivedByUserId: string; + receivedByName: string; + receivedAtUtc: string; + notes: string | null; + purchaseOrderId: string | null; + /** 1-5. Null when no rating was recorded for this delivery. */ + qualityRating: number | null; + hasIssue: boolean; + lines: StockMovementLine[]; +} + +export interface GoodsReceivedNoteSummary { + id: string; + supplierId: string; + supplierName: string; + receivedByName: string; + receivedAtUtc: string; + notes: string | null; + purchaseOrderId: string | null; + qualityRating: number | null; + hasIssue: boolean; + lineCount: number; +} + +export interface StockRelease { + id: string; + requestedByUserId: string; + requestedByName: string; + requestedAtUtc: string; + approvedByUserId: string; + approvedByName: string; + approvedAtUtc: string; + notes: string | null; + lines: StockMovementLine[]; +} + +export interface StockReleaseSummary { + id: string; + requestedByName: string; + requestedAtUtc: string; + approvedByName: string; + approvedAtUtc: string; + notes: string | null; + lineCount: number; +} + +export interface StockLineInput { + rawMaterialId: string; + quantity: number; +} + +export interface CreateGoodsReceivedNotePayload { + supplierId: string; + lines: StockLineInput[]; + notes: string | null; + purchaseOrderId?: string | null; + qualityRating?: number | null; + hasIssue?: boolean; +} + +export interface CreateStockReleasePayload { + lines: StockLineInput[]; + pin: string; + notes: string | null; +} + +export interface CreateStockAdjustmentPayload { + rawMaterialId: string; + store: StoreType; + quantityDelta: number; + reason: string; +} + +export interface StockMovementFilters { + rawMaterialId?: string; + from?: string; + to?: string; +} + +export interface ConsumedLine { + rawMaterialId: string; + rawMaterialName: string; + quantityDeducted: number; + unitOfMeasurement: UnitOfMeasurement; +} + +export interface ConsumptionResult { + deducted: boolean; + lines: ConsumedLine[]; +} diff --git a/frontend/src/entities/kitchen/index.ts b/frontend/src/entities/kitchen/index.ts new file mode 100644 index 0000000..c8fda20 --- /dev/null +++ b/frontend/src/entities/kitchen/index.ts @@ -0,0 +1,2 @@ +export type { KitchenTicket } from "./model/types"; +export { nextTicketStatus, TICKET_ACTION_LABELS } from "./model/types"; diff --git a/frontend/src/entities/kitchen/model/types.ts b/frontend/src/entities/kitchen/model/types.ts new file mode 100644 index 0000000..e7fa3d7 --- /dev/null +++ b/frontend/src/entities/kitchen/model/types.ts @@ -0,0 +1,42 @@ +import type { KitchenTicketKind, KitchenTicketStatus, KotDocumentLine } from "@/entities/order"; + +/** One card on the kitchen display. */ +export interface KitchenTicket { + id: string; + orderId: string; + orderNumber: number | null; + tableNumber: string; + ticketNumber: number; + kind: KitchenTicketKind; + status: KitchenTicketStatus; + printedAtUtc: string; + startedAtUtc: string | null; + readyAtUtc: string | null; + servedAtUtc: string | null; + printCount: number; + /** Minutes since the slip printed — what tells the kitchen what is going cold. */ + waitingMinutes: number; + lines: KotDocumentLine[]; +} + +/** The status a ticket moves to next, or null once it is finished. */ +export function nextTicketStatus(status: KitchenTicketStatus): KitchenTicketStatus | null { + switch (status) { + case "New": + return "Preparing"; + case "Preparing": + return "Ready"; + case "Ready": + return "Served"; + default: + return null; + } +} + +/** The verb on the button that moves a ticket on. */ +export const TICKET_ACTION_LABELS: Record = { + New: "Start cooking", + Preparing: "Mark ready", + Ready: "Mark served", + Served: "Done", +}; diff --git a/frontend/src/entities/menu-item/index.ts b/frontend/src/entities/menu-item/index.ts new file mode 100644 index 0000000..1077c45 --- /dev/null +++ b/frontend/src/entities/menu-item/index.ts @@ -0,0 +1,6 @@ +export type { + MenuItem, + CreateMenuItemPayload, + UpdateMenuItemPayload, + MenuItemFilters, +} from "./model/types"; diff --git a/frontend/src/entities/menu-item/model/types.ts b/frontend/src/entities/menu-item/model/types.ts new file mode 100644 index 0000000..a419264 --- /dev/null +++ b/frontend/src/entities/menu-item/model/types.ts @@ -0,0 +1,25 @@ +/** A sellable dish or drink. Mirrors `MenuItemDto` in `RestaurantPOS.Application`. */ +export interface MenuItem { + id: string; + name: string; + category: string; + price: number; + isActive: boolean; + /** Whether Recipe Management has a bill of materials attached to this item. Not every item needs one. */ + hasRecipe: boolean; + createdAtUtc: string; +} + +export interface CreateMenuItemPayload { + name: string; + category: string; + price: number; +} + +export type UpdateMenuItemPayload = CreateMenuItemPayload; + +export interface MenuItemFilters { + search?: string; + category?: string; + isActive?: boolean; +} diff --git a/frontend/src/entities/order/index.ts b/frontend/src/entities/order/index.ts new file mode 100644 index 0000000..1b5f07d --- /dev/null +++ b/frontend/src/entities/order/index.ts @@ -0,0 +1,19 @@ +export type { + OrderStatus, + KitchenTicketStatus, + KitchenTicketKind, + OrderPaymentMethod, + DiscountType, + OrderItem, + OrderPayment, + Order, + OrderSummary, + AddOrderItemInput, + OrderPaymentInput, + KotDocument, + KotDocumentLine, + OrderMutationResult, + ReceiptDocument, + ReceiptLine, +} from "./model/types"; +export { ORDER_PAYMENT_METHODS, PAYMENT_METHOD_LABELS } from "./model/types"; diff --git a/frontend/src/entities/order/model/types.ts b/frontend/src/entities/order/model/types.ts new file mode 100644 index 0000000..083984b --- /dev/null +++ b/frontend/src/entities/order/model/types.ts @@ -0,0 +1,160 @@ +/** Where an order sits in its lifecycle (POS-033). */ +export type OrderStatus = "Draft" | "Open" | "Checkout" | "Completed" | "Cancelled"; + +/** How far the kitchen has got with one printed slip. */ +export type KitchenTicketStatus = "New" | "Preparing" | "Ready" | "Served"; + +/** Why a slip was printed. */ +export type KitchenTicketKind = "New" | "Addition" | "Modification" | "Cancellation"; + +/** How a customer settles a bill (POS-022). */ +export type OrderPaymentMethod = "Cash" | "Card" | "Qr" | "BankTransfer"; + +export const ORDER_PAYMENT_METHODS: OrderPaymentMethod[] = ["Cash", "Card", "Qr", "BankTransfer"]; + +/** Labels for payment methods, since "Qr" and "BankTransfer" do not read well raw. */ +export const PAYMENT_METHOD_LABELS: Record = { + Cash: "Cash", + Card: "Card", + Qr: "QR", + BankTransfer: "Bank Transfer", +}; + +export type DiscountType = "None" | "Percentage" | "Fixed"; + +export interface OrderItem { + id: string; + menuItemId: string; + menuItemName: string; + unitPrice: number; + quantity: number; + specialInstructions: string | null; + /** A voided line stays on the bill as a record but contributes nothing. */ + isCancelled: boolean; + lineTotal: number; +} + +export interface OrderPayment { + id: string; + method: OrderPaymentMethod; + amount: number; + tenderedAmount: number | null; + changeGiven: number; + reference: string | null; + createdAtUtc: string; +} + +export interface Order { + id: string; + orderNumber: number | null; + orderDate: string | null; + tableId: string; + tableNumber: string; + status: OrderStatus; + cashierUserId: string; + cashierName: string; + createdAtUtc: string; + confirmedAtUtc: string | null; + completedAtUtc: string | null; + discountType: DiscountType; + discountValue: number; + subtotal: number; + discountAmount: number; + total: number; + amountPaid: number; + changeDue: number; + kitchenStatus: KitchenTicketStatus | null; + receiptNumber: string | null; + items: OrderItem[]; + payments: OrderPayment[]; +} + +export interface OrderSummary { + id: string; + orderNumber: number | null; + tableId: string; + tableNumber: string; + status: OrderStatus; + cashierName: string; + createdAtUtc: string; + confirmedAtUtc: string | null; + itemCount: number; + total: number; + kitchenStatus: KitchenTicketStatus | null; +} + +export interface AddOrderItemInput { + menuItemId: string; + quantity: number; + specialInstructions: string | null; +} + +export interface OrderPaymentInput { + method: OrderPaymentMethod; + amount: number; + /** Cash handed over when it exceeds the amount; the difference is the change. */ + tenderedAmount: number | null; + reference: string | null; +} + +/** A printable kitchen slip. */ +export interface KotDocument { + ticketId: string; + restaurantName: string; + orderNumber: number | null; + tableNumber: string; + ticketNumber: number; + kind: KitchenTicketKind; + cashierName: string; + printedAtUtc: string; + printCount: number; + lines: KotDocumentLine[]; +} + +export interface KotDocumentLine { + menuItemName: string; + quantity: number; + specialInstructions: string | null; + note: string | null; +} + +/** + * An order after a change, plus any slip the change obliges. `kot` is null when nothing needs + * printing — an edit to a draft the kitchen has never seen. + */ +export interface OrderMutationResult { + order: Order; + kot: KotDocument | null; +} + +/** A printable customer receipt (POS-027). */ +export interface ReceiptDocument { + receiptNumber: string; + restaurantName: string; + addressLine1: string; + addressLine2: string | null; + city: string | null; + phone: string | null; + orderNumber: number | null; + tableNumber: string; + cashierName: string; + issuedAtUtc: string; + printCount: number; + lines: ReceiptLine[]; + subtotal: number; + discountAmount: number; + /** Always zero — no VAT or GST is applied (BR-POS-011). */ + taxAmount: number; + total: number; + changeGiven: number; + payments: OrderPayment[]; + /** Encoded on the slip as a QR code (POS-028). */ + qrPayload: string; +} + +export interface ReceiptLine { + menuItemName: string; + quantity: number; + unitPrice: number; + lineTotal: number; +} diff --git a/frontend/src/entities/raw-material/index.ts b/frontend/src/entities/raw-material/index.ts new file mode 100644 index 0000000..2d27583 --- /dev/null +++ b/frontend/src/entities/raw-material/index.ts @@ -0,0 +1,8 @@ +export type { + UnitOfMeasurement, + RawMaterial, + CreateRawMaterialPayload, + UpdateRawMaterialPayload, + RawMaterialFilters, +} from "./model/types"; +export { UNITS_OF_MEASUREMENT, UNIT_ABBREVIATIONS } from "./model/types"; diff --git a/frontend/src/entities/raw-material/model/types.ts b/frontend/src/entities/raw-material/model/types.ts new file mode 100644 index 0000000..c87e813 --- /dev/null +++ b/frontend/src/entities/raw-material/model/types.ts @@ -0,0 +1,49 @@ +/** Matches the backend `UnitOfMeasurement` enum, serialised by name. */ +export type UnitOfMeasurement = "Kilogram" | "Gram" | "Liter" | "Milliliter" | "Piece" | "Bottle" | "Packet"; + +export const UNITS_OF_MEASUREMENT: UnitOfMeasurement[] = [ + "Kilogram", + "Gram", + "Liter", + "Milliliter", + "Piece", + "Bottle", + "Packet", +]; + +/** Short display form, e.g. for "0.25 kg" next to a quantity. */ +export const UNIT_ABBREVIATIONS: Record = { + Kilogram: "kg", + Gram: "g", + Liter: "L", + Milliliter: "ml", + Piece: "pcs", + Bottle: "bottles", + Packet: "packets", +}; + +/** An ingredient tracked in stock. Its unit is fixed for its whole lifetime. */ +export interface RawMaterial { + id: string; + name: string; + unitOfMeasurement: UnitOfMeasurement; + /** Main Store is flagged low once its stock reaches this. Null means no threshold is set. */ + mainStoreReorderLevel: number | null; + /** Kitchen is flagged low once its stock reaches this. Null means no threshold is set. */ + kitchenParLevel: number | null; + isActive: boolean; +} + +export interface CreateRawMaterialPayload { + name: string; + unitOfMeasurement: UnitOfMeasurement; + mainStoreReorderLevel: number | null; + kitchenParLevel: number | null; +} + +export type UpdateRawMaterialPayload = CreateRawMaterialPayload; + +export interface RawMaterialFilters { + search?: string; + isActive?: boolean; +} diff --git a/frontend/src/entities/recipe/index.ts b/frontend/src/entities/recipe/index.ts new file mode 100644 index 0000000..8811079 --- /dev/null +++ b/frontend/src/entities/recipe/index.ts @@ -0,0 +1 @@ +export type { Recipe, RecipeLine, RecipeLineInput } from "./model/types"; diff --git a/frontend/src/entities/recipe/model/types.ts b/frontend/src/entities/recipe/model/types.ts new file mode 100644 index 0000000..a938a2c --- /dev/null +++ b/frontend/src/entities/recipe/model/types.ts @@ -0,0 +1,23 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +export interface RecipeLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; +} + +/** The bill of materials for one menu item: what it consumes per unit sold. */ +export interface Recipe { + id: string; + menuItemId: string; + isEnabled: boolean; + lines: RecipeLine[]; + createdAtUtc: string; + updatedAtUtc: string | null; +} + +export interface RecipeLineInput { + rawMaterialId: string; + quantity: number; +} diff --git a/frontend/src/entities/supplier/.gitkeep b/frontend/src/entities/supplier/.gitkeep deleted file mode 100644 index fdffa2a..0000000 --- a/frontend/src/entities/supplier/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# placeholder diff --git a/frontend/src/entities/supplier/index.ts b/frontend/src/entities/supplier/index.ts new file mode 100644 index 0000000..a8bcfac --- /dev/null +++ b/frontend/src/entities/supplier/index.ts @@ -0,0 +1,20 @@ +export type { + Supplier, + SupplierPayload, + SupplierFilters, + PurchaseOrderStatus, + PurchaseOrderLine, + PurchaseOrderLineInput, + PurchaseOrderSummary, + PurchaseOrder, + CreatePurchaseOrderPayload, + UpdatePurchaseOrderPayload, + PurchaseOrderFilters, + SupplierPrice, + SupplierPriceHistoryEntry, + PaymentMethod, + SupplierPayment, + RecordSupplierPaymentPayload, + SupplierPerformance, +} from "./model/types"; +export { PURCHASE_ORDER_STATUSES, PAYMENT_METHODS } from "./model/types"; diff --git a/frontend/src/entities/supplier/model/types.ts b/frontend/src/entities/supplier/model/types.ts new file mode 100644 index 0000000..f703348 --- /dev/null +++ b/frontend/src/entities/supplier/model/types.ts @@ -0,0 +1,168 @@ +import type { UnitOfMeasurement } from "@/entities/raw-material"; + +/** A goods supplier: who a GRN credits stock to, who a PO is sent to. */ +export interface Supplier { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + /** Days after delivery payment is due. 0 means cash on delivery. */ + paymentTermsDays: number; + /** Maximum outstanding balance this supplier extends. Null means no limit is tracked. */ + creditLimit: number | null; + /** Typical days between placing an order and delivery. Null means not yet known. */ + leadTimeDays: number | null; + isActive: boolean; + createdAtUtc: string; +} + +export interface SupplierPayload { + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + paymentTermsDays: number; + creditLimit: number | null; + leadTimeDays: number | null; +} + +export interface SupplierFilters { + search?: string; + isActive?: boolean; +} + +/** Matches the backend `PurchaseOrderStatus` enum, serialised by name. */ +export type PurchaseOrderStatus = "Draft" | "Submitted" | "Confirmed" | "Delivered" | "Cancelled"; + +export const PURCHASE_ORDER_STATUSES: PurchaseOrderStatus[] = [ + "Draft", + "Submitted", + "Confirmed", + "Delivered", + "Cancelled", +]; + +export interface PurchaseOrderLine { + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + quantity: number; + unitPrice: number; + lineTotal: number; +} + +export interface PurchaseOrderLineInput { + rawMaterialId: string; + quantity: number; + unitPrice: number; +} + +/** A purchase order's header for a list screen, without its lines. */ +export interface PurchaseOrderSummary { + id: string; + supplierId: string; + supplierName: string; + status: PurchaseOrderStatus; + createdAtUtc: string; + expectedDeliveryDate: string | null; + totalAmount: number; + amountPaid: number; + balance: number; + lineCount: number; +} + +export interface PurchaseOrder { + id: string; + supplierId: string; + supplierName: string; + status: PurchaseOrderStatus; + createdByUserId: string; + createdByName: string; + createdAtUtc: string; + expectedDeliveryDate: string | null; + submittedAtUtc: string | null; + notes: string | null; + totalAmount: number; + amountPaid: number; + balance: number; + lines: PurchaseOrderLine[]; +} + +export interface CreatePurchaseOrderPayload { + supplierId: string; + lines: PurchaseOrderLineInput[]; + expectedDeliveryDate: string | null; + notes: string | null; +} + +export interface UpdatePurchaseOrderPayload { + lines: PurchaseOrderLineInput[]; + expectedDeliveryDate: string | null; + notes: string | null; +} + +export interface PurchaseOrderFilters { + supplierId?: string; + status?: PurchaseOrderStatus; +} + +export interface SupplierPrice { + supplierId: string; + supplierName: string; + rawMaterialId: string; + rawMaterialName: string; + unitOfMeasurement: UnitOfMeasurement; + price: number; + updatedAtUtc: string; +} + +export interface SupplierPriceHistoryEntry { + price: number; + recordedByUserId: string; + recordedByName: string; + recordedAtUtc: string; +} + +/** Matches the backend `PaymentMethod` enum, serialised by name. */ +export type PaymentMethod = "Cash" | "BankTransfer" | "Cheque" | "Card" | "Other"; + +export const PAYMENT_METHODS: PaymentMethod[] = ["Cash", "BankTransfer", "Cheque", "Card", "Other"]; + +export interface SupplierPayment { + id: string; + purchaseOrderId: string; + amount: number; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string | null; + recordedByUserId: string; + recordedByName: string; + notes: string | null; +} + +export interface RecordSupplierPaymentPayload { + amount: number; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string | null; + notes: string | null; +} + +/** Delivery and quality statistics for a supplier, derived from their purchase orders and GRNs. */ +export interface SupplierPerformance { + supplierId: string; + supplierName: string; + totalOrders: number; + deliveredOrders: number; + onTimeDeliveries: number; + /** Percentage, 0-100. Null when no delivered order had an expected date to compare against. */ + onTimeDeliveryRate: number | null; + /** Null when no order has both a submission date and a recorded delivery. */ + averageDeliveryDays: number | null; + /** 1-5. Null when no GRN for this supplier has a quality rating recorded. */ + averageQualityRating: number | null; + issueCount: number; +} diff --git a/frontend/src/entities/table/index.ts b/frontend/src/entities/table/index.ts new file mode 100644 index 0000000..eb849ed --- /dev/null +++ b/frontend/src/entities/table/index.ts @@ -0,0 +1,7 @@ +export type { + RestaurantTable, + TableOrderSummary, + TablePayload, + TableDisplayStatus, +} from "./model/types"; +export { tableDisplayStatus } from "./model/types"; diff --git a/frontend/src/entities/table/model/types.ts b/frontend/src/entities/table/model/types.ts new file mode 100644 index 0000000..1c93864 --- /dev/null +++ b/frontend/src/entities/table/model/types.ts @@ -0,0 +1,63 @@ +import type { OrderStatus, KitchenTicketStatus } from "@/entities/order"; + +/** A table on the floor plan. */ +export interface RestaurantTable { + id: string; + number: string; + seats: number; + notes: string | null; + isActive: boolean; + /** Null when the table is free. */ + currentOrder: TableOrderSummary | null; +} + +/** The live order sitting on a table, as much as the floor plan shows. */ +export interface TableOrderSummary { + orderId: string; + orderNumber: number | null; + status: OrderStatus; + itemCount: number; + total: number; + confirmedAtUtc: string | null; + cashierName: string; + /** The least-advanced kitchen ticket. Null before anything reaches the kitchen. */ + kitchenStatus: KitchenTicketStatus | null; +} + +export interface TablePayload { + number: string; + seats: number; + notes: string | null; +} + +/** + * What a table tile shows at a glance. Derived rather than stored: a table is occupied exactly + * when it has a live order, and how far along it is comes from the kitchen. + */ +export type TableDisplayStatus = + | "Available" + | "Draft" + | "Ordered" + | "Preparing" + | "Ready" + | "Served" + | "Checkout"; + +export function tableDisplayStatus(table: RestaurantTable): TableDisplayStatus { + const order = table.currentOrder; + + if (!order) return "Available"; + if (order.status === "Draft") return "Draft"; + if (order.status === "Checkout") return "Checkout"; + + switch (order.kitchenStatus) { + case "Preparing": + return "Preparing"; + case "Ready": + return "Ready"; + case "Served": + return "Served"; + default: + return "Ordered"; + } +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 0000000..5364563 --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,13 @@ +export type { + User, + UserRole, + ModuleKey, + ModuleDescriptor, + Session, + Approval, + CreateUserPayload, + UpdateUserPayload, + UserFilters, +} from "./model/types"; + +export { canAccessModule, assignableModules, groupModules } from "./model/permissions"; diff --git a/frontend/src/entities/user/model/permissions.ts b/frontend/src/entities/user/model/permissions.ts new file mode 100644 index 0000000..645eece --- /dev/null +++ b/frontend/src/entities/user/model/permissions.ts @@ -0,0 +1,41 @@ +import type { ModuleDescriptor, ModuleKey, User } from "./types"; + +/** + * True when the user may open the given module. + * + * The backend enforces this on every request; this is only used to decide what to render, so + * the UI never offers a door the API will slam shut. + */ +export function canAccessModule(user: User | null, module: ModuleKey): boolean { + if (!user) return false; + + return user.role === "Admin" || user.modules.includes(module); +} + +/** The modules an administrator may grant to a non-admin user. */ +export function assignableModules(catalog: ModuleDescriptor[]): ModuleDescriptor[] { + return catalog.filter((m) => !m.adminOnly); +} + +/** + * Groups modules by their catalog group, preserving the backend's ordering both between and + * within groups so navigation and the permission editor always agree. + */ +export function groupModules(catalog: ModuleDescriptor[]): Array<{ + group: string; + modules: ModuleDescriptor[]; +}> { + const ordered = [...catalog].sort((a, b) => a.sortOrder - b.sortOrder); + const groups = new Map(); + + for (const descriptor of ordered) { + const existing = groups.get(descriptor.group); + if (existing) { + existing.push(descriptor); + } else { + groups.set(descriptor.group, [descriptor]); + } + } + + return [...groups.entries()].map(([group, modules]) => ({ group, modules })); +} diff --git a/frontend/src/entities/user/model/types.ts b/frontend/src/entities/user/model/types.ts new file mode 100644 index 0000000..52b16a5 --- /dev/null +++ b/frontend/src/entities/user/model/types.ts @@ -0,0 +1,81 @@ +/** + * Mirrors the backend contracts in `RestaurantPOS.Application`. Enums cross the wire as their + * names, so these are string unions rather than numeric enums. + */ + +export type UserRole = "Admin" | "User"; + +/** + * Identifier of an assignable module. Kept as a plain string rather than a closed union: the + * catalog is served by `GET /modules`, so adding a module on the backend must not require a + * frontend change. + */ +export type ModuleKey = string; + +/** A staff account. */ +export interface User { + id: string; + username: string; + fullName: string; + email: string | null; + role: UserRole; + isActive: boolean; + /** True until the user has chosen their own password. */ + mustChangePassword: boolean; + /** The built-in administrator, which cannot be deactivated or demoted. */ + isSystemAdmin: boolean; + hasApprovalPin: boolean; + lastLoginAtUtc: string | null; + createdAtUtc: string; + /** Modules the user can open. For administrators this is the entire catalog. */ + modules: ModuleKey[]; +} + +/** Display metadata for one module, as served by the backend catalog. */ +export interface ModuleDescriptor { + module: ModuleKey; + name: string; + group: string; + description: string; + sortOrder: number; + /** Reserved for administrators; never offered in the permission editor. */ + adminOnly: boolean; +} + +/** A signed-in session. */ +export interface Session { + accessToken: string; + accessTokenExpiresAtUtc: string; + refreshToken: string; + refreshTokenExpiresAtUtc: string; + user: User; +} + +/** Identifies the administrator who authorised a PIN-gated action. */ +export interface Approval { + approvedByUserId: string; + approvedByName: string; + approvedAtUtc: string; +} + +export interface CreateUserPayload { + username: string; + fullName: string; + email: string | null; + password: string; + role: UserRole; + modules: ModuleKey[]; +} + +export interface UpdateUserPayload { + fullName: string; + email: string | null; + role: UserRole; + modules: ModuleKey[]; +} + +export interface UserFilters { + search?: string; + role?: UserRole; + isActive?: boolean; +} diff --git a/frontend/src/features/auth/api/authApi.ts b/frontend/src/features/auth/api/authApi.ts new file mode 100644 index 0000000..8dc92c5 --- /dev/null +++ b/frontend/src/features/auth/api/authApi.ts @@ -0,0 +1,32 @@ +import type { Approval, ModuleDescriptor, Session, User } from "@/entities/user"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +/** Every call the authentication and approval-PIN flows make. */ +export const authApi = { + login: (username: string, password: string) => + apiService.post(API_ENDPOINTS.AUTH.LOGIN, { username, password }), + + logout: (refreshToken: string | null) => + apiService.post(API_ENDPOINTS.AUTH.LOGOUT, { refreshToken }), + + /** Re-reads the signed-in user so permissions are never trusted from cached client state. */ + me: () => apiService.get(API_ENDPOINTS.AUTH.ME), + + changePassword: (currentPassword: string, newPassword: string) => + apiService.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, { + currentPassword, + newPassword, + }), + + modules: () => apiService.get(API_ENDPOINTS.MODULES), + + /** Sets the administrator's approval PIN. Pass `null` to have the server generate one. */ + setApprovalPin: (currentPassword: string, pin: string | null) => + apiService.post<{ pin: string }>(API_ENDPOINTS.AUTH.PIN, { currentPassword, pin }), + + clearApprovalPin: () => apiService.delete(API_ENDPOINTS.AUTH.PIN), + + /** Authorises a privileged action with an administrator's PIN. */ + verifyApprovalPin: (pin: string, reason?: string) => + apiService.post(API_ENDPOINTS.AUTH.VERIFY_PIN, { pin, reason }), +}; diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..02705b9 --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,18 @@ +export { authApi } from "./api/authApi"; +export { + default as authReducer, + sessionEstablished, + sessionEnded, + userLoaded, + authenticating, + type AuthState, +} from "./model/authSlice"; +export { + useAuth, + useLogin, + useLogout, + useChangePassword, + useModules, + useRestoreSession, +} from "./model/useAuth"; +export { useApprovalPin, useRequestApproval } from "./model/useApprovalPin"; diff --git a/frontend/src/features/auth/model/authSlice.ts b/frontend/src/features/auth/model/authSlice.ts index 50394e6..f5fb568 100644 --- a/frontend/src/features/auth/model/authSlice.ts +++ b/frontend/src/features/auth/model/authSlice.ts @@ -1,43 +1,51 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -export interface User { - id: string; - name: string; - email: string; - role: string; -} +import type { Session, User } from "@/entities/user"; +import { tokenStorage } from "@/shared/api/tokenStorage"; export interface AuthState { user: User | null; - isAuthenticated: boolean; - token: string | null; + /** + * Whether the stored token has been checked against the server yet. Routing waits on this so + * a refresh does not briefly bounce a signed-in user to the login screen. + */ + status: "idle" | "authenticating" | "authenticated" | "unauthenticated"; } const initialState: AuthState = { user: null, - isAuthenticated: false, - token: null, + status: "idle", }; export const authSlice = createSlice({ name: "auth", initialState, reducers: { - setCredentials: ( - state, - action: PayloadAction<{ user: User; token: string }> - ) => { + /** Records a new session and persists its tokens. */ + sessionEstablished: (state, action: PayloadAction) => { + tokenStorage.save(action.payload.accessToken, action.payload.refreshToken); state.user = action.payload.user; - state.token = action.payload.token; - state.isAuthenticated = true; + state.status = "authenticated"; + }, + + /** Refreshes the cached profile without touching tokens, e.g. after `GET /auth/me`. */ + userLoaded: (state, action: PayloadAction) => { + state.user = action.payload; + state.status = "authenticated"; }, - logout: (state) => { + + authenticating: (state) => { + state.status = "authenticating"; + }, + + /** Clears all session state. Used for sign-out and for an unrecoverable 401. */ + sessionEnded: (state) => { + tokenStorage.clear(); state.user = null; - state.token = null; - state.isAuthenticated = false; + state.status = "unauthenticated"; }, }, }); -export const { setCredentials, logout } = authSlice.actions; +export const { sessionEstablished, userLoaded, authenticating, sessionEnded } = authSlice.actions; + export default authSlice.reducer; diff --git a/frontend/src/features/auth/model/useApprovalPin.ts b/frontend/src/features/auth/model/useApprovalPin.ts new file mode 100644 index 0000000..7f38eb1 --- /dev/null +++ b/frontend/src/features/auth/model/useApprovalPin.ts @@ -0,0 +1,46 @@ +import { useMutation } from "@tanstack/react-query"; +import type { Approval } from "@/entities/user"; +import { useAppDispatch } from "@/shared/store"; +import { authApi } from "../api/authApi"; +import { userLoaded } from "./authSlice"; + +/** + * Manages the signed-in administrator's own approval PIN. + * + * `hasApprovalPin` lives on the cached Redux user, not a react-query cache, so both mutations + * re-fetch `/auth/me` on success and dispatch the result — otherwise the UI would keep showing + * the PIN's old presence/absence until the next full page load. + */ +export function useApprovalPin() { + const dispatch = useAppDispatch(); + + const refreshUser = async () => { + const user = await authApi.me(); + dispatch(userLoaded(user)); + }; + + const set = useMutation({ + mutationFn: ({ currentPassword, pin }: { currentPassword: string; pin: string | null }) => + authApi.setApprovalPin(currentPassword, pin), + onSuccess: refreshUser, + }); + + const clear = useMutation({ + mutationFn: () => authApi.clearApprovalPin(), + onSuccess: refreshUser, + }); + + return { set, clear }; +} + +/** + * Requests an administrator's authorisation for a privileged action. + * + * This is the hook other modules will reuse: a cashier attempting to void an order calls it, + * an administrator types their PIN, and the resolved {@link Approval} records who approved it. + */ +export function useRequestApproval() { + return useMutation({ + mutationFn: ({ pin, reason }) => authApi.verifyApprovalPin(pin, reason), + }); +} diff --git a/frontend/src/features/auth/model/useAuth.ts b/frontend/src/features/auth/model/useAuth.ts new file mode 100644 index 0000000..56fa9d3 --- /dev/null +++ b/frontend/src/features/auth/model/useAuth.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ModuleKey } from "@/entities/user"; +import { canAccessModule } from "@/entities/user"; +import { tokenStorage } from "@/shared/api/tokenStorage"; +import { useAppDispatch, useAppSelector } from "@/shared/store"; +import { authApi } from "../api/authApi"; +import { authenticating, sessionEnded, sessionEstablished, userLoaded } from "./authSlice"; + +/** + * Restores a session from the stored token on start-up. + * + * Mounted once by the app shell. The stored profile is never trusted: the server is asked who + * the token belongs to and what they may open. + */ +export function useRestoreSession(): void { + const dispatch = useAppDispatch(); + const status = useAppSelector((s) => s.auth.status); + + useEffect(() => { + if (status !== "idle") return; + + if (!tokenStorage.getAccessToken()) { + dispatch(sessionEnded()); + return; + } + + dispatch(authenticating()); + + authApi + .me() + .then((user) => dispatch(userLoaded(user))) + .catch(() => dispatch(sessionEnded())); + }, [dispatch, status]); +} + +/** The signed-in user and helpers derived from them. */ +export function useAuth() { + const { user, status } = useAppSelector((s) => s.auth); + + const can = useCallback((module: ModuleKey) => canAccessModule(user, module), [user]); + + return { + user, + status, + isAuthenticated: status === "authenticated", + /** True while the stored token is still being checked. */ + isResolving: status === "idle" || status === "authenticating", + isAdmin: user?.role === "Admin", + mustChangePassword: user?.mustChangePassword ?? false, + can, + }; +} + +/** Signs in with a username and password. */ +export function useLogin() { + const dispatch = useAppDispatch(); + + return useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + authApi.login(username, password), + onSuccess: (session) => dispatch(sessionEstablished(session)), + }); +} + +/** Changes the signed-in user's password and adopts the fresh session it returns. */ +export function useChangePassword() { + const dispatch = useAppDispatch(); + + return useMutation({ + mutationFn: ({ + currentPassword, + newPassword, + }: { + currentPassword: string; + newPassword: string; + }) => authApi.changePassword(currentPassword, newPassword), + onSuccess: (session) => dispatch(sessionEstablished(session)), + }); +} + +/** Signs out, clearing local state even if the server call fails. */ +export function useLogout() { + const dispatch = useAppDispatch(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => authApi.logout(tokenStorage.getRefreshToken()), + // Local state is cleared either way: a failed revoke must not strand the user signed in. + onSettled: () => { + dispatch(sessionEnded()); + queryClient.clear(); + }, + }); +} + +/** The module catalog, used for navigation and the permission editor. */ +export function useModules() { + const { isAuthenticated } = useAuth(); + + return useQuery({ + queryKey: ["modules"], + queryFn: authApi.modules, + enabled: isAuthenticated, + // The catalog only changes when the application itself is updated. + staleTime: Infinity, + }); +} diff --git a/frontend/src/features/inventory/api/inventoryApi.ts b/frontend/src/features/inventory/api/inventoryApi.ts new file mode 100644 index 0000000..1bc2bf3 --- /dev/null +++ b/frontend/src/features/inventory/api/inventoryApi.ts @@ -0,0 +1,56 @@ +import type { + CreateGoodsReceivedNotePayload, + CreateStockAdjustmentPayload, + CreateStockReleasePayload, + GoodsReceivedNote, + GoodsReceivedNoteSummary, + StockLevel, + StockMovement, + StockMovementFilters, + StockRelease, + StockReleaseSummary, +} from "@/entities/inventory"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const inventoryApi = { + mainStoreStock: (lowStockOnly = false) => + apiService.get(API_ENDPOINTS.INVENTORY.MAIN_STORE_STOCK, { lowStockOnly }), + + mainStoreMovements: (filters: StockMovementFilters = {}) => + apiService.get(API_ENDPOINTS.INVENTORY.MAIN_STORE_MOVEMENTS, { + rawMaterialId: filters.rawMaterialId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }), + + createAdjustment: (store: "MainStore" | "Kitchen", payload: CreateStockAdjustmentPayload) => + apiService.post( + store === "MainStore" ? API_ENDPOINTS.INVENTORY.MAIN_STORE_ADJUSTMENTS : API_ENDPOINTS.INVENTORY.KITCHEN_ADJUSTMENTS, + payload, + ), + + goodsReceivedNotes: () => apiService.get(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED), + + goodsReceivedNoteById: (id: string) => + apiService.get(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED_BY_ID(id)), + + createGoodsReceivedNote: (payload: CreateGoodsReceivedNotePayload) => + apiService.post(API_ENDPOINTS.INVENTORY.GOODS_RECEIVED, payload), + + kitchenStock: (lowStockOnly = false) => + apiService.get(API_ENDPOINTS.INVENTORY.KITCHEN_STOCK, { lowStockOnly }), + + kitchenMovements: (filters: StockMovementFilters = {}) => + apiService.get(API_ENDPOINTS.INVENTORY.KITCHEN_MOVEMENTS, { + rawMaterialId: filters.rawMaterialId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }), + + releases: () => apiService.get(API_ENDPOINTS.INVENTORY.RELEASES), + + releaseById: (id: string) => apiService.get(API_ENDPOINTS.INVENTORY.RELEASE_BY_ID(id)), + + createRelease: (payload: CreateStockReleasePayload) => + apiService.post(API_ENDPOINTS.INVENTORY.RELEASES, payload), +}; diff --git a/frontend/src/features/inventory/index.ts b/frontend/src/features/inventory/index.ts new file mode 100644 index 0000000..8f01940 --- /dev/null +++ b/frontend/src/features/inventory/index.ts @@ -0,0 +1,14 @@ +export { inventoryApi } from "./api/inventoryApi"; +export { + useMainStoreStock, + useMainStoreMovements, + useKitchenStock, + useKitchenMovements, + useGoodsReceivedNotes, + useStockReleases, + useInventoryMutations, +} from "./model/useInventory"; +export { StockLinesEditor } from "./ui/StockLinesEditor"; +export { GoodsReceivedNoteDialog } from "./ui/GoodsReceivedNoteDialog"; +export { StockReleaseDialog } from "./ui/StockReleaseDialog"; +export { StockAdjustmentDialog } from "./ui/StockAdjustmentDialog"; diff --git a/frontend/src/features/inventory/model/useInventory.ts b/frontend/src/features/inventory/model/useInventory.ts new file mode 100644 index 0000000..61af345 --- /dev/null +++ b/frontend/src/features/inventory/model/useInventory.ts @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreateGoodsReceivedNotePayload, + CreateStockAdjustmentPayload, + CreateStockReleasePayload, + GoodsReceivedNote, + StockMovementFilters, + StockRelease, +} from "@/entities/inventory"; +import { inventoryApi } from "../api/inventoryApi"; + +const MAIN_STORE_STOCK_KEY = "main-store-stock"; +const MAIN_STORE_MOVEMENTS_KEY = "main-store-movements"; +const KITCHEN_STOCK_KEY = "kitchen-stock"; +const KITCHEN_MOVEMENTS_KEY = "kitchen-movements"; +const GRNS_KEY = "goods-received-notes"; +const RELEASES_KEY = "stock-releases"; + +export function useMainStoreStock(lowStockOnly = false) { + return useQuery({ + queryKey: [MAIN_STORE_STOCK_KEY, lowStockOnly], + queryFn: () => inventoryApi.mainStoreStock(lowStockOnly), + }); +} + +export function useMainStoreMovements(filters: StockMovementFilters = {}) { + return useQuery({ + queryKey: [MAIN_STORE_MOVEMENTS_KEY, filters], + queryFn: () => inventoryApi.mainStoreMovements(filters), + }); +} + +export function useKitchenStock(lowStockOnly = false) { + return useQuery({ + queryKey: [KITCHEN_STOCK_KEY, lowStockOnly], + queryFn: () => inventoryApi.kitchenStock(lowStockOnly), + }); +} + +export function useKitchenMovements(filters: StockMovementFilters = {}) { + return useQuery({ + queryKey: [KITCHEN_MOVEMENTS_KEY, filters], + queryFn: () => inventoryApi.kitchenMovements(filters), + }); +} + +export function useGoodsReceivedNotes() { + return useQuery({ queryKey: [GRNS_KEY], queryFn: inventoryApi.goodsReceivedNotes }); +} + +export function useStockReleases() { + return useQuery({ queryKey: [RELEASES_KEY], queryFn: inventoryApi.releases }); +} + +/** Commands that move stock. Each invalidates every view the change could affect. */ +export function useInventoryMutations() { + const queryClient = useQueryClient(); + + const invalidateMainStore = () => { + queryClient.invalidateQueries({ queryKey: [MAIN_STORE_STOCK_KEY] }); + queryClient.invalidateQueries({ queryKey: [MAIN_STORE_MOVEMENTS_KEY] }); + }; + + const invalidateKitchen = () => { + queryClient.invalidateQueries({ queryKey: [KITCHEN_STOCK_KEY] }); + queryClient.invalidateQueries({ queryKey: [KITCHEN_MOVEMENTS_KEY] }); + }; + + const createGoodsReceivedNote = useMutation({ + mutationFn: inventoryApi.createGoodsReceivedNote, + onSuccess: () => { + invalidateMainStore(); + queryClient.invalidateQueries({ queryKey: [GRNS_KEY] }); + }, + }); + + const createRelease = useMutation({ + mutationFn: inventoryApi.createRelease, + onSuccess: () => { + invalidateMainStore(); + invalidateKitchen(); + queryClient.invalidateQueries({ queryKey: [RELEASES_KEY] }); + }, + }); + + const createMainStoreAdjustment = useMutation< + Awaited>, + Error, + CreateStockAdjustmentPayload + >({ + mutationFn: (payload) => inventoryApi.createAdjustment("MainStore", payload), + onSuccess: invalidateMainStore, + }); + + const createKitchenAdjustment = useMutation< + Awaited>, + Error, + CreateStockAdjustmentPayload + >({ + mutationFn: (payload) => inventoryApi.createAdjustment("Kitchen", payload), + onSuccess: invalidateKitchen, + }); + + return { createGoodsReceivedNote, createRelease, createMainStoreAdjustment, createKitchenAdjustment }; +} diff --git a/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx b/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx new file mode 100644 index 0000000..e123572 --- /dev/null +++ b/frontend/src/features/inventory/ui/GoodsReceivedNoteDialog.tsx @@ -0,0 +1,232 @@ +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StockLineInput } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import type { Supplier } from "@/entities/supplier"; +import { usePurchaseOrders } from "@/features/purchase-orders"; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; +import { StockLinesEditor } from "./StockLinesEditor"; + +export interface GoodsReceivedNoteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + suppliers: Supplier[]; + rawMaterials: RawMaterial[]; +} + +const NO_PURCHASE_ORDER = "none"; +const NOT_RATED = "unrated"; +const QUALITY_RATINGS = [1, 2, 3, 4, 5]; + +interface FormValues { + supplierId: string; + purchaseOrderId: string; + qualityRating: string; + hasIssue: boolean; + notes: string; +} + +/** + * Records stock received from a supplier. There is no separate confirmation step — saving this + * increases Main Store stock immediately, since a GRN is only ever entered once the goods have + * actually been counted in. + */ +export function GoodsReceivedNoteDialog({ open, onOpenChange, suppliers, rawMaterials }: GoodsReceivedNoteDialogProps) { + const { createGoodsReceivedNote } = useInventoryMutations(); + const [lines, setLines] = useState([]); + + const defaults: FormValues = { + supplierId: "", + purchaseOrderId: NO_PURCHASE_ORDER, + qualityRating: NOT_RATED, + hasIssue: false, + notes: "", + }; + + const { + control, + register, + handleSubmit, + reset, + watch, + setValue, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const supplierId = watch("supplierId"); + + // Only orders already sent to (or received from) this supplier make sense to link a delivery + // to — a draft has not been placed yet, and a cancelled order cannot be fulfilled. + const { data: supplierOrders } = usePurchaseOrders({ supplierId }, !!supplierId); + const linkableOrders = useMemo( + () => (supplierOrders ?? []).filter((o) => o.status !== "Draft" && o.status !== "Cancelled"), + [supplierOrders], + ); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset(defaults); + setLines([]); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + try { + await createGoodsReceivedNote.mutateAsync({ + supplierId: values.supplierId, + lines, + notes: values.notes.trim() || null, + purchaseOrderId: values.purchaseOrderId === NO_PURCHASE_ORDER ? null : values.purchaseOrderId, + qualityRating: values.qualityRating === NOT_RATED ? null : Number(values.qualityRating), + hasIssue: values.hasIssue, + }); + toast.success("Stock received. Main Store balances have been updated."); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Receive goods + Main Store stock increases as soon as you save this. + + +
+ + ( + + )} + /> + + + {linkableOrders.length > 0 && ( + + ( + + )} + /> + + )} + +
+

Raw materials received

+ +
+ +
+ + ( + + )} + /> + + + + + +
+ + ( + + )} + /> + + + + + + +
+
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx b/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx new file mode 100644 index 0000000..2299ab2 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockAdjustmentDialog.tsx @@ -0,0 +1,157 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StoreType } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; + +export interface StockAdjustmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + store: StoreType; + rawMaterials: RawMaterial[]; +} + +interface FormValues { + rawMaterialId: string; + direction: "increase" | "decrease"; + quantity: string; + reason: string; +} + +/** + * Corrects a raw material's balance to match a physical stock count. The direction and quantity + * are entered separately (rather than one signed number) so it reads naturally: "decrease Rice + * by 2 kg", not "adjust Rice by -2". + */ +export function StockAdjustmentDialog({ open, onOpenChange, store, rawMaterials }: StockAdjustmentDialogProps) { + const { createMainStoreAdjustment, createKitchenAdjustment } = useInventoryMutations(); + const mutation = store === "MainStore" ? createMainStoreAdjustment : createKitchenAdjustment; + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { rawMaterialId: "", direction: "decrease", quantity: "", reason: "" }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset({ rawMaterialId: "", direction: "decrease", quantity: "", reason: "" }); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const magnitude = Number(values.quantity); + + if (!Number.isFinite(magnitude) || magnitude <= 0) { + return; + } + + const quantityDelta = values.direction === "decrease" ? -magnitude : magnitude; + + try { + await mutation.mutateAsync({ rawMaterialId: values.rawMaterialId, store, quantityDelta, reason: values.reason }); + toast.success("Stock adjustment recorded."); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Adjust stock + Correct a raw material's balance to match a physical count. + + +
+ + ( + + )} + /> + + +
+ + + + + + ( + + )} + /> + +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockLinesEditor.tsx b/frontend/src/features/inventory/ui/StockLinesEditor.tsx new file mode 100644 index 0000000..40d22e4 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockLinesEditor.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import type { StockLineInput } from "@/entities/inventory"; +import { + Button, + EmptyState, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +export interface StockLinesEditorProps { + /** Raw materials that may be picked. Callers pass an already-filtered active list. */ + rawMaterials: RawMaterial[]; + lines: StockLineInput[]; + onChange: (lines: StockLineInput[]) => void; + emptyMessage?: string; +} + +/** + * Add/remove/quantity editor for a list of (raw material, quantity) lines — the shared shape + * behind both a Goods Received Note and a stock release. + */ +export function StockLinesEditor({ + rawMaterials, + lines, + onChange, + emptyMessage = "Add a raw material below to get started.", +}: StockLinesEditorProps) { + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + + const byId = (id: string) => rawMaterials.find((r) => r.id === id); + const availableToAdd = rawMaterials.filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + + const addLine = () => { + const quantity = Number(pendingQuantity); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + onChange([...lines, { rawMaterialId: pendingRawMaterialId, quantity }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + }; + + const removeLine = (rawMaterialId: string) => + onChange(lines.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateQuantity = (rawMaterialId: string, quantity: string) => { + const parsed = Number(quantity); + onChange( + lines.map((l) => (l.rawMaterialId === rawMaterialId ? { ...l, quantity: Number.isFinite(parsed) ? parsed : l.quantity } : l)), + ); + }; + + return ( +
+ {lines.length === 0 ? ( + + ) : ( +
+ {lines.map((line) => { + const material = byId(line.rawMaterialId); + + return ( +
+
+

{material?.name ?? "Unknown"}

+
+ updateQuantity(line.rawMaterialId, e.target.value)} + className="w-24" + aria-label={`Quantity of ${material?.name ?? "raw material"}`} + /> + + {material ? UNIT_ABBREVIATIONS[material.unitOfMeasurement] : ""} + + +
+ ); + })} +
+ )} + + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="5" + /> +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/features/inventory/ui/StockReleaseDialog.tsx b/frontend/src/features/inventory/ui/StockReleaseDialog.tsx new file mode 100644 index 0000000..5eecc34 --- /dev/null +++ b/frontend/src/features/inventory/ui/StockReleaseDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { StockLineInput } from "@/entities/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Alert, + AlertDescription, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useInventoryMutations } from "../model/useInventory"; +import { StockLinesEditor } from "./StockLinesEditor"; + +export interface StockReleaseDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rawMaterials: RawMaterial[]; +} + +interface FormValues { + pin: string; + notes: string; +} + +/** + * Releases stock from the Main Store to the Kitchen. An administrator's approval PIN travels in + * the same request — there is no separate pending/approve step, so the release only ever exists + * already authorised. + */ +export function StockReleaseDialog({ open, onOpenChange, rawMaterials }: StockReleaseDialogProps) { + const { createRelease } = useInventoryMutations(); + const [lines, setLines] = useState([]); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: { pin: "", notes: "" } }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset({ pin: "", notes: "" }); + setLines([]); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + try { + const release = await createRelease.mutateAsync({ lines, pin: values.pin, notes: values.notes.trim() || null }); + toast.success(`Release approved by ${release.approvedByName}.`); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Release stock to the kitchen + An administrator's approval PIN is required to authorise this. + + +
+
+

Raw materials to release

+ +
+ + + + + + + An administrator must type their PIN to approve this release. + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/kitchen/api/kitchenApi.ts b/frontend/src/features/kitchen/api/kitchenApi.ts new file mode 100644 index 0000000..ccc3e6b --- /dev/null +++ b/frontend/src/features/kitchen/api/kitchenApi.ts @@ -0,0 +1,13 @@ +import type { KitchenTicket } from "@/entities/kitchen"; +import type { KitchenTicketStatus, KotDocument } from "@/entities/order"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const kitchenApi = { + tickets: (includeServed = false) => + apiService.get(API_ENDPOINTS.KITCHEN.TICKETS, { includeServed }), + + advance: (id: string, status: KitchenTicketStatus) => + apiService.put(API_ENDPOINTS.KITCHEN.TICKET_STATUS(id), { status }), + + reprint: (id: string) => apiService.post(API_ENDPOINTS.KITCHEN.TICKET_REPRINT(id)), +}; diff --git a/frontend/src/features/kitchen/index.ts b/frontend/src/features/kitchen/index.ts new file mode 100644 index 0000000..a8feca0 --- /dev/null +++ b/frontend/src/features/kitchen/index.ts @@ -0,0 +1,2 @@ +export { kitchenApi } from "./api/kitchenApi"; +export { useKitchenTickets, useKitchenMutations } from "./model/useKitchen"; diff --git a/frontend/src/features/kitchen/model/useKitchen.ts b/frontend/src/features/kitchen/model/useKitchen.ts new file mode 100644 index 0000000..c49a24c --- /dev/null +++ b/frontend/src/features/kitchen/model/useKitchen.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { KitchenTicket } from "@/entities/kitchen"; +import type { KitchenTicketStatus, KotDocument } from "@/entities/order"; +import { printKot } from "@/features/printing"; +import { kitchenApi } from "../api/kitchenApi"; + +const KITCHEN_TICKETS_KEY = "kitchen-tickets"; + +/** + * The kitchen queue. + * + * Polled hard — every five seconds — because this screen hangs on a wall in the kitchen with + * nobody touching it. A new order has to appear on its own, and a few seconds of staleness is a + * few seconds a dish is not being cooked. + */ +export function useKitchenTickets(includeServed = false, pollMs = 5_000) { + return useQuery({ + queryKey: [KITCHEN_TICKETS_KEY, includeServed], + queryFn: () => kitchenApi.tickets(includeServed), + refetchInterval: pollMs, + placeholderData: (previous) => previous, + }); +} + +export function useKitchenMutations() { + const queryClient = useQueryClient(); + + const advance = useMutation({ + mutationFn: ({ id, status }) => kitchenApi.advance(id, status), + onSuccess: () => queryClient.invalidateQueries({ queryKey: [KITCHEN_TICKETS_KEY] }), + }); + + const reprint = useMutation({ + mutationFn: kitchenApi.reprint, + onSuccess: async (kot) => { + queryClient.invalidateQueries({ queryKey: [KITCHEN_TICKETS_KEY] }); + const outcome = await printKot(kot); + + if (!outcome.success) { + toast.error(`The slip did not print: ${outcome.message}`); + } + }, + }); + + return { advance, reprint }; +} diff --git a/frontend/src/features/menu-items/api/menuItemsApi.ts b/frontend/src/features/menu-items/api/menuItemsApi.ts new file mode 100644 index 0000000..8697983 --- /dev/null +++ b/frontend/src/features/menu-items/api/menuItemsApi.ts @@ -0,0 +1,22 @@ +import type { CreateMenuItemPayload, MenuItem, MenuItemFilters, UpdateMenuItemPayload } from "@/entities/menu-item"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const menuItemsApi = { + list: (filters: MenuItemFilters = {}) => + apiService.get(API_ENDPOINTS.MENU_ITEMS.BASE, { + search: filters.search || undefined, + category: filters.category || undefined, + isActive: filters.isActive, + }), + + byId: (id: string) => apiService.get(API_ENDPOINTS.MENU_ITEMS.BY_ID(id)), + + create: (payload: CreateMenuItemPayload) => + apiService.post(API_ENDPOINTS.MENU_ITEMS.BASE, payload), + + update: (id: string, payload: UpdateMenuItemPayload) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.STATUS(id), { isActive }), +}; diff --git a/frontend/src/features/menu-items/index.ts b/frontend/src/features/menu-items/index.ts new file mode 100644 index 0000000..df740d7 --- /dev/null +++ b/frontend/src/features/menu-items/index.ts @@ -0,0 +1,3 @@ +export { menuItemsApi } from "./api/menuItemsApi"; +export { useMenuItems, useMenuItemMutations } from "./model/useMenuItems"; +export { MenuItemFormDialog } from "./ui/MenuItemFormDialog"; diff --git a/frontend/src/features/menu-items/model/menuItemSchema.ts b/frontend/src/features/menu-items/model/menuItemSchema.ts new file mode 100644 index 0000000..1545da4 --- /dev/null +++ b/frontend/src/features/menu-items/model/menuItemSchema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const menuItemSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(150), + category: z.string().trim().min(1, "Category is required.").max(80), + price: z + .string() + .trim() + .refine((v) => v !== "" && !Number.isNaN(Number(v)) && Number(v) >= 0, { + message: "Enter a valid, non-negative price.", + }), +}); + +export type MenuItemForm = z.infer; + +/** Converts the form's string price field to the number the API expects. */ +export const toPriceNumber = (value: string): number => Number(value); diff --git a/frontend/src/features/menu-items/model/useMenuItems.ts b/frontend/src/features/menu-items/model/useMenuItems.ts new file mode 100644 index 0000000..7111055 --- /dev/null +++ b/frontend/src/features/menu-items/model/useMenuItems.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateMenuItemPayload, MenuItem, MenuItemFilters, UpdateMenuItemPayload } from "@/entities/menu-item"; +import { menuItemsApi } from "../api/menuItemsApi"; + +const MENU_ITEMS_KEY = "menu-items"; + +export function useMenuItems(filters: MenuItemFilters = {}) { + return useQuery({ + queryKey: [MENU_ITEMS_KEY, filters], + queryFn: () => menuItemsApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useMenuItemMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [MENU_ITEMS_KEY] }); + + const create = useMutation({ + mutationFn: menuItemsApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => menuItemsApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => menuItemsApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} diff --git a/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx b/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx new file mode 100644 index 0000000..ba1fdbd --- /dev/null +++ b/frontend/src/features/menu-items/ui/MenuItemFormDialog.tsx @@ -0,0 +1,111 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { MenuItem } from "@/entities/menu-item"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useMenuItemMutations } from "../model/useMenuItems"; +import { MenuItemForm, menuItemSchema, toPriceNumber } from "../model/menuItemSchema"; + +export interface MenuItemFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The item being edited, or omitted to create a new one. */ + item?: MenuItem; +} + +/** Creates or edits a menu item's name, category and price. */ +export function MenuItemFormDialog({ open, onOpenChange, item }: MenuItemFormDialogProps) { + const isEditing = !!item; + const { create, update } = useMenuItemMutations(); + const pending = create.isPending || update.isPending; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(menuItemSchema), + defaultValues: { + name: item?.name ?? "", + category: item?.category ?? "", + price: item ? String(item.price) : "", + }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset({ + name: item?.name ?? "", + category: item?.category ?? "", + price: item ? String(item.price) : "", + }); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { name: values.name, category: values.category, price: toPriceNumber(values.price) }; + + try { + if (isEditing) { + await update.mutateAsync({ id: item.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added to the menu.`); + } + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + {isEditing ? "Edit menu item" : "Add menu item"} + + {isEditing + ? "Changing the price only affects new orders." + : "You can attach a recipe once it's been added to the menu."} + + + +
+ + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/orders/api/ordersApi.ts b/frontend/src/features/orders/api/ordersApi.ts new file mode 100644 index 0000000..0ca5ea1 --- /dev/null +++ b/frontend/src/features/orders/api/ordersApi.ts @@ -0,0 +1,57 @@ +import type { + AddOrderItemInput, + DiscountType, + Order, + OrderMutationResult, + OrderPaymentInput, + OrderStatus, + OrderSummary, + ReceiptDocument, +} from "@/entities/order"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export interface OrderFilters { + openOnly?: boolean; + status?: OrderStatus; + search?: string; +} + +export const ordersApi = { + list: (filters: OrderFilters = {}) => + apiService.get(API_ENDPOINTS.ORDERS.BASE, { + openOnly: filters.openOnly, + status: filters.status, + search: filters.search || undefined, + }), + + getById: (id: string) => apiService.get(API_ENDPOINTS.ORDERS.BY_ID(id)), + + create: (tableId: string) => apiService.post(API_ENDPOINTS.ORDERS.BASE, { tableId }), + + addItems: (id: string, items: AddOrderItemInput[]) => + apiService.post(API_ENDPOINTS.ORDERS.ITEMS(id), { items }), + + confirm: (id: string) => apiService.post(API_ENDPOINTS.ORDERS.CONFIRM(id)), + + changeItemQuantity: (id: string, itemId: string, quantity: number, pin: string | null) => + apiService.put(API_ENDPOINTS.ORDERS.ITEM_QUANTITY(id, itemId), { quantity, pin }), + + voidItem: (id: string, itemId: string, pin: string | null) => + apiService.post(API_ENDPOINTS.ORDERS.VOID_ITEM(id, itemId), { pin }), + + cancel: (id: string, pin: string | null, reason: string | null) => + apiService.post(API_ENDPOINTS.ORDERS.CANCEL(id), { pin, reason }), + + setDiscount: (id: string, type: DiscountType, value: number) => + apiService.put(API_ENDPOINTS.ORDERS.DISCOUNT(id), { type, value }), + + startCheckout: (id: string) => apiService.post(API_ENDPOINTS.ORDERS.CHECKOUT(id)), + + reopen: (id: string) => apiService.post(API_ENDPOINTS.ORDERS.REOPEN(id)), + + pay: (id: string, payments: OrderPaymentInput[]) => + apiService.post(API_ENDPOINTS.ORDERS.PAYMENTS(id), { payments }), + + reprintReceipt: (id: string) => + apiService.post(API_ENDPOINTS.ORDERS.REPRINT_RECEIPT(id)), +}; diff --git a/frontend/src/features/orders/index.ts b/frontend/src/features/orders/index.ts new file mode 100644 index 0000000..5d39dbb --- /dev/null +++ b/frontend/src/features/orders/index.ts @@ -0,0 +1,3 @@ +export { ordersApi, type OrderFilters } from "./api/ordersApi"; +export { useOrders, useOrder, useOrderMutations, ORDERS_KEY, ORDER_KEY } from "./model/useOrders"; +export { ManagerPinDialog } from "./ui/ManagerPinDialog"; diff --git a/frontend/src/features/orders/model/useOrders.ts b/frontend/src/features/orders/model/useOrders.ts new file mode 100644 index 0000000..6cf0c79 --- /dev/null +++ b/frontend/src/features/orders/model/useOrders.ts @@ -0,0 +1,165 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { + AddOrderItemInput, + DiscountType, + Order, + OrderMutationResult, + OrderPaymentInput, + ReceiptDocument, +} from "@/entities/order"; +import { printKot, printReceipt } from "@/features/printing"; +import { TABLES_KEY } from "@/features/tables"; +import { ordersApi, type OrderFilters } from "../api/ordersApi"; + +export const ORDERS_KEY = "orders"; +export const ORDER_KEY = "order"; + +export function useOrders(filters: OrderFilters = {}, pollMs = 10_000) { + return useQuery({ + queryKey: [ORDERS_KEY, filters], + queryFn: () => ordersApi.list(filters), + refetchInterval: pollMs, + placeholderData: (previous) => previous, + }); +} + +export function useOrder(id: string | undefined) { + return useQuery({ + queryKey: [ORDER_KEY, id], + queryFn: () => ordersApi.getById(id!), + enabled: !!id, + }); +} + +/** + * Every command the till can issue against a bill. + * + * Any response carrying a kitchen slip is printed here rather than at the call site. The rule + * that the kitchen is told whenever a confirmed order changes (BR-POS-004, BR-POS-006, POS-020) + * is not something a cashier should be able to forget, and it would be forgotten eventually if + * each of the six screens that can change an order had to remember it separately. + */ +export function useOrderMutations() { + const queryClient = useQueryClient(); + + const refresh = (orderId?: string) => { + queryClient.invalidateQueries({ queryKey: [ORDERS_KEY] }); + queryClient.invalidateQueries({ queryKey: [TABLES_KEY] }); + if (orderId) queryClient.invalidateQueries({ queryKey: [ORDER_KEY, orderId] }); + }; + + const handleMutation = async (result: OrderMutationResult) => { + refresh(result.order.id); + + if (!result.kot) { + return; + } + + const outcome = await printKot(result.kot); + + if (!outcome.success) { + // The order itself is already saved, so this is a printing problem, not an ordering one — + // said plainly so the cashier walks the slip to the kitchen rather than re-keying anything. + toast.warning(`Order saved, but the kitchen slip did not print: ${outcome.message}`); + } + }; + + const create = useMutation({ + mutationFn: ordersApi.create, + onSuccess: (order) => refresh(order.id), + }); + + const addItems = useMutation({ + mutationFn: ({ id, items }) => ordersApi.addItems(id, items), + onSuccess: handleMutation, + }); + + const confirm = useMutation({ + mutationFn: ordersApi.confirm, + onSuccess: handleMutation, + }); + + const changeQuantity = useMutation< + OrderMutationResult, + Error, + { id: string; itemId: string; quantity: number; pin: string | null } + >({ + mutationFn: ({ id, itemId, quantity, pin }) => ordersApi.changeItemQuantity(id, itemId, quantity, pin), + onSuccess: handleMutation, + }); + + const voidItem = useMutation< + OrderMutationResult, + Error, + { id: string; itemId: string; pin: string | null } + >({ + mutationFn: ({ id, itemId, pin }) => ordersApi.voidItem(id, itemId, pin), + onSuccess: handleMutation, + }); + + const cancel = useMutation< + OrderMutationResult, + Error, + { id: string; pin: string | null; reason: string | null } + >({ + mutationFn: ({ id, pin, reason }) => ordersApi.cancel(id, pin, reason), + onSuccess: handleMutation, + }); + + const setDiscount = useMutation< + OrderMutationResult, + Error, + { id: string; type: DiscountType; value: number } + >({ + mutationFn: ({ id, type, value }) => ordersApi.setDiscount(id, type, value), + onSuccess: handleMutation, + }); + + const startCheckout = useMutation({ + mutationFn: ordersApi.startCheckout, + onSuccess: handleMutation, + }); + + const reopen = useMutation({ + mutationFn: ordersApi.reopen, + onSuccess: handleMutation, + }); + + const pay = useMutation({ + mutationFn: ({ id, payments }) => ordersApi.pay(id, payments), + onSuccess: async (receipt, { id }) => { + refresh(id); + const outcome = await printReceipt(receipt); + + if (!outcome.success) { + toast.warning(`Payment recorded, but the receipt did not print: ${outcome.message}`); + } + }, + }); + + const reprintReceipt = useMutation({ + mutationFn: ordersApi.reprintReceipt, + onSuccess: async (receipt) => { + const outcome = await printReceipt(receipt); + + if (!outcome.success) { + toast.error(`The receipt did not print: ${outcome.message}`); + } + }, + }); + + return { + create, + addItems, + confirm, + changeQuantity, + voidItem, + cancel, + setDiscount, + startCheckout, + reopen, + pay, + reprintReceipt, + }; +} diff --git a/frontend/src/features/orders/ui/ManagerPinDialog.tsx b/frontend/src/features/orders/ui/ManagerPinDialog.tsx new file mode 100644 index 0000000..abde424 --- /dev/null +++ b/frontend/src/features/orders/ui/ManagerPinDialog.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from "react"; +import { ShieldAlert } from "lucide-react"; +import { + Alert, + AlertDescription, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@/shared/ui"; + +export interface ManagerPinDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** What the manager is being asked to approve, shown so they know what they are authorising. */ + action: string; + /** Runs the guarded command. Reject with a message to keep the dialog open and show it. */ + onConfirm: (pin: string) => Promise; + pending?: boolean; +} + +const PIN_LENGTH = 4; + +/** + * Collects a manager's 4-digit approval PIN for a guarded action (POS-016, POS-017). + * + * The PIN is only ever passed up to the caller, which sends it with the command for the server to + * check. Nothing here decides whether it was right — a client-side check would be theatre, since + * anything that can reach the API could simply skip this dialog. + */ +export function ManagerPinDialog({ + open, + onOpenChange, + action, + onConfirm, + pending = false, +}: ManagerPinDialogProps) { + const [pin, setPin] = useState(""); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setPin(""); + setError(null); + // Focus after the dialog's own opening animation, or the caret lands nowhere. + const timer = window.setTimeout(() => inputRef.current?.focus(), 60); + + return () => window.clearTimeout(timer); + } + + return undefined; + }, [open]); + + const submit = async () => { + if (pin.length !== PIN_LENGTH) { + setError(`The PIN is ${PIN_LENGTH} digits.`); + return; + } + + try { + setError(null); + await onConfirm(pin); + onOpenChange(false); + } catch (failure) { + // The server counts the attempts and says how many are left, so its message is shown as-is + // rather than replaced with a generic one. + setError(failure instanceof Error ? failure.message : "That PIN was not accepted."); + setPin(""); + inputRef.current?.focus(); + } + }; + + return ( + + + + Manager approval + {action} + + +
+
+ + { + setPin(event.target.value.replace(/\D/g, "").slice(0, PIN_LENGTH)); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submit(); + } + }} + className="text-center text-2xl tracking-[0.6em]" + placeholder="••••" + /> +
+ + {error && ( + + + {error} + + )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/features/printing/index.ts b/frontend/src/features/printing/index.ts new file mode 100644 index 0000000..1a179fe --- /dev/null +++ b/frontend/src/features/printing/index.ts @@ -0,0 +1,2 @@ +export { printKot, printReceipt, canPrintSilently, type PrintOutcome } from "./model/printService"; +export { renderKotHtml, renderReceiptHtml } from "./lib/thermalTemplates"; diff --git a/frontend/src/features/printing/lib/thermalTemplates.ts b/frontend/src/features/printing/lib/thermalTemplates.ts new file mode 100644 index 0000000..689be93 --- /dev/null +++ b/frontend/src/features/printing/lib/thermalTemplates.ts @@ -0,0 +1,171 @@ +import type { KotDocument, ReceiptDocument } from "@/entities/order"; + +/** + * Renders print documents as standalone 80mm HTML pages. + * + * Thermal roll paper is 80mm wide with no fixed height, so the page is laid out in millimetres + * against a monospace face and left to run as long as it needs. Everything is inlined into one + * document because it is handed straight to a printer window that loads nothing else. + */ + +const KOT_KIND_HEADINGS: Record = { + New: "NEW ORDER", + Addition: "ADDED ITEMS", + Modification: "QUANTITY CHANGED", + Cancellation: "** CANCELLED **", +}; + +/** Escapes text bound for the print document. Order data is user-entered and untrusted. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const money = (value: number) => value.toFixed(2); + +function formatDateTime(iso: string): string { + const date = new Date(iso); + + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; +} + +const PAGE_STYLES = ` + @page { size: 80mm auto; margin: 0; } + * { box-sizing: border-box; } + body { + width: 80mm; + margin: 0; + padding: 4mm 3mm; + font-family: "Consolas", "Courier New", monospace; + font-size: 11px; + line-height: 1.45; + color: #000; + background: #fff; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + .center { text-align: center; } + .right { text-align: right; } + .bold { font-weight: 700; } + .lg { font-size: 15px; } + .xl { font-size: 19px; } + .rule { border-top: 1px dashed #000; margin: 2mm 0; } + .solid { border-top: 1px solid #000; margin: 2mm 0; } + table { width: 100%; border-collapse: collapse; } + td { vertical-align: top; padding: 0.4mm 0; } + .muted { font-size: 10px; } + .note { padding-left: 4mm; font-style: italic; } + .row { display: flex; justify-content: space-between; gap: 2mm; } +`; + +function page(title: string, body: string): string { + return ` + +${escapeHtml(title)} +${body} +`; +} + +/** + * A kitchen slip. Deliberately sparse and large: it is read at arm's length across a hot pass, + * so the table number and quantities carry the layout and prices appear nowhere at all — the + * kitchen has no use for them. + */ +export function renderKotHtml(kot: KotDocument): string { + const lines = kot.lines + .map( + (line) => ` + + ${line.quantity}× + ${escapeHtml(line.menuItemName)} + + ${ + line.specialInstructions + ? `${escapeHtml(line.specialInstructions)}` + : "" + } + ${line.note ? `** ${escapeHtml(line.note)} **` : ""}`, + ) + .join(""); + + const body = ` +
${escapeHtml(KOT_KIND_HEADINGS[kot.kind])}
+
+
TABLE ${escapeHtml(kot.tableNumber)}
+
Order #${String(kot.orderNumber ?? 0).padStart(3, "0")} · KOT-${kot.ticketNumber}
+
+ ${lines}
+
+
${escapeHtml(formatDateTime(kot.printedAtUtc))}
+
Cashier: ${escapeHtml(kot.cashierName)}
+ ${kot.printCount > 1 ? `
-- REPRINT #${kot.printCount} --
` : ""} + `; + + return page(`KOT ${kot.tableNumber}-${kot.ticketNumber}`, body); +} + +/** A customer receipt, carrying the restaurant's details, the itemised bill and the tenders. */ +export function renderReceiptHtml(receipt: ReceiptDocument, qrDataUri: string | null): string { + const lines = receipt.lines + .map( + (line) => ` + + ${escapeHtml(line.menuItemName)} + ${line.quantity} + ${money(line.unitPrice)} + ${money(line.lineTotal)} + `, + ) + .join(""); + + const payments = receipt.payments + .map( + (payment) => ` +
${escapeHtml(payment.method)}${money(payment.amount)}
`, + ) + .join(""); + + const address = [receipt.addressLine1, receipt.addressLine2, receipt.city] + .filter(Boolean) + .map((part) => `
${escapeHtml(part as string)}
`) + .join(""); + + const body = ` +
${escapeHtml(receipt.restaurantName)}
+
${address}
+ ${receipt.phone ? `
${escapeHtml(receipt.phone)}
` : ""} +
+
Receipt #${escapeHtml(receipt.receiptNumber)}
+
Date${escapeHtml(formatDateTime(receipt.issuedAtUtc))}
+
Order #${String(receipt.orderNumber ?? 0).padStart(3, "0")}
+
Table${escapeHtml(receipt.tableNumber)}
+
Cashier${escapeHtml(receipt.cashierName)}
+
+ + + + + ${lines} +
ItemQtyPriceTotal
+
+
Subtotal${money(receipt.subtotal)}
+
Discount${money(receipt.discountAmount)}
+
Tax / GST${money(receipt.taxAmount)}
+
+
TOTAL${money(receipt.total)}
+
+
PAYMENT
+ ${payments} + ${receipt.changeGiven > 0 ? `
Change${money(receipt.changeGiven)}
` : ""} +
+ ${qrDataUri ? `
` : ""} +
Thank You! Come Again!
+ ${receipt.printCount > 1 ? `
-- REPRINT #${receipt.printCount} --
` : ""} + `; + + return page(`Receipt ${receipt.receiptNumber}`, body); +} diff --git a/frontend/src/features/printing/model/printService.ts b/frontend/src/features/printing/model/printService.ts new file mode 100644 index 0000000..8cf5f68 --- /dev/null +++ b/frontend/src/features/printing/model/printService.ts @@ -0,0 +1,92 @@ +import QRCode from "qrcode"; +import type { KotDocument, ReceiptDocument } from "@/entities/order"; +import { renderKotHtml, renderReceiptHtml } from "../lib/thermalTemplates"; + +export interface PrintOutcome { + success: boolean; + message: string; + /** True when the document went to a preview instead of straight to a printer. */ + previewed?: boolean; +} + +/** True when running inside the desktop shell, where silent printing is available. */ +export const canPrintSilently = (): boolean => typeof window !== "undefined" && !!window.electronAPI?.printHtml; + +async function toQrDataUri(payload: string): Promise { + try { + return await QRCode.toDataURL(payload, { margin: 0, width: 256, errorCorrectionLevel: "M" }); + } catch { + // A receipt without its QR code is still a valid receipt, so a failure here must not stop + // the customer being handed one. + return null; + } +} + +/** + * Prints a finished HTML document. + * + * In the desktop shell this goes straight to the printer with no dialog, because a cashier + * confirming an order should not have to dismiss a print prompt every time. In a plain browser + * — development, or someone opening the till in Chrome — it falls back to an offscreen iframe + * and the browser's own print dialog. An iframe rather than a popup window, since popups are + * blocked by default and would silently swallow the receipt. + */ +async function printHtml(html: string, title: string): Promise { + if (canPrintSilently()) { + const result = await window.electronAPI!.printHtml(html, { silent: true, widthMm: 80 }); + + return { success: result.success, message: result.message }; + } + + return printViaIframe(html, title); +} + +function printViaIframe(html: string, title: string): Promise { + return new Promise((resolve) => { + const frame = document.createElement("iframe"); + frame.setAttribute("aria-hidden", "true"); + frame.style.position = "fixed"; + frame.style.right = "0"; + frame.style.bottom = "0"; + frame.style.width = "0"; + frame.style.height = "0"; + frame.style.border = "0"; + frame.title = title; + + const cleanUp = () => { + // Deferred: removing the frame while the print dialog is still reading it cancels the job + // in some browsers. + window.setTimeout(() => frame.remove(), 1000); + }; + + frame.onload = () => { + try { + frame.contentWindow?.focus(); + frame.contentWindow?.print(); + resolve({ success: true, message: "Sent to the print dialog", previewed: true }); + } catch (error) { + resolve({ + success: false, + message: error instanceof Error ? error.message : "The browser refused to print", + }); + } finally { + cleanUp(); + } + }; + + document.body.appendChild(frame); + frame.srcdoc = html; + }); +} + +/** Prints a kitchen slip (POS-010, POS-014, POS-020). */ +export async function printKot(kot: KotDocument): Promise { + return printHtml(renderKotHtml(kot), `KOT ${kot.tableNumber}-${kot.ticketNumber}`); +} + +/** Prints a customer receipt (POS-026). */ +export async function printReceipt(receipt: ReceiptDocument): Promise { + const qr = await toQrDataUri(receipt.qrPayload); + + return printHtml(renderReceiptHtml(receipt, qr), `Receipt ${receipt.receiptNumber}`); +} diff --git a/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts b/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts new file mode 100644 index 0000000..12ef6ac --- /dev/null +++ b/frontend/src/features/purchase-orders/api/purchaseOrdersApi.ts @@ -0,0 +1,37 @@ +import type { + CreatePurchaseOrderPayload, + PurchaseOrder, + PurchaseOrderFilters, + PurchaseOrderSummary, + RecordSupplierPaymentPayload, + SupplierPayment, + UpdatePurchaseOrderPayload, +} from "@/entities/supplier"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const purchaseOrdersApi = { + list: (filters: PurchaseOrderFilters = {}) => + apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.BASE, { + supplierId: filters.supplierId, + status: filters.status, + }), + + getById: (id: string) => apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.BY_ID(id)), + + create: (payload: CreatePurchaseOrderPayload) => + apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.BASE, payload), + + update: (id: string, payload: UpdatePurchaseOrderPayload) => + apiService.put(API_ENDPOINTS.PURCHASE_ORDERS.BY_ID(id), payload), + + submit: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.SUBMIT(id)), + + confirm: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.CONFIRM(id)), + + cancel: (id: string) => apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.CANCEL(id)), + + payments: (id: string) => apiService.get(API_ENDPOINTS.PURCHASE_ORDERS.PAYMENTS(id)), + + recordPayment: (id: string, payload: RecordSupplierPaymentPayload) => + apiService.post(API_ENDPOINTS.PURCHASE_ORDERS.PAYMENTS(id), payload), +}; diff --git a/frontend/src/features/purchase-orders/index.ts b/frontend/src/features/purchase-orders/index.ts new file mode 100644 index 0000000..008a4d0 --- /dev/null +++ b/frontend/src/features/purchase-orders/index.ts @@ -0,0 +1,12 @@ +export { purchaseOrdersApi } from "./api/purchaseOrdersApi"; +export { + usePurchaseOrders, + usePurchaseOrder, + usePurchaseOrderPayments, + usePurchaseOrderMutations, +} from "./model/usePurchaseOrders"; +export { PURCHASE_ORDER_STATUS_BADGE } from "./lib/status"; +export { PurchaseOrderLinesEditor } from "./ui/PurchaseOrderLinesEditor"; +export { PurchaseOrderFormDialog } from "./ui/PurchaseOrderFormDialog"; +export { PurchaseOrderDetailDialog } from "./ui/PurchaseOrderDetailDialog"; +export { RecordPaymentDialog } from "./ui/RecordPaymentDialog"; diff --git a/frontend/src/features/purchase-orders/lib/status.ts b/frontend/src/features/purchase-orders/lib/status.ts new file mode 100644 index 0000000..b0b5c6d --- /dev/null +++ b/frontend/src/features/purchase-orders/lib/status.ts @@ -0,0 +1,10 @@ +import type { PurchaseOrderStatus } from "@/entities/supplier"; +import type { BadgeProps } from "@/shared/ui"; + +export const PURCHASE_ORDER_STATUS_BADGE: Record> = { + Draft: "secondary", + Submitted: "default", + Confirmed: "warning", + Delivered: "success", + Cancelled: "destructive", +}; diff --git a/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts b/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts new file mode 100644 index 0000000..cfda7e6 --- /dev/null +++ b/frontend/src/features/purchase-orders/model/usePurchaseOrders.ts @@ -0,0 +1,90 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreatePurchaseOrderPayload, + PurchaseOrder, + PurchaseOrderFilters, + RecordSupplierPaymentPayload, + SupplierPayment, + UpdatePurchaseOrderPayload, +} from "@/entities/supplier"; +import { purchaseOrdersApi } from "../api/purchaseOrdersApi"; + +const PURCHASE_ORDERS_KEY = "purchase-orders"; +const PURCHASE_ORDER_KEY = "purchase-order"; +const PURCHASE_ORDER_PAYMENTS_KEY = "purchase-order-payments"; +const SUPPLIER_PERFORMANCE_KEY = "supplier-performance"; + +export function usePurchaseOrders(filters: PurchaseOrderFilters = {}, enabled = true) { + return useQuery({ + queryKey: [PURCHASE_ORDERS_KEY, filters], + queryFn: () => purchaseOrdersApi.list(filters), + placeholderData: (previous) => previous, + enabled, + }); +} + +export function usePurchaseOrder(id: string | undefined) { + return useQuery({ + queryKey: [PURCHASE_ORDER_KEY, id], + queryFn: () => purchaseOrdersApi.getById(id!), + enabled: !!id, + }); +} + +export function usePurchaseOrderPayments(id: string | undefined) { + return useQuery({ + queryKey: [PURCHASE_ORDER_PAYMENTS_KEY, id], + queryFn: () => purchaseOrdersApi.payments(id!), + enabled: !!id, + }); +} + +/** Commands across a purchase order's lifecycle: draft edits, status transitions, and payments. */ +export function usePurchaseOrderMutations() { + const queryClient = useQueryClient(); + + const invalidate = (id?: string) => { + queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDERS_KEY] }); + if (id) queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDER_KEY, id] }); + }; + + const create = useMutation({ + mutationFn: purchaseOrdersApi.create, + onSuccess: () => invalidate(), + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => purchaseOrdersApi.update(id, payload), + onSuccess: (_, { id }) => invalidate(id), + }); + + const submit = useMutation({ + mutationFn: purchaseOrdersApi.submit, + onSuccess: (_, id) => invalidate(id), + }); + + const confirm = useMutation({ + mutationFn: purchaseOrdersApi.confirm, + onSuccess: (_, id) => invalidate(id), + }); + + const cancel = useMutation({ + mutationFn: purchaseOrdersApi.cancel, + onSuccess: (_, id) => invalidate(id), + }); + + const recordPayment = useMutation< + SupplierPayment, + Error, + { id: string; supplierId: string; payload: RecordSupplierPaymentPayload } + >({ + mutationFn: ({ id, payload }) => purchaseOrdersApi.recordPayment(id, payload), + onSuccess: (_, { id, supplierId }) => { + invalidate(id); + queryClient.invalidateQueries({ queryKey: [PURCHASE_ORDER_PAYMENTS_KEY, id] }); + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PERFORMANCE_KEY, supplierId] }); + }, + }); + + return { create, update, submit, confirm, cancel, recordPayment }; +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx new file mode 100644 index 0000000..1d66b8a --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderDetailDialog.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrder, usePurchaseOrderMutations, usePurchaseOrderPayments } from "../model/usePurchaseOrders"; +import { PURCHASE_ORDER_STATUS_BADGE } from "../lib/status"; +import { RecordPaymentDialog } from "./RecordPaymentDialog"; + +export interface PurchaseOrderDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + purchaseOrderId: string; +} + +/** A purchase order's full lines, totals, status actions and payment history. */ +export function PurchaseOrderDetailDialog({ open, onOpenChange, purchaseOrderId }: PurchaseOrderDetailDialogProps) { + const { data: order, isLoading } = usePurchaseOrder(open ? purchaseOrderId : undefined); + const { data: payments } = usePurchaseOrderPayments(open ? purchaseOrderId : undefined); + const { submit, confirm, cancel } = usePurchaseOrderMutations(); + const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); + + const runAction = async (action: "submit" | "confirm" | "cancel") => { + try { + if (action === "submit") { + await submit.mutateAsync(purchaseOrderId); + toast.success("Purchase order submitted to the supplier."); + } else if (action === "confirm") { + await confirm.mutateAsync(purchaseOrderId); + toast.success("Purchase order confirmed."); + } else { + await cancel.mutateAsync(purchaseOrderId); + toast.success("Purchase order cancelled."); + } + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( + <> + + + + Purchase order + + + {isLoading || !order ? ( + + ) : ( +
+
+
+

{order.supplierName}

+

+ Created by {order.createdByName} on {new Date(order.createdAtUtc).toLocaleDateString()} +

+ {order.expectedDeliveryDate && ( +

+ Expected delivery: {new Date(order.expectedDeliveryDate).toLocaleDateString()} +

+ )} +
+ {order.status} +
+ + + + + Raw material + Qty + Unit price + Line total + + + + {order.lines.map((line) => ( + + {line.rawMaterialName} + {line.quantity} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} + + ))} + +
+ +
+ + Total: {order.totalAmount.toFixed(2)} + + + Paid: {order.amountPaid.toFixed(2)} + + + Balance: {order.balance.toFixed(2)} + +
+ + {order.notes &&

Notes: {order.notes}

} + + {payments && payments.length > 0 && ( +
+

Payments

+ + + + Date + Method + Amount + Recorded by + + + + {payments.map((payment) => ( + + + {new Date(payment.paymentDateUtc).toLocaleDateString()} + + {payment.method} + {payment.amount.toFixed(2)} + {payment.recordedByName} + + ))} + +
+
+ )} +
+ )} + + +
+ {order?.status === "Draft" && ( + + )} + {order?.status === "Submitted" && ( + + )} + {order && order.status !== "Delivered" && order.status !== "Cancelled" && ( + + )} + {order && order.status !== "Draft" && order.status !== "Cancelled" && order.balance > 0 && ( + + )} +
+ +
+
+
+ + {order && ( + + )} + + ); +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx new file mode 100644 index 0000000..031d26a --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderFormDialog.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import type { PurchaseOrder, PurchaseOrderLineInput, Supplier } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrderMutations } from "../model/usePurchaseOrders"; +import { PurchaseOrderLinesEditor } from "./PurchaseOrderLinesEditor"; + +export interface PurchaseOrderFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + suppliers: Supplier[]; + rawMaterials: RawMaterial[]; + /** The draft order being edited, or omitted to create a new one. */ + order?: PurchaseOrder; +} + +interface FormValues { + supplierId: string; + expectedDeliveryDate: string; + notes: string; +} + +/** + * Creates a new draft purchase order, or edits an existing one that has not yet been submitted — + * the supplier is fixed once a draft exists, since submitting sends it to that specific supplier. + */ +export function PurchaseOrderFormDialog({ + open, + onOpenChange, + suppliers, + rawMaterials, + order, +}: PurchaseOrderFormDialogProps) { + const isEditing = !!order; + const { create, update } = usePurchaseOrderMutations(); + const pending = create.isPending || update.isPending; + + const [lines, setLines] = useState( + order?.lines.map((l) => ({ rawMaterialId: l.rawMaterialId, quantity: l.quantity, unitPrice: l.unitPrice })) ?? [], + ); + + const defaults: FormValues = { + supplierId: order?.supplierId ?? "", + expectedDeliveryDate: order?.expectedDeliveryDate?.slice(0, 10) ?? "", + notes: order?.notes ?? "", + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset(defaults); + setLines(order?.lines.map((l) => ({ rawMaterialId: l.rawMaterialId, quantity: l.quantity, unitPrice: l.unitPrice })) ?? []); + } + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + if (lines.length === 0) { + toast.error("Add at least one raw material."); + return; + } + + const expectedDeliveryDate = values.expectedDeliveryDate || null; + const notes = values.notes.trim() || null; + + try { + if (isEditing) { + await update.mutateAsync({ id: order.id, payload: { lines, expectedDeliveryDate, notes } }); + toast.success("Purchase order updated."); + } else { + await create.mutateAsync({ supplierId: values.supplierId, lines, expectedDeliveryDate, notes }); + toast.success("Purchase order created as a draft."); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit draft purchase order" : "New purchase order"} + + {isEditing ? "Only draft orders can be edited." : "Created as a draft — submit it to send it to the supplier."} + + + +
+ + ( + + )} + /> + + +
+

Raw materials ordered

+ +
+ +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx b/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx new file mode 100644 index 0000000..6a3d4f7 --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/PurchaseOrderLinesEditor.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import type { PurchaseOrderLineInput } from "@/entities/supplier"; +import { + Button, + EmptyState, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +export interface PurchaseOrderLinesEditorProps { + /** Raw materials that may be picked. Callers pass an already-filtered active list. */ + rawMaterials: RawMaterial[]; + lines: PurchaseOrderLineInput[]; + onChange: (lines: PurchaseOrderLineInput[]) => void; +} + +/** Add/remove/quantity/price editor for a purchase order's lines, with a running total. */ +export function PurchaseOrderLinesEditor({ rawMaterials, lines, onChange }: PurchaseOrderLinesEditorProps) { + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + const [pendingUnitPrice, setPendingUnitPrice] = useState(""); + + const byId = (id: string) => rawMaterials.find((r) => r.id === id); + const availableToAdd = rawMaterials.filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + const total = lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0); + + const addLine = () => { + const quantity = Number(pendingQuantity); + const unitPrice = Number(pendingUnitPrice); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + if (!Number.isFinite(unitPrice) || unitPrice < 0) { + toast.error("Enter a unit price of zero or more."); + return; + } + + onChange([...lines, { rawMaterialId: pendingRawMaterialId, quantity, unitPrice }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + setPendingUnitPrice(""); + }; + + const removeLine = (rawMaterialId: string) => + onChange(lines.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateLine = (rawMaterialId: string, field: "quantity" | "unitPrice", value: string) => { + const parsed = Number(value); + onChange( + lines.map((l) => + l.rawMaterialId === rawMaterialId && Number.isFinite(parsed) ? { ...l, [field]: parsed } : l, + ), + ); + }; + + return ( +
+ {lines.length === 0 ? ( + + ) : ( +
+ {lines.map((line) => { + const material = byId(line.rawMaterialId); + + return ( +
+
+

{material?.name ?? "Unknown"}

+
+ updateLine(line.rawMaterialId, "quantity", e.target.value)} + className="w-24" + aria-label={`Quantity of ${material?.name ?? "raw material"}`} + /> + + {material ? UNIT_ABBREVIATIONS[material.unitOfMeasurement] : ""} + + @ + updateLine(line.rawMaterialId, "unitPrice", e.target.value)} + className="w-28" + aria-label={`Unit price of ${material?.name ?? "raw material"}`} + /> + + = {(line.quantity * line.unitPrice).toFixed(2)} + + +
+ ); + })} + +
+ Total: {total.toFixed(2)} +
+
+ )} + + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="5" + /> +
+
+ + setPendingUnitPrice(e.target.value)} + placeholder="250.00" + /> +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx b/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx new file mode 100644 index 0000000..c2d12f6 --- /dev/null +++ b/frontend/src/features/purchase-orders/ui/RecordPaymentDialog.tsx @@ -0,0 +1,160 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { PAYMENT_METHODS, type PaymentMethod } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { usePurchaseOrderMutations } from "../model/usePurchaseOrders"; + +export interface RecordPaymentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + purchaseOrderId: string; + supplierId: string; + /** Shown so the person recording the payment can see how much is left to pay. */ + balance: number; +} + +interface FormValues { + amount: string; + paymentDateUtc: string; + method: PaymentMethod; + invoiceReference: string; + notes: string; +} + +const todayIso = () => new Date().toISOString().slice(0, 10); + +/** Records a payment toward a purchase order's outstanding balance. */ +export function RecordPaymentDialog({ + open, + onOpenChange, + purchaseOrderId, + supplierId, + balance, +}: RecordPaymentDialogProps) { + const { recordPayment } = usePurchaseOrderMutations(); + + const defaults: FormValues = { + amount: balance > 0 ? balance.toFixed(2) : "", + paymentDateUtc: todayIso(), + method: "Cash", + invoiceReference: "", + notes: "", + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const amount = Number(values.amount); + + if (!Number.isFinite(amount) || amount <= 0) { + toast.error("Enter a payment amount greater than zero."); + return; + } + + try { + await recordPayment.mutateAsync({ + id: purchaseOrderId, + supplierId, + payload: { + amount, + paymentDateUtc: new Date(values.paymentDateUtc).toISOString(), + method: values.method, + invoiceReference: values.invoiceReference.trim() || null, + notes: values.notes.trim() || null, + }, + }); + toast.success("Payment recorded."); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + Record payment + Balance outstanding: {balance.toFixed(2)} + + +
+
+ + + + + + + +
+ + + ( + + )} + /> + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/raw-materials/api/rawMaterialsApi.ts b/frontend/src/features/raw-materials/api/rawMaterialsApi.ts new file mode 100644 index 0000000..8aeb04d --- /dev/null +++ b/frontend/src/features/raw-materials/api/rawMaterialsApi.ts @@ -0,0 +1,24 @@ +import type { + CreateRawMaterialPayload, + RawMaterial, + RawMaterialFilters, + UpdateRawMaterialPayload, +} from "@/entities/raw-material"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const rawMaterialsApi = { + list: (filters: RawMaterialFilters = {}) => + apiService.get(API_ENDPOINTS.RAW_MATERIALS.BASE, { + search: filters.search || undefined, + isActive: filters.isActive, + }), + + create: (payload: CreateRawMaterialPayload) => + apiService.post(API_ENDPOINTS.RAW_MATERIALS.BASE, payload), + + update: (id: string, payload: UpdateRawMaterialPayload) => + apiService.put(API_ENDPOINTS.RAW_MATERIALS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.RAW_MATERIALS.STATUS(id), { isActive }), +}; diff --git a/frontend/src/features/raw-materials/index.ts b/frontend/src/features/raw-materials/index.ts new file mode 100644 index 0000000..046a389 --- /dev/null +++ b/frontend/src/features/raw-materials/index.ts @@ -0,0 +1,3 @@ +export { rawMaterialsApi } from "./api/rawMaterialsApi"; +export { useRawMaterials, useRawMaterialMutations } from "./model/useRawMaterials"; +export { RawMaterialFormDialog } from "./ui/RawMaterialFormDialog"; diff --git a/frontend/src/features/raw-materials/model/rawMaterialSchema.ts b/frontend/src/features/raw-materials/model/rawMaterialSchema.ts new file mode 100644 index 0000000..9f96b36 --- /dev/null +++ b/frontend/src/features/raw-materials/model/rawMaterialSchema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { UNITS_OF_MEASUREMENT } from "@/entities/raw-material"; + +const optionalNonNegativeNumber = z + .string() + .trim() + .refine((v) => v === "" || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: "Enter a non-negative number, or leave blank.", + }); + +export const rawMaterialSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(150), + unitOfMeasurement: z.enum(UNITS_OF_MEASUREMENT as [string, ...string[]]), + mainStoreReorderLevel: optionalNonNegativeNumber, + kitchenParLevel: optionalNonNegativeNumber, +}); + +export type RawMaterialForm = z.infer; + +/** Converts the form's empty-string "no threshold" sentinel to the `null` the API expects. */ +export const toNullableThreshold = (value: string): number | null => (value === "" ? null : Number(value)); diff --git a/frontend/src/features/raw-materials/model/useRawMaterials.ts b/frontend/src/features/raw-materials/model/useRawMaterials.ts new file mode 100644 index 0000000..5542c86 --- /dev/null +++ b/frontend/src/features/raw-materials/model/useRawMaterials.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CreateRawMaterialPayload, + RawMaterial, + RawMaterialFilters, + UpdateRawMaterialPayload, +} from "@/entities/raw-material"; +import { rawMaterialsApi } from "../api/rawMaterialsApi"; + +const RAW_MATERIALS_KEY = "raw-materials"; + +export function useRawMaterials(filters: RawMaterialFilters = {}) { + return useQuery({ + queryKey: [RAW_MATERIALS_KEY, filters], + queryFn: () => rawMaterialsApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useRawMaterialMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [RAW_MATERIALS_KEY] }); + + const create = useMutation({ + mutationFn: rawMaterialsApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => rawMaterialsApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => rawMaterialsApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} diff --git a/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx b/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx new file mode 100644 index 0000000..8c08ea5 --- /dev/null +++ b/frontend/src/features/raw-materials/ui/RawMaterialFormDialog.tsx @@ -0,0 +1,148 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial, UnitOfMeasurement } from "@/entities/raw-material"; +import { UNITS_OF_MEASUREMENT } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useRawMaterialMutations } from "../model/useRawMaterials"; +import { RawMaterialForm, rawMaterialSchema, toNullableThreshold } from "../model/rawMaterialSchema"; + +export interface RawMaterialFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The raw material being edited, or omitted to create a new one. */ + rawMaterial?: RawMaterial; +} + +/** Creates or edits a raw material: its name, unit of measurement, and stock thresholds. */ +export function RawMaterialFormDialog({ open, onOpenChange, rawMaterial }: RawMaterialFormDialogProps) { + const isEditing = !!rawMaterial; + const { create, update } = useRawMaterialMutations(); + const pending = create.isPending || update.isPending; + + const defaults = { + name: rawMaterial?.name ?? "", + unitOfMeasurement: rawMaterial?.unitOfMeasurement ?? "Kilogram", + mainStoreReorderLevel: rawMaterial?.mainStoreReorderLevel != null ? String(rawMaterial.mainStoreReorderLevel) : "", + kitchenParLevel: rawMaterial?.kitchenParLevel != null ? String(rawMaterial.kitchenParLevel) : "", + }; + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ resolver: zodResolver(rawMaterialSchema), defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { + name: values.name, + unitOfMeasurement: values.unitOfMeasurement as UnitOfMeasurement, + mainStoreReorderLevel: toNullableThreshold(values.mainStoreReorderLevel), + kitchenParLevel: toNullableThreshold(values.kitchenParLevel), + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: rawMaterial.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added.`); + } + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + {isEditing ? "Edit raw material" : "Add raw material"} + + The unit chosen here is used everywhere this ingredient appears — recipes, stock counts and history. + + + +
+ + + + + + ( + + )} + /> + + +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/recipes/api/recipesApi.ts b/frontend/src/features/recipes/api/recipesApi.ts new file mode 100644 index 0000000..9bdf98c --- /dev/null +++ b/frontend/src/features/recipes/api/recipesApi.ts @@ -0,0 +1,23 @@ +import type { Recipe, RecipeLineInput } from "@/entities/recipe"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const recipesApi = { + /** + * Null means the menu item has no recipe yet — a normal, expected state, not an error. The + * server sends a genuinely empty body for that case, which axios surfaces as `""` rather than + * JSON `null`, so that's normalised here rather than leaking into every consumer of this call. + */ + get: (menuItemId: string) => + apiService + .get(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId)) + .then((data) => data || null), + + /** Creates the recipe if none exists, or replaces its lines if one already does. */ + upsert: (menuItemId: string, lines: RecipeLineInput[]) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId), { lines }), + + setEnabled: (menuItemId: string, isEnabled: boolean) => + apiService.put(API_ENDPOINTS.MENU_ITEMS.RECIPE_STATUS(menuItemId), { isEnabled }), + + remove: (menuItemId: string) => apiService.delete(API_ENDPOINTS.MENU_ITEMS.RECIPE(menuItemId)), +}; diff --git a/frontend/src/features/recipes/index.ts b/frontend/src/features/recipes/index.ts new file mode 100644 index 0000000..555fc41 --- /dev/null +++ b/frontend/src/features/recipes/index.ts @@ -0,0 +1,3 @@ +export { recipesApi } from "./api/recipesApi"; +export { useRecipe, useRecipeMutations } from "./model/useRecipe"; +export { RecipeEditorDialog } from "./ui/RecipeEditorDialog"; diff --git a/frontend/src/features/recipes/model/useRecipe.ts b/frontend/src/features/recipes/model/useRecipe.ts new file mode 100644 index 0000000..3721ae6 --- /dev/null +++ b/frontend/src/features/recipes/model/useRecipe.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { RecipeLineInput } from "@/entities/recipe"; +import { recipesApi } from "../api/recipesApi"; + +const MENU_ITEMS_KEY = "menu-items"; +const recipeKey = (menuItemId: string) => ["recipe", menuItemId]; + +/** The recipe for one menu item. `data` is `null` (not an error) when it has none. */ +export function useRecipe(menuItemId: string | undefined) { + return useQuery({ + queryKey: recipeKey(menuItemId ?? ""), + queryFn: () => recipesApi.get(menuItemId!), + enabled: !!menuItemId, + }); +} + +export function useRecipeMutations(menuItemId: string) { + const queryClient = useQueryClient(); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: recipeKey(menuItemId) }); + // hasRecipe on the menu item list/detail depends on whether a recipe now exists. + queryClient.invalidateQueries({ queryKey: [MENU_ITEMS_KEY] }); + }; + + const upsert = useMutation({ + mutationFn: (lines: RecipeLineInput[]) => recipesApi.upsert(menuItemId, lines), + onSuccess: invalidate, + }); + + const setEnabled = useMutation({ + mutationFn: (isEnabled: boolean) => recipesApi.setEnabled(menuItemId, isEnabled), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: () => recipesApi.remove(menuItemId), + onSuccess: invalidate, + }); + + return { upsert, setEnabled, remove }; +} diff --git a/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx b/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx new file mode 100644 index 0000000..cbc62b8 --- /dev/null +++ b/frontend/src/features/recipes/ui/RecipeEditorDialog.tsx @@ -0,0 +1,263 @@ +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { MenuItem } from "@/entities/menu-item"; +import type { RecipeLineInput } from "@/entities/recipe"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { useRawMaterials } from "@/features/raw-materials"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyState, + Input, + Label, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from "@/shared/ui"; +import { useRecipe, useRecipeMutations } from "../model/useRecipe"; + +export interface RecipeEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + menuItem: MenuItem; +} + +/** + * Attaches, edits or removes a menu item's recipe: which raw materials a single sale consumes, + * and how much of each. + */ +export function RecipeEditorDialog({ open, onOpenChange, menuItem }: RecipeEditorDialogProps) { + return ( + + + onOpenChange(false)} /> + + + ); +} + +function RecipeEditorBody({ menuItem, onDone }: { menuItem: MenuItem; onDone: () => void }) { + const { data: recipe, isLoading: recipeLoading } = useRecipe(menuItem.id); + const { data: rawMaterials, isLoading: materialsLoading } = useRawMaterials({ isActive: true }); + const { upsert, setEnabled, remove } = useRecipeMutations(menuItem.id); + + const [lines, setLines] = useState(() => recipe?.lines?.map(toLineInput) ?? []); + const [linesInitialised, setLinesInitialised] = useState(false); + const [pendingRawMaterialId, setPendingRawMaterialId] = useState(""); + const [pendingQuantity, setPendingQuantity] = useState(""); + + // The recipe query resolves after the initial render, so seed local editable state the first + // time real data (or its absence) arrives rather than trying to derive it inline. + if (!linesInitialised && !recipeLoading) { + setLines(recipe?.lines?.map(toLineInput) ?? []); + setLinesInitialised(true); + } + + const nameFor = (rawMaterialId: string) => + recipe?.lines?.find((l) => l.rawMaterialId === rawMaterialId)?.rawMaterialName ?? + rawMaterials?.find((r) => r.id === rawMaterialId)?.name ?? + "Unknown"; + + const unitFor = (rawMaterialId: string) => + recipe?.lines?.find((l) => l.rawMaterialId === rawMaterialId)?.unitOfMeasurement ?? + rawMaterials?.find((r) => r.id === rawMaterialId)?.unitOfMeasurement; + + const availableToAdd = (rawMaterials ?? []).filter((r) => !lines.some((l) => l.rawMaterialId === r.id)); + + const addLine = () => { + const quantity = Number(pendingQuantity); + + if (!pendingRawMaterialId || !Number.isFinite(quantity) || quantity <= 0) { + toast.error("Choose a raw material and enter a quantity greater than zero."); + return; + } + + setLines((prev) => [...prev, { rawMaterialId: pendingRawMaterialId, quantity }]); + setPendingRawMaterialId(""); + setPendingQuantity(""); + }; + + const removeLine = (rawMaterialId: string) => + setLines((prev) => prev.filter((l) => l.rawMaterialId !== rawMaterialId)); + + const updateQuantity = (rawMaterialId: string, quantity: string) => { + const parsed = Number(quantity); + setLines((prev) => + prev.map((l) => (l.rawMaterialId === rawMaterialId ? { ...l, quantity: Number.isFinite(parsed) ? parsed : l.quantity } : l)), + ); + }; + + const handleSave = async () => { + if (lines.length === 0) { + toast.error("Add at least one raw material before saving."); + return; + } + + try { + await upsert.mutateAsync(lines); + toast.success(`Recipe saved for ${menuItem.name}.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const handleToggleEnabled = async (checked: boolean) => { + try { + await setEnabled.mutateAsync(checked); + toast.success(checked ? "Recipe enabled." : "Recipe disabled — it can't be used for a new sale."); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const handleRemove = async () => { + try { + await remove.mutateAsync(); + toast.success(`Recipe removed for ${menuItem.name}.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }; + + const loading = recipeLoading || materialsLoading; + + return ( + <> + + Recipe for {menuItem.name} + + Ingredients here are deducted from Kitchen stock automatically whenever this item is sold. + + + + {loading ? ( + + ) : ( +
+ {recipe && ( +
+
+ +

+ {recipe.isEnabled ? "Used for new sales." : "Disabled — cannot be used for a new sale."} +

+
+ +
+ )} + +
+ {lines.length === 0 ? ( + + ) : ( + lines.map((line) => ( +
+
+

{nameFor(line.rawMaterialId)}

+
+ updateQuantity(line.rawMaterialId, e.target.value)} + className="w-24" + aria-label={`Quantity of ${nameFor(line.rawMaterialId)}`} + /> + + {UNIT_ABBREVIATIONS[unitFor(line.rawMaterialId)!]} + + +
+ )) + )} +
+ + {availableToAdd.length > 0 && ( +
+
+ + +
+
+ + setPendingQuantity(e.target.value)} + placeholder="0.25" + /> +
+ +
+ )} +
+ )} + + + {recipe ? ( + + ) : ( + + )} +
+ + +
+
+ + ); +} + +function toLineInput(line: { rawMaterialId: string; quantity: number }): RecipeLineInput { + return { rawMaterialId: line.rawMaterialId, quantity: line.quantity }; +} diff --git a/frontend/src/features/suppliers/api/suppliersApi.ts b/frontend/src/features/suppliers/api/suppliersApi.ts new file mode 100644 index 0000000..e9e4028 --- /dev/null +++ b/frontend/src/features/suppliers/api/suppliersApi.ts @@ -0,0 +1,37 @@ +import type { + Supplier, + SupplierPayload, + SupplierFilters, + SupplierPrice, + SupplierPriceHistoryEntry, + SupplierPerformance, +} from "@/entities/supplier"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const suppliersApi = { + list: (filters: SupplierFilters = {}) => + apiService.get(API_ENDPOINTS.SUPPLIERS.BASE, { + search: filters.search || undefined, + isActive: filters.isActive, + }), + + create: (payload: SupplierPayload) => apiService.post(API_ENDPOINTS.SUPPLIERS.BASE, payload), + + update: (id: string, payload: SupplierPayload) => + apiService.put(API_ENDPOINTS.SUPPLIERS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.SUPPLIERS.STATUS(id), { isActive }), + + prices: (supplierId?: string, rawMaterialId?: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PRICES, { supplierId, rawMaterialId }), + + setPrice: (supplierId: string, rawMaterialId: string, price: number) => + apiService.put(API_ENDPOINTS.SUPPLIERS.SET_PRICE(supplierId), { rawMaterialId, price }), + + priceHistory: (supplierId: string, rawMaterialId: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PRICE_HISTORY(supplierId, rawMaterialId)), + + performance: (supplierId: string) => + apiService.get(API_ENDPOINTS.SUPPLIERS.PERFORMANCE(supplierId)), +}; diff --git a/frontend/src/features/suppliers/index.ts b/frontend/src/features/suppliers/index.ts new file mode 100644 index 0000000..1e1fa2a --- /dev/null +++ b/frontend/src/features/suppliers/index.ts @@ -0,0 +1,12 @@ +export { suppliersApi } from "./api/suppliersApi"; +export { + useSuppliers, + useSupplierMutations, + useSupplierPrices, + useSupplierPriceHistory, + useSetSupplierPrice, + useSupplierPerformance, +} from "./model/useSuppliers"; +export { SupplierFormDialog } from "./ui/SupplierFormDialog"; +export { SetSupplierPriceDialog } from "./ui/SetSupplierPriceDialog"; +export { SupplierPriceHistoryDialog } from "./ui/SupplierPriceHistoryDialog"; diff --git a/frontend/src/features/suppliers/model/supplierSchema.ts b/frontend/src/features/suppliers/model/supplierSchema.ts new file mode 100644 index 0000000..66f8a56 --- /dev/null +++ b/frontend/src/features/suppliers/model/supplierSchema.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const optionalTrimmed = z.string().trim().max(200).optional().or(z.literal("")); + +const optionalNonNegativeInteger = z + .string() + .trim() + .refine((v) => v === "" || (Number.isInteger(Number(v)) && Number(v) >= 0), { + message: "Enter a whole number of zero or more, or leave blank.", + }); + +const nonNegativeInteger = z + .string() + .trim() + .refine((v) => Number.isInteger(Number(v)) && Number(v) >= 0, { + message: "Enter a whole number of zero or more.", + }); + +const optionalNonNegativeNumber = z + .string() + .trim() + .refine((v) => v === "" || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: "Enter a non-negative number, or leave blank.", + }); + +export const supplierSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(200), + contactName: optionalTrimmed, + phone: optionalTrimmed, + email: z.string().trim().max(200).email("Enter a valid email address.").optional().or(z.literal("")), + address: optionalTrimmed, + paymentTermsDays: nonNegativeInteger, + creditLimit: optionalNonNegativeNumber, + leadTimeDays: optionalNonNegativeInteger, +}); + +export type SupplierForm = z.infer; + +/** Converts a form's empty-string "not set" sentinel to the `null` the API expects. */ +export const toNullableString = (value: string | undefined): string | null => (value ? value : null); + +export const toNullableNumber = (value: string): number | null => (value === "" ? null : Number(value)); diff --git a/frontend/src/features/suppliers/model/useSuppliers.ts b/frontend/src/features/suppliers/model/useSuppliers.ts new file mode 100644 index 0000000..feb8845 --- /dev/null +++ b/frontend/src/features/suppliers/model/useSuppliers.ts @@ -0,0 +1,75 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Supplier, SupplierFilters, SupplierPayload, SupplierPrice } from "@/entities/supplier"; +import { suppliersApi } from "../api/suppliersApi"; + +const SUPPLIERS_KEY = "suppliers"; +const SUPPLIER_PRICES_KEY = "supplier-prices"; +const SUPPLIER_PRICE_HISTORY_KEY = "supplier-price-history"; +const SUPPLIER_PERFORMANCE_KEY = "supplier-performance"; + +export function useSuppliers(filters: SupplierFilters = {}) { + return useQuery({ + queryKey: [SUPPLIERS_KEY, filters], + queryFn: () => suppliersApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useSupplierMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [SUPPLIERS_KEY] }); + + const create = useMutation({ + mutationFn: suppliersApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => suppliersApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => suppliersApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} + +/** The current price list, filterable by supplier (a price list) or raw material (a comparison). */ +export function useSupplierPrices(supplierId?: string, rawMaterialId?: string) { + return useQuery({ + queryKey: [SUPPLIER_PRICES_KEY, supplierId, rawMaterialId], + queryFn: () => suppliersApi.prices(supplierId, rawMaterialId), + enabled: !!supplierId || !!rawMaterialId, + }); +} + +export function useSupplierPriceHistory(supplierId: string, rawMaterialId: string, enabled: boolean) { + return useQuery({ + queryKey: [SUPPLIER_PRICE_HISTORY_KEY, supplierId, rawMaterialId], + queryFn: () => suppliersApi.priceHistory(supplierId, rawMaterialId), + enabled, + }); +} + +export function useSetSupplierPrice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ supplierId, rawMaterialId, price }) => suppliersApi.setPrice(supplierId, rawMaterialId, price), + onSuccess: (_, { supplierId, rawMaterialId }) => { + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PRICES_KEY] }); + queryClient.invalidateQueries({ queryKey: [SUPPLIER_PRICE_HISTORY_KEY, supplierId, rawMaterialId] }); + }, + }); +} + +export function useSupplierPerformance(supplierId: string, enabled: boolean) { + return useQuery({ + queryKey: [SUPPLIER_PERFORMANCE_KEY, supplierId], + queryFn: () => suppliersApi.performance(supplierId), + enabled, + }); +} diff --git a/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx b/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx new file mode 100644 index 0000000..af7916b --- /dev/null +++ b/frontend/src/features/suppliers/ui/SetSupplierPriceDialog.tsx @@ -0,0 +1,133 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { useSetSupplierPrice } from "../model/useSuppliers"; + +export interface SetSupplierPriceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplierId: string; + supplierName: string; + rawMaterials: RawMaterial[]; + /** Preselects a raw material, e.g. when opened from that row's "Update price" action. */ + rawMaterialId?: string; +} + +interface FormValues { + rawMaterialId: string; + price: string; +} + +/** Records what a supplier currently charges for a raw material, keeping the prior price in history. */ +export function SetSupplierPriceDialog({ + open, + onOpenChange, + supplierId, + supplierName, + rawMaterials, + rawMaterialId, +}: SetSupplierPriceDialogProps) { + const setPrice = useSetSupplierPrice(); + + const defaults: FormValues = { rawMaterialId: rawMaterialId ?? "", price: "" }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const price = Number(values.price); + + if (!values.rawMaterialId) { + toast.error("Choose a raw material."); + return; + } + + if (!Number.isFinite(price) || price < 0) { + toast.error("Enter a price of zero or more."); + return; + } + + try { + await setPrice.mutateAsync({ supplierId, rawMaterialId: values.rawMaterialId, price }); + toast.success("Price recorded."); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + Set price + What {supplierName} currently charges for a raw material. + + +
+ + ( + + )} + /> + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx b/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx new file mode 100644 index 0000000..5161f87 --- /dev/null +++ b/frontend/src/features/suppliers/ui/SupplierFormDialog.tsx @@ -0,0 +1,152 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { Supplier } from "@/entities/supplier"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { useSupplierMutations } from "../model/useSuppliers"; +import { SupplierForm, supplierSchema, toNullableNumber, toNullableString } from "../model/supplierSchema"; + +export interface SupplierFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplier?: Supplier; +} + +/** Creates or edits a supplier: contact details, credit terms and lead time. */ +export function SupplierFormDialog({ open, onOpenChange, supplier }: SupplierFormDialogProps) { + const isEditing = !!supplier; + const { create, update } = useSupplierMutations(); + const pending = create.isPending || update.isPending; + + const defaults: SupplierForm = { + name: supplier?.name ?? "", + contactName: supplier?.contactName ?? "", + phone: supplier?.phone ?? "", + email: supplier?.email ?? "", + address: supplier?.address ?? "", + paymentTermsDays: String(supplier?.paymentTermsDays ?? 0), + creditLimit: supplier?.creditLimit != null ? String(supplier.creditLimit) : "", + leadTimeDays: supplier?.leadTimeDays != null ? String(supplier.leadTimeDays) : "", + }; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ resolver: zodResolver(supplierSchema), defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const payload = { + name: values.name, + contactName: toNullableString(values.contactName), + phone: toNullableString(values.phone), + email: toNullableString(values.email), + address: toNullableString(values.address), + paymentTermsDays: Number(values.paymentTermsDays), + creditLimit: toNullableNumber(values.creditLimit), + leadTimeDays: toNullableNumber(values.leadTimeDays), + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: supplier.id, payload }); + toast.success(`${values.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${values.name} was added.`); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit supplier" : "Add supplier"} + + +
+ + + + +
+ + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx b/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx new file mode 100644 index 0000000..9e3b16f --- /dev/null +++ b/frontend/src/features/suppliers/ui/SupplierPriceHistoryDialog.tsx @@ -0,0 +1,74 @@ +import { History } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; +import { useSupplierPriceHistory } from "../model/useSuppliers"; + +export interface SupplierPriceHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supplierId: string; + rawMaterialId: string; + rawMaterialName: string; +} + +/** Every price a supplier has been recorded as charging for one raw material, newest first. */ +export function SupplierPriceHistoryDialog({ + open, + onOpenChange, + supplierId, + rawMaterialId, + rawMaterialName, +}: SupplierPriceHistoryDialogProps) { + const { data: history, isLoading } = useSupplierPriceHistory(supplierId, rawMaterialId, open); + + return ( + + + + Price history + {rawMaterialName} + + + {isLoading ? ( + + ) : !history || history.length === 0 ? ( + } title="No price history yet" /> + ) : ( + + + + Price + Recorded by + Date + + + + {history.map((entry, index) => ( + + {entry.price.toFixed(2)} + {entry.recordedByName} + + {new Date(entry.recordedAtUtc).toLocaleString()} + + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/tables/api/tablesApi.ts b/frontend/src/features/tables/api/tablesApi.ts new file mode 100644 index 0000000..9f2543d --- /dev/null +++ b/frontend/src/features/tables/api/tablesApi.ts @@ -0,0 +1,15 @@ +import type { RestaurantTable, TablePayload } from "@/entities/table"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const tablesApi = { + list: (isActive?: boolean) => + apiService.get(API_ENDPOINTS.TABLES.BASE, { isActive }), + + create: (payload: TablePayload) => apiService.post(API_ENDPOINTS.TABLES.BASE, payload), + + update: (id: string, payload: TablePayload) => + apiService.put(API_ENDPOINTS.TABLES.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.TABLES.STATUS(id), { isActive }), +}; diff --git a/frontend/src/features/tables/index.ts b/frontend/src/features/tables/index.ts new file mode 100644 index 0000000..5d50dd4 --- /dev/null +++ b/frontend/src/features/tables/index.ts @@ -0,0 +1,3 @@ +export { tablesApi } from "./api/tablesApi"; +export { useTables, useTableMutations, TABLES_KEY } from "./model/useTables"; +export { TableFormDialog } from "./ui/TableFormDialog"; diff --git a/frontend/src/features/tables/model/useTables.ts b/frontend/src/features/tables/model/useTables.ts new file mode 100644 index 0000000..7d16cbe --- /dev/null +++ b/frontend/src/features/tables/model/useTables.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { RestaurantTable, TablePayload } from "@/entities/table"; +import { tablesApi } from "../api/tablesApi"; + +export const TABLES_KEY = "tables"; + +/** + * The floor plan. + * + * Polled rather than fetched once: a table's tile shows kitchen progress, which changes on a + * different screen in a different room. Without polling, a cashier would be looking at a board + * that says "preparing" for food that was plated ten minutes ago. + */ +export function useTables(options: { isActive?: boolean; pollMs?: number } = {}) { + const { isActive, pollMs = 10_000 } = options; + + return useQuery({ + queryKey: [TABLES_KEY, isActive], + queryFn: () => tablesApi.list(isActive), + refetchInterval: pollMs, + placeholderData: (previous) => previous, + }); +} + +export function useTableMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [TABLES_KEY] }); + + const create = useMutation({ + mutationFn: tablesApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => tablesApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => tablesApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + return { create, update, setActive }; +} diff --git a/frontend/src/features/tables/ui/TableFormDialog.tsx b/frontend/src/features/tables/ui/TableFormDialog.tsx new file mode 100644 index 0000000..7e1fafa --- /dev/null +++ b/frontend/src/features/tables/ui/TableFormDialog.tsx @@ -0,0 +1,123 @@ +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { RestaurantTable } from "@/entities/table"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { toApiError } from "@/shared/api/problem"; +import { useTableMutations } from "../model/useTables"; + +export interface TableFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + table?: RestaurantTable; +} + +interface FormValues { + number: string; + seats: string; + notes: string; +} + +/** Adds a table to the floor plan, or renames one. */ +export function TableFormDialog({ open, onOpenChange, table }: TableFormDialogProps) { + const isEditing = !!table; + const { create, update } = useTableMutations(); + const pending = create.isPending || update.isPending; + + const defaults: FormValues = { + number: table?.number ?? "", + seats: table?.seats ? String(table.seats) : "", + notes: table?.notes ?? "", + }; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const seats = values.seats.trim() === "" ? 0 : Number(values.seats); + + if (!Number.isInteger(seats) || seats < 0) { + toast.error("Seats must be a whole number of zero or more."); + return; + } + + const payload = { + number: values.number.trim(), + seats, + notes: values.notes.trim() || null, + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: table.id, payload }); + toast.success(`Table ${payload.number} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`Table ${payload.number} was added.`); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit table" : "Add table"} + + The number is what staff call the table — it appears on every KOT and receipt. + + + +
+
+ + + + + + + +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/users/api/usersApi.ts b/frontend/src/features/users/api/usersApi.ts new file mode 100644 index 0000000..82c8c48 --- /dev/null +++ b/frontend/src/features/users/api/usersApi.ts @@ -0,0 +1,24 @@ +import type { CreateUserPayload, UpdateUserPayload, User, UserFilters } from "@/entities/user"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const usersApi = { + list: (filters: UserFilters = {}) => + apiService.get(API_ENDPOINTS.USERS.BASE, { + search: filters.search || undefined, + role: filters.role, + isActive: filters.isActive, + }), + + byId: (id: string) => apiService.get(API_ENDPOINTS.USERS.BY_ID(id)), + + create: (payload: CreateUserPayload) => apiService.post(API_ENDPOINTS.USERS.BASE, payload), + + update: (id: string, payload: UpdateUserPayload) => + apiService.put(API_ENDPOINTS.USERS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.USERS.STATUS(id), { isActive }), + + resetPassword: (id: string, newPassword: string) => + apiService.post(API_ENDPOINTS.USERS.PASSWORD(id), { newPassword }), +}; diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts new file mode 100644 index 0000000..52ae497 --- /dev/null +++ b/frontend/src/features/users/index.ts @@ -0,0 +1,5 @@ +export { usersApi } from "./api/usersApi"; +export { useUsers, useUserMutations } from "./model/useUsers"; +export { UserFormDialog } from "./ui/UserFormDialog"; +export { ResetPasswordDialog } from "./ui/ResetPasswordDialog"; +export { ModulePermissionPicker } from "./ui/ModulePermissionPicker"; diff --git a/frontend/src/features/users/model/useUsers.ts b/frontend/src/features/users/model/useUsers.ts new file mode 100644 index 0000000..88edef6 --- /dev/null +++ b/frontend/src/features/users/model/useUsers.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateUserPayload, UpdateUserPayload, User, UserFilters } from "@/entities/user"; +import { usersApi } from "../api/usersApi"; + +const USERS_KEY = "users"; + +/** Staff accounts matching the given filters. */ +export function useUsers(filters: UserFilters = {}) { + return useQuery({ + queryKey: [USERS_KEY, filters], + queryFn: () => usersApi.list(filters), + // Keeps the previous page visible while a new search runs, avoiding a flash of empty table. + placeholderData: (previous) => previous, + }); +} + +/** + * Commands that change staff accounts. Each invalidates the list so the table reflects the + * server's view rather than an optimistic guess — correctness matters more than instant + * feedback for an administration screen. + */ +export function useUserMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [USERS_KEY] }); + + const create = useMutation({ + mutationFn: usersApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => usersApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => usersApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + const resetPassword = useMutation({ + mutationFn: ({ id, newPassword }) => usersApi.resetPassword(id, newPassword), + onSuccess: invalidate, + }); + + return { create, update, setActive, resetPassword }; +} diff --git a/frontend/src/features/users/model/userSchema.ts b/frontend/src/features/users/model/userSchema.ts new file mode 100644 index 0000000..757ae11 --- /dev/null +++ b/frontend/src/features/users/model/userSchema.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +/** + * Mirrors the server's password policy so the user is told what is wrong before a round trip. + * The server remains the authority; this only spares them a failed submit. + */ +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters.") + .max(128, "Password cannot exceed 128 characters.") + .regex(/[A-Z]/, "Password must contain an upper-case letter.") + .regex(/[a-z]/, "Password must contain a lower-case letter.") + .regex(/[0-9]/, "Password must contain a digit."); + +export const PASSWORD_HINT = + "At least 8 characters, with an upper-case letter, a lower-case letter and a digit."; + +const usernameSchema = z + .string() + .trim() + .toLowerCase() + .min(3, "Username must be at least 3 characters.") + .max(32, "Username cannot exceed 32 characters.") + .regex( + /^[a-z0-9._-]+$/, + "Use only letters, digits, dots, hyphens and underscores.", + ); + +// Kept as a plain string (not transformed to null) so the input/output types stay identical — +// react-hook-form's zodResolver requires that when useForm is given a single type argument. +// Callers convert "" to null with `toNullableEmail` immediately before sending it to the API. +const emailSchema = z + .string() + .trim() + .max(200) + .refine((value) => value === "" || z.string().email().safeParse(value).success, { + message: "Enter a valid email address.", + }); + +/** Converts the form's empty-string "no email" sentinel to the `null` the API expects. */ +export const toNullableEmail = (value: string): string | null => (value === "" ? null : value); + +const baseUserFields = { + fullName: z.string().trim().min(1, "Full name is required.").max(120), + email: emailSchema, + role: z.enum(["Admin", "User"]), + modules: z.array(z.string()), +}; + +/** New account: a username and starting password are required. */ +export const createUserSchema = z.object({ + ...baseUserFields, + username: usernameSchema, + password: passwordSchema, +}); + +/** Existing account: the username is immutable and the password is changed separately. */ +export const updateUserSchema = z.object(baseUserFields); + +export const resetPasswordSchema = z.object({ + newPassword: passwordSchema, +}); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Your current password is required."), + newPassword: passwordSchema, + confirmPassword: z.string().min(1, "Please confirm your new password."), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "The two passwords do not match.", + path: ["confirmPassword"], + }) + .refine((data) => data.newPassword !== data.currentPassword, { + message: "The new password must be different from your current one.", + path: ["newPassword"], + }); + +export const approvalPinSchema = z + .string() + .regex(/^[0-9]{4}$/, "The approval PIN must be exactly 4 digits."); + +export type CreateUserForm = z.infer; +export type UpdateUserForm = z.infer; +export type ResetPasswordForm = z.infer; +export type ChangePasswordForm = z.infer; diff --git a/frontend/src/features/users/ui/ModulePermissionPicker.tsx b/frontend/src/features/users/ui/ModulePermissionPicker.tsx new file mode 100644 index 0000000..03f9989 --- /dev/null +++ b/frontend/src/features/users/ui/ModulePermissionPicker.tsx @@ -0,0 +1,135 @@ +import { ShieldCheck } from "lucide-react"; +import type { ModuleDescriptor, ModuleKey } from "@/entities/user"; +import { assignableModules, groupModules } from "@/entities/user"; +import { cn } from "@/shared/lib/utils"; +import { Alert, AlertDescription, Button, Checkbox, Label } from "@/shared/ui"; + +export interface ModulePermissionPickerProps { + catalog: ModuleDescriptor[]; + selected: ModuleKey[]; + onChange: (modules: ModuleKey[]) => void; + /** Administrators hold every module implicitly, so the picker is replaced by an explanation. */ + isAdmin: boolean; + disabled?: boolean; +} + +/** + * Grants modules to a staff account. + * + * Only non-administrative modules are offered: `UserManagement` and `SystemSettings` carry + * administrative authority and come with the Admin role instead of being handed out one by one. + */ +export function ModulePermissionPicker({ + catalog, + selected, + onChange, + isAdmin, + disabled = false, +}: ModulePermissionPickerProps) { + const grantable = assignableModules(catalog); + const groups = groupModules(grantable); + + if (isAdmin) { + return ( + + + + Administrators can open every module, including user management and system settings. + Module selection does not apply to this role. + + + ); + } + + const toggle = (module: ModuleKey, checked: boolean) => { + onChange(checked ? [...selected, module] : selected.filter((m) => m !== module)); + }; + + const toggleGroup = (modules: ModuleDescriptor[], grantAll: boolean) => { + const keys = modules.map((m) => m.module); + + onChange( + grantAll + ? [...new Set([...selected, ...keys])] + : selected.filter((m) => !keys.includes(m)), + ); + }; + + return ( +
+
+

+ {selected.length === 0 + ? "No modules selected — this user will be able to sign in but not open anything." + : `${selected.length} of ${grantable.length} modules granted`} +

+ + {selected.length > 0 && !disabled && ( + + )} +
+ + {groups.map(({ group, modules }) => { + const allGranted = modules.every((m) => selected.includes(m.module)); + + return ( +
+
+ + {group} + + {!disabled && ( + + )} +
+ +
+ {modules.map((descriptor) => { + const checked = selected.includes(descriptor.module); + const id = `module-${descriptor.module}`; + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/features/users/ui/ResetPasswordDialog.tsx b/frontend/src/features/users/ui/ResetPasswordDialog.tsx new file mode 100644 index 0000000..a370d6d --- /dev/null +++ b/frontend/src/features/users/ui/ResetPasswordDialog.tsx @@ -0,0 +1,107 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { User } from "@/entities/user"; +import { + Alert, + AlertDescription, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useUserMutations } from "../model/useUsers"; +import { PASSWORD_HINT, ResetPasswordForm, resetPasswordSchema } from "../model/userSchema"; + +export interface ResetPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: User; +} + +/** + * Sets a temporary password for a staff account on an administrator's behalf. + * + * The user is forced to choose their own password at their next sign-in, and every session + * they currently hold open is ended — this is meant for "I forgot my password", not a way to + * quietly access someone else's account. + */ +export function ResetPasswordDialog({ open, onOpenChange, user }: ResetPasswordDialogProps) { + const { resetPassword } = useUserMutations(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { newPassword: "" }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset({ newPassword: "" }); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async ({ newPassword }) => { + try { + await resetPassword.mutateAsync({ id: user.id, newPassword }); + toast.success(`${user.fullName}'s password was reset. They must choose a new one at sign-in.`); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Reset password + + Set a temporary password for {user.fullName}. + + + + + + This immediately signs {user.fullName} out everywhere. They will need the temporary + password to sign back in, and will be asked to choose their own straight away. + + + +
+ + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/users/ui/UserFormDialog.tsx b/frontend/src/features/users/ui/UserFormDialog.tsx new file mode 100644 index 0000000..fc7221c --- /dev/null +++ b/frontend/src/features/users/ui/UserFormDialog.tsx @@ -0,0 +1,313 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { ModuleDescriptor, ModuleKey, User } from "@/entities/user"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useUserMutations } from "../model/useUsers"; +import type { CreateUserForm, UpdateUserForm } from "../model/userSchema"; +import { + PASSWORD_HINT, + createUserSchema, + toNullableEmail, + updateUserSchema, +} from "../model/userSchema"; +import { ModulePermissionPicker } from "./ModulePermissionPicker"; + +export interface UserFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The account being edited, or omitted to create a new one. */ + user?: User; + catalog: ModuleDescriptor[]; +} + +/** + * Creates a staff account, or edits an existing one's profile, role and module grants. + * + * Rendered as two distinct form bodies rather than one form with optional fields: creation and + * editing have genuinely different shapes (a username and starting password only make sense + * once), and giving each its own `useForm` keeps both strongly typed instead of forcing + * `react-hook-form` through a union schema. + */ +export function UserFormDialog({ open, onOpenChange, user, catalog }: UserFormDialogProps) { + return ( + + + {user ? ( + onOpenChange(false)} /> + ) : ( + onOpenChange(false)} /> + )} + + + ); +} + +function CreateUserFormBody({ catalog, onDone }: { catalog: ModuleDescriptor[]; onDone: () => void }) { + const { create } = useUserMutations(); + + const { + register, + handleSubmit, + control, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { username: "", fullName: "", email: "", password: "", role: "User", modules: [] }, + }); + + const role = watch("role"); + + const onSubmit = handleSubmit(async (values) => { + try { + await create.mutateAsync({ + username: values.username, + fullName: values.fullName, + email: toNullableEmail(values.email), + password: values.password, + role: values.role, + modules: values.modules as ModuleKey[], + }); + toast.success(`${values.fullName} was added. They'll set their own password at first sign-in.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + <> + + Add staff account + + The new account must choose its own password the first time it signs in. + + + +
+
+ + + + + + + + + + + + + + ( + + )} + /> + + + + + +
+ +
+

Module access

+ ( + + )} + /> +
+ + + + + +
+ + ); +} + +function EditUserFormBody({ + user, + catalog, + onDone, +}: { + user: User; + catalog: ModuleDescriptor[]; + onDone: () => void; +}) { + const { update } = useUserMutations(); + + const { + register, + handleSubmit, + control, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + fullName: user.fullName, + email: user.email ?? "", + role: user.role, + modules: user.modules, + }, + }); + + const role = watch("role"); + + const onSubmit = handleSubmit(async (values) => { + try { + await update.mutateAsync({ + id: user.id, + payload: { + fullName: values.fullName, + email: toNullableEmail(values.email), + role: values.role, + modules: values.modules as ModuleKey[], + }, + }); + toast.success(`${values.fullName}'s account was updated.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + <> + + Edit staff account + + Changes to role or modules take effect the next time this user signs in. + + + +
+
+ + + + + + + + + + ( + + )} + /> + +
+ +
+

Module access

+ ( + + )} + /> +
+ + + + + +
+ + ); +} diff --git a/frontend/src/pages/account/ApprovalPinCard.tsx b/frontend/src/pages/account/ApprovalPinCard.tsx new file mode 100644 index 0000000..833093b --- /dev/null +++ b/frontend/src/pages/account/ApprovalPinCard.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Copy, KeyRound, ShieldCheck } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useApprovalPin, useAuth } from "@/features/auth"; +import { toApiError } from "@/shared/api/problem"; +import { + Alert, + AlertDescription, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FormField, + Input, +} from "@/shared/ui"; + +interface PinFormValues { + currentPassword: string; + pin: string; +} + +/** + * Lets an administrator set, regenerate or remove their 4-digit approval PIN — the code other + * staff will ask them to type in at the till to authorise things like an order cancellation. + */ +export function ApprovalPinCard() { + const { user } = useAuth(); + const [revealedPin, setRevealedPin] = useState(null); + + const { set, clear } = useApprovalPin(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: { currentPassword: "", pin: "" } }); + + if (!user || user.role !== "Admin") return null; + + const onGenerate = handleSubmit(async ({ currentPassword }) => { + try { + const result = await set.mutateAsync({ currentPassword, pin: null }); + setRevealedPin(result.pin); + reset(); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + const onSetChosen = handleSubmit(async ({ currentPassword, pin }) => { + if (!/^\d{4}$/.test(pin)) { + toast.error("The approval PIN must be exactly 4 digits."); + return; + } + + try { + await set.mutateAsync({ currentPassword, pin }); + setRevealedPin(pin); + reset(); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + const onClear = async () => { + try { + await clear.mutateAsync(); + setRevealedPin(null); + toast.success("Your approval PIN was removed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const copyPin = async () => { + if (!revealedPin) return; + + await navigator.clipboard.writeText(revealedPin); + toast.success("PIN copied to clipboard."); + }; + + return ( + + + + + Approval PIN + + + A 4-digit PIN staff will ask you to enter at the till to authorise actions like + cancelling an order. Only you know it — it is stored as a one-way hash, never in + plain text. + + + + + {revealedPin && ( + + + + + Your new PIN is {revealedPin}. + Make a note of it now — it won't be shown again. + + + + + )} + +

+ Status:{" "} + + {user.hasApprovalPin ? "A PIN is currently set." : "No PIN has been set yet."} + +

+ +
+ + + + + + + +
+ +
+ + + {user.hasApprovalPin && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx new file mode 100644 index 0000000..546f196 --- /dev/null +++ b/frontend/src/pages/account/index.tsx @@ -0,0 +1,140 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useAuth, useChangePassword } from "@/features/auth"; +import { ChangePasswordForm, PASSWORD_HINT, changePasswordSchema } from "@/features/users/model/userSchema"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FormField, + Input, +} from "@/shared/ui"; +import { ApprovalPinCard } from "./ApprovalPinCard"; + +export default function AccountPage() { + const { user } = useAuth(); + + if (!user) return null; + + return ( +
+
+

My account

+

+ Manage your sign-in details{user.role === "Admin" ? " and approval PIN" : ""}. +

+
+ + + + Profile + + +
+

Full name

+

{user.fullName}

+
+
+

Username

+

@{user.username}

+
+
+

Email

+

{user.email ?? "—"}

+
+
+

Role

+ {user.role} +
+
+
+ + + + {user.role === "Admin" && } +
+ ); +} + +function ChangePasswordCard() { + const changePassword = useChangePassword(); + + const { + register, + handleSubmit, + reset, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => { + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + toast.success("Your password has been updated."); + reset(); + } catch (error) { + const apiError = toApiError(error); + + if (apiError.code === "Auth.PasswordMismatch") { + setError("currentPassword", { message: apiError.message }); + } else { + setError("newPassword", { message: apiError.message }); + } + } + }); + + return ( + + + Change password + This signs you out on every other device you're signed in on. + + +
+ + + + + + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/change-password/index.tsx b/frontend/src/pages/change-password/index.tsx new file mode 100644 index 0000000..8b2fbe5 --- /dev/null +++ b/frontend/src/pages/change-password/index.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { KeyRound } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { useAuth, useChangePassword } from "@/features/auth"; +import { changePasswordSchema, ChangePasswordForm, PASSWORD_HINT } from "@/features/users/model/userSchema"; +import { toApiError } from "@/shared/api/problem"; +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, FormField, Input } from "@/shared/ui"; + +/** + * Forced for any account with a pending password change (the seeded admin, anyone just + * created, anyone whose password was just reset); reachable voluntarily otherwise. + */ +export default function ChangePasswordPage() { + const { mustChangePassword } = useAuth(); + const changePassword = useChangePassword(); + const navigate = useNavigate(); + + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => { + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + toast.success("Your password has been updated."); + + // This screen sits outside the app shell (no nav chrome), so a forced change must send + // the user on into the app itself rather than leaving them stranded here. + if (mustChangePassword) { + navigate("/", { replace: true }); + } + } catch (error) { + const apiError = toApiError(error); + + if (apiError.code === "Auth.PasswordMismatch") { + setError("currentPassword", { message: apiError.message }); + } else { + setError("newPassword", { message: apiError.message }); + } + } + }); + + return ( +
+ + +
+ +
+ {mustChangePassword ? "Choose a new password" : "Change your password"} + + {mustChangePassword + ? "For your security, you must set your own password before continuing." + : "Signing out everywhere else you're currently signed in."} + +
+ + +
+ + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/checkout/index.tsx b/frontend/src/pages/checkout/index.tsx deleted file mode 100644 index 7c455c0..0000000 --- a/frontend/src/pages/checkout/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function CheckoutPage() { - return ( -
-

POS Terminal Checkout

-
- ); -} diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index 5f61f1d..3d2354e 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -1,7 +1,69 @@ +import { Link } from "react-router-dom"; +import { groupModules } from "@/entities/user"; +import { useAuth, useModules } from "@/features/auth"; +import { MODULE_ROUTES, DEFAULT_MODULE_ICON } from "@/shared/config/moduleRoutes"; +import { Card, CardContent, LoadingState } from "@/shared/ui"; + export default function DashboardPage() { + const { user, can } = useAuth(); + const { data: catalog, isLoading } = useModules(); + + if (isLoading || !catalog) { + return ; + } + + const accessible = groupModules(catalog) + .map((group) => ({ ...group, modules: group.modules.filter((m) => can(m.module)) })) + .filter((group) => group.modules.length > 0); + return ( -
-

Dashboard

+
+
+

Hi welcome back, {user?.fullName?.split(" ")[0]}

+

Here's what you can open today.

+
+ + {accessible.map(({ group, modules }) => ( +
+

+ {group} +

+
+ {modules.map((descriptor) => { + const route = MODULE_ROUTES[descriptor.module]; + const Icon = route?.icon ?? DEFAULT_MODULE_ICON; + const content = ( + + + + + +
+

{descriptor.name}

+

{descriptor.description}

+ {!route?.path && ( +

+ Coming soon +

+ )} +
+
+
+ ); + + return route?.path ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+
+ ))}
); } diff --git a/frontend/src/pages/inventory/kitchen/index.tsx b/frontend/src/pages/inventory/kitchen/index.tsx new file mode 100644 index 0000000..6a934aa --- /dev/null +++ b/frontend/src/pages/inventory/kitchen/index.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { AlertTriangle, ClipboardList, History } from "lucide-react"; +import { StockAdjustmentDialog, useKitchenMovements, useKitchenStock } from "@/features/inventory"; +import { useRawMaterials } from "@/features/raw-materials"; +import { Button, Card, SegmentedTabs } from "@/shared/ui"; +import { MovementHistoryTable } from "../main-store/MovementHistoryTable"; +import { StockTable } from "../main-store/StockTable"; + +type Tab = "stock" | "history"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "stock", label: "Current Stock", icon: }, + { value: "history", label: "History", icon: }, +]; + +export default function KitchenPage() { + const [tab, setTab] = useState("stock"); + const [adjustmentDialogOpen, setAdjustmentDialogOpen] = useState(false); + + const { data: stock, isLoading: stockLoading } = useKitchenStock(); + const { data: movements, isLoading: movementsLoading } = useKitchenMovements(); + const { data: rawMaterials } = useRawMaterials(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + const lowStockCount = (stock ?? []).filter((s) => s.isLowStock).length; + + return ( +
+
+
+

Kitchen Stock Tracking

+

+ Stock here comes only from an approved release from the Main Store, and is consumed automatically as + orders are prepared. +

+
+ +
+ + {lowStockCount > 0 && ( +
+ + + {lowStockCount} raw material{lowStockCount === 1 ? " is" : "s are"} running low — consider requesting a + release from the Main Store. + +
+ )} + + setTab(v as Tab)} /> + + + {tab === "stock" && } + {tab === "history" && } + + + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx b/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx new file mode 100644 index 0000000..0aba586 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/GoodsReceivedTable.tsx @@ -0,0 +1,52 @@ +import { Truck } from "lucide-react"; +import type { GoodsReceivedNoteSummary } from "@/entities/inventory"; +import { EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +export function GoodsReceivedTable({ + notes, + isLoading, +}: { + notes: GoodsReceivedNoteSummary[] | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ; + } + + if (!notes || notes.length === 0) { + return ( + } + title="No goods received yet" + description="Record stock coming in from a supplier with “Receive goods”." + /> + ); + } + + return ( + + + + Date + Supplier + Raw materials + Received by + Notes + + + + {notes.map((note) => ( + + + {new Date(note.receivedAtUtc).toLocaleString()} + + {note.supplierName} + {note.lineCount} + {note.receivedByName} + {note.notes ?? "—"} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx b/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx new file mode 100644 index 0000000..10a5f92 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/MovementHistoryTable.tsx @@ -0,0 +1,62 @@ +import { History } from "lucide-react"; +import type { StockMovement, StockMovementType } from "@/entities/inventory"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { Badge, EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +const TYPE_LABELS: Record = { + GoodsReceived: "Goods received", + StockReleaseOut: "Released to kitchen", + StockReleaseIn: "Received from store", + Adjustment: "Adjustment", + Consumption: "Sale", +}; + +export function MovementHistoryTable({ movements, isLoading }: { movements: StockMovement[] | undefined; isLoading: boolean }) { + if (isLoading) { + return ; + } + + if (!movements || movements.length === 0) { + return ( + } + title="No stock movements yet" + description="Receiving goods, releasing stock and adjustments will all show up here." + /> + ); + } + + return ( + + + + When + Raw material + Type + Change + By + Notes + + + + {movements.map((m) => ( + + + {new Date(m.occurredAtUtc).toLocaleString()} + + {m.rawMaterialName} + + {TYPE_LABELS[m.type]} + + + {m.quantityDelta > 0 ? "+" : ""} + {m.quantityDelta} {UNIT_ABBREVIATIONS[m.unitOfMeasurement]} + + {m.performedByName} + {m.notes ?? "—"} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx b/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx new file mode 100644 index 0000000..a028c83 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/RawMaterialsTable.tsx @@ -0,0 +1,120 @@ +import { MoreHorizontal, Package, Pencil, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { RawMaterial } from "@/entities/raw-material"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { useRawMaterialMutations } from "@/features/raw-materials"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function RawMaterialsTable({ + rawMaterials, + isLoading, + onEdit, +}: { + rawMaterials: RawMaterial[] | undefined; + isLoading: boolean; + onEdit: (rawMaterial: RawMaterial) => void; +}) { + const { setActive } = useRawMaterialMutations(); + + const toggleActive = async (material: RawMaterial) => { + try { + await setActive.mutateAsync({ id: material.id, isActive: !material.isActive }); + toast.success(material.isActive ? `${material.name} was deactivated.` : `${material.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!rawMaterials || rawMaterials.length === 0) { + return ( + } + title="No raw materials yet" + description="Add one to start building recipes and tracking stock." + /> + ); + } + + return ( + + + + Name + Unit + Main Store reorder level + Kitchen par level + Status + + + + + {rawMaterials.map((material) => ( + + {material.name} + + {UNIT_ABBREVIATIONS[material.unitOfMeasurement]} + + + {material.mainStoreReorderLevel ?? "—"} + + + {material.kitchenParLevel ?? "—"} + + + {material.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + onEdit(material)}> + Edit + + toggleActive(material)}> + {material.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/StockTable.tsx b/frontend/src/pages/inventory/main-store/StockTable.tsx new file mode 100644 index 0000000..9f5b40c --- /dev/null +++ b/frontend/src/pages/inventory/main-store/StockTable.tsx @@ -0,0 +1,43 @@ +import { PackageOpen } from "lucide-react"; +import type { StockLevel } from "@/entities/inventory"; +import { UNIT_ABBREVIATIONS } from "@/entities/raw-material"; +import { Badge, EmptyState, LoadingState, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui"; + +export function StockTable({ stock, isLoading }: { stock: StockLevel[] | undefined; isLoading: boolean }) { + if (isLoading) { + return ; + } + + if (!stock || stock.length === 0) { + return ( + } + title="No raw materials yet" + description="Add a raw material to start tracking stock." + /> + ); + } + + return ( + + + + Raw material + On hand + + + + + {stock.map((row) => ( + + {row.rawMaterialName} + + {row.quantityOnHand} {UNIT_ABBREVIATIONS[row.unitOfMeasurement]} + + {row.isLowStock && Low stock} + + ))} + +
+ ); +} diff --git a/frontend/src/pages/inventory/main-store/index.tsx b/frontend/src/pages/inventory/main-store/index.tsx new file mode 100644 index 0000000..e9228f7 --- /dev/null +++ b/frontend/src/pages/inventory/main-store/index.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { AlertTriangle, ClipboardList, History, Package, Plus, Warehouse } from "lucide-react"; +import type { RawMaterial } from "@/entities/raw-material"; +import { GoodsReceivedNoteDialog, StockAdjustmentDialog, useGoodsReceivedNotes, useMainStoreMovements, useMainStoreStock } from "@/features/inventory"; +import { RawMaterialFormDialog, useRawMaterials } from "@/features/raw-materials"; +import { useSuppliers } from "@/features/suppliers"; +import { Button, Card, SegmentedTabs } from "@/shared/ui"; +import { GoodsReceivedTable } from "./GoodsReceivedTable"; +import { MovementHistoryTable } from "./MovementHistoryTable"; +import { RawMaterialsTable } from "./RawMaterialsTable"; +import { StockTable } from "./StockTable"; + +type Tab = "stock" | "raw-materials" | "goods-received" | "history"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "stock", label: "Current Stock", icon: }, + { value: "raw-materials", label: "Raw Materials", icon: }, + { value: "goods-received", label: "Goods Received", icon: }, + { value: "history", label: "History", icon: }, +]; + +export default function MainStorePage() { + const [tab, setTab] = useState("stock"); + + const { data: stock, isLoading: stockLoading } = useMainStoreStock(); + const { data: rawMaterials, isLoading: rawMaterialsLoading } = useRawMaterials(); + // Suppliers are managed on their own screen; this page only needs the active list for the GRN dialog. + const { data: suppliers } = useSuppliers({ isActive: true }); + const { data: goodsReceived, isLoading: goodsReceivedLoading } = useGoodsReceivedNotes(); + const { data: movements, isLoading: movementsLoading } = useMainStoreMovements(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + const activeSuppliers = suppliers ?? []; + const lowStockCount = (stock ?? []).filter((s) => s.isLowStock).length; + + const [rawMaterialForm, setRawMaterialForm] = useState(undefined); + const [grnDialogOpen, setGrnDialogOpen] = useState(false); + const [adjustmentDialogOpen, setAdjustmentDialogOpen] = useState(false); + + return ( +
+
+
+

Store Stock Management

+

+ Receive goods from suppliers and keep the Main Store's stock accurate. +

+
+
+ + +
+
+ + {lowStockCount > 0 && ( +
+ + + {lowStockCount} raw material{lowStockCount === 1 ? " is" : "s are"} at or below its reorder level. + +
+ )} + + setTab(v as Tab)} /> + + + {tab === "stock" && } + + {tab === "raw-materials" && ( + <> +
+ +
+ + + )} + + {tab === "goods-received" && } + + {tab === "history" && } +
+ + !open && setRawMaterialForm(undefined)} + rawMaterial={rawMaterialForm ?? undefined} + /> + + + + +
+ ); +} diff --git a/frontend/src/pages/inventory/releases/index.tsx b/frontend/src/pages/inventory/releases/index.tsx new file mode 100644 index 0000000..83ff100 --- /dev/null +++ b/frontend/src/pages/inventory/releases/index.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { PackageSearch, Plus } from "lucide-react"; +import { StockReleaseDialog, useStockReleases } from "@/features/inventory"; +import { useRawMaterials } from "@/features/raw-materials"; +import { + Button, + Card, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export default function ReleasesPage() { + const [dialogOpen, setDialogOpen] = useState(false); + const { data: releases, isLoading } = useStockReleases(); + const { data: rawMaterials } = useRawMaterials(); + + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + + return ( +
+
+
+

Kitchen Stock Release

+

+ Move stock from the Main Store to the Kitchen, authorised by an administrator's PIN. +

+
+ +
+ + + {isLoading ? ( + + ) : !releases || releases.length === 0 ? ( + } + title="No stock has been released yet" + description="Release raw materials to the kitchen with the button above." + /> + ) : ( + + + + Date + Raw materials + Requested by + Approved by + Notes + + + + {releases.map((release) => ( + + + {new Date(release.requestedAtUtc).toLocaleString()} + + {release.lineCount} + {release.requestedByName} + {release.approvedByName} + + {release.notes ?? "—"} + + + ))} + +
+ )} +
+ + +
+ ); +} diff --git a/frontend/src/pages/kitchen/index.tsx b/frontend/src/pages/kitchen/index.tsx new file mode 100644 index 0000000..51e8d4f --- /dev/null +++ b/frontend/src/pages/kitchen/index.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { ChefHat, CircleCheck, Clock, Printer, TriangleAlert } from "lucide-react"; +import { toast } from "sonner"; +import type { KitchenTicket } from "@/entities/kitchen"; +import { nextTicketStatus, TICKET_ACTION_LABELS } from "@/entities/kitchen"; +import type { KitchenTicketStatus } from "@/entities/order"; +import { useKitchenMutations, useKitchenTickets } from "@/features/kitchen"; +import { toApiError } from "@/shared/api/problem"; +import { Badge, Button, Card, EmptyState, LoadingState } from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +/** How long a ticket may wait before the card starts shouting about it. */ +const LATE_AFTER_MINUTES = 15; +const OVERDUE_AFTER_MINUTES = 25; + +const STATUS_BADGE: Record = { + New: "outline", + Preparing: "warning", + Ready: "success", + Served: "secondary", +}; + +function TicketCard({ + ticket, + onAdvance, + onReprint, + busy, +}: { + ticket: KitchenTicket; + onAdvance: (ticket: KitchenTicket, status: KitchenTicketStatus) => void; + onReprint: (ticket: KitchenTicket) => void; + busy: boolean; +}) { + const next = nextTicketStatus(ticket.status); + const isCancellation = ticket.kind === "Cancellation"; + const overdue = ticket.waitingMinutes >= OVERDUE_AFTER_MINUTES; + const late = ticket.waitingMinutes >= LATE_AFTER_MINUTES; + + return ( + +
+
+

Table {ticket.tableNumber}

+

+ Order #{String(ticket.orderNumber ?? 0).padStart(3, "0")} · KOT-{ticket.ticketNumber} +

+
+
+ + {isCancellation ? "Cancelled" : ticket.status} + + + + {ticket.waitingMinutes}m + +
+
+ + {ticket.kind !== "New" && !isCancellation && ( + + + {ticket.kind === "Addition" ? "Added to an existing order" : "Quantity changed"} + + )} + +
    + {ticket.lines.map((line, index) => ( +
  • + {line.quantity}× +
    +

    + {line.menuItemName} +

    + {line.specialInstructions && ( +

    {line.specialInstructions}

    + )} + {line.note &&

    ** {line.note} **

    } +
    +
  • + ))} +
+ +
+ {next && ( + + )} + +
+
+ ); +} + +/** + * The kitchen display (POS "Kitchen Operations"). + * + * A wall screen nobody touches except to bump a ticket forward, so it refreshes itself and leans + * on size and colour: table number and quantities are the largest things on each card, and a + * ticket that has been waiting turns amber and then red on its own. + */ +export default function KitchenDisplayPage() { + const [showServed, setShowServed] = useState(false); + const { data: tickets, isLoading } = useKitchenTickets(showServed); + const { advance, reprint } = useKitchenMutations(); + + const busy = advance.isPending || reprint.isPending; + + const advanceTicket = async (ticket: KitchenTicket, status: KitchenTicketStatus) => { + try { + await advance.mutateAsync({ id: ticket.id, status }); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const reprintTicket = async (ticket: KitchenTicket) => { + try { + await reprint.mutateAsync(ticket.id); + toast.success("Slip sent to the printer."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const waiting = (tickets ?? []).filter((t) => t.status !== "Served").length; + + return ( +
+
+
+

Kitchen

+

+ {waiting === 0 ? "Nothing waiting." : `${waiting} ticket${waiting === 1 ? "" : "s"} on the pass`} +

+
+ +
+ + {isLoading ? ( + + ) : !tickets || tickets.length === 0 ? ( + : } + title="Nothing to cook" + description="New orders appear here the moment the till confirms them." + /> + ) : ( +
+ {tickets.map((ticket) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index f149ccc..ab195b1 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -1,7 +1,77 @@ +import { useState } from "react"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { useLogin } from "@/features/auth"; +import { toApiError } from "@/shared/api/problem"; +import { Alert, AlertDescription, Button, Card, CardContent, FormField, Input } from "@/shared/ui"; + +interface LoginFormValues { + username: string; + password: string; +} + export default function LoginPage() { + const login = useLogin(); + const [formError, setFormError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ defaultValues: { username: "", password: "" } }); + + const onSubmit = handleSubmit(async ({ username, password }) => { + setFormError(null); + + try { + await login.mutateAsync({ username, password }); + } catch (error) { + setFormError(toApiError(error).message); + } + }); + return ( -
-

Login Screen

+
+ + +
+
+ SL +
+

Sri Lakshmi Family Restaurant

+

Sign in to the POS terminal

+
+ +
+ {formError && ( + + + {formError} + + )} + + + + + + + + + + +
+
+
); } diff --git a/frontend/src/pages/pos/TableCard.tsx b/frontend/src/pages/pos/TableCard.tsx new file mode 100644 index 0000000..deb09e9 --- /dev/null +++ b/frontend/src/pages/pos/TableCard.tsx @@ -0,0 +1,136 @@ +import { ChefHat, CircleCheck, Clock, CreditCard, Pencil, UtensilsCrossed } from "lucide-react"; +import type { RestaurantTable, TableDisplayStatus } from "@/entities/table"; +import { tableDisplayStatus } from "@/entities/table"; +import { Badge } from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +/** + * How each state paints the tile. Colour is doing real work on this screen: the cashier reads + * the room at a glance from across the till, so "food is ready to carry out" has to be findable + * without reading any words. + */ +const STATUS_STYLES: Record< + TableDisplayStatus, + { label: string; badge: "default" | "secondary" | "success" | "warning" | "destructive" | "outline"; card: string; icon: React.ReactNode } +> = { + Available: { + label: "Available", + badge: "outline", + card: "border-dashed hover:border-primary/50 hover:bg-primary/5", + icon: null, + }, + Draft: { + label: "Taking order", + badge: "secondary", + card: "border-muted-foreground/40 bg-muted/40", + icon: , + }, + Ordered: { + label: "Sent to kitchen", + badge: "default", + card: "border-primary/40 bg-primary/5", + icon: , + }, + Preparing: { + label: "Preparing", + badge: "warning", + card: "border-warning/50 bg-warning/5", + icon: , + }, + Ready: { + label: "Ready to serve", + badge: "success", + card: "border-success/60 bg-success/10", + icon: , + }, + Served: { + label: "Served", + badge: "secondary", + card: "border-success/30 bg-success/5", + icon: , + }, + Checkout: { + label: "Paying", + badge: "warning", + card: "border-warning/60 bg-warning/10", + icon: , + }, +}; + +export function TableCard({ + table, + onOpen, + busy, +}: { + table: RestaurantTable; + onOpen: (table: RestaurantTable) => void; + busy: boolean; +}) { + const status = tableDisplayStatus(table); + const style = STATUS_STYLES[status]; + const order = table.currentOrder; + + return ( + + ); +} + +/** A compact legend, so a new cashier can learn the colours without being told. */ +export function TableStatusLegend() { + const shown: TableDisplayStatus[] = ["Available", "Ordered", "Preparing", "Ready", "Checkout"]; + + return ( +
+ {shown.map((status) => ( + + {STATUS_STYLES[status].icon} + {STATUS_STYLES[status].label} + + ))} +
+ ); +} + +export { STATUS_STYLES }; diff --git a/frontend/src/pages/pos/checkout/index.tsx b/frontend/src/pages/pos/checkout/index.tsx new file mode 100644 index 0000000..5b7669e --- /dev/null +++ b/frontend/src/pages/pos/checkout/index.tsx @@ -0,0 +1,304 @@ +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ArrowLeft, Banknote, CheckCircle2, Plus, Printer, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { OrderPaymentMethod } from "@/entities/order"; +import { ORDER_PAYMENT_METHODS, PAYMENT_METHOD_LABELS } from "@/entities/order"; +import { useOrder, useOrderMutations } from "@/features/orders"; +import { toApiError } from "@/shared/api/problem"; +import { + Alert, + AlertDescription, + Badge, + Button, + Card, + Input, + Label, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, +} from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +/** One tender line being keyed in. Amounts stay as strings until submitted so the field can be empty. */ +interface TenderDraft { + id: string; + method: OrderPaymentMethod; + amount: string; + tendered: string; +} + +const newTender = (amount: string): TenderDraft => ({ + id: crypto.randomUUID(), + method: "Cash", + amount, + tendered: "", +}); + +/** + * The payment screen (POS-023, POS-024). + * + * Starts with a single tender pre-filled to the whole bill, since that is what almost every table + * does; splitting is one tap away for the ones that do not. Nothing is sent until the tenders add + * up to the bill exactly (BR-POS-013), so the button says why it is disabled rather than failing + * after the customer has handed over money. + */ +export default function CheckoutScreen() { + const { orderId } = useParams<{ orderId: string }>(); + const navigate = useNavigate(); + + const { data: order, isLoading } = useOrder(orderId); + const { pay, reopen, reprintReceipt } = useOrderMutations(); + + const [tenders, setTenders] = useState(null); + + // Seeded from the bill on first render, once the order has actually loaded. + const rows = useMemo(() => { + if (tenders) return tenders; + if (!order) return []; + + return [newTender(order.total.toFixed(2))]; + }, [tenders, order]); + + if (isLoading || !order) { + return ; + } + + const isSettled = order.status === "Completed"; + const paidTotal = rows.reduce((sum, row) => sum + (Number(row.amount) || 0), 0); + const remaining = Math.round((order.total - paidTotal) * 100) / 100; + + const changeDue = rows.reduce((sum, row) => { + const tendered = Number(row.tendered); + const amount = Number(row.amount) || 0; + + return sum + (Number.isFinite(tendered) && tendered > amount ? tendered - amount : 0); + }, 0); + + const update = (id: string, patch: Partial) => + setTenders(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + + const addTender = () => setTenders([...rows, newTender(remaining > 0 ? remaining.toFixed(2) : "")]); + + const removeTender = (id: string) => setTenders(rows.filter((row) => row.id !== id)); + + const completePayment = async () => { + try { + await pay.mutateAsync({ + id: order.id, + payments: rows.map((row) => ({ + method: row.method, + amount: Number(row.amount), + tenderedAmount: row.tendered.trim() === "" ? null : Number(row.tendered), + reference: null, + })), + }); + toast.success("Payment complete. Receipt printed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const backToOrder = async () => { + try { + await reopen.mutateAsync(order.id); + navigate(`/pos/orders/${order.id}`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isSettled) { + return ( +
+ + +
+

Payment complete

+

+ Table {order.tableNumber} is free again · Receipt {order.receiptNumber} +

+
+

{order.total.toFixed(2)}

+ {order.changeDue > 0 && ( +

+ Change given: {order.changeDue.toFixed(2)} +

+ )} + +
+ + +
+
+
+ ); + } + + return ( +
+
+ +
+

Checkout · Table {order.tableNumber}

+ {order.orderNumber && ( + Order #{String(order.orderNumber).padStart(3, "0")} + )} +
+
+ + +

Bill

+
    + {order.items + .filter((item) => !item.isCancelled) + .map((item) => ( +
  • + + {item.menuItemName} × {item.quantity} + + {item.lineTotal.toFixed(2)} +
  • + ))} +
+ + + +
+
+
Subtotal
+
{order.subtotal.toFixed(2)}
+
+
+
Discount
+
−{order.discountAmount.toFixed(2)}
+
+
+
Total
+
{order.total.toFixed(2)}
+
+
+
+ + +
+

Payment

+ +
+ + {rows.map((row, index) => ( +
+
+ + +
+ +
+ + update(row.id, { amount: event.target.value })} + className="tabular" + /> +
+ +
+ + update(row.id, { tendered: event.target.value })} + placeholder={row.method === "Cash" ? "For change" : "—"} + disabled={row.method !== "Cash"} + className="tabular" + /> +
+ + +
+ ))} + + + +
+
+ Total paid + {paidTotal.toFixed(2)} +
+
+ + {remaining > 0 ? "Still to pay" : remaining < 0 ? "Over by" : "Balance"} + + {Math.abs(remaining).toFixed(2)} +
+ {changeDue > 0 && ( +
+ Change + {changeDue.toFixed(2)} +
+ )} +
+ + {remaining !== 0 && ( + + + + The payments must come to exactly {order.total.toFixed(2)} before the bill can be settled. + + + )} + + +
+
+ ); +} diff --git a/frontend/src/pages/pos/index.tsx b/frontend/src/pages/pos/index.tsx new file mode 100644 index 0000000..aece130 --- /dev/null +++ b/frontend/src/pages/pos/index.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { LayoutGrid, Plus, Search, Settings2 } from "lucide-react"; +import { toast } from "sonner"; +import type { RestaurantTable } from "@/entities/table"; +import { tableDisplayStatus } from "@/entities/table"; +import { useOrderMutations } from "@/features/orders"; +import { useTables } from "@/features/tables"; +import { toApiError } from "@/shared/api/problem"; +import { Button, Card, EmptyState, Input, LoadingState } from "@/shared/ui"; +import { TableCard, TableStatusLegend } from "./TableCard"; + +/** + * The cashier's home screen: the whole room at a glance (POS-034), with every table one tap from + * its bill (POS-035). Tapping a free table starts an order; tapping a busy one opens it. + */ +export default function PosDashboardPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + + const { data: tables, isLoading } = useTables(); + const { create } = useOrderMutations(); + + const visibleTables = useMemo(() => { + const active = (tables ?? []).filter((t) => t.isActive); + const term = search.trim().toLowerCase(); + + if (!term) return active; + + return active.filter( + (t) => + t.number.toLowerCase().includes(term) || + String(t.currentOrder?.orderNumber ?? "").includes(term), + ); + }, [tables, search]); + + const openTables = (tables ?? []).filter((t) => t.currentOrder); + const readyCount = (tables ?? []).filter((t) => tableDisplayStatus(t) === "Ready").length; + + const openTable = async (table: RestaurantTable) => { + if (table.currentOrder) { + navigate(`/pos/orders/${table.currentOrder.orderId}`); + return; + } + + try { + const order = await create.mutateAsync(table.id); + navigate(`/pos/orders/${order.id}`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

POS & Billing

+

+ {openTables.length === 0 + ? "Every table is free." + : `${openTables.length} table${openTables.length === 1 ? "" : "s"} open` + + (readyCount > 0 ? ` · ${readyCount} ready to serve` : "")} +

+
+ +
+
+ + setSearch(event.target.value)} + placeholder="Table or order number" + className="w-56 pl-9" + aria-label="Search tables by number or order number" + /> +
+ +
+
+ + + + + {isLoading ? ( + + ) : visibleTables.length === 0 ? ( + } + title={search ? "No table matches that" : "No tables yet"} + description={ + search + ? "Try a different table or order number." + : "Add the restaurant's tables before taking orders." + } + /> + ) : ( +
+ {visibleTables.map((table) => ( + + ))} +
+ )} +
+ + {!isLoading && (tables ?? []).length === 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/pos/order/AddItemDialog.tsx b/frontend/src/pages/pos/order/AddItemDialog.tsx new file mode 100644 index 0000000..22c4a34 --- /dev/null +++ b/frontend/src/pages/pos/order/AddItemDialog.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { Minus, Plus } from "lucide-react"; +import type { MenuItem } from "@/entities/menu-item"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; + +export interface AddItemDialogProps { + /** The dish being added, or null when the dialog is closed. */ + item: MenuItem | null; + onOpenChange: (open: boolean) => void; + onAdd: (quantity: number, specialInstructions: string | null) => Promise; + pending: boolean; +} + +/** + * Confirms quantity and any special instructions for one dish (POS-004, POS-005). + * + * Opens on every pick rather than only when instructions are wanted, because "no chilli" is the + * kind of thing a customer says in passing and a cashier has no second chance to capture once + * the KOT has printed. + */ +export function AddItemDialog({ item, onOpenChange, onAdd, pending }: AddItemDialogProps) { + const [quantity, setQuantity] = useState(1); + const [instructions, setInstructions] = useState(""); + + useEffect(() => { + if (item) { + setQuantity(1); + setInstructions(""); + } + }, [item]); + + const submit = async () => { + await onAdd(quantity, instructions.trim() || null); + }; + + return ( + + + + {item?.name} + + {item ? `${item.price.toFixed(2)} each · ${item.category}` : ""} + + + +
+ +
+ + { + const parsed = Number(event.target.value.replace(/\D/g, "")); + setQuantity(Number.isFinite(parsed) && parsed > 0 ? parsed : 1); + }} + className="w-20 text-center text-lg tabular" + /> + + + {item ? (item.price * quantity).toFixed(2) : ""} + +
+
+ + + setInstructions(event.target.value)} + placeholder="e.g. No chilli" + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submit(); + } + }} + /> + +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/pages/pos/order/BillPanel.tsx b/frontend/src/pages/pos/order/BillPanel.tsx new file mode 100644 index 0000000..38cb26b --- /dev/null +++ b/frontend/src/pages/pos/order/BillPanel.tsx @@ -0,0 +1,132 @@ +import { Minus, Plus, Trash2 } from "lucide-react"; +import type { Order, OrderItem } from "@/entities/order"; +import { Badge, Button, EmptyState, Separator } from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +/** + * The bill as the customer would read it (POS-037). + * + * Voided lines stay visible, struck through, rather than disappearing: the cashier needs to see + * that the sandwich was taken off, not wonder whether they ever keyed it in. + */ +export function BillPanel({ + order, + onChangeQuantity, + onVoid, + busy, +}: { + order: Order; + onChangeQuantity: (item: OrderItem, quantity: number) => void; + onVoid: (item: OrderItem) => void; + busy: boolean; +}) { + const editable = order.status === "Draft" || order.status === "Open"; + + if (order.items.length === 0) { + return ( + + ); + } + + return ( +
+
    + {order.items.map((item) => ( +
  • +
    +
    +

    + {item.menuItemName} +

    +

    + {item.quantity} × {item.unitPrice.toFixed(2)} +

    + {item.specialInstructions && ( +

    + {item.specialInstructions} +

    + )} + {item.isCancelled && ( + + Voided + + )} +
    + +
    + + {(item.unitPrice * item.quantity).toFixed(2)} + +
    +
    + + {editable && !item.isCancelled && ( +
    + + {item.quantity} + + +
    + )} +
  • + ))} +
+ + + +
+
+
Subtotal
+
{order.subtotal.toFixed(2)}
+
+
+
+ Discount + {order.discountType === "Percentage" && order.discountValue > 0 && ` (${order.discountValue}%)`} +
+
−{order.discountAmount.toFixed(2)}
+
+
+
Total
+
{order.total.toFixed(2)}
+
+
+
+ ); +} diff --git a/frontend/src/pages/pos/order/DiscountDialog.tsx b/frontend/src/pages/pos/order/DiscountDialog.tsx new file mode 100644 index 0000000..d9e971b --- /dev/null +++ b/frontend/src/pages/pos/order/DiscountDialog.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import type { DiscountType, Order } from "@/entities/order"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +export interface DiscountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + order: Order; + onApply: (type: DiscountType, value: number) => Promise; + pending: boolean; +} + +const TYPES: { value: DiscountType; label: string }[] = [ + { value: "None", label: "No discount" }, + { value: "Percentage", label: "Percentage" }, + { value: "Fixed", label: "Fixed amount" }, +]; + +/** Applies money off the bill (POS-007), never more than the bill itself (BR-POS-010). */ +export function DiscountDialog({ open, onOpenChange, order, onApply, pending }: DiscountDialogProps) { + const [type, setType] = useState(order.discountType); + const [value, setValue] = useState(order.discountValue ? String(order.discountValue) : ""); + + const parsed = Number(value); + const preview = + type === "Percentage" + ? (order.subtotal * (Number.isFinite(parsed) ? parsed : 0)) / 100 + : Math.min(Number.isFinite(parsed) ? parsed : 0, order.subtotal); + + const submit = async () => { + if (type === "None") { + await onApply("None", 0); + return; + } + + if (!Number.isFinite(parsed) || parsed < 0) { + toast.error("Enter a discount of zero or more."); + return; + } + + if (type === "Percentage" && parsed > 100) { + toast.error("A percentage discount cannot exceed 100%."); + return; + } + + if (type === "Fixed" && parsed > order.subtotal) { + toast.error(`A discount cannot exceed the subtotal of ${order.subtotal.toFixed(2)}.`); + return; + } + + await onApply(type, parsed); + }; + + return ( + + + + Discount + Subtotal is {order.subtotal.toFixed(2)}. + + +
+
+ {TYPES.map((option) => ( + + ))} +
+ + {type !== "None" && ( + + setValue(event.target.value)} + placeholder={type === "Percentage" ? "10" : "100.00"} + autoFocus + /> + + )} + + {type !== "None" && ( +
+
+ Discount + −{preview.toFixed(2)} +
+
+ New total + {Math.max(0, order.subtotal - preview).toFixed(2)} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/pages/pos/order/MenuPicker.tsx b/frontend/src/pages/pos/order/MenuPicker.tsx new file mode 100644 index 0000000..71c6867 --- /dev/null +++ b/frontend/src/pages/pos/order/MenuPicker.tsx @@ -0,0 +1,98 @@ +import { useMemo, useState } from "react"; +import { Search, UtensilsCrossed } from "lucide-react"; +import type { MenuItem } from "@/entities/menu-item"; +import { Button, EmptyState, Input } from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; + +/** + * The menu, grouped by category (POS-002). + * + * Built as big tap targets rather than a dropdown: at a busy till the cashier is looking at the + * customer, not the screen, and a grid can be hit by muscle memory in a way a select cannot. + */ +export function MenuPicker({ + menuItems, + onPick, + disabled, +}: { + menuItems: MenuItem[]; + onPick: (item: MenuItem) => void; + disabled: boolean; +}) { + const [search, setSearch] = useState(""); + const [category, setCategory] = useState("All"); + + const categories = useMemo( + () => ["All", ...Array.from(new Set(menuItems.map((m) => m.category))).sort()], + [menuItems], + ); + + const visible = useMemo(() => { + const term = search.trim().toLowerCase(); + + return menuItems.filter( + (item) => + (category === "All" || item.category === category) && + (term === "" || item.name.toLowerCase().includes(term)), + ); + }, [menuItems, category, search]); + + return ( +
+
+ + setSearch(event.target.value)} + placeholder="Search the menu" + className="pl-9" + aria-label="Search the menu" + /> +
+ +
+ {categories.map((name) => ( + + ))} +
+ +
+ {visible.length === 0 ? ( + } + title="Nothing on the menu matches" + description={menuItems.length === 0 ? "Add menu items in Recipe Management first." : undefined} + /> + ) : ( +
+ {visible.map((item) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/pos/order/index.tsx b/frontend/src/pages/pos/order/index.tsx new file mode 100644 index 0000000..bd52646 --- /dev/null +++ b/frontend/src/pages/pos/order/index.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { ArrowLeft, Ban, CreditCard, Percent, Send } from "lucide-react"; +import { toast } from "sonner"; +import type { MenuItem } from "@/entities/menu-item"; +import type { DiscountType, OrderItem } from "@/entities/order"; +import { useMenuItems } from "@/features/menu-items"; +import { ManagerPinDialog, useOrder, useOrderMutations } from "@/features/orders"; +import { toApiError } from "@/shared/api/problem"; +import { Badge, Button, Card, LoadingState } from "@/shared/ui"; +import { AddItemDialog } from "./AddItemDialog"; +import { BillPanel } from "./BillPanel"; +import { DiscountDialog } from "./DiscountDialog"; +import { MenuPicker } from "./MenuPicker"; + +/** A guarded action waiting on a manager's PIN. */ +interface PendingApproval { + action: string; + run: (pin: string) => Promise; +} + +/** + * One table's bill: the menu on the left, the running total on the right (POS-006). + * + * Which actions need a manager depends on whether the kitchen has the order yet. While it is a + * draft the cashier edits freely; once confirmed, changing or removing a line needs approval + * (BR-POS-007, BR-POS-008) — but adding never does (BR-POS-005). + */ +export default function OrderScreen() { + const { orderId } = useParams<{ orderId: string }>(); + const navigate = useNavigate(); + + const { data: order, isLoading } = useOrder(orderId); + const { data: menuItems } = useMenuItems(); + const mutations = useOrderMutations(); + + const [picked, setPicked] = useState(null); + const [discountOpen, setDiscountOpen] = useState(false); + const [approval, setApproval] = useState(null); + + const busy = + mutations.addItems.isPending || + mutations.changeQuantity.isPending || + mutations.voidItem.isPending || + mutations.confirm.isPending || + mutations.cancel.isPending || + mutations.setDiscount.isPending || + mutations.startCheckout.isPending; + + if (isLoading || !order) { + return ; + } + + const isDraft = order.status === "Draft"; + const isOpen = order.status === "Open"; + const activeItems = order.items.filter((item) => !item.isCancelled); + + /** Runs a command directly on a draft, or behind a PIN prompt once the kitchen has the order. */ + const guarded = (action: string, run: (pin: string | null) => Promise) => { + if (isDraft) { + void run(null).catch((error) => toast.error(toApiError(error).message)); + return; + } + + setApproval({ action, run: (pin) => run(pin) }); + }; + + const addItem = async (quantity: number, specialInstructions: string | null) => { + if (!picked) return; + + try { + await mutations.addItems.mutateAsync({ + id: order.id, + items: [{ menuItemId: picked.id, quantity, specialInstructions }], + }); + setPicked(null); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const changeQuantity = (item: OrderItem, quantity: number) => + guarded(`Change ${item.menuItemName} from ${item.quantity} to ${quantity}.`, async (pin) => { + await mutations.changeQuantity.mutateAsync({ id: order.id, itemId: item.id, quantity, pin }); + }); + + const voidItem = (item: OrderItem) => + guarded(`Remove ${item.menuItemName} from the bill.`, async (pin) => { + await mutations.voidItem.mutateAsync({ id: order.id, itemId: item.id, pin }); + }); + + const cancelOrder = () => + guarded("Cancel this entire order.", async (pin) => { + await mutations.cancel.mutateAsync({ id: order.id, pin, reason: null }); + toast.success("Order cancelled."); + navigate("/pos"); + }); + + const confirmOrder = async () => { + try { + await mutations.confirm.mutateAsync(order.id); + toast.success("Order confirmed and sent to the kitchen."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const applyDiscount = async (type: DiscountType, value: number) => { + try { + await mutations.setDiscount.mutateAsync({ id: order.id, type, value }); + setDiscountOpen(false); + toast.success(type === "None" ? "Discount removed." : "Discount applied."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const goToCheckout = async () => { + try { + await mutations.startCheckout.mutateAsync(order.id); + navigate(`/pos/orders/${order.id}/checkout`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+ +
+

Table {order.tableNumber}

+ {order.orderNumber && ( + Order #{String(order.orderNumber).padStart(3, "0")} + )} + {order.status} + {order.kitchenStatus && Kitchen: {order.kitchenStatus}} +
+
+ +
+ {(isDraft || isOpen) && ( + + )} + {(isDraft || isOpen) && ( + + )} + {isDraft && ( + + )} + {isOpen && ( + + )} +
+
+ + {isDraft && ( +

+ Nothing has been sent to the kitchen yet. Confirm the order to print the KOT — after that + you can keep adding items without a manager PIN. +

+ )} + +
+ + m.isActive)} + onPick={setPicked} + disabled={busy || (!isDraft && !isOpen)} + /> + + + +

Current bill

+ +
+
+ + !open && setPicked(null)} + onAdd={addItem} + pending={mutations.addItems.isPending} + /> + + + + !open && setApproval(null)} + action={approval?.action ?? ""} + pending={busy} + onConfirm={async (pin) => { + try { + await approval!.run(pin); + } catch (error) { + // Rethrown so the dialog stays open and shows how many attempts are left. + throw new Error(toApiError(error).message); + } + }} + /> +
+ ); +} diff --git a/frontend/src/pages/pos/tables/index.tsx b/frontend/src/pages/pos/tables/index.tsx new file mode 100644 index 0000000..a557807 --- /dev/null +++ b/frontend/src/pages/pos/tables/index.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowLeft, LayoutGrid, MoreHorizontal, Pencil, Plus, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { RestaurantTable } from "@/entities/table"; +import { TableFormDialog, useTableMutations, useTables } from "@/features/tables"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +/** The floor plan's setup screen: which tables exist and what they are called. */ +export default function TableManagementPage() { + // undefined = closed, null = adding, a table = editing. + const [form, setForm] = useState(undefined); + const { data: tables, isLoading } = useTables({ pollMs: 0 }); + const { setActive } = useTableMutations(); + + const toggleActive = async (table: RestaurantTable) => { + try { + await setActive.mutateAsync({ id: table.id, isActive: !table.isActive }); + toast.success( + table.isActive + ? `Table ${table.number} is out of service.` + : `Table ${table.number} is back in service.`, + ); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+ +

Tables

+

+ Every table customers can be seated at. A table with a live order cannot be taken out of service. +

+
+ +
+ + + {isLoading ? ( + + ) : !tables || tables.length === 0 ? ( + } + title="No tables yet" + description="Add the restaurant's tables so orders can be taken against them." + /> + ) : ( + + + + Number + Seats + Notes + State + + + + + {tables.map((table) => ( + + {table.number} + {table.seats || "—"} + + {table.notes ?? "—"} + + + {!table.isActive ? ( + Out of service + ) : table.currentOrder ? ( + Occupied + ) : ( + Available + )} + + + + + + + + setForm(table)}> + Edit + + toggleActive(table)} + > + {table.isActive ? ( + <> + Take out of service + + ) : ( + <> + Put back in service + + )} + + + + + + ))} + +
+ )} +
+ + !open && setForm(undefined)} + table={form ?? undefined} + /> +
+ ); +} diff --git a/frontend/src/pages/recipes/index.tsx b/frontend/src/pages/recipes/index.tsx new file mode 100644 index 0000000..d66a29e --- /dev/null +++ b/frontend/src/pages/recipes/index.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { ChefHat, MoreHorizontal, Pencil, Plus, Search, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { MenuItem, MenuItemFilters } from "@/entities/menu-item"; +import { MenuItemFormDialog, useMenuItemMutations, useMenuItems } from "@/features/menu-items"; +import { RecipeEditorDialog } from "@/features/recipes"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const STATUS_FILTER_ALL = "all"; + +const currency = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" }); + +export default function RecipesPage() { + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(STATUS_FILTER_ALL); + + const filters: MenuItemFilters = { + search: search || undefined, + isActive: statusFilter === STATUS_FILTER_ALL ? undefined : statusFilter === "active", + }; + + const { data: items, isLoading } = useMenuItems(filters); + const { setActive } = useMenuItemMutations(); + + const [formItem, setFormItem] = useState(undefined); + const [recipeItem, setRecipeItem] = useState(null); + + const toggleActive = async (item: MenuItem) => { + try { + await setActive.mutateAsync({ id: item.id, isActive: !item.isActive }); + toast.success(item.isActive ? `${item.name} was deactivated.` : `${item.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

Recipe Management

+

+ Manage your menu and attach a recipe to track what each dish consumes. +

+
+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by name or category…" + className="pl-9" + /> +
+ + +
+ + + {isLoading ? ( + + ) : !items || items.length === 0 ? ( + } + title="No menu items match your filters" + description="Try clearing the search or filters, or add a new item." + /> + ) : ( + + + + Name + Category + Price + Recipe + Status + + + + + {items.map((item) => ( + + {item.name} + {item.category} + {currency.format(item.price)} + + {item.hasRecipe ? ( + Recipe set + ) : ( + No recipe + )} + + + {item.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + setRecipeItem(item)}> + Manage recipe + + setFormItem(item)}> + Edit + + toggleActive(item)}> + {item.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ )} +
+ + !open && setFormItem(undefined)} + item={formItem ?? undefined} + /> + + {recipeItem && ( + !open && setRecipeItem(null)} + menuItem={recipeItem} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx b/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx new file mode 100644 index 0000000..bbf2b4d --- /dev/null +++ b/frontend/src/pages/suppliers/PurchaseOrdersTable.tsx @@ -0,0 +1,140 @@ +import { ClipboardList, Eye, MoreHorizontal, Pencil, Send, ShieldCheck, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import type { PurchaseOrderSummary } from "@/entities/supplier"; +import { PURCHASE_ORDER_STATUS_BADGE, usePurchaseOrderMutations } from "@/features/purchase-orders"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function PurchaseOrdersTable({ + orders, + isLoading, + onView, + onEdit, +}: { + orders: PurchaseOrderSummary[] | undefined; + isLoading: boolean; + onView: (order: PurchaseOrderSummary) => void; + onEdit: (order: PurchaseOrderSummary) => void; +}) { + const { submit, confirm, cancel } = usePurchaseOrderMutations(); + + const submitOrder = async (order: PurchaseOrderSummary) => { + try { + await submit.mutateAsync(order.id); + toast.success("Purchase order submitted to the supplier."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const confirmOrder = async (order: PurchaseOrderSummary) => { + try { + await confirm.mutateAsync(order.id); + toast.success("Purchase order confirmed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const cancelOrder = async (order: PurchaseOrderSummary) => { + try { + await cancel.mutateAsync(order.id); + toast.success("Purchase order cancelled."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!orders || orders.length === 0) { + return ( + } + title="No purchase orders yet" + description="Create one to start ordering from a supplier." + /> + ); + } + + return ( + + + + Date + Supplier + Status + Total + Balance + + + + + {orders.map((order) => ( + + + {new Date(order.createdAtUtc).toLocaleDateString()} + + {order.supplierName} + + {order.status} + + {order.totalAmount.toFixed(2)} + {order.balance.toFixed(2)} + + + + + + + onView(order)}> + View details + + {order.status === "Draft" && ( + <> + onEdit(order)}> + Edit draft + + submitOrder(order)}> + Submit to supplier + + + )} + {order.status === "Submitted" && ( + confirmOrder(order)}> + Mark confirmed + + )} + {order.status !== "Delivered" && order.status !== "Cancelled" && ( + cancelOrder(order)}> + Cancel order + + )} + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx b/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx new file mode 100644 index 0000000..09011a6 --- /dev/null +++ b/frontend/src/pages/suppliers/SupplierPerformancePanel.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { BarChart3 } from "lucide-react"; +import type { Supplier } from "@/entities/supplier"; +import { useSupplierPerformance } from "@/features/suppliers"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + EmptyState, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + + + {label} + {value} + + {hint && ( + {hint} + )} + + ); +} + +export function SupplierPerformancePanel({ suppliers }: { suppliers: Supplier[] }) { + const [supplierId, setSupplierId] = useState(suppliers[0]?.id ?? ""); + const { data: performance, isLoading } = useSupplierPerformance(supplierId, !!supplierId); + + if (suppliers.length === 0) { + return ( + } title="Add a supplier first" description="Performance is derived from their orders and deliveries." /> + ); + } + + return ( +
+
+ +
+ + {isLoading ? ( + + ) : !performance ? ( + } title="No data yet" /> + ) : performance.totalOrders === 0 ? ( + } + title="No orders yet" + description="Performance builds up once purchase orders have been placed and delivered." + /> + ) : ( +
+ + + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/SupplierPricingPanel.tsx b/frontend/src/pages/suppliers/SupplierPricingPanel.tsx new file mode 100644 index 0000000..0efeffc --- /dev/null +++ b/frontend/src/pages/suppliers/SupplierPricingPanel.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { History, Pencil, Tag } from "lucide-react"; +import type { Supplier } from "@/entities/supplier"; +import type { RawMaterial } from "@/entities/raw-material"; +import { + SetSupplierPriceDialog, + SupplierPriceHistoryDialog, + useSupplierPrices, +} from "@/features/suppliers"; +import { + Button, + EmptyState, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function SupplierPricingPanel({ suppliers, rawMaterials }: { suppliers: Supplier[]; rawMaterials: RawMaterial[] }) { + const [supplierId, setSupplierId] = useState(suppliers[0]?.id ?? ""); + const { data: prices, isLoading } = useSupplierPrices(supplierId || undefined); + + const [priceDialog, setPriceDialog] = useState<{ rawMaterialId?: string } | null>(null); + const [historyEntry, setHistoryEntry] = useState<{ rawMaterialId: string; rawMaterialName: string } | null>(null); + + const supplier = suppliers.find((s) => s.id === supplierId); + + if (suppliers.length === 0) { + return } title="Add a supplier first" description="Pricing is recorded per supplier." />; + } + + return ( +
+
+
+ +
+ +
+ + {isLoading ? ( + + ) : !prices || prices.length === 0 ? ( + } title="No prices recorded yet" description="Set what this supplier charges for a raw material." /> + ) : ( + + + + Raw material + Price + Updated + + + + + {prices.map((price) => ( + + {price.rawMaterialName} + + {price.price.toFixed(2)} + / {price.unitOfMeasurement} + + + {new Date(price.updatedAtUtc).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+ )} + + {priceDialog && supplier && ( + !open && setPriceDialog(null)} + supplierId={supplier.id} + supplierName={supplier.name} + rawMaterials={rawMaterials} + rawMaterialId={priceDialog.rawMaterialId} + /> + )} + + {historyEntry && supplierId && ( + !open && setHistoryEntry(null)} + supplierId={supplierId} + rawMaterialId={historyEntry.rawMaterialId} + rawMaterialName={historyEntry.rawMaterialName} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/suppliers/SuppliersTable.tsx b/frontend/src/pages/suppliers/SuppliersTable.tsx new file mode 100644 index 0000000..ac1c471 --- /dev/null +++ b/frontend/src/pages/suppliers/SuppliersTable.tsx @@ -0,0 +1,111 @@ +import { MoreHorizontal, Pencil, ShieldCheck, ShieldOff, Truck } from "lucide-react"; +import { toast } from "sonner"; +import type { Supplier } from "@/entities/supplier"; +import { useSupplierMutations } from "@/features/suppliers"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export function SuppliersTable({ + suppliers, + isLoading, + onEdit, +}: { + suppliers: Supplier[] | undefined; + isLoading: boolean; + onEdit: (supplier: Supplier) => void; +}) { + const { setActive } = useSupplierMutations(); + + const toggleActive = async (supplier: Supplier) => { + try { + await setActive.mutateAsync({ id: supplier.id, isActive: !supplier.isActive }); + toast.success(supplier.isActive ? `${supplier.name} was deactivated.` : `${supplier.name} was reactivated.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + if (isLoading) { + return ; + } + + if (!suppliers || suppliers.length === 0) { + return ( + } title="No suppliers yet" description="Add one to get started." /> + ); + } + + return ( + + + + Name + Contact + Phone + Terms + Status + + + + + {suppliers.map((supplier) => ( + + {supplier.name} + {supplier.contactName ?? "—"} + {supplier.phone ?? "—"} + + {supplier.paymentTermsDays === 0 ? "Cash on delivery" : `${supplier.paymentTermsDays} days`} + + + {supplier.isActive ? ( + Active + ) : ( + Deactivated + )} + + + + + + + + onEdit(supplier)}> + Edit + + toggleActive(supplier)}> + {supplier.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/suppliers/index.tsx b/frontend/src/pages/suppliers/index.tsx new file mode 100644 index 0000000..0d9b584 --- /dev/null +++ b/frontend/src/pages/suppliers/index.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { BarChart3, ClipboardList, Plus, Tag, Truck } from "lucide-react"; +import type { PurchaseOrderStatus, PurchaseOrderSummary, Supplier } from "@/entities/supplier"; +import { PURCHASE_ORDER_STATUSES } from "@/entities/supplier"; +import { + PurchaseOrderDetailDialog, + PurchaseOrderFormDialog, + usePurchaseOrder, + usePurchaseOrders, +} from "@/features/purchase-orders"; +import { useRawMaterials } from "@/features/raw-materials"; +import { SupplierFormDialog, useSuppliers } from "@/features/suppliers"; +import { + Button, + Card, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SegmentedTabs, +} from "@/shared/ui"; +import { PurchaseOrdersTable } from "./PurchaseOrdersTable"; +import { SuppliersTable } from "./SuppliersTable"; +import { SupplierPerformancePanel } from "./SupplierPerformancePanel"; +import { SupplierPricingPanel } from "./SupplierPricingPanel"; + +type Tab = "suppliers" | "purchase-orders" | "pricing" | "performance"; + +const TABS: ReadonlyArray<{ value: Tab; label: string; icon: React.ReactNode }> = [ + { value: "suppliers", label: "Suppliers", icon: }, + { value: "purchase-orders", label: "Purchase Orders", icon: }, + { value: "pricing", label: "Pricing", icon: }, + { value: "performance", label: "Performance", icon: }, +]; + +export default function SuppliersPage() { + const [tab, setTab] = useState("suppliers"); + const [statusFilter, setStatusFilter] = useState("All"); + + const { data: suppliers, isLoading: suppliersLoading } = useSuppliers(); + const { data: rawMaterials } = useRawMaterials(); + const { data: orders, isLoading: ordersLoading } = usePurchaseOrders( + statusFilter === "All" ? {} : { status: statusFilter }, + ); + + const activeSuppliers = (suppliers ?? []).filter((s) => s.isActive); + const activeRawMaterials = (rawMaterials ?? []).filter((r) => r.isActive); + + const [supplierForm, setSupplierForm] = useState(undefined); + const [poCreateOpen, setPoCreateOpen] = useState(false); + const [poEditId, setPoEditId] = useState(null); + const [poViewId, setPoViewId] = useState(null); + + const { data: editingOrder } = usePurchaseOrder(poEditId ?? undefined); + + return ( +
+
+
+

Supplier Management

+

+ Suppliers, purchase orders, negotiated pricing and delivery performance. +

+
+
+ {tab === "suppliers" && ( + + )} + {tab === "purchase-orders" && ( + + )} +
+
+ + setTab(v as Tab)} /> + + + {tab === "suppliers" && ( + + )} + + {tab === "purchase-orders" && ( + <> +
+
+ +
+
+ setPoViewId(order.id)} + onEdit={(order: PurchaseOrderSummary) => setPoEditId(order.id)} + /> + + )} + + {tab === "pricing" && } + + {tab === "performance" && } +
+ + !open && setSupplierForm(undefined)} + supplier={supplierForm ?? undefined} + /> + + { + if (!open) { + setPoCreateOpen(false); + setPoEditId(null); + } + }} + suppliers={activeSuppliers} + rawMaterials={activeRawMaterials} + order={poEditId ? editingOrder : undefined} + /> + + {poViewId && ( + !open && setPoViewId(null)} + purchaseOrderId={poViewId} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/users/index.tsx b/frontend/src/pages/users/index.tsx new file mode 100644 index 0000000..e9dd154 --- /dev/null +++ b/frontend/src/pages/users/index.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import { + KeyRound, + MoreHorizontal, + Pencil, + Plus, + Search, + ShieldOff, + ShieldCheck, + Users as UsersIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import type { User, UserFilters, UserRole } from "@/entities/user"; +import { useAuth, useModules } from "@/features/auth"; +import { ResetPasswordDialog, UserFormDialog, useUserMutations, useUsers } from "@/features/users"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const ROLE_FILTER_ALL = "all"; +const STATUS_FILTER_ALL = "all"; + +export default function UsersPage() { + const { user: me } = useAuth(); + const { data: catalog } = useModules(); + + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(ROLE_FILTER_ALL); + const [statusFilter, setStatusFilter] = useState(STATUS_FILTER_ALL); + + const filters: UserFilters = { + search: search || undefined, + role: roleFilter === ROLE_FILTER_ALL ? undefined : (roleFilter as UserRole), + isActive: statusFilter === STATUS_FILTER_ALL ? undefined : statusFilter === "active", + }; + + const { data: users, isLoading } = useUsers(filters); + const { setActive } = useUserMutations(); + + const [formUser, setFormUser] = useState(undefined); + const [resetTarget, setResetTarget] = useState(null); + + const toggleActive = async (target: User) => { + try { + await setActive.mutateAsync({ id: target.id, isActive: !target.isActive }); + toast.success( + target.isActive ? `${target.fullName} was deactivated.` : `${target.fullName} was reactivated.`, + ); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

User Management & Roles

+

+ Create staff accounts and control which modules they can open. +

+
+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by name or username…" + className="pl-9" + /> +
+ + + + +
+ + + {isLoading ? ( + + ) : !users || users.length === 0 ? ( + } + title="No staff accounts match your filters" + description="Try clearing the search or filters, or add a new account." + /> + ) : ( + + + + Name + Role + Modules + Status + Last sign-in + + + + + {users.map((row) => ( + + +

{row.fullName}

+

@{row.username}

+
+ + {row.role} + + + {row.role === "Admin" ? "All modules" : `${row.modules.length} granted`} + + + {row.isActive ? ( + Active + ) : ( + Deactivated + )} + {row.mustChangePassword && ( + + Pending reset + + )} + + + {row.lastLoginAtUtc ? new Date(row.lastLoginAtUtc).toLocaleString() : "Never"} + + + + + + + + setFormUser(row)}> + Edit + + setResetTarget(row)}> + Reset password + + {row.id !== me?.id && !row.isSystemAdmin && ( + toggleActive(row)}> + {row.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + )} + + + +
+ ))} +
+
+ )} +
+ + !open && setFormUser(undefined)} + user={formUser ?? undefined} + catalog={catalog ?? []} + /> + + {resetTarget && ( + !open && setResetTarget(null)} + user={resetTarget} + /> + )} +
+ ); +} diff --git a/frontend/src/shared/api/axiosClient.ts b/frontend/src/shared/api/axiosClient.ts index fb3dd97..7aeb13e 100644 --- a/frontend/src/shared/api/axiosClient.ts +++ b/frontend/src/shared/api/axiosClient.ts @@ -1,16 +1,30 @@ import axios from "axios"; +import { APP_CONFIG } from "@/shared/config"; import { setupAuthInterceptor } from "./interceptors/authInterceptor"; -import { setupRefreshTokenInterceptor } from "./interceptors/refreshTokenInterceptor"; import { setupErrorInterceptor } from "./interceptors/errorInterceptor"; +import { setupRefreshTokenInterceptor } from "./interceptors/refreshTokenInterceptor"; export const axiosClient = axios.create({ - baseURL: import.meta.env.VITE_API_URL || "http://localhost:5207/api/v1", + baseURL: APP_CONFIG.apiBaseUrl, headers: { "Content-Type": "application/json", }, - timeout: 10000, + timeout: 15000, }); +/** + * Called when a session cannot be renewed. The app layer registers a handler that clears store + * state and routes to sign-in; keeping it injectable stops this module from having to know + * about the router or the Redux store. + */ +let sessionExpiredHandler: (() => void) | null = null; + +export function onSessionExpired(handler: () => void): void { + sessionExpiredHandler = handler; +} + +// Order matters. Auth stamps the token on the way out; on the way back, refresh gets first +// look at a 401 so it can retry, and the error interceptor normalises whatever is left. setupAuthInterceptor(axiosClient); -setupRefreshTokenInterceptor(axiosClient); +setupRefreshTokenInterceptor(axiosClient, () => sessionExpiredHandler?.()); setupErrorInterceptor(axiosClient); diff --git a/frontend/src/shared/api/endpoints/index.ts b/frontend/src/shared/api/endpoints/index.ts index d4a1e79..57cc8c5 100644 --- a/frontend/src/shared/api/endpoints/index.ts +++ b/frontend/src/shared/api/endpoints/index.ts @@ -1,40 +1,95 @@ import { axiosClient } from "../axiosClient"; +/** + * Every API path in one place. Modules beyond user management are listed as they are built. + */ export const API_ENDPOINTS = { AUTH: { LOGIN: "/auth/login", REFRESH: "/auth/refresh", LOGOUT: "/auth/logout", ME: "/auth/me", + CHANGE_PASSWORD: "/auth/change-password", + PIN: "/auth/pin", + VERIFY_PIN: "/auth/pin/verify", }, - ORDERS: { - BASE: "/orders", - BY_ID: (id: string) => `/orders/${id}`, - STATUS: (id: string) => `/orders/${id}/status`, - }, - PRODUCTS: { - BASE: "/products", - BY_ID: (id: string) => `/products/${id}`, - CATEGORIES: "/products/categories", + MODULES: "/modules", + USERS: { + BASE: "/users", + BY_ID: (id: string) => `/users/${id}`, + STATUS: (id: string) => `/users/${id}/status`, + PASSWORD: (id: string) => `/users/${id}/password`, }, - INVENTORY: { - BASE: "/inventory", - STOCK: "/inventory/stock", + MENU_ITEMS: { + BASE: "/menu-items", + BY_ID: (id: string) => `/menu-items/${id}`, + STATUS: (id: string) => `/menu-items/${id}/status`, + RECIPE: (menuItemId: string) => `/menu-items/${menuItemId}/recipe`, + RECIPE_STATUS: (menuItemId: string) => `/menu-items/${menuItemId}/recipe/status`, }, - KITCHEN: { - KOT: "/kitchen/tickets", - UPDATE_STATUS: (id: string) => `/kitchen/tickets/${id}/status`, + RAW_MATERIALS: { + BASE: "/raw-materials", + BY_ID: (id: string) => `/raw-materials/${id}`, + STATUS: (id: string) => `/raw-materials/${id}/status`, }, SUPPLIERS: { BASE: "/suppliers", BY_ID: (id: string) => `/suppliers/${id}`, + STATUS: (id: string) => `/suppliers/${id}/status`, + PRICES: "/suppliers/prices", + SET_PRICE: (id: string) => `/suppliers/${id}/prices`, + PRICE_HISTORY: (id: string, rawMaterialId: string) => + `/suppliers/${id}/prices/${rawMaterialId}/history`, + PERFORMANCE: (id: string) => `/suppliers/${id}/performance`, + }, + PURCHASE_ORDERS: { + BASE: "/purchase-orders", + BY_ID: (id: string) => `/purchase-orders/${id}`, + SUBMIT: (id: string) => `/purchase-orders/${id}/submit`, + CONFIRM: (id: string) => `/purchase-orders/${id}/confirm`, + CANCEL: (id: string) => `/purchase-orders/${id}/cancel`, + PAYMENTS: (id: string) => `/purchase-orders/${id}/payments`, + }, + TABLES: { + BASE: "/tables", + BY_ID: (id: string) => `/tables/${id}`, + STATUS: (id: string) => `/tables/${id}/status`, }, - REPORTS: { - SALES: "/reports/sales", - INVENTORY: "/reports/inventory", + ORDERS: { + BASE: "/orders", + BY_ID: (id: string) => `/orders/${id}`, + ITEMS: (id: string) => `/orders/${id}/items`, + ITEM_QUANTITY: (id: string, itemId: string) => `/orders/${id}/items/${itemId}/quantity`, + VOID_ITEM: (id: string, itemId: string) => `/orders/${id}/items/${itemId}/void`, + CONFIRM: (id: string) => `/orders/${id}/confirm`, + CANCEL: (id: string) => `/orders/${id}/cancel`, + DISCOUNT: (id: string) => `/orders/${id}/discount`, + CHECKOUT: (id: string) => `/orders/${id}/checkout`, + REOPEN: (id: string) => `/orders/${id}/reopen`, + PAYMENTS: (id: string) => `/orders/${id}/payments`, + REPRINT_RECEIPT: (id: string) => `/orders/${id}/receipt/reprint`, + }, + KITCHEN: { + TICKETS: "/kitchen/tickets", + TICKET_STATUS: (id: string) => `/kitchen/tickets/${id}/status`, + TICKET_REPRINT: (id: string) => `/kitchen/tickets/${id}/reprint`, + }, + INVENTORY: { + MAIN_STORE_STOCK: "/inventory/main-store/stock", + MAIN_STORE_MOVEMENTS: "/inventory/main-store/movements", + MAIN_STORE_ADJUSTMENTS: "/inventory/main-store/adjustments", + GOODS_RECEIVED: "/inventory/main-store/goods-received", + GOODS_RECEIVED_BY_ID: (id: string) => `/inventory/main-store/goods-received/${id}`, + KITCHEN_STOCK: "/inventory/kitchen/stock", + KITCHEN_MOVEMENTS: "/inventory/kitchen/movements", + KITCHEN_ADJUSTMENTS: "/inventory/kitchen/adjustments", + CONSUMPTION: (menuItemId: string) => `/inventory/kitchen/consumption/${menuItemId}`, + RELEASES: "/inventory/releases", + RELEASE_BY_ID: (id: string) => `/inventory/releases/${id}`, }, } as const; +/** Thin typed wrapper that unwraps `response.data`. */ export const apiService = { get: (url: string, params?: Record) => axiosClient.get(url, { params }).then((res) => res.data), @@ -42,9 +97,7 @@ export const apiService = { post: (url: string, data?: unknown) => axiosClient.post(url, data).then((res) => res.data), - put: (url: string, data?: unknown) => - axiosClient.put(url, data).then((res) => res.data), + put: (url: string, data?: unknown) => axiosClient.put(url, data).then((res) => res.data), - delete: (url: string) => - axiosClient.delete(url).then((res) => res.data), + delete: (url: string) => axiosClient.delete(url).then((res) => res.data), }; diff --git a/frontend/src/shared/api/interceptors/authInterceptor.ts b/frontend/src/shared/api/interceptors/authInterceptor.ts index 8ed8f57..30619fd 100644 --- a/frontend/src/shared/api/interceptors/authInterceptor.ts +++ b/frontend/src/shared/api/interceptors/authInterceptor.ts @@ -1,14 +1,18 @@ import { AxiosInstance, InternalAxiosRequestConfig } from "axios"; +import { tokenStorage } from "../tokenStorage"; +/** Attaches the current access token to every outgoing request. */ export function setupAuthInterceptor(axiosInstance: AxiosInstance): void { axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = typeof window !== "undefined" ? localStorage.getItem("access_token") : null; + const token = tokenStorage.getAccessToken(); + if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } + return config; }, - (error) => Promise.reject(error) + (error) => Promise.reject(error), ); } diff --git a/frontend/src/shared/api/interceptors/errorInterceptor.ts b/frontend/src/shared/api/interceptors/errorInterceptor.ts index f02239b..1db20b1 100644 --- a/frontend/src/shared/api/interceptors/errorInterceptor.ts +++ b/frontend/src/shared/api/interceptors/errorInterceptor.ts @@ -1,16 +1,15 @@ -import { AxiosInstance, AxiosError } from "axios"; +import { AxiosError, AxiosInstance } from "axios"; +import { toApiError } from "../problem"; +/** + * Normalises every failure into an {@link import('../problem').ApiError} so callers handle one + * error shape rather than picking apart axios internals and RFC 7807 bodies at each call site. + * + * Registered last so it sees only failures the refresh interceptor could not recover from. + */ export function setupErrorInterceptor(axiosInstance: AxiosInstance): void { axiosInstance.interceptors.response.use( (response) => response, - (error: AxiosError) => { - if (!error.response) { - console.error("Network Error or Server Unreachable"); - } else { - const { status, data } = error.response; - console.error(`[API Error ${status}]:`, data); - } - return Promise.reject(error); - } + (error: AxiosError) => Promise.reject(toApiError(error)), ); } diff --git a/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts b/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts index edf42d5..225005f 100644 --- a/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts +++ b/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts @@ -1,79 +1,111 @@ -import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; +import { tokenStorage } from "../tokenStorage"; -interface FailedRequestQueueItem { +interface QueuedRequest { resolve: (token: string) => void; - reject: (error: any) => void; + reject: (error: unknown) => void; } -let isRefreshing = false; -let failedQueue: FailedRequestQueueItem[] = []; +/** + * Endpoints that must never trigger a refresh attempt. + * + * A 401 from sign-in means "wrong password", not "expired session". A 401 from the refresh + * endpoint itself means the refresh token is dead, and retrying it would recurse forever. + */ +const NON_REFRESHABLE_PATHS = ["/auth/login", "/auth/refresh", "/auth/logout"]; -const processQueue = (error: any, token: string | null = null): void => { - failedQueue.forEach((prom) => { - if (error) { - prom.reject(error); - } else if (token) { - prom.resolve(token); +const isNonRefreshable = (url: string | undefined): boolean => + !!url && NON_REFRESHABLE_PATHS.some((path) => url.includes(path)); + +/** + * Transparently renews an expired access token and replays the request that hit the 401. + * + * Concurrent failures queue behind a single refresh: because the server rotates refresh tokens, + * firing several refreshes at once would consume tokens it has already invalidated and drop the + * session entirely. + */ +export function setupRefreshTokenInterceptor( + axiosInstance: AxiosInstance, + onSessionExpired?: () => void, +): void { + let isRefreshing = false; + let queue: QueuedRequest[] = []; + + const flushQueue = (error: unknown, token: string | null): void => { + for (const pending of queue) { + if (token) { + pending.resolve(token); + } else { + pending.reject(error); + } } - }); - failedQueue = []; -}; -export function setupRefreshTokenInterceptor(axiosInstance: AxiosInstance): void { + queue = []; + }; + + const failSession = (error: unknown): Promise => { + tokenStorage.clear(); + flushQueue(error, null); + onSessionExpired?.(); + + return Promise.reject(error); + }; + axiosInstance.interceptors.response.use( (response) => response, async (error: AxiosError) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - - if (error.response?.status === 401 && !originalRequest._retry) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then((token) => { - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${token}`; - } - return axiosInstance(originalRequest); - }); - } + const request = error.config as + | (InternalAxiosRequestConfig & { _retried?: boolean }) + | undefined; - originalRequest._retry = true; - isRefreshing = true; + const shouldAttemptRefresh = + error.response?.status === 401 && + !!request && + !request._retried && + !isNonRefreshable(request.url); - try { - const refreshToken = typeof window !== "undefined" ? localStorage.getItem("refresh_token") : null; - if (!refreshToken) { - throw new Error("No refresh token available"); - } + if (!shouldAttemptRefresh) { + return Promise.reject(error); + } - const response = await axiosInstance.post("/auth/refresh", { refreshToken }); - const { accessToken, refreshToken: newRefreshToken } = response.data; + request._retried = true; - if (typeof window !== "undefined") { - localStorage.setItem("access_token", accessToken); - localStorage.setItem("refresh_token", newRefreshToken); + // A refresh is already in flight, so wait for it rather than starting another. + if (isRefreshing) { + return new Promise((resolve, reject) => { + queue.push({ resolve, reject }); + }).then((token) => { + if (request.headers) { + request.headers.Authorization = `Bearer ${token}`; } - processQueue(null, accessToken); + return axiosInstance(request); + }); + } + + const refreshToken = tokenStorage.getRefreshToken(); + if (!refreshToken) { + return failSession(error); + } - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - } - return axiosInstance(originalRequest); - } catch (refreshError) { - processQueue(refreshError, null); - if (typeof window !== "undefined") { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - window.location.href = "/login"; - } - return Promise.reject(refreshError); - } finally { - isRefreshing = false; + isRefreshing = true; + + try { + const { data } = await axiosInstance.post("/auth/refresh", { refreshToken }); + + tokenStorage.save(data.accessToken, data.refreshToken); + flushQueue(null, data.accessToken); + + if (request.headers) { + request.headers.Authorization = `Bearer ${data.accessToken}`; } - } - return Promise.reject(error); - } + return await axiosInstance(request); + } catch (refreshError) { + return failSession(refreshError); + } finally { + isRefreshing = false; + } + }, ); } diff --git a/frontend/src/shared/api/problem.ts b/frontend/src/shared/api/problem.ts new file mode 100644 index 0000000..7c2e73d --- /dev/null +++ b/frontend/src/shared/api/problem.ts @@ -0,0 +1,90 @@ +import { AxiosError } from "axios"; + +/** RFC 7807 problem body as produced by the API. */ +interface ProblemDetails { + title?: string; + status?: number; + detail?: string; + /** Stable machine-readable code, e.g. `Auth.InvalidCredentials`. */ + code?: string; + /** Field-keyed validation messages, present on 400 responses. */ + errors?: Record; +} + +/** + * A server or network failure in the shape the UI actually needs: a message safe to show, a + * stable code to branch on, and per-field messages to attach to form inputs. + */ +export class ApiError extends Error { + readonly status: number; + readonly code: string | null; + readonly fieldErrors: Record; + + constructor(message: string, status: number, code: string | null, fieldErrors: Record = {}) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.fieldErrors = fieldErrors; + } + + /** True when the caller should re-authenticate. */ + get isUnauthorized(): boolean { + return this.status === 401; + } + + /** True when the API is refusing to serve anything until the password is changed. */ + get requiresPasswordChange(): boolean { + return this.code === "Auth.PasswordChangeRequired"; + } + + /** First message recorded against a field, if any. */ + fieldError(field: string): string | undefined { + const key = Object.keys(this.fieldErrors).find( + (k) => k.toLowerCase() === field.toLowerCase(), + ); + + return key ? this.fieldErrors[key]?.[0] : undefined; + } +} + +/** Converts an axios failure into an {@link ApiError}. */ +export function toApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error; + + if (error instanceof AxiosError) { + if (!error.response) { + return new ApiError( + "Cannot reach the server. Check that the POS service is running.", + 0, + "Network.Unreachable", + ); + } + + const { status, data } = error.response; + const problem = (data ?? {}) as ProblemDetails; + + return new ApiError( + problem.title ?? problem.detail ?? defaultMessageFor(status), + status, + problem.code ?? null, + problem.errors ?? {}, + ); + } + + return new ApiError( + error instanceof Error ? error.message : "Something went wrong.", + 0, + null, + ); +} + +function defaultMessageFor(status: number): string { + if (status === 401) return "Your session has expired. Please sign in again."; + if (status === 403) return "You do not have permission to do that."; + if (status === 404) return "That item could not be found."; + if (status === 429) return "Too many attempts. Please wait a moment and try again."; + if (status >= 500) return "The server ran into a problem. Please try again."; + + return "The request could not be completed."; +} diff --git a/frontend/src/shared/api/tokenStorage.ts b/frontend/src/shared/api/tokenStorage.ts new file mode 100644 index 0000000..728f64f --- /dev/null +++ b/frontend/src/shared/api/tokenStorage.ts @@ -0,0 +1,34 @@ +const ACCESS_TOKEN_KEY = "access_token"; +const REFRESH_TOKEN_KEY = "refresh_token"; + +const isBrowser = () => typeof window !== "undefined" && !!window.localStorage; + +/** + * The single place tokens are read from and written to. + * + * Centralised so the axios interceptors, the auth slice and the sign-out path cannot drift out + * of step over key names or clean-up. + */ +export const tokenStorage = { + getAccessToken(): string | null { + return isBrowser() ? window.localStorage.getItem(ACCESS_TOKEN_KEY) : null; + }, + + getRefreshToken(): string | null { + return isBrowser() ? window.localStorage.getItem(REFRESH_TOKEN_KEY) : null; + }, + + save(accessToken: string, refreshToken: string): void { + if (!isBrowser()) return; + + window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + }, + + clear(): void { + if (!isBrowser()) return; + + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(REFRESH_TOKEN_KEY); + }, +}; diff --git a/frontend/src/shared/config/moduleRoutes.ts b/frontend/src/shared/config/moduleRoutes.ts new file mode 100644 index 0000000..e645d57 --- /dev/null +++ b/frontend/src/shared/config/moduleRoutes.ts @@ -0,0 +1,51 @@ +import { + LayoutDashboard, + ChefHat, + ClipboardList, + PackageSearch, + Truck, + Warehouse, + Receipt, + BarChart3, + Bell, + Users, + Settings, + type LucideIcon, +} from "lucide-react"; + +/** + * Where each module lives in the app, and what to show for it in the sidebar before its screen + * exists yet. + * + * This is the one place a module goes from "in the catalog" to "has a page" — add a `path` here + * once its screen is built. Until then the module still appears in navigation (so the menu + * structure matches what the restaurant was promised) as a disabled "coming soon" item. + * + * Lives in `shared` rather than `entities/user` (where `ModuleKey` is defined) or `app` (where + * the router lives) because it is consumed by both a widget (the sidebar) and pages — the FSD + * boundary rules this project enforces mean `shared` is the only layer both can reach. The map + * is keyed by the same plain-string module identifier as `ModuleKey`, just without importing it, + * so this file carries no dependency on the entity layer. + */ +export interface ModuleRoute { + /** Present once the module's screen exists; absent renders as a disabled entry. */ + path?: string; + icon: LucideIcon; +} + +export const MODULE_ROUTES: Record = { + PosBilling: { path: "/pos", icon: Receipt }, + RecipeManagement: { path: "/recipes", icon: ChefHat }, + StoreStockManagement: { path: "/inventory/main-store", icon: Warehouse }, + KitchenStockRelease: { path: "/inventory/releases", icon: PackageSearch }, + KitchenStockTracking: { path: "/inventory/kitchen", icon: ClipboardList }, + KitchenOperations: { path: "/kitchen", icon: ChefHat }, + ReportsAnalytics: { path: "/reports", icon: BarChart3 }, + Notifications: { icon: Bell }, + UserManagement: { path: "/users", icon: Users }, + SupplierManagement: { path: "/suppliers", icon: Truck }, + ExpensesManagement: { icon: Receipt }, + SystemSettings: { icon: Settings }, +}; + +export const DEFAULT_MODULE_ICON: LucideIcon = LayoutDashboard; diff --git a/frontend/src/shared/store/index.ts b/frontend/src/shared/store/index.ts index f6a812f..99786f5 100644 --- a/frontend/src/shared/store/index.ts +++ b/frontend/src/shared/store/index.ts @@ -10,10 +10,6 @@ const rootReducer = combineReducers({ export const store = configureStore({ reducer: rootReducer, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: false, - }), }); export type RootState = ReturnType; diff --git a/frontend/src/shared/theme/index.ts b/frontend/src/shared/theme/index.ts index f9955d5..7d48037 100644 --- a/frontend/src/shared/theme/index.ts +++ b/frontend/src/shared/theme/index.ts @@ -1,7 +1,33 @@ -export const theme = { - colors: { - primary: "var(--primary)", - background: "var(--background)", - foreground: "var(--foreground)", - }, -}; +/** + * Theme tokens live in `src/app/globals.css` as raw HSL channels and reach components through + * Tailwind classes (`bg-primary`, `text-muted-foreground`, …) configured in `tailwind.config.ts`. + * + * These helpers exist only for the rare case where a colour is needed in JavaScript — a canvas + * chart or an inline SVG fill — so such code still reads from the same source of truth instead + * of hard-coding a hex value. + */ + +export type ThemeToken = + | "background" + | "foreground" + | "card" + | "primary" + | "secondary" + | "muted" + | "accent" + | "destructive" + | "success" + | "warning" + | "border"; + +/** Returns a CSS colour expression for a token, e.g. `hsl(var(--primary) / 0.5)`. */ +export function themeColor(token: ThemeToken, alpha = 1): string { + return alpha === 1 ? `hsl(var(--${token}))` : `hsl(var(--${token}) / ${alpha})`; +} + +/** Applies a theme by toggling the `dark` class that the Tailwind config keys off. */ +export function applyTheme(theme: "light" | "dark"): void { + if (typeof document === "undefined") return; + + document.documentElement.classList.toggle("dark", theme === "dark"); +} diff --git a/frontend/src/shared/types/electron.d.ts b/frontend/src/shared/types/electron.d.ts index 9177562..7b2fcf3 100644 --- a/frontend/src/shared/types/electron.d.ts +++ b/frontend/src/shared/types/electron.d.ts @@ -1,6 +1,24 @@ +/** Options for sending a rendered document to a printer. */ +export interface PrintHtmlOptions { + /** Bypasses the print dialog. Off means the operator picks the printer. */ + silent?: boolean; + /** Target printer name. Omitted uses the system default. */ + deviceName?: string; + /** Paper width in millimetres. 80 for a standard thermal roll. */ + widthMm?: number; +} + +export interface PrintResult { + success: boolean; + message: string; +} + export interface IElectronAPI { ping: () => Promise; - printReceipt: (data: unknown) => Promise<{ success: boolean; message: string }>; + /** Renders a standalone HTML document and sends it to a printer. */ + printHtml: (html: string, options?: PrintHtmlOptions) => Promise; + /** Names of the printers installed on this machine, for the settings screen. */ + listPrinters: () => Promise; onMainProcessMessage: (callback: (message: string) => void) => void; } diff --git a/frontend/src/shared/ui/alert.tsx b/frontend/src/shared/ui/alert.tsx new file mode 100644 index 0000000..e4808ee --- /dev/null +++ b/frontend/src/shared/ui/alert.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/shared/lib/utils"; + +const alertVariants = cva( + "relative flex w-full gap-3 rounded-lg border p-4 text-sm [&>svg]:size-5 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + info: "border-primary/25 bg-primary/5 text-foreground [&>svg]:text-primary", + success: "border-success/25 bg-success/5 text-foreground [&>svg]:text-success", + warning: "border-warning/30 bg-warning/5 text-foreground [&>svg]:text-warning", + destructive: + "border-destructive/30 bg-destructive/5 text-foreground [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface AlertProps + extends React.HTMLAttributes, + VariantProps {} + +const Alert = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ), +); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/shared/ui/badge.tsx b/frontend/src/shared/ui/badge.tsx new file mode 100644 index 0000000..ffd4afd --- /dev/null +++ b/frontend/src/shared/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/shared/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary/10 text-primary", + secondary: "border-transparent bg-secondary text-secondary-foreground", + success: "border-transparent bg-success/10 text-success", + warning: "border-transparent bg-warning/10 text-warning", + destructive: "border-transparent bg-destructive/10 text-destructive", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/shared/ui/button.tsx b/frontend/src/shared/ui/button.tsx new file mode 100644 index 0000000..51c828f --- /dev/null +++ b/frontend/src/shared/ui/button.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const buttonVariants = cva( + // Touch targets are generous by default: this runs on a till, often tapped rather than clicked. + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-12 rounded-md px-6 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** Renders the child element instead of a `button`, e.g. to make a link look like a button. */ + asChild?: boolean; + /** Shows a spinner and disables the button. */ + loading?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + {loading ? ( + <> + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/src/shared/ui/card.tsx b/frontend/src/shared/ui/card.tsx new file mode 100644 index 0000000..dccb90f --- /dev/null +++ b/frontend/src/shared/ui/card.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/frontend/src/shared/ui/checkbox.tsx b/frontend/src/shared/ui/checkbox.tsx new file mode 100644 index 0000000..25ba16f --- /dev/null +++ b/frontend/src/shared/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/src/shared/ui/dialog.tsx b/frontend/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..96439d2 --- /dev/null +++ b/frontend/src/shared/ui/dialog.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/shared/ui/dropdown-menu.tsx b/frontend/src/shared/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0d27e0b --- /dev/null +++ b/frontend/src/shared/ui/dropdown-menu.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/shared/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { destructive?: boolean } +>(({ className, destructive, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, +}; diff --git a/frontend/src/shared/ui/form-field.tsx b/frontend/src/shared/ui/form-field.tsx new file mode 100644 index 0000000..1094544 --- /dev/null +++ b/frontend/src/shared/ui/form-field.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; +import { Label } from "./label"; + +export interface FormFieldProps { + /** Ties the label, hint and error to the control for screen readers. */ + htmlFor: string; + label: string; + /** Explanatory text shown under the control while it is valid. */ + hint?: string; + /** Validation message. When present it replaces the hint and marks the field invalid. */ + error?: string; + required?: boolean; + className?: string; + children: React.ReactNode; +} + +/** + * Labelled wrapper for a single form control. Owns the id conventions for the description and + * error elements so every form in the app announces errors the same way. + */ +export function FormField({ + htmlFor, + label, + hint, + error, + required, + className, + children, +}: FormFieldProps) { + const describedBy = error ? `${htmlFor}-error` : hint ? `${htmlFor}-hint` : undefined; + + return ( +
+ + + {React.isValidElement(children) + ? React.cloneElement(children as React.ReactElement>, { + id: htmlFor, + "aria-invalid": error ? true : undefined, + "aria-describedby": describedBy, + }) + : children} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 95c3e67..aff75dc 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,2 +1,42 @@ -// Export shared UI components here (e.g. Button, Input, Modal, Table) -export {}; +export { Button, buttonVariants, type ButtonProps } from "./button"; +export { Input, type InputProps } from "./input"; +export { Label } from "./label"; +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card"; +export { Badge, badgeVariants, type BadgeProps } from "./badge"; +export { Switch } from "./switch"; +export { Checkbox } from "./checkbox"; +export { Separator } from "./separator"; +export { Alert, AlertTitle, AlertDescription, type AlertProps } from "./alert"; +export { FormField, type FormFieldProps } from "./form-field"; +export { LoadingState, EmptyState, Skeleton, type EmptyStateProps } from "./states"; +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./table"; +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from "./dialog"; +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectItem, +} from "./select"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, +} from "./dropdown-menu"; +export { SegmentedTabs, type SegmentedTabsProps } from "./segmented-tabs"; diff --git a/frontend/src/shared/ui/input.tsx b/frontend/src/shared/ui/input.tsx new file mode 100644 index 0000000..536050e --- /dev/null +++ b/frontend/src/shared/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/src/shared/ui/label.tsx b/frontend/src/shared/ui/label.tsx new file mode 100644 index 0000000..402dae1 --- /dev/null +++ b/frontend/src/shared/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/shared/lib/utils"; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/frontend/src/shared/ui/segmented-tabs.tsx b/frontend/src/shared/ui/segmented-tabs.tsx new file mode 100644 index 0000000..6390373 --- /dev/null +++ b/frontend/src/shared/ui/segmented-tabs.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +export interface SegmentedTabsProps { + tabs: ReadonlyArray<{ value: string; label: string; icon?: React.ReactNode }>; + value: string; + onValueChange: (value: string) => void; + className?: string; +} + +/** + * A simple, accessible tab switcher for a page with a handful of local sections. Built from + * plain buttons rather than a Radix primitive — for a handful of in-page views like these, + * hand-rolled ARIA tab semantics are all that's needed, and it avoids a dependency this project + * doesn't otherwise use. + */ +export function SegmentedTabs({ tabs, value, onValueChange, className }: SegmentedTabsProps) { + return ( +
+ {tabs.map((tab) => { + const selected = tab.value === value; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/shared/ui/select.tsx b/frontend/src/shared/ui/select.tsx new file mode 100644 index 0000000..57cc1e1 --- /dev/null +++ b/frontend/src/shared/ui/select.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }; diff --git a/frontend/src/shared/ui/separator.tsx b/frontend/src/shared/ui/separator.tsx new file mode 100644 index 0000000..c25d3ac --- /dev/null +++ b/frontend/src/shared/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/shared/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/frontend/src/shared/ui/states.tsx b/frontend/src/shared/ui/states.tsx new file mode 100644 index 0000000..1db2f5d --- /dev/null +++ b/frontend/src/shared/ui/states.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +/** Centred spinner for a region that is still loading. */ +export function LoadingState({ label = "Loading…", className }: { label?: string; className?: string }) { + return ( +
+
+ ); +} + +export interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + action?: React.ReactNode; + className?: string; +} + +/** Shown in place of a list or table that has no rows to display. */ +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +/** Placeholder block used while content is being fetched. */ +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} diff --git a/frontend/src/shared/ui/switch.tsx b/frontend/src/shared/ui/switch.tsx new file mode 100644 index 0000000..cf048b8 --- /dev/null +++ b/frontend/src/shared/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cn } from "@/shared/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/frontend/src/shared/ui/table.tsx b/frontend/src/shared/ui/table.tsx new file mode 100644 index 0000000..c76f8db --- /dev/null +++ b/frontend/src/shared/ui/table.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( + // Wrapped so a wide table scrolls within its container rather than the page. +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; diff --git a/frontend/src/widgets/app-shell/AppShell.tsx b/frontend/src/widgets/app-shell/AppShell.tsx new file mode 100644 index 0000000..747952b --- /dev/null +++ b/frontend/src/widgets/app-shell/AppShell.tsx @@ -0,0 +1,26 @@ +import { Outlet } from "react-router-dom"; +import { useModules } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; + +/** The signed-in application frame: sidebar navigation, top bar, and the routed page. */ +export function AppShell() { + const { data: catalog, isLoading } = useModules(); + + if (isLoading || !catalog) { + return ; + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/widgets/app-shell/Sidebar.tsx b/frontend/src/widgets/app-shell/Sidebar.tsx new file mode 100644 index 0000000..ac1019a --- /dev/null +++ b/frontend/src/widgets/app-shell/Sidebar.tsx @@ -0,0 +1,88 @@ +import { NavLink } from "react-router-dom"; +import { groupModules, type ModuleDescriptor } from "@/entities/user"; +import { useAuth } from "@/features/auth"; +import { MODULE_ROUTES, DEFAULT_MODULE_ICON } from "@/shared/config/moduleRoutes"; +import { cn } from "@/shared/lib/utils"; + +export interface SidebarProps { + catalog: ModuleDescriptor[]; +} + +/** + * Primary navigation, built from the module catalog rather than a hard-coded list — a module a + * user cannot open is left out entirely rather than shown and blocked, so the menu only ever + * promises what it can deliver. + */ +export function Sidebar({ catalog }: SidebarProps) { + const { can } = useAuth(); + const groups = groupModules(catalog).map((group) => ({ + ...group, + modules: group.modules.filter((m) => can(m.module)), + })); + + return ( + + ); +} + +function ModuleNavItem({ descriptor }: { descriptor: ModuleDescriptor }) { + const route = MODULE_ROUTES[descriptor.module]; + const Icon = route?.icon ?? DEFAULT_MODULE_ICON; + + if (!route?.path) { + return ( +
+ + {descriptor.name} + Soon +
+ ); + } + + return ( + + cn( + "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", + isActive + ? "bg-primary/10 text-primary" + : "text-foreground/80 hover:bg-accent hover:text-accent-foreground", + ) + } + > + + {descriptor.name} + + ); +} diff --git a/frontend/src/widgets/app-shell/Topbar.tsx b/frontend/src/widgets/app-shell/Topbar.tsx new file mode 100644 index 0000000..ddcec6b --- /dev/null +++ b/frontend/src/widgets/app-shell/Topbar.tsx @@ -0,0 +1,57 @@ +import { LogOut, ShieldCheck, User as UserIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAuth, useLogout } from "@/features/auth"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui"; + +/** Top bar: current user, role, and account actions. */ +export function Topbar() { + const { user } = useAuth(); + const logout = useLogout(); + const navigate = useNavigate(); + + if (!user) return null; + + return ( +
+
+ + + + + + + + {user.fullName} + @{user.username} + + {user.role === "Admin" && ( + + + Admin + + )} + + + + Signed in as {user.username} + + navigate("/account")}> + My account + + + logout.mutate()}> + Sign out + + + +
+ ); +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index ac59a97..4d08855 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,25 +1,82 @@ import type { Config } from "tailwindcss"; +/** Maps a CSS custom property holding raw HSL channels to a Tailwind colour. */ +const hsl = (variable: string) => `hsl(var(--${variable}) / )`; + const config: Config = { darkMode: ["class"], content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - "./src/widgets/**/*.{js,ts,jsx,tsx,mdx}", - "./src/features/**/*.{js,ts,jsx,tsx,mdx}", - "./src/entities/**/*.{js,ts,jsx,tsx,mdx}", - "./src/shared/**/*.{js,ts,jsx,tsx,mdx}", + "./index.html", + "./src/app/**/*.{js,ts,jsx,tsx}", + "./src/pages/**/*.{js,ts,jsx,tsx}", + "./src/widgets/**/*.{js,ts,jsx,tsx}", + "./src/features/**/*.{js,ts,jsx,tsx}", + "./src/entities/**/*.{js,ts,jsx,tsx}", + "./src/shared/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { colors: { - background: "var(--background)", - foreground: "var(--foreground)", + background: hsl("background"), + foreground: hsl("foreground"), + border: hsl("border"), + input: hsl("input"), + ring: hsl("ring"), + card: { + DEFAULT: hsl("card"), + foreground: hsl("card-foreground"), + }, + popover: { + DEFAULT: hsl("popover"), + foreground: hsl("popover-foreground"), + }, primary: { - DEFAULT: "var(--primary)", - foreground: "var(--primary-foreground)", + DEFAULT: hsl("primary"), + foreground: hsl("primary-foreground"), + }, + secondary: { + DEFAULT: hsl("secondary"), + foreground: hsl("secondary-foreground"), + }, + muted: { + DEFAULT: hsl("muted"), + foreground: hsl("muted-foreground"), + }, + accent: { + DEFAULT: hsl("accent"), + foreground: hsl("accent-foreground"), + }, + destructive: { + DEFAULT: hsl("destructive"), + foreground: hsl("destructive-foreground"), }, + success: { + DEFAULT: hsl("success"), + foreground: hsl("success-foreground"), + }, + warning: { + DEFAULT: hsl("warning"), + foreground: hsl("warning-foreground"), + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "fade-in": { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + "slide-up": { + from: { opacity: "0", transform: "translateY(6px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + }, + animation: { + "fade-in": "fade-in 150ms ease-out", + "slide-up": "slide-up 200ms ease-out", }, }, }, diff --git a/frontend/tests/components/LoginPage.test.tsx b/frontend/tests/components/LoginPage.test.tsx new file mode 100644 index 0000000..eed8ea6 --- /dev/null +++ b/frontend/tests/components/LoginPage.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import LoginPage from "@/pages/login"; +import { server } from "@tests/mocks/server"; +import { renderWithProviders } from "@tests/utils/render"; + +describe("LoginPage", () => { + it("requires both fields before submitting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText("Username is required.")).toBeInTheDocument(); + expect(screen.getByText("Password is required.")).toBeInTheDocument(); + }); + + it("shows the server's message when credentials are rejected", async () => { + server.use( + http.post("*/api/v1/auth/login", () => + HttpResponse.json( + { title: "The username or password is incorrect.", code: "Auth.InvalidCredentials" }, + { status: 401 }, + ), + ), + ); + + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/username/i), "admin"); + await user.type(screen.getByLabelText(/password/i), "wrong-password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText("The username or password is incorrect.")).toBeInTheDocument(); + }); + + it("establishes a session on success", async () => { + const user = userEvent.setup(); + const { store } = renderWithProviders(); + + await user.type(screen.getByLabelText(/username/i), "admin"); + await user.type(screen.getByLabelText(/password/i), "ChangeMe!123"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(store.getState().auth.status).toBe("authenticated")); + expect(store.getState().auth.user?.username).toBe("admin"); + }); +}); diff --git a/frontend/tests/components/ModulePermissionPicker.test.tsx b/frontend/tests/components/ModulePermissionPicker.test.tsx new file mode 100644 index 0000000..e7b1dd4 --- /dev/null +++ b/frontend/tests/components/ModulePermissionPicker.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { render } from "@testing-library/react"; +import { ModulePermissionPicker } from "@/features/users"; +import { moduleCatalog } from "@tests/mocks/fixtures"; + +describe("ModulePermissionPicker", () => { + it("explains that module selection does not apply to administrators", () => { + render( + , + ); + + expect(screen.getByText(/administrators can open every module/i)).toBeInTheDocument(); + expect(screen.queryByText("POS & Billing")).not.toBeInTheDocument(); + }); + + it("never offers an admin-only module to a staff account", () => { + render( + , + ); + + expect(screen.getByText("POS & Billing")).toBeInTheDocument(); + expect(screen.queryByText("User Management & Roles")).not.toBeInTheDocument(); + }); + + it("toggles a module on and off", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: /pos & billing/i })); + + expect(onChange).toHaveBeenCalledWith(["PosBilling"]); + }); + + it("deselects a currently-granted module", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: /pos & billing/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); + + it("clears every selection at once", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /clear all/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/tests/components/StockLinesEditor.test.tsx b/frontend/tests/components/StockLinesEditor.test.tsx new file mode 100644 index 0000000..1c50695 --- /dev/null +++ b/frontend/tests/components/StockLinesEditor.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StockLinesEditor } from "@/features/inventory"; +import type { RawMaterial } from "@/entities/raw-material"; + +const rawMaterials: RawMaterial[] = [ + { id: "rice", name: "Rice", unitOfMeasurement: "Kilogram", mainStoreReorderLevel: null, kitchenParLevel: null, isActive: true }, + { id: "chicken", name: "Chicken", unitOfMeasurement: "Kilogram", mainStoreReorderLevel: null, kitchenParLevel: null, isActive: true }, +]; + +describe("StockLinesEditor", () => { + it("shows an empty state with no lines", () => { + render(); + + expect(screen.getByText("No raw materials added yet")).toBeInTheDocument(); + }); + + it("adds a line with the chosen raw material and quantity", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Rice" })); + await user.type(screen.getByLabelText("Quantity"), "5"); + await user.click(screen.getByRole("button", { name: /add/i })); + + expect(onChange).toHaveBeenCalledWith([{ rawMaterialId: "rice", quantity: 5 }]); + }); + + it("refuses to add without a quantity greater than zero", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Rice" })); + await user.click(screen.getByRole("button", { name: /add/i })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("excludes a raw material already on the list from the picker", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByText("Rice")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox")); + + // Only Chicken remains selectable — Rice is already a line, not an option. + expect(screen.getByRole("option", { name: "Chicken" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Rice" })).not.toBeInTheDocument(); + }); + + it("removes a line", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /remove rice/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/tests/components/example.test.tsx b/frontend/tests/components/example.test.tsx deleted file mode 100644 index c8fe3bb..0000000 --- a/frontend/tests/components/example.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import App from "@/app/App"; -import { renderWithProviders } from "../utils/render"; - -describe("App Component Test", () => { - it("renders system title heading", () => { - renderWithProviders(); - expect( - screen.getByRole("heading", { name: /Restaurant POS System/i }) - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/tests/integration/example.test.ts b/frontend/tests/integration/example.test.ts deleted file mode 100644 index 589756e..0000000 --- a/frontend/tests/integration/example.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { apiService } from "@/shared/api/endpoints"; - -describe("MSW API Integration Tests", () => { - it("fetches mock health status successfully", async () => { - const response = await apiService.get<{ status: string }>("/health"); - expect(response.status).toBe("Healthy"); - }); -}); diff --git a/frontend/tests/mocks/fixtures.ts b/frontend/tests/mocks/fixtures.ts new file mode 100644 index 0000000..96e94cd --- /dev/null +++ b/frontend/tests/mocks/fixtures.ts @@ -0,0 +1,76 @@ +import type { ModuleDescriptor, Session, User } from "@/entities/user"; + +/** A signed-in administrator who still owes a password change. */ +export const adminPendingChange: User = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + fullName: "System Administrator", + email: null, + role: "Admin", + isActive: true, + mustChangePassword: true, + isSystemAdmin: true, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-01T00:00:00Z", + modules: [], +}; + +/** A fully provisioned administrator. */ +export const adminProvisioned: User = { + ...adminPendingChange, + mustChangePassword: false, +}; + +/** A staff account limited to POS & Billing. */ +export const cashierUser: User = { + id: "22222222-2222-2222-2222-222222222222", + username: "cashier01", + fullName: "Ravi Kumar", + email: null, + role: "User", + isActive: true, + mustChangePassword: false, + isSystemAdmin: false, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-02T00:00:00Z", + modules: ["PosBilling"], +}; + +export const moduleCatalog: ModuleDescriptor[] = [ + { + module: "PosBilling", + name: "POS & Billing", + group: "Operations", + description: "Take orders, split and settle bills, and print receipts.", + sortOrder: 10, + adminOnly: false, + }, + { + module: "ReportsAnalytics", + name: "Reports & Analytics", + group: "Administration", + description: "Sales, inventory, expense and staff performance reporting.", + sortOrder: 90, + adminOnly: false, + }, + { + module: "UserManagement", + name: "User Management & Roles", + group: "Administration", + description: "Create staff accounts and control which modules they can open.", + sortOrder: 110, + adminOnly: true, + }, +]; + +export function sessionFor(user: User): Session { + return { + accessToken: "mock-access-token", + accessTokenExpiresAtUtc: "2026-01-01T01:00:00Z", + refreshToken: "mock-refresh-token", + refreshTokenExpiresAtUtc: "2026-01-15T00:00:00Z", + user, + }; +} diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 63aaed2..bcf9271 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -1,14 +1,20 @@ import { http, HttpResponse } from "msw"; +import { adminPendingChange, moduleCatalog, sessionFor } from "./fixtures"; +const API = "*/api/v1"; + +/** + * Default handlers matching the real API's contracts. Individual tests override specific + * routes with `server.use(...)` for the scenario under test, e.g. a login failure. + */ export const handlers = [ - http.get("*/api/v1/health", () => { - return HttpResponse.json({ status: "Healthy" }); - }), - http.post("*/api/v1/auth/login", () => { - return HttpResponse.json({ - accessToken: "mock_access_token", - refreshToken: "mock_refresh_token", - user: { id: "1", name: "Admin User", email: "admin@pos.com", role: "Admin" }, - }); - }), + http.get("*/health", () => HttpResponse.json({ status: "Healthy" })), + + http.post(`${API}/auth/login`, () => HttpResponse.json(sessionFor(adminPendingChange))), + + http.get(`${API}/auth/me`, () => HttpResponse.json(adminPendingChange)), + + http.get(`${API}/modules`, () => HttpResponse.json(moduleCatalog)), + + http.get(`${API}/users`, () => HttpResponse.json([])), ]; diff --git a/frontend/tests/unit/entities/user-permissions.test.ts b/frontend/tests/unit/entities/user-permissions.test.ts new file mode 100644 index 0000000..453a590 --- /dev/null +++ b/frontend/tests/unit/entities/user-permissions.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { ModuleDescriptor, User } from "@/entities/user"; +import { assignableModules, canAccessModule, groupModules } from "@/entities/user"; + +function makeUser(overrides: Partial = {}): User { + return { + id: "1", + username: "cashier01", + fullName: "Ravi Kumar", + email: null, + role: "User", + isActive: true, + mustChangePassword: false, + isSystemAdmin: false, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-01T00:00:00Z", + modules: [], + ...overrides, + }; +} + +const catalog: ModuleDescriptor[] = [ + { module: "PosBilling", name: "POS & Billing", group: "Operations", description: "", sortOrder: 10, adminOnly: false }, + { module: "ExpensesManagement", name: "Expenses Management", group: "Operations", description: "", sortOrder: 80, adminOnly: false }, + { module: "ReportsAnalytics", name: "Reports & Analytics", group: "Administration", description: "", sortOrder: 90, adminOnly: false }, + { module: "UserManagement", name: "User Management & Roles", group: "Administration", description: "", sortOrder: 110, adminOnly: true }, +]; + +describe("canAccessModule", () => { + it("is false for a signed-out user regardless of module", () => { + expect(canAccessModule(null, "PosBilling")).toBe(false); + }); + + it("is limited to granted modules for a staff user", () => { + const user = makeUser({ modules: ["PosBilling"] }); + + expect(canAccessModule(user, "PosBilling")).toBe(true); + expect(canAccessModule(user, "ReportsAnalytics")).toBe(false); + }); + + it("is unconditional for an administrator, even with no listed modules", () => { + const admin = makeUser({ role: "Admin", modules: [] }); + + expect(canAccessModule(admin, "UserManagement")).toBe(true); + }); +}); + +describe("assignableModules", () => { + it("excludes admin-only modules", () => { + const result = assignableModules(catalog); + + expect(result.map((m) => m.module)).toEqual(["PosBilling", "ExpensesManagement", "ReportsAnalytics"]); + }); +}); + +describe("groupModules", () => { + it("groups by the catalog's group label and preserves sort order within and across groups", () => { + const shuffled = [...catalog].reverse(); + + const groups = groupModules(shuffled); + + expect(groups.map((g) => g.group)).toEqual(["Operations", "Administration"]); + expect(groups[0].modules.map((m) => m.module)).toEqual(["PosBilling", "ExpensesManagement"]); + expect(groups[1].modules.map((m) => m.module)).toEqual(["ReportsAnalytics", "UserManagement"]); + }); +}); diff --git a/frontend/tests/unit/features/menuItemSchema.test.ts b/frontend/tests/unit/features/menuItemSchema.test.ts new file mode 100644 index 0000000..736aa4e --- /dev/null +++ b/frontend/tests/unit/features/menuItemSchema.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { menuItemSchema, toPriceNumber } from "@/features/menu-items/model/menuItemSchema"; + +const valid = { name: "Chicken Fried Rice", category: "Rice & Curry", price: "850" }; + +describe("menuItemSchema", () => { + it("accepts a well-formed submission", () => { + expect(menuItemSchema.safeParse(valid).success).toBe(true); + }); + + it("requires a name and category", () => { + expect(menuItemSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + expect(menuItemSchema.safeParse({ ...valid, category: "" }).success).toBe(false); + }); + + it.each(["", "abc", "-1", "-0.01"])("rejects an invalid price: %s", (price) => { + expect(menuItemSchema.safeParse({ ...valid, price }).success).toBe(false); + }); + + it.each(["0", "850", "12.50"])("accepts a valid non-negative price: %s", (price) => { + expect(menuItemSchema.safeParse({ ...valid, price }).success).toBe(true); + }); +}); + +describe("toPriceNumber", () => { + it("converts the form's string price to a number", () => { + expect(toPriceNumber("850")).toBe(850); + expect(toPriceNumber("12.5")).toBe(12.5); + }); +}); diff --git a/frontend/tests/unit/features/rawMaterialSchema.test.ts b/frontend/tests/unit/features/rawMaterialSchema.test.ts new file mode 100644 index 0000000..8f66146 --- /dev/null +++ b/frontend/tests/unit/features/rawMaterialSchema.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { rawMaterialSchema, toNullableThreshold } from "@/features/raw-materials/model/rawMaterialSchema"; + +const valid = { + name: "Rice", + unitOfMeasurement: "Kilogram", + mainStoreReorderLevel: "", + kitchenParLevel: "", +}; + +describe("rawMaterialSchema", () => { + it("accepts a submission with no thresholds set", () => { + expect(rawMaterialSchema.safeParse(valid).success).toBe(true); + }); + + it("accepts thresholds when provided", () => { + const result = rawMaterialSchema.safeParse({ ...valid, mainStoreReorderLevel: "10", kitchenParLevel: "2" }); + expect(result.success).toBe(true); + }); + + it("rejects a negative threshold", () => { + expect(rawMaterialSchema.safeParse({ ...valid, mainStoreReorderLevel: "-1" }).success).toBe(false); + }); + + it("rejects an unrecognised unit of measurement", () => { + expect(rawMaterialSchema.safeParse({ ...valid, unitOfMeasurement: "Stone" }).success).toBe(false); + }); + + it("requires a name", () => { + expect(rawMaterialSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + }); +}); + +describe("toNullableThreshold", () => { + it("converts the empty-string sentinel to null", () => { + expect(toNullableThreshold("")).toBeNull(); + }); + + it("converts a populated value to a number", () => { + expect(toNullableThreshold("10")).toBe(10); + }); +}); diff --git a/frontend/tests/unit/features/recipesApi.test.ts b/frontend/tests/unit/features/recipesApi.test.ts new file mode 100644 index 0000000..7587722 --- /dev/null +++ b/frontend/tests/unit/features/recipesApi.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { recipesApi } from "@/features/recipes/api/recipesApi"; +import { server } from "@tests/mocks/server"; + +const MENU_ITEM_ID = "11111111-1111-1111-1111-111111111111"; + +describe("recipesApi.get", () => { + afterEach(() => server.resetHandlers()); + + it("normalises the server's empty body (no recipe yet) to null", async () => { + // The backend sends a genuinely empty body for "no recipe" rather than the JSON literal + // `null`, which is exactly the shape that broke the recipe editor before this was fixed: + // axios turns an empty body into `""`, and `""?.lines` is not short-circuited by optional + // chaining the way `null?.lines` is. + server.use( + http.get(`*/api/v1/menu-items/${MENU_ITEM_ID}/recipe`, () => new HttpResponse("", { status: 200 })), + ); + + const result = await recipesApi.get(MENU_ITEM_ID); + + expect(result).toBeNull(); + }); + + it("passes a real recipe straight through", async () => { + const recipe = { + id: "22222222-2222-2222-2222-222222222222", + menuItemId: MENU_ITEM_ID, + isEnabled: true, + lines: [{ rawMaterialId: "r1", rawMaterialName: "Rice", unitOfMeasurement: "Kilogram", quantity: 0.25 }], + createdAtUtc: "2026-01-01T00:00:00Z", + updatedAtUtc: null, + }; + + server.use(http.get(`*/api/v1/menu-items/${MENU_ITEM_ID}/recipe`, () => HttpResponse.json(recipe))); + + const result = await recipesApi.get(MENU_ITEM_ID); + + expect(result).toEqual(recipe); + }); +}); diff --git a/frontend/tests/unit/features/userSchema.test.ts b/frontend/tests/unit/features/userSchema.test.ts new file mode 100644 index 0000000..abf2667 --- /dev/null +++ b/frontend/tests/unit/features/userSchema.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + approvalPinSchema, + changePasswordSchema, + createUserSchema, + toNullableEmail, + updateUserSchema, +} from "@/features/users/model/userSchema"; + +const validCreate = { + username: "cashier01", + fullName: "Ravi Kumar", + email: "", + password: "Cashier@2026", + role: "User" as const, + modules: ["PosBilling"], +}; + +describe("createUserSchema", () => { + it("accepts a well-formed submission", () => { + expect(createUserSchema.safeParse(validCreate).success).toBe(true); + }); + + it.each(["ab", "has spaces", "bad!char"])("rejects a malformed username %s", (username) => { + const result = createUserSchema.safeParse({ ...validCreate, username }); + expect(result.success).toBe(false); + }); + + it.each(["short1A", "alllowercase1", "ALLUPPERCASE1", "NoDigitsHere"])( + "rejects a password that fails the policy: %s", + (password) => { + const result = createUserSchema.safeParse({ ...validCreate, password }); + expect(result.success).toBe(false); + }, + ); + + it("allows an empty email but rejects a malformed one", () => { + expect(createUserSchema.safeParse({ ...validCreate, email: "" }).success).toBe(true); + expect(createUserSchema.safeParse({ ...validCreate, email: "not-an-email" }).success).toBe(false); + expect(createUserSchema.safeParse({ ...validCreate, email: "ravi@srilakshmi.lk" }).success).toBe(true); + }); +}); + +describe("updateUserSchema", () => { + it("does not require a username or password", () => { + const result = updateUserSchema.safeParse({ + fullName: "Ravi Kumar", + email: "", + role: "User", + modules: [], + }); + + expect(result.success).toBe(true); + }); +}); + +describe("toNullableEmail", () => { + it("converts the empty-string sentinel to null and leaves real addresses alone", () => { + expect(toNullableEmail("")).toBeNull(); + expect(toNullableEmail("ravi@srilakshmi.lk")).toBe("ravi@srilakshmi.lk"); + }); +}); + +describe("changePasswordSchema", () => { + const base = { + currentPassword: "Current@2026", + newPassword: "Brand@2026New", + confirmPassword: "Brand@2026New", + }; + + it("accepts matching, policy-compliant passwords", () => { + expect(changePasswordSchema.safeParse(base).success).toBe(true); + }); + + it("rejects when the confirmation does not match", () => { + const result = changePasswordSchema.safeParse({ ...base, confirmPassword: "Different@2026" }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0].path).toEqual(["confirmPassword"]); + }); + + it("rejects reusing the current password as the new one", () => { + const result = changePasswordSchema.safeParse({ + ...base, + newPassword: base.currentPassword, + confirmPassword: base.currentPassword, + }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0].path).toEqual(["newPassword"]); + }); +}); + +describe("approvalPinSchema", () => { + it.each(["4821", "0000", "9999"])("accepts a 4-digit PIN: %s", (pin) => { + expect(approvalPinSchema.safeParse(pin).success).toBe(true); + }); + + it.each(["123", "12345", "abcd", ""])("rejects an invalid PIN: %s", (pin) => { + expect(approvalPinSchema.safeParse(pin).success).toBe(false); + }); +}); diff --git a/frontend/tests/utils/render.tsx b/frontend/tests/utils/render.tsx index cbff3bf..c1c581d 100644 --- a/frontend/tests/utils/render.tsx +++ b/frontend/tests/utils/render.tsx @@ -2,13 +2,17 @@ import React, { ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { Provider } from "react-redux"; import { QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { RootState } from "@/shared/store"; import { createTestStore } from "./testStore"; import { createTestQueryClient } from "./testQueryClient"; interface ExtendedRenderOptions extends Omit { - preloadedState?: any; + preloadedState?: Partial; store?: ReturnType; queryClient?: ReturnType; + /** Starting URL(s) for components that use router hooks (`useNavigate`, `Link`, ...). */ + route?: string; } export function renderWithProviders( @@ -17,14 +21,15 @@ export function renderWithProviders( preloadedState = {}, store = createTestStore(preloadedState), queryClient = createTestQueryClient(), + route = "/", ...renderOptions - }: ExtendedRenderOptions = {} + }: ExtendedRenderOptions = {}, ) { function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); diff --git a/frontend/tests/vitest.setup.ts b/frontend/tests/vitest.setup.ts index 6c54f0a..0a0040b 100644 --- a/frontend/tests/vitest.setup.ts +++ b/frontend/tests/vitest.setup.ts @@ -2,6 +2,22 @@ import "@testing-library/jest-dom"; import { beforeAll, afterEach, afterAll } from "vitest"; import { server } from "./mocks/server"; +// jsdom doesn't implement the Pointer Events API, which Radix's Select (and anything else built +// on @radix-ui/react-use-previous / pointer capture) calls unconditionally. Without these, any +// test that opens a Select throws "target.hasPointerCapture is not a function". +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());