diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 14b912d9d..48ec1ed29 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -15,6 +15,7 @@ import { workRoutes } from '~/apps/work' import { calendarRoutes } from '~/apps/calendar' import { engagementsRoutes } from '~/apps/engagements' import { customerPortalRoutes } from '~/apps/customer-portal' +import { procurementRoutes } from '~/apps/procurement' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -45,6 +46,7 @@ export const platformRoutes: Array = [ ...workRoutes, ...calendarRoutes, ...engagementsRoutes, + ...procurementRoutes, ...homeRoutes, ...adminRoutes, ...reportsRoutes, diff --git a/src/apps/procurement/index.ts b/src/apps/procurement/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/procurement/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/procurement/src/ProcurementApp.module.scss b/src/apps/procurement/src/ProcurementApp.module.scss new file mode 100644 index 000000000..7130da5b4 --- /dev/null +++ b/src/apps/procurement/src/ProcurementApp.module.scss @@ -0,0 +1,65 @@ +.outer { + padding-top: 24px; +} + +.inner { + max-width: 1280px; +} + +.navigation { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 24px; + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #f9fafb; +} + +.navigationLink { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 8px 14px; + border-radius: 6px; + color: #374151; + font-size: 14px; + font-weight: 700; + line-height: 1.2; + text-decoration: none; + + &:hover { + background: #ffffff; + color: #0f766e; + } +} + +.active { + background: #0f766e; + color: #ffffff; + + &:hover { + background: #0f766e; + color: #ffffff; + } +} + +.content { + min-width: 0; +} + +@media (max-width: 720px) { + .outer { + padding-top: 16px; + } + + .navigation { + gap: 4px; + } + + .navigationLink { + flex: 1 1 120px; + justify-content: center; + } +} diff --git a/src/apps/procurement/src/ProcurementApp.tsx b/src/apps/procurement/src/ProcurementApp.tsx new file mode 100644 index 000000000..bb02ff3cc --- /dev/null +++ b/src/apps/procurement/src/ProcurementApp.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/jsx-no-bind */ +/** + * Shell for the hidden procurement app. + */ +import { FC, useContext, useMemo } from 'react' +import { NavLink, Outlet, Routes } from 'react-router-dom' +import classNames from 'classnames' + +import { routerContext, RouterContextData } from '~/libs/core' +import { ContentLayout } from '~/libs/ui' + +import { + buildProcurementPath, + procurementContractsRoute, + procurementDashboardRoute, + procurementInvoicesRoute, + procurementRenewalsRoute, + procurementVendorsRoute, +} from './config/routes.config' +import { toolTitle } from './procurement-app.routes' +import styles from './ProcurementApp.module.scss' + +interface ProcurementNavigationItem { + label: string + route: string + to: string +} + +const navigationItems: ProcurementNavigationItem[] = [ + { + label: 'Dashboard', + route: procurementDashboardRoute, + to: buildProcurementPath(procurementDashboardRoute), + }, + { + label: 'Vendors', + route: procurementVendorsRoute, + to: buildProcurementPath(procurementVendorsRoute), + }, + { + label: 'Contracts', + route: procurementContractsRoute, + to: buildProcurementPath(procurementContractsRoute), + }, + { + label: 'Invoices', + route: procurementInvoicesRoute, + to: buildProcurementPath(procurementInvoicesRoute), + }, + { + label: 'Renewals', + route: procurementRenewalsRoute, + to: buildProcurementPath(procurementRenewalsRoute), + }, +] + +const ProcurementApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + return ( + + + +
+ + {childRoutes} +
+
+ ) +} + +export default ProcurementApp diff --git a/src/apps/procurement/src/config/routes.config.ts b/src/apps/procurement/src/config/routes.config.ts new file mode 100644 index 000000000..048268030 --- /dev/null +++ b/src/apps/procurement/src/config/routes.config.ts @@ -0,0 +1,37 @@ +/** + * Common config for routes in procurement app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.procurement + ? '' + : `/${AppSubdomain.procurement}` + +export const procurementContractsRouteId = 'procurement-contracts' +export const procurementDashboardRouteId = 'procurement-dashboard' +export const procurementInvoicesRouteId = 'procurement-invoices' +export const procurementRenewalsRouteId = 'procurement-renewals' +export const procurementVendorsRouteId = 'procurement-vendors' + +export const procurementDashboardRoute = '' +export const procurementContractsRoute = 'contracts' +export const procurementInvoicesRoute = 'invoices' +export const procurementRenewalsRoute = 'renewals' +export const procurementVendorsRoute = 'vendors' + +/** + * Builds an absolute procurement path from a child route segment. + * + * @param route Child route segment from the procurement route map. + * @returns Absolute path rooted at the configured procurement root route. + */ +export function buildProcurementPath(route: string = procurementDashboardRoute): string { + const normalizedRoute: string = route.replace(/^\/+/, '') + + if (!normalizedRoute) { + return rootRoute || '/' + } + + return `${rootRoute}/${normalizedRoute}` +} diff --git a/src/apps/procurement/src/index.ts b/src/apps/procurement/src/index.ts new file mode 100644 index 000000000..91562b3bb --- /dev/null +++ b/src/apps/procurement/src/index.ts @@ -0,0 +1,2 @@ +export { procurementRoutes } from './procurement-app.routes' +export { rootRoute as procurementRootRoute } from './config/routes.config' diff --git a/src/apps/procurement/src/lib/constants/roles.constants.ts b/src/apps/procurement/src/lib/constants/roles.constants.ts new file mode 100644 index 000000000..1a38a30cf --- /dev/null +++ b/src/apps/procurement/src/lib/constants/roles.constants.ts @@ -0,0 +1,12 @@ +/** + * Procurement role constants accepted by the procurement app routes. + */ +export const ProcurementRole = { + admin: 'procurement-admin', + user: 'procurement-user', +} as const + +export const PROCUREMENT_ALLOWED_ROLES: Array = [ + ProcurementRole.admin, + ProcurementRole.user, +] diff --git a/src/apps/procurement/src/lib/models/contract.model.ts b/src/apps/procurement/src/lib/models/contract.model.ts new file mode 100644 index 000000000..dc0da11ec --- /dev/null +++ b/src/apps/procurement/src/lib/models/contract.model.ts @@ -0,0 +1,47 @@ +import { ContractLifecycle, ContractStatus } from './procurement-common.model' + +/** + * Vendor summary embedded in contract responses. + */ +export interface ContractVendorSummary { + category?: string + id: string + name: string +} + +/** + * Contract read model with stored status and backend-derived lifecycle. + */ +export interface Contract { + autoRenew: boolean + contractNumber: string + createdAt: string + description?: string + endDate: string + id: string + lifecycle: ContractLifecycle + renewalNoticeDays?: number | null + startDate: string + status: ContractStatus + title: string + updatedAt: string + value: number + vendor: ContractVendorSummary + vendorId: string +} + +/** + * Editable contract fields accepted by create and update endpoints. + */ +export interface ContractMutationPayload { + autoRenew?: boolean + contractNumber: string + description?: string + endDate: string + renewalNoticeDays?: number + startDate: string + status?: ContractStatus + title: string + value: number + vendorId: string +} diff --git a/src/apps/procurement/src/lib/models/dashboard-summary.model.ts b/src/apps/procurement/src/lib/models/dashboard-summary.model.ts new file mode 100644 index 000000000..97cacdc0b --- /dev/null +++ b/src/apps/procurement/src/lib/models/dashboard-summary.model.ts @@ -0,0 +1,16 @@ +import { Contract } from './contract.model' + +/** + * Summary response returned by the procurement dashboard endpoint. + */ +export interface DashboardSummary { + activeContractCount: number + activeRenewalCount: number + expiringContractCount: number + expiringContracts: Contract[] + overdueInvoiceCount: number + overdueInvoiceTotal: number + pendingInvoiceCount: number + pendingInvoiceTotal: number + vendorCount: number +} diff --git a/src/apps/procurement/src/lib/models/index.ts b/src/apps/procurement/src/lib/models/index.ts new file mode 100644 index 000000000..7913b59d7 --- /dev/null +++ b/src/apps/procurement/src/lib/models/index.ts @@ -0,0 +1,6 @@ +export * from './contract.model' +export * from './dashboard-summary.model' +export * from './invoice.model' +export * from './procurement-common.model' +export * from './renewal.model' +export * from './vendor.model' diff --git a/src/apps/procurement/src/lib/models/invoice.model.ts b/src/apps/procurement/src/lib/models/invoice.model.ts new file mode 100644 index 000000000..ef5f0ee28 --- /dev/null +++ b/src/apps/procurement/src/lib/models/invoice.model.ts @@ -0,0 +1,54 @@ +import { InvoicePaymentState, InvoiceStatus } from './procurement-common.model' + +/** + * Contract summary embedded in invoice responses. + */ +export interface InvoiceContractSummary { + contractNumber: string + id: string + title: string +} + +/** + * Vendor summary embedded in invoice responses. + */ +export interface InvoiceVendorSummary { + id: string + name: string +} + +/** + * Invoice read model with stored status and backend-derived payment state. + */ +export interface Invoice { + amount: number + contract?: InvoiceContractSummary + contractId?: string + createdAt: string + description?: string + dueDate: string + id: string + invoiceDate: string + invoiceNumber: string + paidDate?: string + paymentState: InvoicePaymentState + status: InvoiceStatus + updatedAt: string + vendor: InvoiceVendorSummary + vendorId: string +} + +/** + * Editable invoice fields accepted by create and update endpoints. + */ +export interface InvoiceMutationPayload { + amount: number + contractId?: string + description?: string + dueDate: string + invoiceDate: string + invoiceNumber: string + paidDate?: string + status?: InvoiceStatus + vendorId: string +} diff --git a/src/apps/procurement/src/lib/models/procurement-common.model.ts b/src/apps/procurement/src/lib/models/procurement-common.model.ts new file mode 100644 index 000000000..6cbcf7796 --- /dev/null +++ b/src/apps/procurement/src/lib/models/procurement-common.model.ts @@ -0,0 +1,20 @@ +/** + * Shared procurement enum-like frontend types returned by the procurement API. + */ +export type ContractLifecycle = 'active' | 'draft' | 'expired' | 'expiring' | 'terminated' + +export type ContractStatus = 'active' | 'draft' | 'expired' | 'terminated' + +export type InvoicePaymentState = 'cancelled' | 'draft' | 'overdue' | 'paid' | 'pending' + +export type InvoiceStatus = 'cancelled' | 'draft' | 'issued' | 'paid' + +export type RenewalStage = + | 'cio_approval' + | 'legal_review' + | 'order_form_signed' + | 'po_release' + | 'pr_approvals' + | 'pr_creation' + | 'quotation' + | 'vra' diff --git a/src/apps/procurement/src/lib/models/renewal.model.ts b/src/apps/procurement/src/lib/models/renewal.model.ts new file mode 100644 index 000000000..797982d29 --- /dev/null +++ b/src/apps/procurement/src/lib/models/renewal.model.ts @@ -0,0 +1,70 @@ +import { RenewalStage } from './procurement-common.model' + +/** + * Vendor summary embedded in renewal responses. + */ +export interface RenewalVendorSummary { + id: string + name: string +} + +/** + * Contract summary embedded in renewal responses. + */ +export interface RenewalContractSummary { + contractNumber: string + id: string + title: string + vendor: RenewalVendorSummary +} + +/** + * Renewal read model with backend workflow metadata. + */ +export interface Renewal { + assignee?: string + cioApprovalAt?: string + contract: RenewalContractSummary + contractId: string + createdAt: string + id: string + legalReviewAt?: string + newEndDate: string + newStartDate: string + newValue?: number | null + notes?: string + orderFormSignedAt?: string + poReleaseAt?: string + prApprovalsAt?: string + prCreationAt?: string + quotationAt?: string + renewalTermMonths: number + stage: RenewalStage + stageLabel: string + stageOrder: number + updatedAt: string + vraAt?: string +} + +/** + * Available renewal workflow stage metadata returned by the API. + */ +export interface RenewalStageMetadata { + label: string + order: number + stage: RenewalStage + terminal: boolean +} + +/** + * Editable renewal fields accepted by create and update endpoints. + */ +export interface RenewalMutationPayload { + assignee?: string + contractId: string + newEndDate: string + newStartDate: string + newValue?: number + notes?: string + renewalTermMonths: number +} diff --git a/src/apps/procurement/src/lib/models/vendor.model.ts b/src/apps/procurement/src/lib/models/vendor.model.ts new file mode 100644 index 000000000..769e2204c --- /dev/null +++ b/src/apps/procurement/src/lib/models/vendor.model.ts @@ -0,0 +1,28 @@ +/** + * Vendor read model returned by procurement vendor endpoints. + */ +export interface Vendor { + address?: string + category?: string + contactEmail?: string + contactName?: string + contactPhone?: string + createdAt: string + id: string + name: string + notes?: string + updatedAt: string +} + +/** + * Editable vendor fields accepted by create and update endpoints. + */ +export interface VendorMutationPayload { + address?: string + category?: string + contactEmail?: string + contactName?: string + contactPhone?: string + name: string + notes?: string +} diff --git a/src/apps/procurement/src/lib/services/contracts.service.ts b/src/apps/procurement/src/lib/services/contracts.service.ts new file mode 100644 index 000000000..9c65b9e4c --- /dev/null +++ b/src/apps/procurement/src/lib/services/contracts.service.ts @@ -0,0 +1,85 @@ +import { + deleteAsync, + getAsync, + postAsync, + putAsync, +} from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' + +import { Contract, ContractMutationPayload } from '../models' +import { + buildProcurementApiUrl, + normalizeDateOnly, + normalizeOptionalNumber, + normalizeRequiredText, +} from '../utils/api.utils' + +/** + * API helpers for procurement contract CRUD endpoints. + */ + +/** + * Creates a procurement contract. + * + * @param payload Editable contract fields. + * @returns Created contract response. + */ +export function createContract(payload: ContractMutationPayload): Promise { + return postAsync( + buildProcurementApiUrl('contracts'), + normalizeContractPayload(payload), + ) +} + +/** + * Deletes a procurement contract. + * + * @param contractId Contract identifier to delete. + * @returns Deleted contract response. + */ +export function deleteContract(contractId: string): Promise { + return deleteAsync(buildProcurementApiUrl(`contracts/${contractId}`)) +} + +/** + * Loads all procurement contracts. + * + * @returns Contracts sorted by the backend. + */ +export function getContracts(): Promise { + return getAsync(buildProcurementApiUrl('contracts')) +} + +/** + * Replaces editable procurement contract fields. + * + * @param contractId Contract identifier to update. + * @param payload Editable contract fields. + * @returns Updated contract response. + */ +export function updateContract(contractId: string, payload: ContractMutationPayload): Promise { + return putAsync( + buildProcurementApiUrl(`contracts/${contractId}`), + normalizeContractPayload(payload), + ) +} + +/** + * Normalizes contract mutation payloads before xhr submission. + * + * @param payload Editable contract fields. + * @returns API-ready contract payload. + */ +function normalizeContractPayload(payload: ContractMutationPayload): ContractMutationPayload { + return { + autoRenew: payload.autoRenew, + contractNumber: normalizeRequiredText(payload.contractNumber), + description: payload.description === undefined ? undefined : payload.description.trim(), + endDate: normalizeDateOnly(payload.endDate) || payload.endDate, + renewalNoticeDays: normalizeOptionalNumber(payload.renewalNoticeDays), + startDate: normalizeDateOnly(payload.startDate) || payload.startDate, + status: payload.status, + title: normalizeRequiredText(payload.title), + value: payload.value, + vendorId: payload.vendorId, + } +} diff --git a/src/apps/procurement/src/lib/services/dashboard.service.ts b/src/apps/procurement/src/lib/services/dashboard.service.ts new file mode 100644 index 000000000..2a41ff27f --- /dev/null +++ b/src/apps/procurement/src/lib/services/dashboard.service.ts @@ -0,0 +1,17 @@ +import { getAsync } from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' + +import { DashboardSummary } from '../models' +import { buildProcurementApiUrl } from '../utils/api.utils' + +/** + * API helpers for procurement dashboard endpoints. + */ + +/** + * Loads the procurement dashboard summary. + * + * @returns Dashboard summary response. + */ +export function getDashboardSummary(): Promise { + return getAsync(buildProcurementApiUrl('dashboard')) +} diff --git a/src/apps/procurement/src/lib/services/index.ts b/src/apps/procurement/src/lib/services/index.ts new file mode 100644 index 000000000..9efff3cc7 --- /dev/null +++ b/src/apps/procurement/src/lib/services/index.ts @@ -0,0 +1,5 @@ +export * from './contracts.service' +export * from './dashboard.service' +export * from './invoices.service' +export * from './renewals.service' +export * from './vendors.service' diff --git a/src/apps/procurement/src/lib/services/invoices.service.ts b/src/apps/procurement/src/lib/services/invoices.service.ts new file mode 100644 index 000000000..fa866eb8d --- /dev/null +++ b/src/apps/procurement/src/lib/services/invoices.service.ts @@ -0,0 +1,96 @@ +import { + deleteAsync, + getAsync, + postAsync, + putAsync, +} from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' + +import { Invoice, InvoiceMutationPayload, InvoicePaymentState } from '../models' +import { + buildProcurementApiUrl, + normalizeDateOnly, + normalizeOptionalText, + normalizeRequiredText, +} from '../utils/api.utils' + +export type InvoiceStateFilter = Extract + +/** + * API helpers for procurement invoice endpoints. + */ + +/** + * Creates a procurement invoice. + * + * @param payload Editable invoice fields. + * @returns Created invoice response. + */ +export function createInvoice(payload: InvoiceMutationPayload): Promise { + return postAsync( + buildProcurementApiUrl('invoices'), + normalizeInvoicePayload(payload), + ) +} + +/** + * Deletes a procurement invoice. + * + * @param invoiceId Invoice identifier to delete. + * @returns Deleted invoice response. + */ +export function deleteInvoice(invoiceId: string): Promise { + return deleteAsync(buildProcurementApiUrl(`invoices/${invoiceId}`)) +} + +/** + * Loads procurement invoices, optionally filtered by backend-derived payment state. + * + * @param state Optional payment-state filter. + * @returns Invoices sorted by the backend. + */ +export function getInvoices(state?: InvoiceStateFilter): Promise { + return getAsync(buildProcurementApiUrl('invoices', { state })) +} + +/** + * Loads overdue procurement invoices for urgent dashboard tables. + * + * @returns Overdue invoices sorted by the backend. + */ +export function getOverdueInvoices(): Promise { + return getAsync(buildProcurementApiUrl('invoices/overdue')) +} + +/** + * Replaces editable procurement invoice fields. + * + * @param invoiceId Invoice identifier to update. + * @param payload Editable invoice fields. + * @returns Updated invoice response. + */ +export function updateInvoice(invoiceId: string, payload: InvoiceMutationPayload): Promise { + return putAsync( + buildProcurementApiUrl(`invoices/${invoiceId}`), + normalizeInvoicePayload(payload), + ) +} + +/** + * Normalizes invoice mutation payloads before xhr submission. + * + * @param payload Editable invoice fields. + * @returns API-ready invoice payload. + */ +function normalizeInvoicePayload(payload: InvoiceMutationPayload): InvoiceMutationPayload { + return { + amount: payload.amount, + contractId: normalizeOptionalText(payload.contractId), + description: payload.description === undefined ? undefined : payload.description.trim(), + dueDate: normalizeDateOnly(payload.dueDate) || payload.dueDate, + invoiceDate: normalizeDateOnly(payload.invoiceDate) || payload.invoiceDate, + invoiceNumber: normalizeRequiredText(payload.invoiceNumber), + paidDate: normalizeDateOnly(payload.paidDate), + status: payload.status, + vendorId: payload.vendorId, + } +} diff --git a/src/apps/procurement/src/lib/services/renewals.service.ts b/src/apps/procurement/src/lib/services/renewals.service.ts new file mode 100644 index 000000000..fa1276dd0 --- /dev/null +++ b/src/apps/procurement/src/lib/services/renewals.service.ts @@ -0,0 +1,110 @@ +import { + deleteAsync, + getAsync, + patchAsync, + postAsync, + putAsync, +} from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' + +import { Renewal, RenewalMutationPayload, RenewalStage, RenewalStageMetadata } from '../models' +import { + buildProcurementApiUrl, + normalizeDateOnly, + normalizeOptionalNumber, + normalizeOptionalText, +} from '../utils/api.utils' + +interface RenewalStageMutationPayload { + targetStage: RenewalStage +} + +/** + * API helpers for procurement renewal CRUD and workflow endpoints. + */ + +/** + * Creates a renewal workflow at the backend starting stage. + * + * @param payload Editable renewal fields. + * @returns Created renewal response. + */ +export function createRenewal(payload: RenewalMutationPayload): Promise { + return postAsync( + buildProcurementApiUrl('renewals'), + normalizeRenewalPayload(payload), + ) +} + +/** + * Deletes a renewal workflow. + * + * @param renewalId Renewal identifier to delete. + * @returns Deleted renewal response. + */ +export function deleteRenewal(renewalId: string): Promise { + return deleteAsync(buildProcurementApiUrl(`renewals/${renewalId}`)) +} + +/** + * Loads all renewal workflows. + * + * @returns Renewals sorted by the backend. + */ +export function getRenewals(): Promise { + return getAsync(buildProcurementApiUrl('renewals')) +} + +/** + * Loads backend renewal workflow stage metadata. + * + * @returns Stage metadata sorted by the backend. + */ +export function getRenewalStages(): Promise { + return getAsync(buildProcurementApiUrl('renewals/stages')) +} + +/** + * Moves a renewal workflow one adjacent stage forward or backward. + * + * @param renewalId Renewal identifier to transition. + * @param targetStage Adjacent target stage. + * @returns Updated renewal response. + */ +export function moveRenewalStage(renewalId: string, targetStage: RenewalStage): Promise { + return patchAsync( + buildProcurementApiUrl(`renewals/${renewalId}/stage`), + { targetStage }, + ) +} + +/** + * Replaces editable renewal fields without changing workflow stage. + * + * @param payload Editable renewal fields. + * @param renewalId Renewal identifier to update. + * @returns Updated renewal response. + */ +export function updateRenewal(renewalId: string, payload: RenewalMutationPayload): Promise { + return putAsync( + buildProcurementApiUrl(`renewals/${renewalId}`), + normalizeRenewalPayload(payload), + ) +} + +/** + * Normalizes renewal mutation payloads before xhr submission. + * + * @param payload Editable renewal fields. + * @returns API-ready renewal payload. + */ +function normalizeRenewalPayload(payload: RenewalMutationPayload): RenewalMutationPayload { + return { + assignee: normalizeOptionalText(payload.assignee), + contractId: payload.contractId, + newEndDate: normalizeDateOnly(payload.newEndDate) || payload.newEndDate, + newStartDate: normalizeDateOnly(payload.newStartDate) || payload.newStartDate, + newValue: normalizeOptionalNumber(payload.newValue), + notes: normalizeOptionalText(payload.notes), + renewalTermMonths: payload.renewalTermMonths, + } +} diff --git a/src/apps/procurement/src/lib/services/vendors.service.ts b/src/apps/procurement/src/lib/services/vendors.service.ts new file mode 100644 index 000000000..5dbcda449 --- /dev/null +++ b/src/apps/procurement/src/lib/services/vendors.service.ts @@ -0,0 +1,81 @@ +import { + deleteAsync, + getAsync, + postAsync, + putAsync, +} from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' + +import { Vendor, VendorMutationPayload } from '../models' +import { + buildProcurementApiUrl, + normalizeOptionalText, + normalizeRequiredText, +} from '../utils/api.utils' + +/** + * API helpers for procurement vendor CRUD endpoints. + */ + +/** + * Loads all procurement vendors. + * + * @returns Vendors sorted by the backend. + */ +export function getVendors(): Promise { + return getAsync(buildProcurementApiUrl('vendors')) +} + +/** + * Creates a procurement vendor. + * + * @param payload Editable vendor fields. + * @returns Created vendor response. + */ +export function createVendor(payload: VendorMutationPayload): Promise { + return postAsync( + buildProcurementApiUrl('vendors'), + normalizeVendorPayload(payload), + ) +} + +/** + * Deletes a procurement vendor. + * + * @param vendorId Vendor identifier to delete. + * @returns Deleted vendor response. + */ +export function deleteVendor(vendorId: string): Promise { + return deleteAsync(buildProcurementApiUrl(`vendors/${vendorId}`)) +} + +/** + * Replaces editable procurement vendor fields. + * + * @param payload Editable vendor fields. + * @param vendorId Vendor identifier to update. + * @returns Updated vendor response. + */ +export function updateVendor(vendorId: string, payload: VendorMutationPayload): Promise { + return putAsync( + buildProcurementApiUrl(`vendors/${vendorId}`), + normalizeVendorPayload(payload), + ) +} + +/** + * Normalizes vendor mutation payloads before xhr submission. + * + * @param payload Editable vendor fields. + * @returns API-ready vendor payload. + */ +function normalizeVendorPayload(payload: VendorMutationPayload): VendorMutationPayload { + return { + address: normalizeOptionalText(payload.address), + category: normalizeOptionalText(payload.category), + contactEmail: normalizeOptionalText(payload.contactEmail), + contactName: normalizeOptionalText(payload.contactName), + contactPhone: normalizeOptionalText(payload.contactPhone), + name: normalizeRequiredText(payload.name), + notes: normalizeOptionalText(payload.notes), + } +} diff --git a/src/apps/procurement/src/lib/utils/api.utils.ts b/src/apps/procurement/src/lib/utils/api.utils.ts new file mode 100644 index 000000000..7e61fed56 --- /dev/null +++ b/src/apps/procurement/src/lib/utils/api.utils.ts @@ -0,0 +1,154 @@ +import { toast } from 'react-toastify' + +import { EnvironmentConfig } from '~/config' + +/** + * Builds a procurement API URL using the configured v6 procurement base URL. + * + * @param path Endpoint path inside the procurement API. + * @param query Optional query string values. + * @returns Absolute procurement API URL. + */ +export function buildProcurementApiUrl( + path: string, + query?: Record, +): string { + const normalizedPath: string = path.replace(/^\/+/, '') + const searchParams: URLSearchParams = new URLSearchParams() + + Object.entries(query || {}) + .forEach(([key, value]: [string, string | undefined]) => { + if (value !== undefined && value.trim() !== '') { + searchParams.set(key, value) + } + }) + + const queryString: string = searchParams.toString() + + return `${EnvironmentConfig.PROCUREMENT_API}/${normalizedPath}${queryString ? `?${queryString}` : ''}` +} + +/** + * Extracts a readable validation or conflict message from procurement API errors. + * + * @param error Error thrown by the xhr layer. + * @returns User-facing error message. + */ +export function getProcurementApiErrorMessage(error: any): string { + const responseData: any = error?.response?.data || error?.data || {} + const responseMessage: string | undefined = extractMessage(responseData.message) + const responseErrors: string | undefined = extractMessage(responseData.errors) + + return responseMessage + || responseErrors + || error?.message + || 'An unexpected error occurred.' +} + +/** + * Shows a procurement API error through the shared toast channel. + * + * @param error Error thrown by the xhr layer. + * @returns Nothing. + */ +export function handleProcurementApiError(error: any): void { + toast.error(getProcurementApiErrorMessage(error)) +} + +/** + * Converts a date input or ISO timestamp to the date-only string accepted by procurement mutations. + * + * @param value Date-like value from a form or API response. + * @returns Date-only ISO string, or `undefined` when the input is empty. + */ +export function normalizeDateOnly(value?: Date | string): string | undefined { + if (value === undefined) { + return undefined + } + + if (value instanceof Date) { + return value.toISOString() + .slice(0, 10) + } + + const normalizedValue: string = String(value) + .trim() + + if (!normalizedValue) { + return undefined + } + + const dateOnlyMatch: RegExpMatchArray | undefined = normalizedValue.match(/^\d{4}-\d{2}-\d{2}/) || undefined + + if (dateOnlyMatch) { + return dateOnlyMatch[0] + } + + const parsedDate: Date = new Date(normalizedValue) + + return Number.isNaN(parsedDate.getTime()) + ? normalizedValue + : parsedDate.toISOString() + .slice(0, 10) +} + +/** + * Normalizes optional numeric form values before sending mutation payloads. + * + * @param value Number supplied by a form. + * @returns The number when finite, otherwise `undefined`. + */ +export function normalizeOptionalNumber(value?: number): number | undefined { + return value !== undefined && Number.isFinite(value) ? value : undefined +} + +/** + * Normalizes optional text form values before sending mutation payloads. + * + * @param value Text supplied by a form. + * @returns Trimmed text, or `undefined` when blank. + */ +export function normalizeOptionalText(value?: string): string | undefined { + const normalizedValue: string = String(value || '') + .trim() + + return normalizedValue || undefined +} + +/** + * Normalizes required text form values before sending mutation payloads. + * + * @param value Text supplied by a form. + * @returns Trimmed text. + */ +export function normalizeRequiredText(value: string): string { + return value.trim() +} + +/** + * Extracts messages from common API error field shapes. + * + * @param value Unknown error message payload. + * @returns Flattened message string when one can be found. + */ +function extractMessage(value: unknown): string | undefined { + if (!value) { + return undefined + } + + if (Array.isArray(value)) { + return value + .map((item: unknown) => extractMessage(item)) + .filter(Boolean) + .join(', ') + } + + if (typeof value === 'object') { + return Object.values(value as Record) + .map((item: unknown) => extractMessage(item)) + .filter(Boolean) + .join(', ') + } + + return String(value) +} diff --git a/src/apps/procurement/src/lib/utils/format.utils.ts b/src/apps/procurement/src/lib/utils/format.utils.ts new file mode 100644 index 000000000..7e5846e3f --- /dev/null +++ b/src/apps/procurement/src/lib/utils/format.utils.ts @@ -0,0 +1,186 @@ +/** + * One calendar day in milliseconds. + */ +const MS_PER_DAY: number = 86400000 + +const businessDatePattern: RegExp = /^(\d{4})-(\d{2})-(\d{2})/ + +interface BusinessDateParts { + day: number + month: number + year: number +} + +/** + * Formats a backend timestamp for procurement tables using the user's local calendar. + * + * @param value Timestamp-like value from the API. + * @returns Localized timestamp date label, an em dash fallback, or the original invalid value. + */ +export function formatDate(value?: string): string { + if (!value) { + return '-' + } + + const date: Date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + } + + return date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + +/** + * Formats contract, invoice, and renewal business dates without applying local timezone conversion. + * + * @param value Business date value from the API. + * @returns Localized business-date label, an em dash fallback, or the original invalid value. + */ +export function formatBusinessDate(value?: string): string { + if (!value) { + return '-' + } + + const dateParts: BusinessDateParts | undefined = parseBusinessDateParts(value) + + if (!dateParts) { + return value + } + + const date: Date = new Date(Date.UTC(dateParts.year, dateParts.month - 1, dateParts.day)) + + return date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + year: 'numeric', + }) +} + +/** + * Formats a number as USD for procurement money columns. + * + * @param value Money value from the API. + * @returns Currency label. + */ +export function formatMoney(value?: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + style: 'currency', + }) + .format(value || 0) +} + +/** + * Formats enum-like API values as readable labels. + * + * @param value API enum string. + * @returns Human-readable label. + */ +export function formatStatusLabel(value?: string): string { + if (!value) { + return '-' + } + + return value + .split('_') + .map((part: string) => `${part.charAt(0) + .toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +/** + * Calculates a display-only day difference from today to a target business date. + * + * @param value Business date value from the API. + * @returns Days-left label for urgent contract tables. + */ +export function formatDaysLeft(value?: string): string { + if (!value) { + return '-' + } + + const dateParts: BusinessDateParts | undefined = parseBusinessDateParts(value) + + if (!dateParts) { + return '-' + } + + const today: Date = new Date() + const startOfToday: number = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) + const startOfTarget: number = Date.UTC(dateParts.year, dateParts.month - 1, dateParts.day) + const days: number = Math.round((startOfTarget - startOfToday) / MS_PER_DAY) + + if (days < 0) { + return `${Math.abs(days)} days overdue` + } + + if (days === 0) { + return 'Today' + } + + return `${days} days` +} + +/** + * Parses an API business date by preserving its serialized calendar day. + * + * @param value Date-like value from the API. + * @returns Business date parts when the value starts with a valid `YYYY-MM-DD` date. + */ +function parseBusinessDateParts(value: string): BusinessDateParts | undefined { + const dateOnlyMatch: RegExpMatchArray | undefined = value.trim() + .match(businessDatePattern) || undefined + + if (!dateOnlyMatch) { + return undefined + } + + const year: number = Number(dateOnlyMatch[1]) + const month: number = Number(dateOnlyMatch[2]) + const day: number = Number(dateOnlyMatch[3]) + const endOfMonthDay: number = new Date(Date.UTC(year, month, 0)) + .getUTCDate() + + if (month < 1 || month > 12 || day < 1 || day > endOfMonthDay) { + return undefined + } + + return { + day, + month, + year, + } +} + +/** + * Converts a backend date or timestamp to the value expected by date inputs. + * + * @param value Date-like value from the API. + * @returns Date-only input value. + */ +export function toDateInputValue(value?: string): string { + if (!value) { + return '' + } + + const dateOnlyMatch: RegExpMatchArray | undefined = value.match(/^\d{4}-\d{2}-\d{2}/) || undefined + + if (dateOnlyMatch) { + return dateOnlyMatch[0] + } + + const date: Date = new Date(value) + + return Number.isNaN(date.getTime()) + ? '' + : date.toISOString() + .slice(0, 10) +} diff --git a/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.module.scss b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.module.scss new file mode 100644 index 000000000..cfd2e355a --- /dev/null +++ b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.module.scss @@ -0,0 +1,176 @@ +.page { + display: flex; + flex-direction: column; + gap: 18px; +} + +.toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + h2 { + margin: 0; + color: #111827; + font-size: 22px; + font-weight: 800; + } + + p { + margin: 6px 0 0; + color: #4b5563; + font-size: 14px; + } +} + +.tableWrap { + width: 100%; + overflow-x: auto; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #ffffff; +} + +.table { + width: 100%; + border-collapse: collapse; + color: #1f2937; + font-size: 14px; + + th, + td { + padding: 14px 16px; + border-bottom: 1px solid #e5e7eb; + text-align: left; + vertical-align: top; + } + + th { + color: #6b7280; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + white-space: nowrap; + } + + td span { + display: block; + margin-top: 4px; + color: #6b7280; + font-size: 13px; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.expiringRow { + background: #fffbeb; +} + +.expiredRow { + background: #fef2f2; +} + +.badge { + display: inline-flex !important; + width: fit-content; + margin-top: 0 !important; + padding: 4px 8px; + border-radius: 6px; + background: #eef2ff; + color: #3730a3 !important; + font-size: 12px !important; + font-weight: 800; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + + label { + display: flex; + flex-direction: column; + gap: 6px; + color: #374151; + font-size: 13px; + font-weight: 800; + } + + input, + select, + textarea { + width: 100%; + min-height: 40px; + padding: 9px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + color: #111827; + font-size: 14px; + font-weight: 400; + } + + textarea { + min-height: 84px; + resize: vertical; + } +} + +.checkbox { + flex-direction: row !important; + align-items: center; + justify-content: flex-start; + + input { + width: auto; + min-height: 0; + } +} + +.fullWidth, +.modalActions, +.error { + grid-column: 1 / -1; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 8px; +} + +.error, +.state { + padding: 14px 16px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + font-size: 14px; +} + +.state { + border-color: #d1d5db; + background: #ffffff; + color: #374151; +} + +@media (max-width: 720px) { + .toolbar { + flex-direction: column; + } + + .form { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.spec.tsx b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.spec.tsx new file mode 100644 index 000000000..f7c4eab00 --- /dev/null +++ b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.spec.tsx @@ -0,0 +1,127 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind, sort-keys */ +import { PropsWithChildren } from 'react' +import { + fireEvent, + render, + screen, +} from '@testing-library/react' + +import { getContracts, getVendors } from '../../lib/services' + +import { ProcurementContractsPage } from './ProcurementContractsPage' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + PROCUREMENT_API: 'https://api.test/v6/procurement', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren<{ open: boolean }>) => ( + props.open ?
{props.children}
: <> + ), + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + type?: 'button' | 'submit' + }) => ( + + ), + ConfirmModal: (props: PropsWithChildren<{ open: boolean }>) => ( + props.open ?
{props.children}
: <> + ), +}), { + virtual: true, +}) + +jest.mock('../../lib/services', () => ({ + getContracts: jest.fn(), + getVendors: jest.fn(), +})) + +const mockedGetContracts = getContracts as jest.Mock +const mockedGetVendors = getVendors as jest.Mock +const backendNull = JSON.parse('null') as null + +describe('ProcurementContractsPage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedGetContracts.mockResolvedValue([{ + autoRenew: true, + contractNumber: 'MSA-001', + createdAt: '2026-01-01T00:00:00.000Z', + endDate: '2026-07-15T00:00:00.000Z', + id: 'contract-1', + lifecycle: 'expiring', + renewalNoticeDays: 30, + startDate: '2026-01-01T00:00:00.000Z', + status: 'active', + title: 'Design Subscription', + updatedAt: '2026-01-02T00:00:00.000Z', + value: 1200, + vendor: { + id: 'vendor-1', + name: 'Acme Software', + }, + vendorId: 'vendor-1', + }]) + mockedGetVendors.mockResolvedValue([{ + createdAt: '2026-01-01T00:00:00.000Z', + id: 'vendor-1', + name: 'Acme Software', + updatedAt: '2026-01-02T00:00:00.000Z', + }]) + }) + + it('renders stored status and backend-derived lifecycle', async () => { + render() + + expect(await screen.findByText('MSA-001')) + .toBeTruthy() + expect(screen.getByText('Active')) + .toBeTruthy() + expect(screen.getByText('Expiring')) + .toBeTruthy() + }) + + it('keeps nullable renewal notice days blank when editing', async () => { + mockedGetContracts.mockResolvedValue([{ + autoRenew: true, + contractNumber: 'MSA-001', + createdAt: '2026-01-01T00:00:00.000Z', + endDate: '2026-07-15T00:00:00.000Z', + id: 'contract-1', + lifecycle: 'expiring', + renewalNoticeDays: backendNull, + startDate: '2026-01-01T00:00:00.000Z', + status: 'active', + title: 'Design Subscription', + updatedAt: '2026-01-02T00:00:00.000Z', + value: 1200, + vendor: { + id: 'vendor-1', + name: 'Acme Software', + }, + vendorId: 'vendor-1', + }]) + + render() + + expect(await screen.findByText('MSA-001')) + .toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + + expect((screen.getByLabelText('Renewal notice days') as HTMLInputElement).value) + .toBe('') + }) +}) diff --git a/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.tsx b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.tsx new file mode 100644 index 000000000..37eff6811 --- /dev/null +++ b/src/apps/procurement/src/pages/contracts/ProcurementContractsPage.tsx @@ -0,0 +1,534 @@ +/* eslint-disable no-use-before-define, react/jsx-no-bind */ +import { FC, FormEvent, useCallback, useEffect, useState } from 'react' +import classNames from 'classnames' + +import { BaseModal, Button, ConfirmModal } from '~/libs/ui' + +import { + Contract, + ContractMutationPayload, + ContractStatus, + Vendor, +} from '../../lib/models' +import { + createContract, + deleteContract, + getContracts, + getVendors, + updateContract, +} from '../../lib/services' +import { getProcurementApiErrorMessage } from '../../lib/utils/api.utils' +import { + formatBusinessDate, + formatMoney, + formatStatusLabel, + toDateInputValue, +} from '../../lib/utils/format.utils' + +import styles from './ProcurementContractsPage.module.scss' + +type ContractFormField = Exclude +type ContractModalMode = 'create' | 'edit' + +interface ContractFormModalProps { + errorMessage: string + form: ContractFormState + isSaving: boolean + mode: ContractModalMode + onBooleanChange: (field: 'autoRenew', value: boolean) => void + onChange: (field: ContractFormField, value: string) => void + onClose: () => void + onSubmit: (event: FormEvent) => void + open: boolean + vendors: Vendor[] +} + +interface ContractFormState { + autoRenew: boolean + contractNumber: string + description: string + endDate: string + renewalNoticeDays: string + startDate: string + status: ContractStatus + title: string + value: string + vendorId: string +} + +const contractStatuses: ContractStatus[] = [ + 'draft', + 'active', + 'expired', + 'terminated', +] + +const emptyContractForm: ContractFormState = { + autoRenew: false, + contractNumber: '', + description: '', + endDate: '', + renewalNoticeDays: '', + startDate: '', + status: 'active', + title: '', + value: '', + vendorId: '', +} + +/** + * Contracts screen with modal CRUD and backend lifecycle/status visibility. + */ +export const ProcurementContractsPage: FC = () => { + const [contracts, setContracts] = useState([]) + const [deleteTarget, setDeleteTarget] = useState() + const [errorMessage, setErrorMessage] = useState('') + const [form, setForm] = useState(emptyContractForm) + const [isDeleting, setIsDeleting] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [modalMode, setModalMode] = useState() + const [selectedContract, setSelectedContract] = useState() + const [vendors, setVendors] = useState([]) + + const loadContracts = useCallback(async (): Promise => { + setErrorMessage('') + setIsLoading(true) + + try { + const [nextContracts, nextVendors]: [Contract[], Vendor[]] = await Promise.all([ + getContracts(), + getVendors(), + ]) + + setContracts(nextContracts) + setVendors(nextVendors) + } catch (error) { + setErrorMessage(getProcurementApiErrorMessage(error)) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + loadContracts() + }, [loadContracts]) + + const closeDeleteModal = useCallback((): void => { + setDeleteTarget(undefined) + }, []) + + const closeFormModal = useCallback((): void => { + setErrorMessage('') + setForm(emptyContractForm) + setModalMode(undefined) + setSelectedContract(undefined) + }, []) + + const handleBooleanChange = useCallback((field: 'autoRenew', value: boolean): void => { + setForm((previousForm: ContractFormState) => ({ + ...previousForm, + [field]: value, + })) + }, []) + + const handleChange = useCallback((field: ContractFormField, value: string): void => { + setForm((previousForm: ContractFormState) => ({ + ...previousForm, + [field]: value, + })) + }, []) + + const handleDelete = useCallback(async (): Promise => { + if (!deleteTarget) { + return + } + + setErrorMessage('') + setIsDeleting(true) + + try { + await deleteContract(deleteTarget.id) + closeDeleteModal() + await loadContracts() + } catch (error) { + setErrorMessage(getProcurementApiErrorMessage(error)) + } finally { + setIsDeleting(false) + } + }, [closeDeleteModal, deleteTarget, loadContracts]) + + const handleSubmit = useCallback(async (event: FormEvent): Promise => { + event.preventDefault() + setErrorMessage('') + setIsSaving(true) + + try { + const payload: ContractMutationPayload = toContractPayload(form) + + if (modalMode === 'edit' && selectedContract) { + await updateContract(selectedContract.id, payload) + } else { + await createContract(payload) + } + + closeFormModal() + await loadContracts() + } catch (error) { + setErrorMessage(getProcurementApiErrorMessage(error)) + } finally { + setIsSaving(false) + } + }, [closeFormModal, form, loadContracts, modalMode, selectedContract]) + + const openCreateModal = useCallback((): void => { + setErrorMessage('') + setForm({ + ...emptyContractForm, + vendorId: vendors[0]?.id || '', + }) + setModalMode('create') + setSelectedContract(undefined) + }, [vendors]) + + const openEditModal = useCallback((contract: Contract): void => { + setErrorMessage('') + setForm(toContractForm(contract)) + setModalMode('edit') + setSelectedContract(contract) + }, []) + + return ( +
+
+
+

Contracts

+

Track supplier contracts, stored status, and backend-derived lifecycle.

+
+
+ + {!!errorMessage && ( +
+ {errorMessage} +
+ )} + + {isLoading ? ( +
Loading contracts...
+ ) : ( + + )} + + {!!modalMode && ( + + )} + + +

+ Delete + {' '} + {deleteTarget?.contractNumber} + ? Contracts referenced by invoices or renewals cannot be deleted. +

+
+
+ ) +} + +/** + * Converts contract form state into the API mutation payload. + * + * @param formState Controlled contract form state. + * @returns Contract mutation payload. + */ +function toContractPayload(formState: ContractFormState): ContractMutationPayload { + return { + autoRenew: formState.autoRenew, + contractNumber: formState.contractNumber, + description: formState.description, + endDate: formState.endDate, + renewalNoticeDays: parseOptionalNumber(formState.renewalNoticeDays), + startDate: formState.startDate, + status: formState.status, + title: formState.title, + value: Number(formState.value) || 0, + vendorId: formState.vendorId, + } +} + +/** + * Converts a contract response into controlled form state. + * + * @param contract Contract row selected for editing. + * @returns Contract form state with absent optional read values kept blank. + */ +function toContractForm(contract: Contract): ContractFormState { + return { + autoRenew: contract.autoRenew, + contractNumber: contract.contractNumber, + description: contract.description || '', + endDate: toDateInputValue(contract.endDate), + renewalNoticeDays: typeof contract.renewalNoticeDays === 'number' + ? String(contract.renewalNoticeDays) + : '', + startDate: toDateInputValue(contract.startDate), + status: contract.status, + title: contract.title, + value: String(contract.value), + vendorId: contract.vendorId, + } +} + +/** + * Parses optional numeric form fields. + * + * @param value Numeric form value. + * @returns Number when supplied. + */ +function parseOptionalNumber(value: string): number | undefined { + return value.trim() ? Number(value) : undefined +} + +/** + * Renders the contracts table and row actions. + * + * @param props Table props. + * @returns Contracts table. + */ +const ContractsTable: FC<{ + contracts: Contract[] + onDelete: (contract: Contract) => void + onEdit: (contract: Contract) => void +}> = props => ( +
+ + + + + + + + + + + + + + {props.contracts.length === 0 && ( + + + + )} + {props.contracts.map((contract: Contract) => ( + + + + + + + + + + ))} + +
ContractVendorDatesValueStatusLifecycleActions
No contracts found.
+ {contract.contractNumber} + {contract.title} + {contract.vendor.name} + {formatBusinessDate(contract.startDate)} + {' - '} + {formatBusinessDate(contract.endDate)} + {formatMoney(contract.value)} + {formatStatusLabel(contract.status)} + + {formatStatusLabel(contract.lifecycle)} + +
+
+
+
+) + +/** + * Renders create and edit controls for contract mutations. + * + * @param props Modal props. + * @returns Contract form modal. + */ +const ContractFormModal: FC = props => ( + +
+ {!!props.errorMessage && ( +
+ {props.errorMessage} +
+ )} + + + + + + + + + + + + + + + + + + + +