2026-03-14 23:03:17 -05:00
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js" ;
import type { SalesCustomerOptionDto , SalesDocumentDetailDto , SalesDocumentInput , SalesLineInput } from "@mrp/shared/dist/sales/types.js" ;
import { useEffect , useState } from "react" ;
import { Link , useNavigate , useParams } from "react-router-dom" ;
import { useAuth } from "../../auth/AuthProvider" ;
import { api , ApiError } from "../../lib/api" ;
import { inventoryUnitOptions } from "../inventory/config" ;
import { emptySalesDocumentInput , salesConfigs , salesStatusOptions , type SalesDocumentEntity } from "./config" ;
export function SalesFormPage ( { entity , mode } : { entity : SalesDocumentEntity ; mode : "create" | "edit" } ) {
const { token } = useAuth ( ) ;
const navigate = useNavigate ( ) ;
const { quoteId , orderId } = useParams ( ) ;
const documentId = entity === "quote" ? quoteId : orderId ;
const config = salesConfigs [ entity ] ;
const [ form , setForm ] = useState < SalesDocumentInput > ( emptySalesDocumentInput ) ;
const [ status , setStatus ] = useState ( mode === "create" ? ` Create a new ${ config . singularLabel . toLowerCase ( ) } . ` : ` Loading ${ config . singularLabel . toLowerCase ( ) } ... ` ) ;
const [ customers , setCustomers ] = useState < SalesCustomerOptionDto [ ] > ( [ ] ) ;
2026-03-14 23:07:43 -05:00
const [ customerSearchTerm , setCustomerSearchTerm ] = useState ( "" ) ;
const [ customerPickerOpen , setCustomerPickerOpen ] = useState ( false ) ;
2026-03-14 23:03:17 -05:00
const [ itemOptions , setItemOptions ] = useState < InventoryItemOptionDto [ ] > ( [ ] ) ;
const [ lineSearchTerms , setLineSearchTerms ] = useState < string [ ] > ( [ ] ) ;
const [ activeLinePicker , setActiveLinePicker ] = useState < number | null > ( null ) ;
const [ isSaving , setIsSaving ] = useState ( false ) ;
useEffect ( ( ) = > {
if ( ! token ) {
return ;
}
api . getSalesCustomers ( token ) . then ( setCustomers ) . catch ( ( ) = > setCustomers ( [ ] ) ) ;
api . getInventoryItemOptions ( token ) . then ( setItemOptions ) . catch ( ( ) = > setItemOptions ( [ ] ) ) ;
} , [ token ] ) ;
useEffect ( ( ) = > {
if ( ! token || mode !== "edit" || ! documentId ) {
return ;
}
const loader = entity === "quote" ? api . getQuote ( token , documentId ) : api . getSalesOrder ( token , documentId ) ;
loader
. then ( ( document ) = > {
setForm ( {
customerId : document.customerId ,
status : document.status ,
issueDate : document.issueDate ,
expiresAt : entity === "quote" ? document . expiresAt : null ,
notes : document.notes ,
lines : document.lines.map ( ( line ) = > ( {
itemId : line.itemId ,
description : line.description ,
quantity : line.quantity ,
unitOfMeasure : line.unitOfMeasure ,
unitPrice : line.unitPrice ,
position : line.position ,
} ) ) ,
} ) ;
2026-03-14 23:07:43 -05:00
setCustomerSearchTerm ( document . customerName ) ;
2026-03-14 23:03:17 -05:00
setLineSearchTerms ( document . lines . map ( ( line : SalesDocumentDetailDto [ "lines" ] [ number ] ) = > line . itemSku ) ) ;
setStatus ( ` ${ config . singularLabel } loaded. ` ) ;
} )
. catch ( ( error : unknown ) = > {
const message = error instanceof ApiError ? error . message : ` Unable to load ${ config . singularLabel . toLowerCase ( ) } . ` ;
setStatus ( message ) ;
} ) ;
} , [ config . singularLabel , documentId , entity , mode , token ] ) ;
function updateField < Key extends keyof SalesDocumentInput > ( key : Key , value : SalesDocumentInput [ Key ] ) {
setForm ( ( current : SalesDocumentInput ) = > ( { . . . current , [ key ] : value } ) ) ;
}
2026-03-14 23:07:43 -05:00
function getSelectedCustomerName ( customerId : string ) {
return customers . find ( ( customer ) = > customer . id === customerId ) ? . name ? ? "" ;
}
2026-03-14 23:03:17 -05:00
function updateLine ( index : number , nextLine : SalesLineInput ) {
setForm ( ( current : SalesDocumentInput ) = > ( {
. . . current ,
lines : current.lines.map ( ( line : SalesLineInput , lineIndex : number ) = > ( lineIndex === index ? nextLine : line ) ) ,
} ) ) ;
}
function updateLineSearchTerm ( index : number , value : string ) {
setLineSearchTerms ( ( current : string [ ] ) = > {
const next = [ . . . current ] ;
next [ index ] = value ;
return next ;
} ) ;
}
function addLine() {
setForm ( ( current : SalesDocumentInput ) = > ( {
. . . current ,
lines : [
. . . current . lines ,
{
itemId : "" ,
description : "" ,
quantity : 1 ,
unitOfMeasure : "EA" ,
unitPrice : 0 ,
position : current.lines.length === 0 ? 10 : Math.max ( . . . current . lines . map ( ( line : SalesLineInput ) = > line . position ) ) + 10 ,
} ,
] ,
} ) ) ;
setLineSearchTerms ( ( current : string [ ] ) = > [ . . . current , "" ] ) ;
}
function removeLine ( index : number ) {
setForm ( ( current : SalesDocumentInput ) = > ( {
. . . current ,
lines : current.lines.filter ( ( _line : SalesLineInput , lineIndex : number ) = > lineIndex !== index ) ,
} ) ) ;
setLineSearchTerms ( ( current : string [ ] ) = > current . filter ( ( _term : string , termIndex : number ) = > termIndex !== index ) ) ;
}
async function handleSubmit ( event : React.FormEvent < HTMLFormElement > ) {
event . preventDefault ( ) ;
if ( ! token ) {
return ;
}
setIsSaving ( true ) ;
setStatus ( ` Saving ${ config . singularLabel . toLowerCase ( ) } ... ` ) ;
try {
const saved =
entity === "quote"
? mode === "create"
? await api . createQuote ( token , form )
: await api . updateQuote ( token , documentId ? ? "" , form )
: mode === "create"
? await api . createSalesOrder ( token , { . . . form , expiresAt : null } )
: await api . updateSalesOrder ( token , documentId ? ? "" , { . . . form , expiresAt : null } ) ;
navigate ( ` ${ config . routeBase } / ${ saved . id } ` ) ;
} catch ( error : unknown ) {
const message = error instanceof ApiError ? error . message : ` Unable to save ${ config . singularLabel . toLowerCase ( ) } . ` ;
setStatus ( message ) ;
setIsSaving ( false ) ;
}
}
return (
< form className = "space-y-6" onSubmit = { handleSubmit } >
< section className = "rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" >
< div className = "flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between" >
< div >
< p className = "text-xs font-semibold uppercase tracking-[0.24em] text-muted" > { config . detailEyebrow } Editor < / p >
< h3 className = "mt-2 text-xl font-bold text-text" > { mode === "create" ? ` New ${ config . singularLabel } ` : ` Edit ${ config . singularLabel } ` } < / h3 >
< / div >
< Link to = { mode === "create" ? config . routeBase : ` ${ config . routeBase } / ${ documentId } ` } className = "inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text" >
Cancel
< / Link >
< / div >
< / section >
< section className = "space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" >
< div className = "grid gap-3 xl:grid-cols-4" >
< label className = "block xl:col-span-2" >
< span className = "mb-2 block text-sm font-semibold text-text" > Customer < / span >
2026-03-14 23:07:43 -05:00
< div className = "relative" >
< input
value = { customerSearchTerm }
onChange = { ( event ) = > {
setCustomerSearchTerm ( event . target . value ) ;
updateField ( "customerId" , "" ) ;
setCustomerPickerOpen ( true ) ;
} }
onFocus = { ( ) = > setCustomerPickerOpen ( true ) }
onBlur = { ( ) = > {
window . setTimeout ( ( ) = > {
setCustomerPickerOpen ( false ) ;
if ( form . customerId ) {
setCustomerSearchTerm ( getSelectedCustomerName ( form . customerId ) ) ;
}
} , 120 ) ;
} }
placeholder = "Search customer"
className = "w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/ >
{ customerPickerOpen ? (
< div className = "absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel" >
{ customers
. filter ( ( customer ) = > {
const query = customerSearchTerm . trim ( ) . toLowerCase ( ) ;
if ( ! query ) {
return true ;
}
return (
customer . name . toLowerCase ( ) . includes ( query ) ||
customer . email . toLowerCase ( ) . includes ( query )
) ;
} )
. slice ( 0 , 12 )
. map ( ( customer ) = > (
< button
key = { customer . id }
type = "button"
onMouseDown = { ( event ) = > {
event . preventDefault ( ) ;
updateField ( "customerId" , customer . id ) ;
setCustomerSearchTerm ( customer . name ) ;
setCustomerPickerOpen ( false ) ;
} }
className = "block w-full border-b border-line/50 px-4 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
< div className = "font-semibold text-text" > { customer . name } < / div >
< div className = "mt-1 text-xs text-muted" > { customer . email } < / div >
< / button >
) ) }
{ customers . filter ( ( customer ) = > {
const query = customerSearchTerm . trim ( ) . toLowerCase ( ) ;
if ( ! query ) {
return true ;
}
return customer . name . toLowerCase ( ) . includes ( query ) || customer . email . toLowerCase ( ) . includes ( query ) ;
} ) . length === 0 ? (
< div className = "px-2 py-2 text-sm text-muted" > No matching customers found . < / div >
) : null }
< / div >
) : null }
< / div >
< div className = "mt-2 min-h-5 text-xs text-muted" >
{ form . customerId ? getSelectedCustomerName ( form . customerId ) : "No customer selected" }
< / div >
2026-03-14 23:03:17 -05:00
< / label >
< label className = "block" >
< span className = "mb-2 block text-sm font-semibold text-text" > Status < / span >
< select
value = { form . status }
onChange = { ( event ) = > updateField ( "status" , event . target . value as SalesDocumentInput [ "status" ] ) }
className = "w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{ salesStatusOptions . map ( ( option ) = > (
< option key = { option . value } value = { option . value } >
{ option . label }
< / option >
) ) }
< / select >
< / label >
< label className = "block" >
< span className = "mb-2 block text-sm font-semibold text-text" > Issue date < / span >
< input
type = "date"
value = { form . issueDate . slice ( 0 , 10 ) }
onChange = { ( event ) = > updateField ( "issueDate" , new Date ( event . target . value ) . toISOString ( ) ) }
className = "w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/ >
< / label >
< / div >
{ entity === "quote" ? (
< label className = "block xl:max-w-sm" >
< span className = "mb-2 block text-sm font-semibold text-text" > Expiration date < / span >
< input
type = "date"
value = { form . expiresAt ? form . expiresAt . slice ( 0 , 10 ) : "" }
onChange = { ( event ) = > updateField ( "expiresAt" , event . target . value ? new Date ( event . target . value ) . toISOString ( ) : null ) }
className = "w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/ >
< / label >
) : null }
< label className = "block" >
< span className = "mb-2 block text-sm font-semibold text-text" > Notes < / span >
< textarea
value = { form . notes }
onChange = { ( event ) = > updateField ( "notes" , event . target . value ) }
rows = { 3 }
className = "w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/ >
< / label >
< / section >
< section className = "rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" >
< div className = "flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between" >
< div >
< p className = "text-xs font-semibold uppercase tracking-[0.24em] text-muted" > Line Items < / p >
< h4 className = "mt-2 text-lg font-bold text-text" > Commercial lines < / h4 >
< / div >
< button type = "button" onClick = { addLine } className = "inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text" >
Add line
< / button >
< / div >
{ form . lines . length === 0 ? (
< div className = "mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted" >
No line items added yet .
< / div >
) : (
< div className = "mt-5 space-y-4" >
{ form . lines . map ( ( line : SalesLineInput , index : number ) = > (
< div key = { index } className = "rounded-3xl border border-line/70 bg-page/60 p-3" >
< div className = "grid gap-3 xl:grid-cols-[1.25fr_1.4fr_0.55fr_0.55fr_0.75fr_auto]" >
< label className = "block" >
< span className = "mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted" > SKU < / span >
< div className = "relative" >
< input
value = { lineSearchTerms [ index ] ? ? "" }
onChange = { ( event ) = > {
updateLineSearchTerm ( index , event . target . value ) ;
updateLine ( index , { . . . line , itemId : "" } ) ;
setActiveLinePicker ( index ) ;
} }
onFocus = { ( ) = > setActiveLinePicker ( index ) }
onBlur = { ( ) = > window . setTimeout ( ( ) = > setActiveLinePicker ( ( current ) = > ( current === index ? null : current ) ) , 120 ) }
placeholder = "Search by SKU"
className = "w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/ >
{ activeLinePicker === index ? (
< div className = "absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel" >
{ itemOptions
. filter ( ( option ) = > option . sku . toLowerCase ( ) . includes ( ( lineSearchTerms [ index ] ? ? "" ) . trim ( ) . toLowerCase ( ) ) )
. slice ( 0 , 12 )
. map ( ( option ) = > (
< button
key = { option . id }
type = "button"
onMouseDown = { ( event ) = > {
event . preventDefault ( ) ;
updateLine ( index , {
. . . line ,
itemId : option.id ,
description : line.description || option . name ,
2026-03-14 23:23:43 -05:00
unitPrice : line.unitPrice > 0 ? line . unitPrice : ( option . defaultPrice ? ? 0 ) ,
2026-03-14 23:03:17 -05:00
} ) ;
updateLineSearchTerm ( index , option . sku ) ;
setActiveLinePicker ( null ) ;
} }
className = "block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
>
{ option . sku }
< / button >
) ) }
< / div >
) : null }
< / div >
< / label >
< label className = "block" >
< span className = "mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted" > Description < / span >
< input value = { line . description } onChange = { ( event ) = > updateLine ( index , { . . . line , description : event.target.value } ) } className = "w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" / >
< / label >
< label className = "block" >
< span className = "mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted" > Qty < / span >
< input type = "number" min = { 1 } step = { 1 } value = { line . quantity } onChange = { ( event ) = > updateLine ( index , { . . . line , quantity : Number.parseInt ( event . target . value , 10 ) || 0 } ) } className = "w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" / >
< / label >
< label className = "block" >
< span className = "mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted" > UOM < / span >
< select value = { line . unitOfMeasure } onChange = { ( event ) = > updateLine ( index , { . . . line , unitOfMeasure : event.target.value as SalesLineInput [ "unitOfMeasure" ] } ) } className = "w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" >
{ inventoryUnitOptions . map ( ( option ) = > (
< option key = { option . value } value = { option . value } >
{ option . label }
< / option >
) ) }
< / select >
< / label >
< label className = "block" >
< span className = "mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted" > Unit Price < / span >
< input type = "number" min = { 0 } step = { 0.01 } value = { line . unitPrice } onChange = { ( event ) = > updateLine ( index , { . . . line , unitPrice : Number ( event . target . value ) } ) } className = "w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" / >
< / label >
< div className = "flex items-end" >
< button type = "button" onClick = { ( ) = > removeLine ( index ) } className = "rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300" >
Remove
< / button >
< / div >
< / div >
< / div >
) ) }
< / div >
) }
< div className = "mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between" >
< span className = "min-w-0 text-sm text-muted" > { status } < / span >
< button type = "submit" disabled = { isSaving } className = "rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60" >
{ isSaving ? "Saving..." : mode === "create" ? ` Create ${ config . singularLabel . toLowerCase ( ) } ` : "Save changes" }
< / button >
< / div >
< / section >
< / form >
) ;
}