Back to all postsA yellow paper tower crane with a blue counterweight and a hook on a grey line, on a tan paper card

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.

riverside-transit-budget.xlsx
ABCDEFGHI
1HARTWELL CONSTRUCTION GROUP
2Riverside Transit Center, Project 24-118
3Budget Detail, Period Ending 07/31/2026
4
5PHASEITEM NO.DESCRIPTION OF WORKCOST TYPESCHEDULED VALUEWORK COMPLETEDMATERIALS PRESENTLY STOREDBALANCE TO FINISH
6FROM PREVIOUS APPLICATIONTHIS PERIOD
7Enabling Works2026-02-20Hazardous Material SurveyO$248,478.66$122,957.44$27,172.23$98,348.99
802-220Site DemolitionProf Services$30,250.00$16,740.55$4,493.96$9,015.49
902-220Site DemolitionM$105,241.85$21,581.12$23,871.00$59,789.73
1002-230Site ClearingL$107,031.81$31,778.76$4,537.25$70,715.80
112026-02-20Hazardous Material SurveyLAB$89,209.76$2,151.61$23,104.98$63,953.17
1201-500Temporary Facilities and ControlsLabor$349,912.69$124,913.75$49,872.58$8,451.69$166,674.67
34 rows not shown
4731 23 00Excavation and Backfill$72,242.65$3,915.41$15,862.63$22,658.15$29,806.46
8 rows not shown
5602.230Site ClearingMAT$466,024.72$237,826.15$41,763.04$186,435.53
14 rows not shown
71Enabling Works Total$15,205,849.02
1 rows not shown
73Substructure03-210Cast-In-Place ConcreteL$472,468.91$174,544.27$20,253.81$277,670.83
582 rows not shown
656Values 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.6734 rows not shown47|31 23 00|Excavation and Backfill||$72,242.65|$3,915.41|$15,862.63|$22,658.15|$29,806.468 rows not shown56|02.230|Site Clearing|MAT|$466,024.72|$237,826.15|$41,763.04||$186,435.5314 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.83582 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.