From cf3cd96384f91b63aaeee74b02ac6bac873ed61f Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Thu, 21 May 2026 00:04:05 -0400 Subject: [PATCH] Added excel import --- frontend/package-lock.json | 167 +++++++++++++- frontend/package.json | 3 +- frontend/src/App.tsx | 452 ++++++++++++++++++++++++++++++++++++- frontend/src/index.css | 21 +- 4 files changed, 639 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d32688..598fe9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "@types/qrcode": "^1.5.6", "qrcode": "^1.5.4", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "read-excel-file": "^9.0.9" }, "devDependencies": { "@types/react": "18.3.12", @@ -1212,6 +1213,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1249,6 +1259,12 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1349,6 +1365,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1389,6 +1411,15 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -1451,6 +1482,12 @@ "node": ">=6" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1464,6 +1501,20 @@ "node": ">=8" } }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1498,6 +1549,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1507,6 +1570,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1539,6 +1608,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1599,6 +1680,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -1696,6 +1783,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -1748,6 +1841,35 @@ "node": ">=0.10.0" } }, + "node_modules/read-excel-file": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz", + "integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.9", + "fflate": "^0.8.2", + "unzipper": "^0.12.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1808,6 +1930,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1843,6 +1971,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1889,6 +2026,28 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1920,6 +2079,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee368d0..c70c72a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "@types/qrcode": "^1.5.6", "qrcode": "^1.5.4", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "read-excel-file": "^9.0.9" }, "devDependencies": { "@types/react": "18.3.12", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3bd2b6..54d5e3f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -199,6 +199,32 @@ type BirdFormState = { publicProfileEnabled: boolean; }; +type BirdImportWeight = { + weightGrams: number; + recordedOn: string; + notes: string; +}; + +type BirdImportProfile = { + key: string; + name: string; + tagId: string; + species: string; + favoriteSnack: string; + motivators: string; + demotivators: string; + gender: BirdGender; + dateOfBirth: string; + gotchaDay: string; + chartColor: string; + weights: BirdImportWeight[]; +}; + +type BirdImportPreview = { + profiles: BirdImportProfile[]; + errors: string[]; +}; + type PublicBirdProfile = { id: string; workspaceId: number; @@ -327,7 +353,7 @@ type PhotoDragState = { }; type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; -type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; +type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'bird-import' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; @@ -373,6 +399,179 @@ const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => { ); }; + +const importHeaderAliases = { + name: ['bird name', 'name'], + tagId: ['band id', 'tag id', 'band'], + species: ['species'], + favoriteSnack: ['favorite snack', 'favorite treat', 'treat'], + motivators: ['motivators'], + demotivators: ['demotivators', 'demotivates'], + gender: ['gender'], + dateOfBirth: ['hatch day', 'hatch date', 'date of birth', 'dob'], + gotchaDay: ['gotcha day', 'gotcha date'], + chartColor: ['chart color', 'color'], + weightGrams: ['weight grams', 'weight g', 'weight'], + weightDate: ['weight date', 'recorded on', 'weight recorded on'], + weightNotes: ['weight notes', 'weight note'], +} as const; + +const normalizeImportHeader = (value: string) => value.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' '); + +const readImportCell = (row: Record, aliases: readonly string[]) => { + const matchingEntry = Object.entries(row).find(([header]) => aliases.includes(normalizeImportHeader(header))); + return matchingEntry?.[1] ?? ''; +}; + +const toImportText = (value: unknown) => (value === null || value === undefined ? '' : String(value).trim()); + +const formatImportDate = (value: Date) => { + if (Number.isNaN(value.getTime())) { + return ''; + } + + const month = `${value.getMonth() + 1}`.padStart(2, '0'); + const day = `${value.getDate()}`.padStart(2, '0'); + return `${value.getFullYear()}-${month}-${day}`; +}; + +const toImportDate = (value: unknown) => { + if (value === null || value === undefined || value === '') { + return ''; + } + + if (value instanceof Date) { + return formatImportDate(value); + } + + if (typeof value === 'number') { + return formatImportDate(new Date(1899, 11, 30 + Math.floor(value))); + } + + const text = toImportText(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(text)) { + return text; + } + + return formatImportDate(new Date(text)); +}; + +const parseImportGender = (value: unknown): BirdGender | null => { + const gender = toImportText(value).toLowerCase(); + + if (!gender || gender === 'unknown') { + return 'unknown'; + } + + if (gender === 'male' || gender === 'female') { + return gender; + } + + return null; +}; + +const getBirdImportKey = (name: string, tagId: string) => (tagId ? `band:${tagId.toLowerCase()}` : `name:${name.toLowerCase()}`); + +const mergeImportText = (current: string, next: string) => current || next; + +const parseBirdImportRows = (rows: Record[]): BirdImportPreview => { + const errors: string[] = []; + const profiles = new Map(); + + rows.forEach((row, index) => { + const rowNumber = index + 2; + const name = toImportText(readImportCell(row, importHeaderAliases.name)); + const tagId = toImportText(readImportCell(row, importHeaderAliases.tagId)); + const gender = parseImportGender(readImportCell(row, importHeaderAliases.gender)); + const dateOfBirthValue = readImportCell(row, importHeaderAliases.dateOfBirth); + const gotchaDayValue = readImportCell(row, importHeaderAliases.gotchaDay); + const dateOfBirth = toImportDate(dateOfBirthValue); + const gotchaDay = toImportDate(gotchaDayValue); + const weightText = toImportText(readImportCell(row, importHeaderAliases.weightGrams)); + const weightDateValue = readImportCell(row, importHeaderAliases.weightDate); + const weightDate = toImportDate(weightDateValue); + + if (!name) { + errors.push(`Row ${rowNumber}: Bird Name is required.`); + return; + } + + if (!gender) { + errors.push(`Row ${rowNumber}: Gender must be male, female, unknown, or blank.`); + return; + } + + if (dateOfBirthValue && !dateOfBirth) { + errors.push(`Row ${rowNumber}: Hatch Day is not a valid date.`); + } + + if (gotchaDayValue && !gotchaDay) { + errors.push(`Row ${rowNumber}: Gotcha Day is not a valid date.`); + } + + const key = getBirdImportKey(name, tagId); + const current = + profiles.get(key) ?? + ({ + key, + name, + tagId, + species: '', + favoriteSnack: '', + motivators: '', + demotivators: '', + gender, + dateOfBirth, + gotchaDay, + chartColor: '', + weights: [], + } satisfies BirdImportProfile); + + current.species = mergeImportText(current.species, toImportText(readImportCell(row, importHeaderAliases.species))); + current.favoriteSnack = mergeImportText(current.favoriteSnack, toImportText(readImportCell(row, importHeaderAliases.favoriteSnack))); + current.motivators = mergeImportText(current.motivators, toImportText(readImportCell(row, importHeaderAliases.motivators))); + current.demotivators = mergeImportText(current.demotivators, toImportText(readImportCell(row, importHeaderAliases.demotivators))); + current.chartColor = mergeImportText(current.chartColor, toImportText(readImportCell(row, importHeaderAliases.chartColor))); + current.dateOfBirth = mergeImportText(current.dateOfBirth, dateOfBirth); + current.gotchaDay = mergeImportText(current.gotchaDay, gotchaDay); + current.gender = current.gender === 'unknown' ? gender : current.gender; + + if (weightText) { + const weightGrams = Number(weightText); + + if (!Number.isFinite(weightGrams) || weightGrams <= 0) { + errors.push(`Row ${rowNumber}: Weight Grams must be a positive number.`); + } else if (!weightDate) { + errors.push(`Row ${rowNumber}: Weight Date is required for a weight entry.`); + } else { + current.weights.push({ + weightGrams, + recordedOn: weightDate, + notes: toImportText(readImportCell(row, importHeaderAliases.weightNotes)), + }); + } + } else if (weightDateValue) { + errors.push(`Row ${rowNumber}: Weight Grams is required when Weight Date is set.`); + } + + profiles.set(key, current); + }); + + profiles.forEach((profile) => { + if (!profile.species) { + errors.push(`${profile.name}: Species is required on at least one row for this bird.`); + } + + if (profile.chartColor && !/^#[0-9a-fA-F]{6}$/.test(profile.chartColor)) { + errors.push(`${profile.name}: Chart Color must be a hex color like #cb3a35.`); + } + }); + + return { + profiles: [...profiles.values()], + errors, + }; +}; const emptyBirdForm: BirdFormState = { name: '', tagId: '', @@ -1203,6 +1402,10 @@ function App() { const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); + const [birdImportPreview, setBirdImportPreview] = useState(null); + const [birdImportFileName, setBirdImportFileName] = useState(''); + const [birdImportNotice, setBirdImportNotice] = useState(''); + const [importingBirds, setImportingBirds] = useState(false); const [memorializeBirdForm, setMemorializeBirdForm] = useState(emptyMemorializeBirdForm); const [birdPhotoName, setBirdPhotoName] = useState(''); const [photoCrop, setPhotoCrop] = useState(null); @@ -2558,6 +2761,154 @@ function App() { } }; + const handleBirdImportFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + setError(''); + setBirdImportNotice(''); + setBirdImportFileName(file.name); + + try { + const { readSheet } = await import('read-excel-file/browser'); + const worksheetRows = await readSheet(file); + const [headerRow, ...dataRows] = worksheetRows; + + if (!headerRow?.length) { + throw new Error('The first worksheet does not have a header row.'); + } + + const headers = headerRow.map((header) => toImportText(header)); + const rows = dataRows + .filter((cells) => cells.some((cell) => toImportText(cell))) + .map((cells) => + Object.fromEntries(headers.map((header, columnIndex) => [header, cells[columnIndex] ?? ''])) as Record, + ); + + if (!rows.length) { + throw new Error('The first worksheet does not have any bird rows.'); + } + + setBirdImportPreview(parseBirdImportRows(rows)); + } catch (importError) { + setBirdImportPreview(null); + setError(importError instanceof Error ? importError.message : 'Unable to read the bird spreadsheet.'); + } finally { + event.currentTarget.value = ''; + } + }; + + const handleBirdImportSubmit = async () => { + if (!birdImportPreview || birdImportPreview.errors.length || importingBirds) { + return; + } + + setError(''); + setBirdImportNotice(''); + setImportingBirds(true); + + let importedBirdCount = 0; + let importedWeightCount = 0; + + try { + for (const profile of birdImportPreview.profiles) { + const birdResponse = await apiFetch('/birds', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: profile.name, + tagId: profile.tagId, + species: profile.species, + motivators: profile.motivators, + demotivators: profile.demotivators, + favoriteSnack: profile.favoriteSnack, + gender: profile.gender, + dateOfBirth: profile.dateOfBirth, + gotchaDay: profile.gotchaDay, + chartColor: profile.chartColor || '#cb3a35', + photoDataUrl: '', + notifyOnDob: false, + notifyOnGotchaDay: false, + publicProfileEnabled: false, + }), + }); + + if (!birdResponse.ok) { + throw new Error(await readErrorMessage(birdResponse, `Unable to import ${profile.name}.`)); + } + + const birdData = await readJsonSafely<{ bird?: Bird }>(birdResponse); + if (!birdData?.bird) { + throw new Error(`Unable to import ${profile.name}.`); + } + + let importedBird = birdData.bird; + const importedWeights: WeightRecord[] = []; + importedBirdCount += 1; + setBirds((current) => sortBirdsByName([...current, importedBird])); + + for (const weight of profile.weights) { + const weightResponse = await apiFetch(`/birds/${importedBird.id}/weights`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + weightGrams: weight.weightGrams, + recordedOn: weight.recordedOn, + notes: weight.notes, + }), + }); + + if (!weightResponse.ok) { + throw new Error(await readErrorMessage(weightResponse, `Unable to import a weight for ${profile.name}.`)); + } + + const weightData = await readJsonSafely<{ weight?: WeightRecord }>(weightResponse); + if (!weightData?.weight) { + throw new Error(`Unable to import a weight for ${profile.name}.`); + } + + importedWeights.push(weightData.weight); + importedWeightCount += 1; + } + + const latestWeight = importedWeights.reduce( + (latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest), + null, + ); + + if (latestWeight) { + importedBird = { + ...importedBird, + latestWeightGrams: latestWeight.weightGrams, + latestRecordedOn: latestWeight.recordedOn, + }; + } + + setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === importedBird.id ? importedBird : bird)))); + setAllBirdWeights((current) => ({ ...current, [importedBird.id]: importedWeights })); + } + + setBirdImportPreview(null); + setBirdImportFileName(''); + setBirdImportNotice( + `Imported ${importedBirdCount} bird${importedBirdCount === 1 ? '' : 's'} and ${importedWeightCount} weight entr${ + importedWeightCount === 1 ? 'y' : 'ies' + }.`, + ); + } catch (importError) { + setError( + `${importError instanceof Error ? importError.message : 'Unable to import bird spreadsheet.'} Imported ${importedBirdCount} bird${ + importedBirdCount === 1 ? '' : 's' + } before the import stopped.`, + ); + } finally { + setImportingBirds(false); + } + }; + const handleBirdSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -5986,6 +6337,105 @@ function App() { ) : null} +
+
+
+

Import

+

Bulk Import

+
+ +
+ {expandedSettingsSection === 'bird-import' ? ( + <> +

+ Import the first worksheet from an Excel file. Use one row per weight entry and repeat the Bird Name for rows that belong to the + same profile. +

+
+ Required: Bird Name, Species + + Optional: Band ID, Gender, Hatch Day, Gotcha Day, Favorite Snack, Motivators, Demotivators, Chart Color, Weight Grams, Weight + Date, Weight Notes + +
+ + {birdImportNotice ?

{birdImportNotice}

: null} + {birdImportPreview ? ( + <> +
+
+ + {birdImportPreview.profiles.length} profile{birdImportPreview.profiles.length === 1 ? '' : 's'} + + {birdImportFileName} +
+
+ {birdImportPreview.profiles.reduce((count, profile) => count + profile.weights.length, 0)} weight entries + Rows with Weight Grams and Weight Date +
+
+ {birdImportPreview.errors.length ? ( +
+ Fix the spreadsheet before importing +
+ {birdImportPreview.errors.map((importIssue) => ( + {importIssue} + ))} +
+
+ ) : ( +
+ {birdImportPreview.profiles.map((profile) => ( +
+ {profile.name} + + {profile.species} • {profile.tagId ? `Band ${profile.tagId}` : 'No Band ID'} • {profile.weights.length} weight entr + {profile.weights.length === 1 ? 'y' : 'ies'} + +
+ ))} +
+ )} +
+ + +
+ + ) : null} + + ) : null} +
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index f44e406..e2e9158 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -622,10 +622,14 @@ textarea { order: 4; } -.settings-card-transfer { +.settings-card-bird-import { order: 5; } +.settings-card-transfer { + order: 6; +} + .settings-card-flock-profile { order: 1; } @@ -668,6 +672,21 @@ textarea { gap: 1rem; } +.import-column-guide { + display: grid; + gap: 0.35rem; + padding: 0.8rem 0.9rem; + border-radius: 16px; + background: rgba(255, 254, 250, 0.72); + color: var(--muted); + font-size: 0.92rem; +} + +.import-preview-list { + max-height: 320px; + overflow: auto; +} + .settings-danger-card { border-color: rgba(203, 58, 53, 0.22); background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));