Added excel import

This commit is contained in:
blaisadmin
2026-05-21 00:04:05 -04:00
parent 38dcb7f49b
commit cf3cd96384
4 changed files with 639 additions and 4 deletions
+451 -1
View File
@@ -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 }) => {
</svg>
);
};
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<string, unknown>, 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<string, unknown>[]): BirdImportPreview => {
const errors: string[] = [];
const profiles = new Map<string, BirdImportProfile>();
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<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
const [birdImportFileName, setBirdImportFileName] = useState('');
const [birdImportNotice, setBirdImportNotice] = useState('');
const [importingBirds, setImportingBirds] = useState(false);
const [memorializeBirdForm, setMemorializeBirdForm] = useState<MemorializeBirdFormState>(emptyMemorializeBirdForm);
const [birdPhotoName, setBirdPhotoName] = useState('');
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
@@ -2558,6 +2761,154 @@ function App() {
}
};
const handleBirdImportFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<string, unknown>,
);
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<WeightRecord | null>(
(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<HTMLFormElement>) => {
event.preventDefault();
setError('');
@@ -5986,6 +6337,105 @@ function App() {
) : null}
</article>
<article className="panel form-panel settings-card-bird-import">
<div className="panel-header">
<div>
<p className="eyebrow">Import</p>
<h2>Bulk Import</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'bird-import' ? null : 'bird-import'))
}
type="button"
aria-expanded={expandedSettingsSection === 'bird-import'}
>
{expandedSettingsSection === 'bird-import' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'bird-import' ? (
<>
<p className="muted">
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.
</p>
<div className="import-column-guide">
<span>Required: Bird Name, Species</span>
<span>
Optional: Band ID, Gender, Hatch Day, Gotcha Day, Favorite Snack, Motivators, Demotivators, Chart Color, Weight Grams, Weight
Date, Weight Notes
</span>
</div>
<label className="file-picker">
Excel file
<input type="file" accept=".xlsx" onChange={handleBirdImportFileChange} disabled={importingBirds} />
</label>
{birdImportNotice ? <p className="success-banner">{birdImportNotice}</p> : null}
{birdImportPreview ? (
<>
<div className="summary-grid">
<article className="summary-card">
<strong>
{birdImportPreview.profiles.length} profile{birdImportPreview.profiles.length === 1 ? '' : 's'}
</strong>
<span>{birdImportFileName}</span>
</article>
<article className="summary-card">
<strong>{birdImportPreview.profiles.reduce((count, profile) => count + profile.weights.length, 0)} weight entries</strong>
<span>Rows with Weight Grams and Weight Date</span>
</article>
</div>
{birdImportPreview.errors.length ? (
<article className="summary-card summary-alert-card" role="alert">
<strong>Fix the spreadsheet before importing</strong>
<div className="summary-list">
{birdImportPreview.errors.map((importIssue) => (
<span key={importIssue}>{importIssue}</span>
))}
</div>
</article>
) : (
<div className="recent-list import-preview-list">
{birdImportPreview.profiles.map((profile) => (
<article key={profile.key} className="vet-visit-card">
<strong>{profile.name}</strong>
<span>
{profile.species} {profile.tagId ? `Band ${profile.tagId}` : 'No Band ID'} {profile.weights.length} weight entr
{profile.weights.length === 1 ? 'y' : 'ies'}
</span>
</article>
))}
</div>
)}
<div className="button-row">
<button
className="primary-button"
onClick={handleBirdImportSubmit}
type="button"
disabled={importingBirds || birdImportPreview.errors.length > 0 || birdImportPreview.profiles.length === 0}
>
{importingBirds ? 'Importing...' : 'Import spreadsheet'}
</button>
<button
className="secondary-button"
onClick={() => {
setBirdImportPreview(null);
setBirdImportFileName('');
setBirdImportNotice('');
}}
type="button"
disabled={importingBirds}
>
Clear
</button>
</div>
</>
) : null}
</>
) : null}
</article>
<article className="panel form-panel settings-card-transfer">
<div className="panel-header">
<div>