mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +00:00
feat(contracts): shared api-contracts adoption, zod schemas, ProblemDetails codes (#222)
* feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType<T>` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * fix(web): install shared api-contracts deps via postinstall Web Frontend Check failed on PR #222: tsc compiles shared/api-contracts/src/schemas.ts through the tsconfig path alias, and module resolution for its zod import walks up from shared/, never reaching web/node_modules. CI only ran npm ci in web/, so the shared package's deps were absent. A postinstall hook installs them wherever web's deps are installed (CI typecheck, web-test, deploy bundling). Passed locally only because a stray repo-root node_modules/zod satisfied the lookup.
This commit is contained in:
parent
8ce2a5cd8f
commit
dfaee830f7
20 changed files with 744 additions and 307 deletions
|
|
@ -2,6 +2,7 @@ using System.Net;
|
|||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProposalSystem.Application.Common;
|
||||
|
||||
namespace ProposalSystem.Api.Middleware;
|
||||
|
||||
|
|
@ -26,54 +27,52 @@ public class GlobalExceptionHandler : IMiddleware
|
|||
}
|
||||
}
|
||||
|
||||
private static ProblemDetails MakeProblem(int status, string title, string detail, string code)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = status,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
};
|
||||
problem.Extensions["code"] = code; // serialized top-level via [JsonExtensionData]
|
||||
return problem;
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
// Every response carries a machine-readable "code" extension (SHOC
|
||||
// error-code vocabulary convention) so clients branch on codes, not
|
||||
// on human-readable text.
|
||||
var (statusCode, problemDetails) = exception switch
|
||||
{
|
||||
ValidationException validationEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 400,
|
||||
Title = "Validation Error",
|
||||
Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)),
|
||||
}
|
||||
MakeProblem(400, "Validation Error",
|
||||
string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)),
|
||||
"ValidationFailed")
|
||||
),
|
||||
BusinessRuleException businessEx => (
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
MakeProblem(422, "Business Rule Violation", businessEx.Message, businessEx.Code)
|
||||
),
|
||||
KeyNotFoundException => (
|
||||
HttpStatusCode.NotFound,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 404,
|
||||
Title = "Not Found",
|
||||
Detail = "The requested resource was not found",
|
||||
}
|
||||
MakeProblem(404, "Not Found", "The requested resource was not found", "NotFound")
|
||||
),
|
||||
UnauthorizedAccessException => (
|
||||
HttpStatusCode.Unauthorized,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 401,
|
||||
Title = "Unauthorized",
|
||||
Detail = "Authentication required",
|
||||
}
|
||||
MakeProblem(401, "Unauthorized", "Authentication required", "Unauthorized")
|
||||
),
|
||||
InvalidOperationException => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 400,
|
||||
Title = "Invalid Operation",
|
||||
Detail = "The requested operation is not valid for the current state",
|
||||
}
|
||||
MakeProblem(400, "Invalid Operation",
|
||||
"The requested operation is not valid for the current state",
|
||||
"InvalidStateTransition")
|
||||
),
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 500,
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An unexpected error occurred",
|
||||
}
|
||||
MakeProblem(500, "Internal Server Error", "An unexpected error occurred", "InternalError")
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ProposalSystem.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Business-rule violation carrying a machine-readable code, surfaced as
|
||||
/// 422 ProblemDetails with a top-level "code" extension (SHOC error-code
|
||||
/// vocabulary convention — e.g. "CancelNotAllowed"). Clients branch on the
|
||||
/// code, never on the human-readable message.
|
||||
/// </summary>
|
||||
public class BusinessRuleException : Exception
|
||||
{
|
||||
public string Code { get; }
|
||||
|
||||
public BusinessRuleException(string code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using ProposalSystem.Api.Middleware;
|
||||
using ProposalSystem.Application.Common;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails
|
||||
/// mapping and the machine-readable "code" extension (SHOC error-code
|
||||
/// vocabulary convention). Clients branch on code, so the code values are
|
||||
/// wire contract: changing one is a breaking API change.
|
||||
/// </summary>
|
||||
public class GlobalExceptionHandlerTests
|
||||
{
|
||||
private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception)
|
||||
{
|
||||
var logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
|
||||
var handler = new GlobalExceptionHandler(logger);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await handler.InvokeAsync(context, _ => throw exception);
|
||||
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone();
|
||||
return (context.Response.StatusCode, body);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")]
|
||||
public async Task BusinessRuleException_Maps422WithCode()
|
||||
{
|
||||
var (status, body) = await InvokeWith(
|
||||
new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled"));
|
||||
|
||||
status.Should().Be(422);
|
||||
body.GetProperty("code").GetString().Should().Be("CancelNotAllowed");
|
||||
body.GetProperty("title").GetString().Should().Be("Business Rule Violation");
|
||||
body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")]
|
||||
public async Task InvalidOperationException_Maps400InvalidStateTransition()
|
||||
{
|
||||
var (status, body) = await InvokeWith(new InvalidOperationException("bad transition"));
|
||||
|
||||
status.Should().Be(400);
|
||||
body.GetProperty("code").GetString().Should().Be("InvalidStateTransition");
|
||||
// Detail must stay generic — no internal exception text on the wire.
|
||||
body.GetProperty("detail").GetString().Should().NotContain("bad transition");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")]
|
||||
public async Task ValidationException_Maps400ValidationFailed()
|
||||
{
|
||||
var failures = new[] { new ValidationFailure("Name", "Name is required") };
|
||||
var (status, body) = await InvokeWith(new ValidationException(failures));
|
||||
|
||||
status.Should().Be(400);
|
||||
body.GetProperty("code").GetString().Should().Be("ValidationFailed");
|
||||
body.GetProperty("detail").GetString().Should().Contain("Name is required");
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Standard exceptions map to their status and code")]
|
||||
[InlineData(typeof(KeyNotFoundException), 404, "NotFound")]
|
||||
[InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")]
|
||||
[InlineData(typeof(ApplicationException), 500, "InternalError")]
|
||||
public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode)
|
||||
{
|
||||
var exception = (Exception)Activator.CreateInstance(exceptionType)!;
|
||||
var (status, body) = await InvokeWith(exception);
|
||||
|
||||
status.Should().Be(expectedStatus);
|
||||
body.GetProperty("code").GetString().Should().Be(expectedCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Responses use application/problem+json")]
|
||||
public async Task Responses_UseProblemJsonContentType()
|
||||
{
|
||||
var logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
|
||||
var handler = new GlobalExceptionHandler(logger);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await handler.InvokeAsync(context, _ => throw new KeyNotFoundException());
|
||||
|
||||
context.Response.ContentType.Should().Be("application/problem+json");
|
||||
}
|
||||
}
|
||||
12
shared/api-contracts/package-lock.json
generated
12
shared/api-contracts/package-lock.json
generated
|
|
@ -7,6 +7,9 @@
|
|||
"": {
|
||||
"name": "@proposal-system/api-contracts",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~7.0.2"
|
||||
}
|
||||
|
|
@ -385,6 +388,15 @@
|
|||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,46 @@
|
|||
// @proposal-system/api-contracts — single source of truth for API wire types.
|
||||
// Fix: WEB-M5 — web and mobile both import these; shapes mirror the .NET DTOs
|
||||
// in api/src/ProposalSystem.Application/DTOs/ (JsonStringEnumConverter → string
|
||||
// enums, Guid → string, DateTime → ISO string, decimal → number).
|
||||
// Runtime validation schemas live in ./schemas (separate entrypoint so
|
||||
// consumers that only need types never pull zod).
|
||||
|
||||
// ── Enums (string over the wire) ──────────────────────────────────────────
|
||||
export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised';
|
||||
// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other')
|
||||
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other';
|
||||
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
|
||||
export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical';
|
||||
export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both';
|
||||
export type UserRole = 'Dispatcher' | 'Admin' | 'SysAdmin';
|
||||
|
||||
export interface Proposal {
|
||||
// ── Shared envelopes ──────────────────────────────────────────────────────
|
||||
export interface PagedResponse<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// ── Proposals (ProposalDtos.cs) ───────────────────────────────────────────
|
||||
export interface ProposalListItem {
|
||||
id: string;
|
||||
proposalNumber: string;
|
||||
customerName: string;
|
||||
workOrderNumber: string;
|
||||
serviceCategory: ServiceCategory;
|
||||
priority: Priority;
|
||||
status: ProposalStatus;
|
||||
totalBidAmount: number;
|
||||
submittedAt: string;
|
||||
submittedByName: string | null;
|
||||
assignedAdminName: string | null;
|
||||
}
|
||||
|
||||
export interface ProposalDetail {
|
||||
id: string;
|
||||
proposalNumber: string;
|
||||
workOrderNumber: string;
|
||||
poNumber: string | null;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
|
|
@ -21,6 +52,7 @@ export interface Proposal {
|
|||
vendorTotalCost: number | null;
|
||||
notes: string;
|
||||
submittedById: string;
|
||||
submittedByName: string | null;
|
||||
submittedAt: string;
|
||||
assignedAdminId: string | null;
|
||||
approvedById: string | null;
|
||||
|
|
@ -32,6 +64,45 @@ export interface Proposal {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateProposalRequest {
|
||||
workOrderNumber: string;
|
||||
poNumber?: string;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
serviceCategory: ServiceCategory;
|
||||
priority: Priority;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProposalRequest {
|
||||
refinedScope?: string;
|
||||
notes?: string;
|
||||
poNumber?: string;
|
||||
workOrderNumber?: string;
|
||||
assignedAdminId?: string;
|
||||
}
|
||||
|
||||
export interface ProposalFilters {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
serviceCategory?: string;
|
||||
priority?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
export interface ProposalStats {
|
||||
totalCount: number;
|
||||
inReviewCount: number;
|
||||
approvedCount: number;
|
||||
sentCount: number;
|
||||
}
|
||||
|
||||
// ── Line items (LineItemDtos.cs) ──────────────────────────────────────────
|
||||
export interface LineItem {
|
||||
id: string;
|
||||
proposalId: string;
|
||||
|
|
@ -47,20 +118,34 @@ export interface LineItem {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateProposalRequest {
|
||||
workOrderNumber: string;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
serviceCategory: ServiceCategory;
|
||||
priority: Priority;
|
||||
notes?: string;
|
||||
export interface CreateLineItemRequest {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number | null;
|
||||
totalPrice: number;
|
||||
pricingMode: PricingMode;
|
||||
sortOrder: number;
|
||||
source: LineItemSource;
|
||||
}
|
||||
|
||||
export interface UpdateLineItemsRequest {
|
||||
lineItems: Omit<LineItem, 'id' | 'proposalId' | 'createdAt' | 'updatedAt'>[];
|
||||
export interface UpdateLineItemEntry {
|
||||
id: string | null;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number | null;
|
||||
totalPrice: number;
|
||||
pricingMode: PricingMode;
|
||||
sortOrder: number;
|
||||
source: LineItemSource;
|
||||
}
|
||||
|
||||
export interface BulkUpdateLineItemsRequest {
|
||||
lineItems: UpdateLineItemEntry[];
|
||||
}
|
||||
|
||||
// ── Customers (CustomerDtos.cs) ───────────────────────────────────────────
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -69,6 +154,12 @@ export interface Customer {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CreateCustomerRequest {
|
||||
name: string;
|
||||
addresses?: string[];
|
||||
|
|
@ -81,14 +172,7 @@ export interface UpdateCustomerRequest {
|
|||
contactEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// PR5: Pricing Library types
|
||||
// ── Pricing library (PricingLibraryDtos.cs) ───────────────────────────────
|
||||
export interface PricingLibraryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -102,12 +186,17 @@ export interface PricingLibraryItem {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PricingLibraryListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreatePricingLibraryItemRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
serviceCategory: ServiceCategory;
|
||||
unit?: string;
|
||||
unitPrice?: number;
|
||||
unitPrice?: number | null;
|
||||
keywords?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
|
@ -117,6 +206,82 @@ export interface UpdatePricingLibraryItemRequest {
|
|||
description?: string;
|
||||
serviceCategory?: ServiceCategory;
|
||||
unit?: string;
|
||||
unitPrice?: number;
|
||||
unitPrice?: number | null;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
// ── Admin (DashboardDtos.cs, AuditDtos.cs) ────────────────────────────────
|
||||
export interface DashboardStats {
|
||||
pendingCount: number;
|
||||
approvedThisWeek: number;
|
||||
avgTurnaroundHours: number;
|
||||
totalProposals: number;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
proposalId: string | null;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
action: string;
|
||||
details: string | null;
|
||||
timestamp: string;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
// ── Sites (SiteDtos.cs) ───────────────────────────────────────────────────
|
||||
export interface Site {
|
||||
siteCode: string;
|
||||
fullAddress: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
}
|
||||
|
||||
// ── Auth / users (UserDtos.cs, AuthController) ────────────────────────────
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
// ── Files / PDFs (FileDtos.cs) ────────────────────────────────────────────
|
||||
export interface PdfVersion {
|
||||
revision: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface PresignedUpload {
|
||||
uploadUrl: string;
|
||||
s3Key: string;
|
||||
expiresAt: string;
|
||||
vendorProposalId: string;
|
||||
}
|
||||
|
||||
// ── Errors (GlobalExceptionHandler.cs) ────────────────────────────────────
|
||||
// RFC 7807 ProblemDetails + machine-readable business code (SHOC error-code
|
||||
// vocabulary convention). Branch on `code`, never on title/detail text.
|
||||
export type ApiProblemCode =
|
||||
| 'ValidationFailed'
|
||||
| 'InvalidStateTransition'
|
||||
| 'NotFound'
|
||||
| 'Unauthorized'
|
||||
| 'InternalError'
|
||||
| (string & {}); // business codes are open-ended (e.g. future CancelNotAllowed)
|
||||
|
||||
export interface ApiProblem {
|
||||
status: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
code: ApiProblemCode;
|
||||
}
|
||||
|
|
|
|||
276
shared/api-contracts/src/schemas.ts
Normal file
276
shared/api-contracts/src/schemas.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
// Runtime validation schemas coupled to the wire types in ./index.
|
||||
// Separate entrypoint by design: consumers that only need types (mobile)
|
||||
// never pull zod. Each schema is compile-time-checked against its type via
|
||||
// `satisfies z.ZodType<T>` — if a DTO and its schema drift, tsc fails here.
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
ProposalStatus,
|
||||
ServiceCategory,
|
||||
Priority,
|
||||
LineItemSource,
|
||||
PricingMode,
|
||||
UserRole,
|
||||
ProposalListItem,
|
||||
ProposalDetail,
|
||||
CreateProposalRequest,
|
||||
UpdateProposalRequest,
|
||||
LineItem,
|
||||
CreateLineItemRequest,
|
||||
UpdateLineItemEntry,
|
||||
BulkUpdateLineItemsRequest,
|
||||
Customer,
|
||||
CreateCustomerRequest,
|
||||
UpdateCustomerRequest,
|
||||
PricingLibraryItem,
|
||||
CreatePricingLibraryItemRequest,
|
||||
UpdatePricingLibraryItemRequest,
|
||||
DashboardStats,
|
||||
AuditEntry,
|
||||
Site,
|
||||
AuthUser,
|
||||
UserProfile,
|
||||
ProposalStats,
|
||||
PdfVersion,
|
||||
PresignedUpload,
|
||||
ApiProblem,
|
||||
} from './index';
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────
|
||||
export const proposalStatusSchema = z.enum(['Draft', 'InReview', 'Approved', 'Sent', 'Revised']) satisfies z.ZodType<ProposalStatus>;
|
||||
export const serviceCategorySchema = z.enum(['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other']) satisfies z.ZodType<ServiceCategory>;
|
||||
export const prioritySchema = z.enum(['Standard', 'Urgent', 'Emergency']) satisfies z.ZodType<Priority>;
|
||||
export const lineItemSourceSchema = z.enum(['AI', 'Vendor', 'Manual', 'Historical']) satisfies z.ZodType<LineItemSource>;
|
||||
export const pricingModeSchema = z.enum(['UnitPrice', 'TotalPrice', 'Both']) satisfies z.ZodType<PricingMode>;
|
||||
export const userRoleSchema = z.enum(['Dispatcher', 'Admin', 'SysAdmin']) satisfies z.ZodType<UserRole>;
|
||||
|
||||
// ── Proposals ─────────────────────────────────────────────────────────────
|
||||
export const proposalListItemSchema = z.object({
|
||||
id: z.string(),
|
||||
proposalNumber: z.string(),
|
||||
customerName: z.string(),
|
||||
workOrderNumber: z.string(),
|
||||
serviceCategory: serviceCategorySchema,
|
||||
priority: prioritySchema,
|
||||
status: proposalStatusSchema,
|
||||
totalBidAmount: z.number(),
|
||||
submittedAt: z.string(),
|
||||
submittedByName: z.string().nullable(),
|
||||
assignedAdminName: z.string().nullable(),
|
||||
}) satisfies z.ZodType<ProposalListItem>;
|
||||
|
||||
export const proposalDetailSchema = z.object({
|
||||
id: z.string(),
|
||||
proposalNumber: z.string(),
|
||||
workOrderNumber: z.string(),
|
||||
poNumber: z.string().nullable(),
|
||||
customerName: z.string(),
|
||||
customerAddress: z.string(),
|
||||
scopeOfWork: z.string(),
|
||||
refinedScope: z.string().nullable(),
|
||||
serviceCategory: serviceCategorySchema,
|
||||
priority: prioritySchema,
|
||||
status: proposalStatusSchema,
|
||||
totalBidAmount: z.number(),
|
||||
vendorTotalCost: z.number().nullable(),
|
||||
notes: z.string(),
|
||||
submittedById: z.string(),
|
||||
submittedByName: z.string().nullable(),
|
||||
submittedAt: z.string(),
|
||||
assignedAdminId: z.string().nullable(),
|
||||
approvedById: z.string().nullable(),
|
||||
approvedAt: z.string().nullable(),
|
||||
sentAt: z.string().nullable(),
|
||||
currentRevision: z.number(),
|
||||
parentProposalId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<ProposalDetail>;
|
||||
|
||||
export const createProposalRequestSchema = z.object({
|
||||
workOrderNumber: z.string().min(1),
|
||||
poNumber: z.string().optional(),
|
||||
customerName: z.string().min(1),
|
||||
customerAddress: z.string().min(1),
|
||||
scopeOfWork: z.string().min(1),
|
||||
serviceCategory: serviceCategorySchema,
|
||||
priority: prioritySchema,
|
||||
notes: z.string().optional(),
|
||||
}) satisfies z.ZodType<CreateProposalRequest>;
|
||||
|
||||
export const updateProposalRequestSchema = z.object({
|
||||
refinedScope: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
poNumber: z.string().optional(),
|
||||
workOrderNumber: z.string().optional(),
|
||||
assignedAdminId: z.string().optional(),
|
||||
}) satisfies z.ZodType<UpdateProposalRequest>;
|
||||
|
||||
export const proposalStatsSchema = z.object({
|
||||
totalCount: z.number(),
|
||||
inReviewCount: z.number(),
|
||||
approvedCount: z.number(),
|
||||
sentCount: z.number(),
|
||||
}) satisfies z.ZodType<ProposalStats>;
|
||||
|
||||
// ── Line items ────────────────────────────────────────────────────────────
|
||||
export const lineItemSchema = z.object({
|
||||
id: z.string(),
|
||||
proposalId: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
unitPrice: z.number().nullable(),
|
||||
totalPrice: z.number(),
|
||||
pricingMode: pricingModeSchema,
|
||||
sortOrder: z.number(),
|
||||
source: lineItemSourceSchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<LineItem>;
|
||||
|
||||
export const createLineItemRequestSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
unitPrice: z.number().nullable(),
|
||||
totalPrice: z.number(),
|
||||
pricingMode: pricingModeSchema,
|
||||
sortOrder: z.number(),
|
||||
source: lineItemSourceSchema,
|
||||
}) satisfies z.ZodType<CreateLineItemRequest>;
|
||||
|
||||
export const updateLineItemEntrySchema = z.object({
|
||||
id: z.string().nullable(),
|
||||
description: z.string().min(1),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
unitPrice: z.number().nullable(),
|
||||
totalPrice: z.number(),
|
||||
pricingMode: pricingModeSchema,
|
||||
sortOrder: z.number(),
|
||||
source: lineItemSourceSchema,
|
||||
}) satisfies z.ZodType<UpdateLineItemEntry>;
|
||||
|
||||
export const bulkUpdateLineItemsRequestSchema = z.object({
|
||||
lineItems: z.array(updateLineItemEntrySchema),
|
||||
}) satisfies z.ZodType<BulkUpdateLineItemsRequest>;
|
||||
|
||||
// ── Customers ─────────────────────────────────────────────────────────────
|
||||
export const customerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
addresses: z.array(z.string()),
|
||||
contactEmail: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
}) satisfies z.ZodType<Customer>;
|
||||
|
||||
export const createCustomerRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
addresses: z.array(z.string()).optional(),
|
||||
contactEmail: z.string().optional(),
|
||||
}) satisfies z.ZodType<CreateCustomerRequest>;
|
||||
|
||||
export const updateCustomerRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
addresses: z.array(z.string()).optional(),
|
||||
contactEmail: z.string().nullable().optional(),
|
||||
}) satisfies z.ZodType<UpdateCustomerRequest>;
|
||||
|
||||
// ── Pricing library ───────────────────────────────────────────────────────
|
||||
export const pricingLibraryItemSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
serviceCategory: serviceCategorySchema,
|
||||
unit: z.string().nullable(),
|
||||
unitPrice: z.number().nullable(),
|
||||
keywords: z.string().nullable(),
|
||||
source: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<PricingLibraryItem>;
|
||||
|
||||
export const createPricingLibraryItemRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
serviceCategory: serviceCategorySchema,
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number().nullable().optional(),
|
||||
keywords: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
}) satisfies z.ZodType<CreatePricingLibraryItemRequest>;
|
||||
|
||||
export const updatePricingLibraryItemRequestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
serviceCategory: serviceCategorySchema.optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number().nullable().optional(),
|
||||
keywords: z.string().optional(),
|
||||
}) satisfies z.ZodType<UpdatePricingLibraryItemRequest>;
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||
export const dashboardStatsSchema = z.object({
|
||||
pendingCount: z.number(),
|
||||
approvedThisWeek: z.number(),
|
||||
avgTurnaroundHours: z.number(),
|
||||
totalProposals: z.number(),
|
||||
}) satisfies z.ZodType<DashboardStats>;
|
||||
|
||||
export const auditEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
proposalId: z.string().nullable(),
|
||||
userId: z.string(),
|
||||
userName: z.string().nullable(),
|
||||
action: z.string(),
|
||||
details: z.string().nullable(),
|
||||
timestamp: z.string(),
|
||||
ipAddress: z.string().nullable(),
|
||||
}) satisfies z.ZodType<AuditEntry>;
|
||||
|
||||
// ── Sites ─────────────────────────────────────────────────────────────────
|
||||
export const siteSchema = z.object({
|
||||
siteCode: z.string(),
|
||||
fullAddress: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
state: z.string().nullable(),
|
||||
zip: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Site>;
|
||||
|
||||
// ── Auth / users ──────────────────────────────────────────────────────────
|
||||
export const authUserSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
role: userRoleSchema,
|
||||
token: z.string(),
|
||||
}) satisfies z.ZodType<AuthUser>;
|
||||
|
||||
export const userProfileSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
role: userRoleSchema,
|
||||
}) satisfies z.ZodType<UserProfile>;
|
||||
|
||||
// ── Files / PDFs ──────────────────────────────────────────────────────────
|
||||
export const pdfVersionSchema = z.object({
|
||||
revision: z.number(),
|
||||
generatedAt: z.string(),
|
||||
}) satisfies z.ZodType<PdfVersion>;
|
||||
|
||||
export const presignedUploadSchema = z.object({
|
||||
uploadUrl: z.string(),
|
||||
s3Key: z.string(),
|
||||
expiresAt: z.string(),
|
||||
vendorProposalId: z.string(),
|
||||
}) satisfies z.ZodType<PresignedUpload>;
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────
|
||||
export const apiProblemSchema = z.object({
|
||||
status: z.number(),
|
||||
title: z.string(),
|
||||
detail: z.string(),
|
||||
code: z.string(),
|
||||
}) satisfies z.ZodType<ApiProblem>;
|
||||
27
web/package-lock.json
generated
27
web/package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
|
|
@ -23,7 +24,8 @@
|
|||
"react-dom": "^19.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-toastify": "^11.0.5"
|
||||
"react-toastify": "^11.0.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
@ -38,6 +40,16 @@
|
|||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"../shared/api-contracts": {
|
||||
"name": "@proposal-system/api-contracts",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
|
|
@ -920,6 +932,10 @@
|
|||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@proposal-system/api-contracts": {
|
||||
"resolved": "../shared/api-contracts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
|
|
@ -4002,6 +4018,15 @@
|
|||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "npm ci --prefix ../shared/api-contracts",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
|
|
@ -26,7 +28,8 @@
|
|||
"react-dom": "^19.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-toastify": "^11.0.5"
|
||||
"react-toastify": "^11.0.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
|
|||
|
|
@ -1,31 +1,8 @@
|
|||
import apiClient from './client';
|
||||
import { type ProposalDetail } from './proposals';
|
||||
// Fix: WEB-M5 — types from the shared contracts package.
|
||||
import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts';
|
||||
|
||||
export interface DashboardStats {
|
||||
pendingCount: number;
|
||||
approvedThisWeek: number;
|
||||
avgTurnaroundHours: number;
|
||||
totalProposals: number;
|
||||
}
|
||||
|
||||
export interface UpdateProposalRequest {
|
||||
refinedScope?: string;
|
||||
notes?: string;
|
||||
poNumber?: string;
|
||||
workOrderNumber?: string;
|
||||
assignedAdminId?: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
proposalId: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: string;
|
||||
details: string | null;
|
||||
timestamp: string;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts';
|
||||
|
||||
export const adminApi = {
|
||||
getDashboard: async (): Promise<DashboardStats> => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,8 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types from the shared contracts package.
|
||||
import type { AuthUser, UserProfile } from '@proposal-system/api-contracts';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
|
||||
}
|
||||
export type { AuthUser, UserProfile } from '@proposal-system/api-contracts';
|
||||
|
||||
export const authApi = {
|
||||
exchangeCode: async (code: string, redirectUri: string): Promise<AuthUser> => {
|
||||
|
|
|
|||
|
|
@ -1,38 +1,23 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes
|
||||
// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest
|
||||
// sent a singular `address` field the API silently dropped.
|
||||
import type {
|
||||
Customer,
|
||||
CustomerListParams,
|
||||
CreateCustomerRequest,
|
||||
UpdateCustomerRequest,
|
||||
PagedResponse,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
addresses: string[];
|
||||
/** Contact email for proposal delivery. Null when not set. */
|
||||
contactEmail?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export type {
|
||||
Customer,
|
||||
CustomerListParams,
|
||||
CreateCustomerRequest,
|
||||
UpdateCustomerRequest,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export interface CustomerListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PagedCustomerResponse {
|
||||
items: Customer[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateCustomerRequest {
|
||||
name: string;
|
||||
address: string;
|
||||
contactEmail?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomerRequest {
|
||||
name: string;
|
||||
address: string;
|
||||
contactEmail?: string;
|
||||
}
|
||||
export type PagedCustomerResponse = PagedResponse<Customer>;
|
||||
|
||||
export const customersApi = {
|
||||
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
|
||||
|
|
|
|||
|
|
@ -1,45 +1,14 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types from the shared contracts package.
|
||||
import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts';
|
||||
|
||||
export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both';
|
||||
export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical';
|
||||
|
||||
export interface LineItem {
|
||||
id: string;
|
||||
proposalId: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number | null;
|
||||
totalPrice: number;
|
||||
pricingMode: PricingMode;
|
||||
sortOrder: number;
|
||||
source: LineItemSource;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateLineItemRequest {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number | null;
|
||||
totalPrice: number;
|
||||
pricingMode: PricingMode;
|
||||
sortOrder: number;
|
||||
source: LineItemSource;
|
||||
}
|
||||
|
||||
export interface UpdateLineItemEntry {
|
||||
id: string | null;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number | null;
|
||||
totalPrice: number;
|
||||
pricingMode: PricingMode;
|
||||
sortOrder: number;
|
||||
source: LineItemSource;
|
||||
}
|
||||
export type {
|
||||
PricingMode,
|
||||
LineItemSource,
|
||||
LineItem,
|
||||
CreateLineItemRequest,
|
||||
UpdateLineItemEntry,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export const lineItemsApi = {
|
||||
getAll: async (proposalId: string): Promise<LineItem[]> => {
|
||||
|
|
|
|||
|
|
@ -1,47 +1,21 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types from the shared contracts package.
|
||||
import type {
|
||||
PricingLibraryItem,
|
||||
PricingLibraryListParams,
|
||||
CreatePricingLibraryItemRequest,
|
||||
UpdatePricingLibraryItemRequest,
|
||||
PagedResponse,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export interface PricingLibraryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
serviceCategory: string;
|
||||
unit?: string | null;
|
||||
unitPrice?: number | null;
|
||||
keywords?: string | null;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export type {
|
||||
PricingLibraryItem,
|
||||
PricingLibraryListParams,
|
||||
CreatePricingLibraryItemRequest,
|
||||
UpdatePricingLibraryItemRequest,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export interface PricingLibraryListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PagedPricingLibraryResponse {
|
||||
items: PricingLibraryItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreatePricingLibraryItemRequest {
|
||||
title: string;
|
||||
serviceCategory: string;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
unitPrice?: number | null;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePricingLibraryItemRequest {
|
||||
title: string;
|
||||
serviceCategory: string;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
unitPrice?: number | null;
|
||||
keywords?: string;
|
||||
}
|
||||
export type PagedPricingLibraryResponse = PagedResponse<PricingLibraryItem>;
|
||||
|
||||
export const pricingLibraryApi = {
|
||||
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
|
||||
|
|
|
|||
|
|
@ -1,80 +1,29 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types now come from the shared contracts package (single source
|
||||
// of truth aligned with the .NET DTOs); re-exported so page imports stay stable.
|
||||
import type {
|
||||
CreateProposalRequest,
|
||||
ProposalDetail,
|
||||
ProposalListItem,
|
||||
ProposalFilters,
|
||||
ProposalStats,
|
||||
PagedResponse,
|
||||
PdfVersion,
|
||||
PresignedUpload,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts)
|
||||
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other';
|
||||
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
|
||||
|
||||
export interface CreateProposalRequest {
|
||||
workOrderNumber: string;
|
||||
poNumber?: string;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
serviceCategory: ServiceCategory;
|
||||
priority: Priority;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProposalListItem {
|
||||
id: string;
|
||||
proposalNumber: string;
|
||||
customerName: string;
|
||||
workOrderNumber: string;
|
||||
serviceCategory: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
totalBidAmount: number;
|
||||
submittedAt: string;
|
||||
submittedByName: string | null;
|
||||
assignedAdminName: string | null;
|
||||
}
|
||||
|
||||
export interface ProposalDetail {
|
||||
id: string;
|
||||
proposalNumber: string;
|
||||
workOrderNumber: string;
|
||||
poNumber: string | null;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
refinedScope: string | null;
|
||||
serviceCategory: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
totalBidAmount: number;
|
||||
vendorTotalCost: number | null;
|
||||
notes: string;
|
||||
submittedById: string;
|
||||
submittedByName: string | null;
|
||||
submittedAt: string;
|
||||
assignedAdminId: string | null;
|
||||
approvedById: string | null;
|
||||
approvedAt: string | null;
|
||||
sentAt: string | null;
|
||||
currentRevision: number;
|
||||
parentProposalId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ProposalFilters {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
serviceCategory?: string;
|
||||
priority?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
mine?: boolean;
|
||||
}
|
||||
export type {
|
||||
ServiceCategory,
|
||||
Priority,
|
||||
CreateProposalRequest,
|
||||
ProposalDetail,
|
||||
ProposalListItem,
|
||||
ProposalFilters,
|
||||
ProposalStats,
|
||||
PagedResponse,
|
||||
PdfVersion,
|
||||
PresignedUpload,
|
||||
} from '@proposal-system/api-contracts';
|
||||
|
||||
export const proposalsApi = {
|
||||
create: async (data: CreateProposalRequest): Promise<ProposalDetail> => {
|
||||
|
|
@ -103,7 +52,7 @@ export const proposalsApi = {
|
|||
return res.data;
|
||||
},
|
||||
|
||||
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => {
|
||||
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<PresignedUpload> => {
|
||||
const params = new URLSearchParams({ fileName });
|
||||
if (vendorName) params.append('vendorName', vendorName);
|
||||
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
|
||||
|
|
@ -146,15 +95,3 @@ export const proposalsApi = {
|
|||
return { downloadUrl };
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProposalStats {
|
||||
totalCount: number;
|
||||
inReviewCount: number;
|
||||
approvedCount: number;
|
||||
sentCount: number;
|
||||
}
|
||||
|
||||
export interface PdfVersion {
|
||||
revision: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import apiClient from './client';
|
||||
// Fix: WEB-M5 — types from the shared contracts package.
|
||||
import type { Site } from '@proposal-system/api-contracts';
|
||||
|
||||
export interface Site {
|
||||
siteCode: string;
|
||||
fullAddress: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
}
|
||||
export type { Site } from '@proposal-system/api-contracts';
|
||||
|
||||
export const sitesApi = {
|
||||
search: async (query: string): Promise<Site[]> => {
|
||||
|
|
|
|||
|
|
@ -176,9 +176,12 @@ export default function CustomerManagementPage() {
|
|||
const handleSubmit = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
|
||||
// the old singular `address` field was silently dropped by model binding.
|
||||
// The form edits the primary address; any additional addresses are preserved.
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
address: form.address.trim(),
|
||||
addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])],
|
||||
...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
type CreatePricingLibraryItemRequest,
|
||||
type UpdatePricingLibraryItemRequest,
|
||||
} from '../../../lib/api/pricingLibrary';
|
||||
import type { ServiceCategory } from '@proposal-system/api-contracts';
|
||||
import { queryClient } from '../../../lib/queryClient';
|
||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
||||
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
|
||||
|
|
@ -56,7 +57,7 @@ const CATEGORY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; bor
|
|||
|
||||
interface ItemFormState {
|
||||
title: string;
|
||||
serviceCategory: string;
|
||||
serviceCategory: ServiceCategory | '';
|
||||
unit: string;
|
||||
unitPrice: string;
|
||||
keywords: string;
|
||||
|
|
@ -210,6 +211,7 @@ export default function PricingLibraryPage() {
|
|||
|
||||
const handleSubmit = () => {
|
||||
if (!validateForm()) return;
|
||||
if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union
|
||||
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@proposal-system/api-contracts": ["../shared/api-contracts/src"],
|
||||
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../shared/api-contracts/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react';
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// @proposal-system/api-contracts is a file: symlink; preserve it so its
|
||||
// 'zod' import resolves from web/node_modules in CI (shared has no
|
||||
// installed node_modules there).
|
||||
preserveSymlinks: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue