Added excel import
This commit is contained in:
+451
-1
@@ -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>
|
||||
|
||||
+20
-1
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user