
Excel Import for Construction Management Software
Construction budgets live in Excel. The accounting system prints a schedule of values, the project manager opens it in a spreadsheet, adds a column for this month's billing, and sends it on. The workbook is a document before it is data, and it is laid out to be printed and signed.
Those documents reach your importer intact. A title block sits above the table, cost codes carry leading zeros a spreadsheet is happy to drop, and subtotal rows sit between the line items your schema wants.
A contractor's budget export writes its column headings across two rows. The importer reads the upper one, and eight of the nine headings reach a field. The row underneath reaches all nine.
Both rows are header rows. Row 5 and row 6 each say PHASE, ITEM NO. and
DESCRIPTION OF WORK, because a spreadsheet merge covers both. They differ in
one place, and that place is a money column your schema keeps.
A construction management platform stores one row per budget line. Its customers export those lines out of the accounting system they are leaving, or out of the workbook the project accountant keeps beside it. Taking a contractor's budget into a schema of your own is customer data onboarding.
The file below holds 656 rows over nine columns and 18 merged ranges, 41 KB of
.xlsx. Every count in this article came out of running that file through the
importer.
The budget an accounting system prints
A schedule of values lists what a contract is worth line by line, and how much of each line is finished. The AIA G703 continuation sheet is the form the industry prints it on, and the AIA instructions name the money columns. One holds work completed by the previous application, one holds work completed this period, one holds materials presently stored, and one holds the total of the three.
Hartwell Construction Group is a general contractor moving onto your platform. The file is their budget detail for the Riverside Transit Center, printed by the system they are leaving. Procore, Autodesk Build and Sage 300 CRE all hold budgets in this shape, and so does the workbook a project accountant keeps by hand.
| A | B | C | D | E | F | G | H | I | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | HARTWELL CONSTRUCTION GROUP | ||||||||
| 2 | Riverside Transit Center, Project 24-118 | ||||||||
| 3 | Budget Detail, Period Ending 07/31/2026 | ||||||||
| 4 | |||||||||
| 5 | PHASE | ITEM NO. | DESCRIPTION OF WORK | COST TYPE | SCHEDULED VALUE | WORK COMPLETED | MATERIALS PRESENTLY STORED | BALANCE TO FINISH | |
| 6 | FROM PREVIOUS APPLICATION | THIS PERIOD | |||||||
| 7 | Enabling Works | 2026-02-20 | Hazardous Material Survey | O | $248,478.66 | $122,957.44 | $27,172.23 | $98,348.99 | |
| 8 | 02-220 | Site Demolition | Prof Services | $30,250.00 | $16,740.55 | $4,493.96 | $9,015.49 | ||
| 9 | 02-220 | Site Demolition | M | $105,241.85 | $21,581.12 | $23,871.00 | $59,789.73 | ||
| 10 | 02-230 | Site Clearing | L | $107,031.81 | $31,778.76 | $4,537.25 | $70,715.80 | ||
| 11 | 2026-02-20 | Hazardous Material Survey | LAB | $89,209.76 | $2,151.61 | $23,104.98 | $63,953.17 | ||
| 12 | 01-500 | Temporary Facilities and Controls | Labor | $349,912.69 | $124,913.75 | $49,872.58 | $8,451.69 | $166,674.67 | |
| 34 rows not shown | |||||||||
| 47 | 31 23 00 | Excavation and Backfill | $72,242.65 | $3,915.41 | $15,862.63 | $22,658.15 | $29,806.46 | ||
| 8 rows not shown | |||||||||
| 56 | 02.230 | Site Clearing | MAT | $466,024.72 | $237,826.15 | $41,763.04 | $186,435.53 | ||
| 14 rows not shown | |||||||||
| 71 | Enabling Works Total | $15,205,849.02 | |||||||
| 1 rows not shown | |||||||||
| 73 | Substructure | 03-210 | Cast-In-Place Concrete | L | $472,468.91 | $174,544.27 | $20,253.81 | $277,670.83 | |
| 582 rows not shown | |||||||||
| 656 | Values shown are current contract amounts including approved change orders. | ||||||||
1HARTWELL CONSTRUCTION GROUP2Riverside Transit Center, Project 24-1183Budget Detail, Period Ending 07/31/202645PHASE|ITEM NO.|DESCRIPTION OF WORK|COST TYPE|SCHEDULED VALUE|WORK COMPLETED||MATERIALS PRESENTLY STORED|BALANCE TO FINISH6|||||FROM PREVIOUS APPLICATION|THIS PERIOD||7Enabling Works|2026-02-20|Hazardous Material Survey|O|$248,478.66|$122,957.44|$27,172.23||$98,348.998|02-220|Site Demolition|Prof Services|$30,250.00|$16,740.55|$4,493.96||$9,015.499|02-220|Site Demolition|M|$105,241.85|$21,581.12|$23,871.00||$59,789.7310|02-230|Site Clearing|L|$107,031.81|$31,778.76|$4,537.25||$70,715.8011|2026-02-20|Hazardous Material Survey|LAB|$89,209.76|$2,151.61|$23,104.98||$63,953.1712|01-500|Temporary Facilities and Controls|Labor|$349,912.69|$124,913.75|$49,872.58|$8,451.69|$166,674.67⋮34 rows not shown47|31 23 00|Excavation and Backfill||$72,242.65|$3,915.41|$15,862.63|$22,658.15|$29,806.46⋮8 rows not shown56|02.230|Site Clearing|MAT|$466,024.72|$237,826.15|$41,763.04||$186,435.53⋮14 rows not shown71||Enabling Works Total||$15,205,849.02||||⋮1 rows not shown73Substructure|03-210|Cast-In-Place Concrete|L|$472,468.91|$174,544.27|$20,253.81||$277,670.83⋮582 rows not shown656||Values shown are current contract amounts including approved change orders.||||||Rows 1 to 3 are the title block, each one a single cell stretched across all nine columns. Rows 5 and 6 are the header. Row 7 is where the lines start, and row 71 is where the first phase closes with a subtotal.
What a budget line is on your side
Procore calls the model a work breakdown structure. A budget code is built from segments, and the three defaults are cost code, cost type and an optional sub job. Cost code is a tiered segment, so it carries a parent and a child with a dash between them. Cost type is a flat segment with a list of items and no hierarchy under it.
Procore publishes seven cost types, and prints a letter beside each one. (E) Equipment, (L) Labor, (M) Materials, (O) Other, (OC) Owner Cost, (S) Commitments, and (SVC) Professional Services. Those seven are the option list your schema declares.
import type { DataEditorColumn } from "@updog/data-editor";
const PHASES = [ "Enabling Works", "Substructure", "Superstructure", "Envelope", "Interiors", "Mechanical and Electrical", "Sitework",];
const COST_TYPES = [ "Equipment", "Labor", "Materials", "Other", "Owner Cost", "Commitment", "Professional Services",];
export const columns: DataEditorColumn[] = [ { id: "phase", title: "Phase", editor: { type: "select", options: PHASES }, }, { id: "budgetCode", title: "Budget code", validators: [ { type: "required" }, { type: "regex", pattern: "^\\d{2}-\\d{2,3}$", message: "Not a cost code", }, ], }, { id: "costCodeName", title: "Cost code name", validators: [{ type: "required" }], }, { id: "costType", title: "Cost type", editor: { type: "select", options: COST_TYPES, enableCustomValue: false }, validators: [{ type: "oneOf", values: COST_TYPES }], }, { id: "originalBudget", title: "Original budget", editor: { type: "number" }, validators: [{ type: "required" }, { type: "number", min: 0 }], }, { id: "completedPrevious", title: "Completed previous", editor: { type: "number" }, validators: [{ type: "number" }], }, { id: "completedThisPeriod", title: "Completed this period", editor: { type: "number" }, validators: [{ type: "number" }], }, { id: "storedMaterials", title: "Stored materials", editor: { type: "number" }, validators: [{ type: "number" }], }, { id: "balanceToFinish", title: "Balance to finish", editor: { type: "number" }, validators: [{ type: "number" }], },];Nine fields. The budget code carries a pattern the contractor's own house style has to clear, and every money column is a number.
The headings the file spells its own way
Five of the nine headings reach a field with nothing configured. PHASE, COST TYPE, MATERIALS PRESENTLY STORED and BALANCE TO FINISH share a word with
the field beside them, and WORK COMPLETED reaches completedPrevious on the
word the two of them share.
Three of the four that miss are places where the file's word and your word are
different words for one thing. ITEM NO. against budgetCode. DESCRIPTION OF WORK against costCodeName. SCHEDULED VALUE against originalBudget.
Nothing is wrong with either name.
export const synonyms = { columns: { budgetCode: ["ITEM NO.", "Cost Code", "Code"], costCodeName: [ "DESCRIPTION OF WORK", "Description", "Cost Code Description", ], originalBudget: ["SCHEDULED VALUE", "Budget", "Original Contract"], completedPrevious: ["FROM PREVIOUS APPLICATION", "Previous"], completedThisPeriod: ["THIS PERIOD"], storedMaterials: ["MATERIALS PRESENTLY STORED"], balanceToFinish: ["BALANCE TO FINISH"], },};Seven rows finish the mapping and eight of the nine headings reach a field. Every string on the left of that list is the vocabulary the AIA form prints, so the next contractor's budget lands on the same table. The ninth heading is the file's own doing.
A heading that spans two columns
WORK COMPLETED is one heading over two columns on the printed form, and the
workbook writes it as a merge across F5 and G5. The seven headings beside it are
merged the other way, each one down two rows.
Updog fills a merged range across its whole area, so every cell the range covers carries the anchor's value. A merge shows its value over its whole area in the spreadsheet, and the import mirrors that.
Row 5 comes out of the fill with nine cells, and two of them read WORK COMPLETED. Two columns cannot share one header, so the importer suffixes the
second and the row ends WORK COMPLETED, WORK COMPLETED (2). Your alias table
has no entry for a name the importer wrote.
Row 6 comes out of the fill with nine cells too. In the file it holds two,
FROM PREVIOUS APPLICATION and THIS PERIOD. The seven label merges above it
reach down one row, so after the fill it holds nine and every one of them is
distinct. The better header row exists because of the fill.
The row the importer was sure about
Updog inspects the first rows of a file to decide which row holds the headers, looking at where each column's values switch from text labels to a consistent type. A row that reads as a banner loses its claim to the next row below it that scores higher, so the three title rows are stepped over.
On this file the detector lands on row 5, at a confidence of 0.79 against a threshold of 0.55. That verdict is confident and it is defensible. Row 5 is a header row.
A confident verdict means no header-selection step. The person reaches column
matching, reads 8/9 matched at the top of the screen, and finds WORK COMPLETED (2) sitting on Select column with nothing to put in it.
The row underneath it
Change header row sits in the footer of the matching step, beside Back and Next. It reopens header selection for the file being matched, including a file whose header was detected confidently and whose screen was skipped.
The screen opens with row 5 already chosen and the title block above it. Choose
row 6, press Next, and the columns match again. Nine of nine, with
FROM PREVIOUS APPLICATION and THIS PERIOD where a duplicate heading used to
be.
Leaving it on row 5 costs two things and both are quiet. The second header row
arrives as data, so the grid holds 643 rows where row 6 gives 642. And
completedThisPeriod lands empty on all 643, because one heading spanning two
columns cannot be aliased twice.
A column you mapped by hand keeps your choice when its header reads the same
on the new row. A match you cleared stays cleared as well, so the alias list
above does not put MATERIALS PRESENTLY STORED back after a header change.
Pressing the button costs nothing already done.
The phase written once
The same fill that duplicates a heading fills a column nobody wants to type.
PHASE is written seven times in this file, once at the top of each block, and
every one of those cells is merged down its own rows. The blocks run 64, 88, 96,
82, 118, 126 and 58 lines. After the fill, seven cells reach 632 rows, and every
line row carries the phase it belongs to.
Unmerging that column by hand means typing seven words 632 times.
The cost code written more than one way
Procore's default cost code list holds 17 divisions aligned with the CSI
MasterFormat, and its tiers are delimited by a dash, so 03-210 is division 03
and cost code 210. MasterFormat itself holds 50 divisions and writes a section
as six digits in three pairs, so 03 30 00 is division 03, section 30. Two
published vocabularies for one catalogue.
This contractor's estimating system and their accounting system disagree about
which one they emit, and both land in the same column. 475 rows are written
03-210, 73 are written 03 30 00, and 34 are written 03.210, which is the
contractor's own third house style.
The pattern on budgetCode accepts one of the three. 157 rows come through
flagged Not a cost code, and 157 is 73 plus 50 plus 34. The 50 in the middle
have a different story behind them.
The code that became a date
Procore publishes a help article on preserving cost code formatting in Excel. Number-to-date reformatting happens when someone opens a file with Excel, and the program looks at the encoded cell values and decides on its own whether a cell should read as a date, a fraction, currency or text.
Four codes in this catalogue carry a two-digit second tier. 02-20 Hazardous
Material Survey, 05-12 Steel Erection, 06-24 Interior Architectural
Woodwork, and 09-30 Tiling. A General-formatted column reads each of them as a
day and a month, and 50 rows reach the store as 2026-02-20, 2026-05-12,
2026-06-24 and 2026-09-30.
The description column beside them survives, so the code is recoverable. A
person reading 2026-05-12 against Steel Erection knows what the contractor
meant.
Every letter Procore prints
The cost type column holds sixteen strings and one of them is blank. L on 91
rows, M on 82, S on 79, Labor on 54, Material on 50, Subcontract on
50, E on 49, Equipment on 30, Subcontractor on 25, LAB on 24, Sub on
23, O on 20, MAT on 19, Prof Services on 14, Owner on 11, and blank on
29.
Six of the fifteen reach an option on their own. Labor and LAB land on
Labor, Material on Materials, Equipment on Equipment, Owner on Owner Cost,
and Prof Services on Professional Services.
The letters Procore itself prints reach nothing. L, M, E, O and S
are the abbreviations published beside the seven options, and a one-letter
string shares no word with a word. MAT misses where LAB lands, because lab
sits inside labor and mat sits inside materials below the tier that would
take it.
Subcontractor, Subcontract and Sub are a decision about the domain.
Procore publishes (S) Commitment, and the word this file writes is absent from
the list. Somebody has to say the two are one thing, and no matcher can say it
for them.
Nine values left unmapped leave 459 of 642 cost type cells empty, against 29
blanks in the file. An unmatched value on a closed select lands as nothing, and
it lands there without an error. The person maps them on the value step, or a
values alias arrives with the next export.
The subtotal that arrives as a line
Ten rows in this file are not budget lines. Seven are phase subtotals reading
Enabling Works Total and the six like it, one is PROJECT TOTAL, and two are
notes at the bottom. The last of them is a sentence.
Every one of them carries a description and no item number, so required on the
budget code flags all ten and the person deletes them.
Common CSV import errors covers the failures
any file can produce, and a total row is the failure a printed form produces.
When a sheet is not a table
Some files read fine and hold no single table. Updog scores every sheet it reads for how safely it reads as one rectangular table, and a sheet that scores badly hands the whole file to a reader you supply.
The score notices a value painted across a whole row, a value merged down its rows, a sheet that changes shape partway down, and a near-empty tail. This file carries all four and still reads as a table, at 0.9635 where 0.65 is the line below which a file goes to your handler. Seven phase blocks under one header is one shape repeated, and one shape repeated is a table.
The scoring runs only when onUnstructuredFile is set. The measure is internal
and it moves as it meets more real files, so build against what your handler
receives.
Nothing here knows what a cost code is
Updog ships no division list, no MasterFormat table, no schedule of values template, and no connector to any construction system. Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS. A budget that survives only as a PDF goes through a parser you supply and arrives as ordinary rows. The nine columns, the alias list, the pattern and the seven options are yours.
The merge story needs a single-sheet file. Merge information survives on the rows a parse returns, and a multi-sheet workbook previews its sheets through another path that carries no merge list. Header detection reads the flattened row either way and reaches the same verdict, so a tab in a workbook behaves the same as the file above. The structure score loses one of its signals.
What your own reader gives back
export const readBudget = async ( file: File, { signal }: { signal: AbortSignal },) => { const body = new FormData(); body.append("file", file); const res = await fetch("/api/read-budget", { method: "POST", body, signal }); return res.json(); // CustomImportTable[]};Your route reads the sheet, takes row 6 as the header, and keeps a row only when it carries both an item number and a description. It answers with one named table of 632 rows, and that table stages as an ordinary card with its own matching screen.
Nine of nine headings reach a field. The ten rows that are not lines never
arrive, every required error goes with them, and 157 rows come through
flagged. All 157 are the cost code column.
This is the one place in the import where the file leaves the browser. It travels from the person's browser to wherever your handler sends it, under your own processing agreement, and nothing about it passes through Updog. Client-side and server-side import follows a file down each of the two routes.
<DataEditor columns={columns} synonyms={synonyms} primaryKey="budgetCode" onUnstructuredFile={readBudget} onComplete={async (result) => { const lines = result.sources .flatMap((source) => source.rows) .filter((r) => r.isValid && !r.isDeleted) .map((r) => r.row) .map((row) => ({ phase: row.phase, budgetCode: row.budgetCode, costCodeName: row.costCodeName, costType: row.costType, originalBudget: Number(row.originalBudget), completedPrevious: Number(row.completedPrevious || 0), completedThisPeriod: Number(row.completedThisPeriod || 0), storedMaterials: Number(row.storedMaterials || 0), balanceToFinish: Number(row.balanceToFinish || 0), }));
await postBudget({ project: "24-118", lines }); }}/>The one problem left in the file
157 lines out of 632 carry a cost code the pattern refuses. 73 are written the MasterFormat way, 34 are written with a dot, and 50 are dates that used to be codes.
All 157 are legible. The description sits beside every one of them, so the project accountant works down the list against the code list their own company publishes, which is the only place that answer lives.
Then 632 lines of a contract become budget codes your platform can add up, and a file printed for a person to read becomes rows.