1- // RateSheetsScreen.tsx — Govern › Rate sheets (dashboard parity).
1+ // RateSheetsScreen.tsx — Govern › Rate sheets. Full management: list + editor
2+ // (name, carrier, services rate table) + CSV import. (EBE-105)
23import { useMemo , useState } from "react" ;
34import { CARRIERS , CarrierLogo } from "~/components/ui/CarrierLogo" ;
45import { Icon } from "~/components/ui/icons" ;
56import { PageHeader , StateRow , TableFooter } from "~/components/ui/primitives" ;
6- import { KV , KVGrid , Section } from "~/components/ui/detail " ;
7- import { Sheet } from "~/components/ui/Sheet " ;
8- import { useRateSheets } from "~/lib/karrio/hooks " ;
7+ import { Sheet , Field } from "~/components/ui/Sheet " ;
8+ import { useRateSheets , useSaveRateSheet , useDeleteRateSheet } from "~/lib/karrio/hooks " ;
9+ import { useReferences } from "~/lib/karrio/references " ;
910import { carrierKey } from "~/lib/karrio/display" ;
10- import type { RateSheet } from "~/lib/karrio/types" ;
11+ import type { RateSheet , RateSheetService } from "~/lib/karrio/types" ;
12+
13+ type EditorState = "closed" | "create" | { edit : RateSheet } ;
1114
1215export function RateSheetsScreen ( ) {
13- const [ preview , setPreview ] = useState < RateSheet | null > ( null ) ;
16+ const [ editor , setEditor ] = useState < EditorState > ( "closed" ) ;
1417 const { data, isLoading, isError, error } = useRateSheets ( ) ;
1518 const rows = useMemo ( ( ) => data ?? [ ] , [ data ] ) ;
1619
1720 return (
1821 < div className = "page" data-testid = "screen-ratesheets" >
19- < PageHeader title = "Rate sheets" actions = { < button className = "btn btn-primary" > < Icon . Plus size = { 14 } /> New rate sheet</ button > } />
22+ < PageHeader
23+ title = "Rate sheets"
24+ actions = {
25+ < button className = "btn btn-primary" onClick = { ( ) => setEditor ( "create" ) } data-testid = "ratesheet-create" >
26+ < Icon . Plus size = { 14 } /> New rate sheet
27+ </ button >
28+ }
29+ />
2030 < div className = "card card-scroll" >
2131 < table className = "table" >
22- < thead > < tr > < th > Name</ th > < th > Carrier</ th > < th > Services</ th > < th > Type </ th > < th className = "actions-cell" /> </ tr > </ thead >
32+ < thead > < tr > < th > Name</ th > < th > Carrier</ th > < th > Services</ th > < th className = "actions-cell" /> </ tr > </ thead >
2333 < tbody >
24- { isLoading && < StateRow colSpan = { 5 } kind = "loading" message = "Loading rate sheets…" /> }
25- { isError && ! isLoading && < StateRow colSpan = { 5 } kind = "error" message = { ( error as Error ) ?. message ?? "Failed to load" } /> }
26- { ! isLoading && ! isError && rows . length === 0 && < StateRow colSpan = { 5 } kind = "empty" message = "No rate sheets yet." /> }
34+ { isLoading && < StateRow colSpan = { 4 } kind = "loading" message = "Loading rate sheets…" /> }
35+ { isError && ! isLoading && < StateRow colSpan = { 4 } kind = "error" message = { ( error as Error ) ?. message ?? "Failed to load" } /> }
36+ { ! isLoading && ! isError && rows . length === 0 && < StateRow colSpan = { 4 } kind = "empty" message = "No rate sheets yet." /> }
2737 { rows . map ( ( rs ) => (
28- < tr key = { rs . id } onClick = { ( ) => setPreview ( rs ) } data-testid = { `ratesheet-row-${ rs . id } ` } >
38+ < tr key = { rs . id } onClick = { ( ) => setEditor ( { edit : rs } ) } data-testid = { `ratesheet-row-${ rs . id } ` } >
2939 < td className = "recipient-name" > { rs . name } </ td >
3040 < td >
3141 < div className = "svc-cell" >
3242 < CarrierLogo carrier = { carrierKey ( rs . carrier_name ) } size = "sm" />
3343 < span > { CARRIERS [ carrierKey ( rs . carrier_name ) ] ?. name ?? rs . carrier_name ?? "—" } </ span >
3444 </ div >
3545 </ td >
36- < td > { rs . services_count ?? "—" } </ td >
37- < td > < span className = "tag" > { rs . is_system ? "system" : "custom" } </ span > </ td >
46+ < td > { rs . services ?. length ?? rs . services_count ?? 0 } </ td >
3847 < td className = "actions-cell" onClick = { ( e ) => e . stopPropagation ( ) } > < span className = "icon-action" > < Icon . Dots size = { 14 } /> </ span > </ td >
3948 </ tr >
4049 ) ) }
@@ -43,20 +52,185 @@ export function RateSheetsScreen() {
4352 < TableFooter shown = { rows . length } total = { rows . length } noun = "rate sheets" />
4453 </ div >
4554
46- { preview && (
47- < Sheet open onClose = { ( ) => setPreview ( null ) } size = "md" crumb = "Rate sheets" title = { preview . name } id = { preview . id } >
48- < div className = "sheet-body-pad" data-testid = "ratesheet-sheet-body" >
49- < Section title = "Details" >
50- < KVGrid >
51- < KV label = "Name" > { preview . name } </ KV >
52- < KV label = "Carrier" > { preview . carrier_name ?? "—" } </ KV >
53- < KV label = "Services" > { preview . services_count ?? "—" } </ KV >
54- < KV label = "Type" > { preview . is_system ? "System" : "Custom" } </ KV >
55- </ KVGrid >
56- </ Section >
57- </ div >
58- </ Sheet >
55+ { editor !== "closed" && (
56+ < RateSheetEditor
57+ key = { editor === "create" ? "create" : editor . edit . id }
58+ initial = { editor === "create" ? undefined : editor . edit }
59+ onClose = { ( ) => setEditor ( "closed" ) }
60+ />
5961 ) }
6062 </ div >
6163 ) ;
6264}
65+
66+ const CSV_COLUMNS = [ "service_code" , "service_name" , "currency" , "cost" , "min_weight" , "max_weight" , "transit_days" ] as const ;
67+ const NUMERIC = new Set ( [ "cost" , "min_weight" , "max_weight" , "transit_days" ] ) ;
68+
69+ // Parse a simple CSV (with or without a header row) into service rows.
70+ function parseCsv ( text : string ) : RateSheetService [ ] {
71+ const lines = text . split ( / \r ? \n / ) . map ( ( l ) => l . trim ( ) ) . filter ( Boolean ) ;
72+ if ( lines . length === 0 ) return [ ] ;
73+ let header = CSV_COLUMNS as readonly string [ ] ;
74+ let start = 0 ;
75+ if ( lines [ 0 ] . toLowerCase ( ) . includes ( "service_code" ) ) {
76+ header = lines [ 0 ] . split ( "," ) . map ( ( h ) => h . trim ( ) ) ;
77+ start = 1 ;
78+ }
79+ const out : RateSheetService [ ] = [ ] ;
80+ for ( let i = start ; i < lines . length ; i ++ ) {
81+ const cells = lines [ i ] . split ( "," ) . map ( ( c ) => c . trim ( ) ) ;
82+ const row : Record < string , unknown > = { } ;
83+ header . forEach ( ( h , idx ) => {
84+ const v = cells [ idx ] ;
85+ if ( v === undefined || v === "" ) return ;
86+ row [ h ] = NUMERIC . has ( h ) ? Number ( v ) : v ;
87+ } ) ;
88+ if ( row . service_code || row . service_name ) {
89+ out . push ( {
90+ service_code : String ( row . service_code ?? `svc_${ i } ` ) ,
91+ service_name : String ( row . service_name ?? row . service_code ?? `Service ${ i } ` ) ,
92+ currency : ( row . currency as string ) ?? "USD" ,
93+ cost : row . cost as number | undefined ,
94+ min_weight : row . min_weight as number | undefined ,
95+ max_weight : row . max_weight as number | undefined ,
96+ transit_days : row . transit_days as number | undefined ,
97+ } ) ;
98+ }
99+ }
100+ return out ;
101+ }
102+
103+ function RateSheetEditor ( { initial, onClose } : { initial ?: RateSheet ; onClose : ( ) => void } ) {
104+ const { data : refs } = useReferences ( ) ;
105+ const carriers = useMemo < [ string , string ] [ ] > (
106+ ( ) => Object . entries ( refs ?. carriers ?? { } ) . sort ( ( a , b ) => a [ 1 ] . localeCompare ( b [ 1 ] ) ) ,
107+ [ refs ] ,
108+ ) ;
109+
110+ const [ name , setName ] = useState ( initial ?. name ?? "" ) ;
111+ const [ carrier , setCarrier ] = useState ( initial ?. carrier_name ?? "" ) ;
112+ const [ services , setServices ] = useState < RateSheetService [ ] > ( initial ?. services ?? [ ] ) ;
113+ const [ csv , setCsv ] = useState ( "" ) ;
114+ const [ err , setErr ] = useState < string | null > ( null ) ;
115+
116+ const save = useSaveRateSheet ( ) ;
117+ const del = useDeleteRateSheet ( ) ;
118+
119+ const addService = ( ) =>
120+ setServices ( ( s ) => [ ...s , { service_code : "" , service_name : "" , currency : "USD" } ] ) ;
121+ const updateService = ( i : number , patch : Partial < RateSheetService > ) =>
122+ setServices ( ( s ) => s . map ( ( row , idx ) => ( idx === i ? { ...row , ...patch } : row ) ) ) ;
123+ const removeService = ( i : number ) => setServices ( ( s ) => s . filter ( ( _ , idx ) => idx !== i ) ) ;
124+
125+ const importCsv = ( ) => {
126+ const parsed = parseCsv ( csv ) ;
127+ if ( parsed . length === 0 ) return setErr ( "No service rows found in the CSV." ) ;
128+ setServices ( ( s ) => [ ...s , ...parsed ] ) ;
129+ setCsv ( "" ) ;
130+ setErr ( null ) ;
131+ } ;
132+
133+ const onSubmit = async ( e : React . FormEvent ) => {
134+ e . preventDefault ( ) ;
135+ setErr ( null ) ;
136+ if ( ! name . trim ( ) ) return setErr ( "A name is required." ) ;
137+ if ( ! carrier ) return setErr ( "Select a carrier." ) ;
138+ const valid = services . filter ( ( s ) => s . service_code . trim ( ) && s . service_name . trim ( ) ) ;
139+ if ( valid . length === 0 ) return setErr ( "Add at least one service (code + name)." ) ;
140+ try {
141+ await save . mutateAsync ( {
142+ id : initial ?. id ,
143+ data : {
144+ name : name . trim ( ) ,
145+ carrier_name : carrier ,
146+ services : valid . map ( ( s ) => ( {
147+ service_code : s . service_code . trim ( ) ,
148+ service_name : s . service_name . trim ( ) ,
149+ currency : s . currency || "USD" ,
150+ cost : s . cost ,
151+ min_weight : s . min_weight ,
152+ max_weight : s . max_weight ,
153+ weight_unit : s . weight_unit || "KG" ,
154+ transit_days : s . transit_days ,
155+ } ) ) ,
156+ } ,
157+ } ) ;
158+ onClose ( ) ;
159+ } catch ( e2 ) {
160+ setErr ( ( e2 as Error ) ?. message ?? "Could not save the rate sheet." ) ;
161+ }
162+ } ;
163+
164+ const onDelete = async ( ) => {
165+ if ( ! initial ) return ;
166+ try {
167+ await del . mutateAsync ( initial . id ) ;
168+ onClose ( ) ;
169+ } catch {
170+ setErr ( "Could not delete the rate sheet." ) ;
171+ }
172+ } ;
173+
174+ return (
175+ < Sheet
176+ open onClose = { onClose } size = "lg" crumb = "Rate sheets"
177+ title = { initial ? "Edit rate sheet" : "New rate sheet" } id = { initial ?. id }
178+ footer = {
179+ < >
180+ { initial && < button className = "btn" onClick = { onDelete } disabled = { del . isPending } data-testid = "ratesheet-delete" > Delete</ button > }
181+ < div style = { { flex : 1 } } />
182+ < button className = "btn" onClick = { onClose } > Cancel</ button >
183+ < button className = "btn btn-primary" form = "ratesheet-form" type = "submit" disabled = { save . isPending } data-testid = "ratesheet-save" >
184+ { save . isPending ? "Saving…" : "Save rate sheet" }
185+ </ button >
186+ </ >
187+ }
188+ >
189+ < form id = "ratesheet-form" className = "sheet-body-pad" onSubmit = { onSubmit } data-testid = "ratesheet-sheet-body" >
190+ < div className = "kv-grid" >
191+ < Field label = "Name *" > < input className = "field-input" value = { name } onChange = { ( e ) => setName ( e . target . value ) } data-testid = "rs-name" /> </ Field >
192+ < Field label = "Carrier *" >
193+ < select className = "field-input" value = { carrier } onChange = { ( e ) => setCarrier ( e . target . value ) } disabled = { ! ! initial } data-testid = "rs-carrier" >
194+ < option value = "" > Select a carrier…</ option >
195+ { carriers . map ( ( [ id , label ] ) => < option key = { id } value = { id } > { label } </ option > ) }
196+ </ select >
197+ </ Field >
198+ </ div >
199+
200+ < div className = "section-head" style = { { display : "flex" , alignItems : "center" , marginTop : 14 } } >
201+ Services & rates
202+ < button type = "button" className = "btn btn-sm" style = { { marginLeft : "auto" } } onClick = { addService } data-testid = "rs-add-service" > < Icon . Plus size = { 12 } /> Add service</ button >
203+ </ div >
204+ < table className = "table" data-testid = "rs-services" >
205+ < thead > < tr > < th > Code</ th > < th > Name</ th > < th > Currency</ th > < th > Cost</ th > < th > Transit</ th > < th /> </ tr > </ thead >
206+ < tbody >
207+ { services . length === 0 && < tr > < td colSpan = { 6 } > < div className = "state-row" style = { { fontSize : 12 } } > No services yet — add one or import a CSV below.</ div > </ td > </ tr > }
208+ { services . map ( ( s , i ) => (
209+ < tr key = { i } data-testid = { `rs-service-${ i } ` } >
210+ < td > < input className = "field-input mono" style = { { minWidth : 90 } } value = { s . service_code } onChange = { ( e ) => updateService ( i , { service_code : e . target . value } ) } data-testid = { `rs-service-code-${ i } ` } /> </ td >
211+ < td > < input className = "field-input" style = { { minWidth : 120 } } value = { s . service_name } onChange = { ( e ) => updateService ( i , { service_name : e . target . value } ) } data-testid = { `rs-service-name-${ i } ` } /> </ td >
212+ < td > < input className = "field-input" style = { { width : 64 } } value = { s . currency ?? "" } onChange = { ( e ) => updateService ( i , { currency : e . target . value } ) } /> </ td >
213+ < td > < input className = "field-input" style = { { width : 72 } } type = "number" step = "0.01" value = { s . cost ?? "" } onChange = { ( e ) => updateService ( i , { cost : e . target . value === "" ? undefined : Number ( e . target . value ) } ) } /> </ td >
214+ < td > < input className = "field-input" style = { { width : 56 } } type = "number" value = { s . transit_days ?? "" } onChange = { ( e ) => updateService ( i , { transit_days : e . target . value === "" ? undefined : Number ( e . target . value ) } ) } /> </ td >
215+ < td > < button type = "button" className = "icon-action" onClick = { ( ) => removeService ( i ) } data-testid = { `rs-service-remove-${ i } ` } > < Icon . X size = { 13 } /> </ button > </ td >
216+ </ tr >
217+ ) ) }
218+ </ tbody >
219+ </ table >
220+
221+ < div className = "section-head" style = { { marginTop : 14 } } > Import CSV</ div >
222+ < textarea
223+ className = "field-input mono"
224+ style = { { width : "100%" , height : 90 , fontSize : 12 } }
225+ placeholder = "service_code,service_name,currency,cost,min_weight,max_weight,transit_days"
226+ value = { csv }
227+ onChange = { ( e ) => setCsv ( e . target . value ) }
228+ data-testid = "rs-csv"
229+ />
230+ < button type = "button" className = "btn btn-sm" style = { { marginTop : 6 } } onClick = { importCsv } data-testid = "rs-csv-import" > < Icon . Download size = { 12 } /> Import services from CSV</ button >
231+
232+ { err && < div className = "auth-error" data-testid = "ratesheet-form-error" > { err } </ div > }
233+ </ form >
234+ </ Sheet >
235+ ) ;
236+ }
0 commit comments