add spreadsheet import/export
This commit is contained in:
@@ -4,6 +4,7 @@ import { ActivitiesView } from './components/ActivitiesView';
|
|||||||
import { FlagsView } from './components/FlagsView';
|
import { FlagsView } from './components/FlagsView';
|
||||||
import { GroupsView } from './components/GroupsView';
|
import { GroupsView } from './components/GroupsView';
|
||||||
import { HomeView } from './components/HomeView';
|
import { HomeView } from './components/HomeView';
|
||||||
|
import { ImportExportView } from './components/ImportExportView';
|
||||||
import { LocationsView } from './components/LocationsView';
|
import { LocationsView } from './components/LocationsView';
|
||||||
import { PlantManagementView } from './components/PlantManagementView';
|
import { PlantManagementView } from './components/PlantManagementView';
|
||||||
import { PlantsView } from './components/PlantsView';
|
import { PlantsView } from './components/PlantsView';
|
||||||
@@ -26,6 +27,7 @@ export function App() {
|
|||||||
dueCount,
|
dueCount,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
loadDashboard,
|
||||||
loadCareModel,
|
loadCareModel,
|
||||||
loadFlagsAndPlants,
|
loadFlagsAndPlants,
|
||||||
loadGroupsAndPlants,
|
loadGroupsAndPlants,
|
||||||
@@ -145,10 +147,13 @@ export function App() {
|
|||||||
} = editors;
|
} = editors;
|
||||||
const {
|
const {
|
||||||
assignFlagToSelectedPlant,
|
assignFlagToSelectedPlant,
|
||||||
|
applyCatalogImportFile,
|
||||||
|
catalogImportResult,
|
||||||
completeBulkTasks,
|
completeBulkTasks,
|
||||||
completeTask,
|
completeTask,
|
||||||
exportSpreadsheet,
|
exportSpreadsheet,
|
||||||
isExporting,
|
isExporting,
|
||||||
|
isImportingCatalog,
|
||||||
isSearchingPlantInfo,
|
isSearchingPlantInfo,
|
||||||
isSaving,
|
isSaving,
|
||||||
importTaxonFromPlantInfo,
|
importTaxonFromPlantInfo,
|
||||||
@@ -178,11 +183,13 @@ export function App() {
|
|||||||
saveResource,
|
saveResource,
|
||||||
saveTaxon,
|
saveTaxon,
|
||||||
searchTaxonInfo,
|
searchTaxonInfo,
|
||||||
|
previewCatalogImportFile,
|
||||||
setManagedPlantLocation,
|
setManagedPlantLocation,
|
||||||
setManagedPlantTaxon,
|
setManagedPlantTaxon,
|
||||||
setPlantInfoQuery,
|
setPlantInfoQuery,
|
||||||
} = useAppActions({
|
} = useAppActions({
|
||||||
editors,
|
editors,
|
||||||
|
loadDashboard,
|
||||||
loadCareModel,
|
loadCareModel,
|
||||||
loadFlagsAndPlants,
|
loadFlagsAndPlants,
|
||||||
loadGroupsAndPlants,
|
loadGroupsAndPlants,
|
||||||
@@ -234,6 +241,13 @@ export function App() {
|
|||||||
>
|
>
|
||||||
Plant Management
|
Plant Management
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={view === 'import-export' ? 'page' : undefined}
|
||||||
|
onClick={() => setView('import-export')}
|
||||||
|
>
|
||||||
|
Import / Export
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nav-group">
|
<div className="nav-group">
|
||||||
@@ -334,7 +348,6 @@ export function App() {
|
|||||||
error={error}
|
error={error}
|
||||||
form={form}
|
form={form}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isExporting={isExporting}
|
|
||||||
isPlantEditorOpen={isPlantEditorOpen}
|
isPlantEditorOpen={isPlantEditorOpen}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
plants={plants}
|
plants={plants}
|
||||||
@@ -343,7 +356,6 @@ export function App() {
|
|||||||
onCloseDetail={() => setSelectedPlantId(null)}
|
onCloseDetail={() => setSelectedPlantId(null)}
|
||||||
onDelete={(plant) => void removePlant(plant)}
|
onDelete={(plant) => void removePlant(plant)}
|
||||||
onEdit={startEditingPlant}
|
onEdit={startEditingPlant}
|
||||||
onExport={() => void exportSpreadsheet()}
|
|
||||||
onFieldChange={updateForm}
|
onFieldChange={updateForm}
|
||||||
onNew={startAddingPlant}
|
onNew={startAddingPlant}
|
||||||
onOpenDetail={openPlantReadOnlyDetail}
|
onOpenDetail={openPlantReadOnlyDetail}
|
||||||
@@ -369,6 +381,16 @@ export function App() {
|
|||||||
onSetLocation={setManagedPlantLocation}
|
onSetLocation={setManagedPlantLocation}
|
||||||
onSetTaxon={setManagedPlantTaxon}
|
onSetTaxon={setManagedPlantTaxon}
|
||||||
/>
|
/>
|
||||||
|
) : view === 'import-export' ? (
|
||||||
|
<ImportExportView
|
||||||
|
catalogImportResult={catalogImportResult}
|
||||||
|
error={error}
|
||||||
|
isExporting={isExporting}
|
||||||
|
isImportingCatalog={isImportingCatalog}
|
||||||
|
onApplyCatalogImport={(file) => void applyCatalogImportFile(file)}
|
||||||
|
onExport={() => void exportSpreadsheet()}
|
||||||
|
onPreviewCatalogImport={(file) => void previewCatalogImportFile(file)}
|
||||||
|
/>
|
||||||
) : view === 'schedules' ? (
|
) : view === 'schedules' ? (
|
||||||
<SchedulesView
|
<SchedulesView
|
||||||
activities={careActivities}
|
activities={careActivities}
|
||||||
@@ -569,6 +591,7 @@ function getViewEyebrow(view: View) {
|
|||||||
return 'Operations';
|
return 'Operations';
|
||||||
case 'plants':
|
case 'plants':
|
||||||
case 'plant-management':
|
case 'plant-management':
|
||||||
|
case 'import-export':
|
||||||
return 'Operations';
|
return 'Operations';
|
||||||
case 'schedules':
|
case 'schedules':
|
||||||
return 'Care';
|
return 'Care';
|
||||||
@@ -593,6 +616,8 @@ function getViewTitle(view: View) {
|
|||||||
return 'Plants';
|
return 'Plants';
|
||||||
case 'plant-management':
|
case 'plant-management':
|
||||||
return 'Plant Management';
|
return 'Plant Management';
|
||||||
|
case 'import-export':
|
||||||
|
return 'Import / Export';
|
||||||
case 'schedules':
|
case 'schedules':
|
||||||
return 'Scheduler';
|
return 'Scheduler';
|
||||||
case 'taxa':
|
case 'taxa':
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CareActivityPayload,
|
CareActivityPayload,
|
||||||
CareAction,
|
CareAction,
|
||||||
CareActionPayload,
|
CareActionPayload,
|
||||||
|
CatalogImportResult,
|
||||||
BulkCompleteCareTasksPayload,
|
BulkCompleteCareTasksPayload,
|
||||||
CareTask,
|
CareTask,
|
||||||
BulkPlantCareSchedulePayload,
|
BulkPlantCareSchedulePayload,
|
||||||
@@ -72,6 +73,30 @@ export async function downloadSpreadsheetExport() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadCatalogImport(path: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<CatalogImportResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewCatalogImport(file: File) {
|
||||||
|
return uploadCatalogImport('/api/import/catalog/preview', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCatalogImport(file: File) {
|
||||||
|
return uploadCatalogImport('/api/import/catalog/apply', file);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPlants() {
|
export async function getPlants() {
|
||||||
return request<Plant[]>('/api/plants');
|
return request<Plant[]>('/api/plants');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Download, FileSearch, Upload } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { CatalogImportResult } from '../domain';
|
||||||
|
|
||||||
|
type ImportExportViewProps = {
|
||||||
|
catalogImportResult: CatalogImportResult | null;
|
||||||
|
error: string | null;
|
||||||
|
isExporting: boolean;
|
||||||
|
isImportingCatalog: boolean;
|
||||||
|
onApplyCatalogImport: (file: File) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
onPreviewCatalogImport: (file: File) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImportExportView({
|
||||||
|
catalogImportResult,
|
||||||
|
error,
|
||||||
|
isExporting,
|
||||||
|
isImportingCatalog,
|
||||||
|
onApplyCatalogImport,
|
||||||
|
onExport,
|
||||||
|
onPreviewCatalogImport,
|
||||||
|
}: ImportExportViewProps) {
|
||||||
|
const [catalogImportFile, setCatalogImportFile] = useState<File | null>(null);
|
||||||
|
const [previewedCatalogImportFile, setPreviewedCatalogImportFile] = useState<File | null>(null);
|
||||||
|
const canApplyPreviewedFile = catalogImportFile !== null
|
||||||
|
&& previewedCatalogImportFile === catalogImportFile
|
||||||
|
&& catalogImportResult?.canApply === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="summary-panel" aria-labelledby="import-export-summary-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Data movement</p>
|
||||||
|
<h2 id="import-export-summary-heading">Import / Export</h2>
|
||||||
|
<p>{error ?? 'Move catalog data in and out of Plant-Man spreadsheets.'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="summary-actions">
|
||||||
|
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
|
||||||
|
<Download size={18} />
|
||||||
|
{isExporting ? 'Exporting' : 'Export spreadsheet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="editor-panel catalog-import-panel" aria-labelledby="catalog-import-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Catalogs</p>
|
||||||
|
<h2 id="catalog-import-heading">Catalog Import</h2>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className="small-action"
|
||||||
|
type="button"
|
||||||
|
disabled={isImportingCatalog || catalogImportFile === null}
|
||||||
|
onClick={() => {
|
||||||
|
if (catalogImportFile) {
|
||||||
|
setPreviewedCatalogImportFile(catalogImportFile);
|
||||||
|
onPreviewCatalogImport(catalogImportFile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileSearch size={16} />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="small-action"
|
||||||
|
type="button"
|
||||||
|
disabled={isImportingCatalog || !canApplyPreviewedFile}
|
||||||
|
onClick={() => catalogImportFile ? onApplyCatalogImport(catalogImportFile) : undefined}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plant-form import-file-row">
|
||||||
|
<label className="form-wide">
|
||||||
|
Workbook
|
||||||
|
<input
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => {
|
||||||
|
setCatalogImportFile(event.target.files?.[0] ?? null);
|
||||||
|
setPreviewedCatalogImportFile(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catalogImportResult ? (
|
||||||
|
<div className="import-preview">
|
||||||
|
<div className="plant-detail-meta compact-meta">
|
||||||
|
<div>
|
||||||
|
<span>Create</span>
|
||||||
|
<strong>{catalogImportResult.created}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Update</span>
|
||||||
|
<strong>{catalogImportResult.updated}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Skipped</span>
|
||||||
|
<strong>{catalogImportResult.skipped}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Status</span>
|
||||||
|
<strong>{catalogImportResult.applied ? 'Applied' : catalogImportResult.canApply ? 'Ready' : 'Blocked'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catalogImportResult.sheets.length > 0 ? (
|
||||||
|
<div className="detail-list import-sheet-list">
|
||||||
|
{catalogImportResult.sheets.map((sheet) => (
|
||||||
|
<div className="detail-row import-row" key={sheet.sheet}>
|
||||||
|
<div>
|
||||||
|
<h4>{sheet.sheet}</h4>
|
||||||
|
<p>
|
||||||
|
{sheet.rows} rows - {sheet.creates} create - {sheet.updates} update - {sheet.skips} skipped
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogImportResult.issues.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="list-label">Issues</p>
|
||||||
|
<div className="detail-list import-issue-list">
|
||||||
|
{catalogImportResult.issues.slice(0, 12).map((issue, index) => (
|
||||||
|
<div className="detail-row import-row" key={`${issue.sheet}-${issue.row}-${issue.field}-${index}`}>
|
||||||
|
<div>
|
||||||
|
<h4>{issue.sheet}{issue.row > 0 ? ` row ${issue.row}` : ''}</h4>
|
||||||
|
<p>{issue.field}: {issue.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||||
import type { Plant } from '../domain';
|
import type { Plant } from '../domain';
|
||||||
import type { PlantFormState } from '../form-state';
|
import type { PlantFormState } from '../form-state';
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ type PlantsViewProps = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
form: PlantFormState;
|
form: PlantFormState;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isExporting: boolean;
|
|
||||||
isPlantEditorOpen: boolean;
|
isPlantEditorOpen: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
plants: Plant[];
|
plants: Plant[];
|
||||||
@@ -16,7 +15,6 @@ type PlantsViewProps = {
|
|||||||
onCloseDetail: () => void;
|
onCloseDetail: () => void;
|
||||||
onDelete: (plant: Plant) => void;
|
onDelete: (plant: Plant) => void;
|
||||||
onEdit: (plant: Plant) => void;
|
onEdit: (plant: Plant) => void;
|
||||||
onExport: () => void;
|
|
||||||
onFieldChange: (field: keyof PlantFormState, value: string) => void;
|
onFieldChange: (field: keyof PlantFormState, value: string) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onOpenDetail: (plant: Plant) => void;
|
onOpenDetail: (plant: Plant) => void;
|
||||||
@@ -28,7 +26,6 @@ export function PlantsView({
|
|||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
isLoading,
|
isLoading,
|
||||||
isExporting,
|
|
||||||
isPlantEditorOpen,
|
isPlantEditorOpen,
|
||||||
isSaving,
|
isSaving,
|
||||||
plants,
|
plants,
|
||||||
@@ -37,7 +34,6 @@ export function PlantsView({
|
|||||||
onCloseDetail,
|
onCloseDetail,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onExport,
|
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onNew,
|
onNew,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
@@ -54,10 +50,6 @@ export function PlantsView({
|
|||||||
<p>{error ?? 'Create and maintain plant objects.'}</p>
|
<p>{error ?? 'Create and maintain plant objects.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-actions">
|
<div className="summary-actions">
|
||||||
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
|
|
||||||
<Download size={18} />
|
|
||||||
{isExporting ? 'Exporting' : 'Export'}
|
|
||||||
</button>
|
|
||||||
<button className="primary-action" type="button" onClick={onNew}>
|
<button className="primary-action" type="button" onClick={onNew}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
New plant
|
New plant
|
||||||
|
|||||||
@@ -69,6 +69,32 @@ export type PlantInfoSearchResult = {
|
|||||||
commonNames: string[];
|
commonNames: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CatalogImportIssue = {
|
||||||
|
sheet: string;
|
||||||
|
row: number;
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
severity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CatalogImportSheetSummary = {
|
||||||
|
sheet: string;
|
||||||
|
rows: number;
|
||||||
|
creates: number;
|
||||||
|
updates: number;
|
||||||
|
skips: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CatalogImportResult = {
|
||||||
|
applied: boolean;
|
||||||
|
canApply: boolean;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
sheets: CatalogImportSheetSummary[];
|
||||||
|
issues: CatalogImportIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PlantLocation = {
|
export type PlantLocation = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
|
|||||||
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
||||||
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
||||||
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
||||||
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags';
|
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||||
|
|
||||||
export function toPlantForm(plant: Plant): PlantFormState {
|
export function toPlantForm(plant: Plant): PlantFormState {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -833,6 +833,28 @@ dd {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-import-panel {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-sheet-list,
|
||||||
|
.import-issue-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.preserve-lines {
|
.preserve-lines {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
applyCatalogImport,
|
||||||
assignPlantFlag,
|
assignPlantFlag,
|
||||||
completeCareTasksBulk,
|
completeCareTasksBulk,
|
||||||
createActionResource,
|
createActionResource,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
downloadSpreadsheetExport,
|
downloadSpreadsheetExport,
|
||||||
importPlantTaxon,
|
importPlantTaxon,
|
||||||
|
previewCatalogImport,
|
||||||
removePlantCareSchedulesBulk,
|
removePlantCareSchedulesBulk,
|
||||||
removePlantFlagAssignment,
|
removePlantFlagAssignment,
|
||||||
resolvePlantFlag,
|
resolvePlantFlag,
|
||||||
@@ -37,7 +39,7 @@ import {
|
|||||||
updatePlantTaxon,
|
updatePlantTaxon,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { CareTask, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
|
import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
|
||||||
import {
|
import {
|
||||||
emptyBulkScheduleForm,
|
emptyBulkScheduleForm,
|
||||||
emptyPlantFlagForm,
|
emptyPlantFlagForm,
|
||||||
@@ -64,6 +66,7 @@ type DashboardData = ReturnType<typeof useDashboardData>;
|
|||||||
|
|
||||||
type AppActionOptions = {
|
type AppActionOptions = {
|
||||||
editors: Editors;
|
editors: Editors;
|
||||||
|
loadDashboard: DashboardData['loadDashboard'];
|
||||||
loadCareModel: DashboardData['loadCareModel'];
|
loadCareModel: DashboardData['loadCareModel'];
|
||||||
loadFlagsAndPlants: DashboardData['loadFlagsAndPlants'];
|
loadFlagsAndPlants: DashboardData['loadFlagsAndPlants'];
|
||||||
loadGroupsAndPlants: DashboardData['loadGroupsAndPlants'];
|
loadGroupsAndPlants: DashboardData['loadGroupsAndPlants'];
|
||||||
@@ -77,6 +80,7 @@ type AppActionOptions = {
|
|||||||
|
|
||||||
export function useAppActions({
|
export function useAppActions({
|
||||||
editors,
|
editors,
|
||||||
|
loadDashboard,
|
||||||
loadCareModel,
|
loadCareModel,
|
||||||
loadFlagsAndPlants,
|
loadFlagsAndPlants,
|
||||||
loadGroupsAndPlants,
|
loadGroupsAndPlants,
|
||||||
@@ -89,6 +93,8 @@ export function useAppActions({
|
|||||||
}: AppActionOptions) {
|
}: AppActionOptions) {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isImportingCatalog, setIsImportingCatalog] = useState(false);
|
||||||
|
const [catalogImportResult, setCatalogImportResult] = useState<CatalogImportResult | null>(null);
|
||||||
const [isSearchingPlantInfo, setIsSearchingPlantInfo] = useState(false);
|
const [isSearchingPlantInfo, setIsSearchingPlantInfo] = useState(false);
|
||||||
const [plantInfoQuery, setPlantInfoQuery] = useState('');
|
const [plantInfoQuery, setPlantInfoQuery] = useState('');
|
||||||
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]);
|
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]);
|
||||||
@@ -224,6 +230,37 @@ export function useAppActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function previewCatalogImportFile(file: File) {
|
||||||
|
setIsImportingCatalog(true);
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const result = await previewCatalogImport(file);
|
||||||
|
setCatalogImportResult(result);
|
||||||
|
} catch {
|
||||||
|
setError('Could not preview the catalog import.');
|
||||||
|
} finally {
|
||||||
|
setIsImportingCatalog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCatalogImportFile(file: File) {
|
||||||
|
setIsImportingCatalog(true);
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const result = await applyCatalogImport(file);
|
||||||
|
setCatalogImportResult(result);
|
||||||
|
if (result.applied) {
|
||||||
|
await loadDashboard();
|
||||||
|
} else {
|
||||||
|
setError('Fix the spreadsheet issues before applying the import.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not apply the catalog import.');
|
||||||
|
} finally {
|
||||||
|
setIsImportingCatalog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTaxon() {
|
async function saveTaxon() {
|
||||||
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
|
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
|
||||||
setError('Name, genus, and species are required.');
|
setError('Name, genus, and species are required.');
|
||||||
@@ -773,9 +810,12 @@ export function useAppActions({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
assignFlagToSelectedPlant,
|
assignFlagToSelectedPlant,
|
||||||
|
applyCatalogImportFile,
|
||||||
|
catalogImportResult,
|
||||||
completeBulkTasks,
|
completeBulkTasks,
|
||||||
completeTask,
|
completeTask,
|
||||||
exportSpreadsheet,
|
exportSpreadsheet,
|
||||||
|
isImportingCatalog,
|
||||||
hasSearchedPlantInfo,
|
hasSearchedPlantInfo,
|
||||||
isExporting,
|
isExporting,
|
||||||
isSearchingPlantInfo,
|
isSearchingPlantInfo,
|
||||||
@@ -806,6 +846,7 @@ export function useAppActions({
|
|||||||
saveResource,
|
saveResource,
|
||||||
saveTaxon,
|
saveTaxon,
|
||||||
searchTaxonInfo,
|
searchTaxonInfo,
|
||||||
|
previewCatalogImportFile,
|
||||||
setManagedPlantLocation,
|
setManagedPlantLocation,
|
||||||
setManagedPlantTaxon,
|
setManagedPlantTaxon,
|
||||||
setPlantInfoQuery,
|
setPlantInfoQuery,
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export function useDashboardData() {
|
|||||||
dueCount,
|
dueCount,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
loadDashboard,
|
||||||
loadCareModel,
|
loadCareModel,
|
||||||
loadFlagsAndPlants,
|
loadFlagsAndPlants,
|
||||||
loadGroupsAndPlants,
|
loadGroupsAndPlants,
|
||||||
|
|||||||
@@ -187,6 +187,29 @@ namespace plant_manager
|
|||||||
string? Notes,
|
string? Notes,
|
||||||
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
||||||
|
|
||||||
|
public record CatalogImportIssue(
|
||||||
|
string Sheet,
|
||||||
|
int Row,
|
||||||
|
string Field,
|
||||||
|
string Message,
|
||||||
|
string Severity);
|
||||||
|
|
||||||
|
public record CatalogImportSheetSummary(
|
||||||
|
string Sheet,
|
||||||
|
int Rows,
|
||||||
|
int Creates,
|
||||||
|
int Updates,
|
||||||
|
int Skips);
|
||||||
|
|
||||||
|
public record CatalogImportResult(
|
||||||
|
bool Applied,
|
||||||
|
bool CanApply,
|
||||||
|
int Created,
|
||||||
|
int Updated,
|
||||||
|
int Skipped,
|
||||||
|
IReadOnlyList<CatalogImportSheetSummary> Sheets,
|
||||||
|
IReadOnlyList<CatalogImportIssue> Issues);
|
||||||
|
|
||||||
public record PlantTaxonDto(
|
public record PlantTaxonDto(
|
||||||
int Id,
|
int Id,
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace plant_manager.Endpoints
|
|||||||
await AddCareSchedulesSheet(workbook, db);
|
await AddCareSchedulesSheet(workbook, db);
|
||||||
await AddActionLogsSheet(workbook, db);
|
await AddActionLogsSheet(workbook, db);
|
||||||
await AddFlagsSheet(workbook, db);
|
await AddFlagsSheet(workbook, db);
|
||||||
|
await AddFlagDefinitionsSheet(workbook, db);
|
||||||
await AddGroupsSheet(workbook, db);
|
await AddGroupsSheet(workbook, db);
|
||||||
await AddTaxaSheet(workbook, db);
|
await AddTaxaSheet(workbook, db);
|
||||||
await AddLocationsSheet(workbook, db);
|
await AddLocationsSheet(workbook, db);
|
||||||
@@ -206,6 +207,25 @@ namespace plant_manager.Endpoints
|
|||||||
], rows);
|
], rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task AddFlagDefinitionsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var rows = await db.PlantFlagDefinitions
|
||||||
|
.OrderBy(definition => definition.Name)
|
||||||
|
.Select(definition => new object?[]
|
||||||
|
{
|
||||||
|
definition.Id,
|
||||||
|
definition.Name,
|
||||||
|
definition.Color
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Flag Definitions", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Color"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task AddGroupsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
private static async Task AddGroupsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
{
|
{
|
||||||
var groups = await db.PlantGroups
|
var groups = await db.PlantGroups
|
||||||
@@ -247,7 +267,11 @@ namespace plant_manager.Endpoints
|
|||||||
taxon.Species,
|
taxon.Species,
|
||||||
taxon.Cultivar ?? "",
|
taxon.Cultivar ?? "",
|
||||||
taxon.Variety ?? "",
|
taxon.Variety ?? "",
|
||||||
taxon.Authority ?? ""
|
taxon.Authority ?? "",
|
||||||
|
taxon.Family ?? "",
|
||||||
|
taxon.CommonName ?? "",
|
||||||
|
taxon.ExternalSource ?? "",
|
||||||
|
taxon.ExternalId ?? ""
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -258,7 +282,11 @@ namespace plant_manager.Endpoints
|
|||||||
"Species",
|
"Species",
|
||||||
"Cultivar",
|
"Cultivar",
|
||||||
"Variety",
|
"Variety",
|
||||||
"Authority"
|
"Authority",
|
||||||
|
"Family",
|
||||||
|
"Common Name",
|
||||||
|
"External Source",
|
||||||
|
"External ID"
|
||||||
], rows);
|
], rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using plant_manager.Data;
|
||||||
|
using plant_manager.Data.Models;
|
||||||
|
|
||||||
|
namespace plant_manager.Endpoints
|
||||||
|
{
|
||||||
|
public static class ImportEndpoints
|
||||||
|
{
|
||||||
|
private const string CareActionsSheet = "Actions";
|
||||||
|
private const string ResourcesSheet = "Resources";
|
||||||
|
private const string LocationsSheet = "Locations";
|
||||||
|
private const string FlagsSheet = "Flags";
|
||||||
|
private const string FlagDefinitionsSheet = "Flag Definitions";
|
||||||
|
private const string TaxaSheet = "Taxa";
|
||||||
|
|
||||||
|
public static void MapImportEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/import/catalog/preview", async (IFormFile file, ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
if (file.Length == 0)
|
||||||
|
{
|
||||||
|
return EndpointHelpers.BadRequest("Choose a spreadsheet to import.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await ImportCatalog(file, db, apply: false);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return EndpointHelpers.BadRequest("Could not read the spreadsheet.");
|
||||||
|
}
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/api/import/catalog/apply", async (IFormFile file, ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
if (file.Length == 0)
|
||||||
|
{
|
||||||
|
return EndpointHelpers.BadRequest("Choose a spreadsheet to import.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await ImportCatalog(file, db, apply: true);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return EndpointHelpers.BadRequest("Could not read the spreadsheet.");
|
||||||
|
}
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<CatalogImportResult> ImportCatalog(IFormFile file, ApplicationDbContext db, bool apply)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook(file.OpenReadStream());
|
||||||
|
var context = new ImportContext();
|
||||||
|
|
||||||
|
await ImportCareActions(workbook, db, context, apply);
|
||||||
|
await ImportResources(workbook, db, context, apply);
|
||||||
|
await ImportLocations(workbook, db, context, apply);
|
||||||
|
if (TryGetWorksheet(workbook, FlagDefinitionsSheet, out _))
|
||||||
|
{
|
||||||
|
await ImportFlags(workbook, db, context, apply, FlagDefinitionsSheet, "Name");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
|
||||||
|
}
|
||||||
|
await ImportTaxa(workbook, db, context, apply);
|
||||||
|
|
||||||
|
if (context.Sheets.Count == 0)
|
||||||
|
{
|
||||||
|
context.Issues.Add(new CatalogImportIssue(
|
||||||
|
"Workbook",
|
||||||
|
0,
|
||||||
|
"Sheets",
|
||||||
|
"No supported catalog sheets were found.",
|
||||||
|
"error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var canApply = context.Issues.All(issue => issue.Severity != "error");
|
||||||
|
if (apply && canApply)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CatalogImportResult(
|
||||||
|
Applied: apply && canApply,
|
||||||
|
CanApply: canApply,
|
||||||
|
Created: context.Sheets.Sum(sheet => sheet.Creates),
|
||||||
|
Updated: context.Sheets.Sum(sheet => sheet.Updates),
|
||||||
|
Skipped: context.Sheets.Sum(sheet => sheet.Skips),
|
||||||
|
Sheets: context.Sheets,
|
||||||
|
Issues: context.Issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportCareActions(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ImportContext context,
|
||||||
|
bool apply)
|
||||||
|
{
|
||||||
|
if (!TryGetWorksheet(workbook, CareActionsSheet, out var worksheet))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = ReadRows(worksheet, CareActionsSheet, context, requiredHeaders: ["Name"]);
|
||||||
|
var existing = await db.CareActions.ToListAsync();
|
||||||
|
var byName = existing.ToDictionary(action => Key(action.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var summary = new MutableSummary(CareActionsSheet);
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var name = Required(row, "Name", context);
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Key(name);
|
||||||
|
if (!seen.Add(key))
|
||||||
|
{
|
||||||
|
Duplicate(row, "Name", context);
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var description = Optional(row, "Description");
|
||||||
|
if (byName.TryGetValue(key, out var action))
|
||||||
|
{
|
||||||
|
summary.Updates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
action.Name = name;
|
||||||
|
action.Description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.Creates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
db.CareActions.Add(new CareAction
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSummary(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportResources(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ImportContext context,
|
||||||
|
bool apply)
|
||||||
|
{
|
||||||
|
if (!TryGetWorksheet(workbook, ResourcesSheet, out var worksheet))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = ReadRows(worksheet, ResourcesSheet, context, requiredHeaders: ["Name"]);
|
||||||
|
var existing = await db.ActionResources.ToListAsync();
|
||||||
|
var byName = existing.ToDictionary(resource => Key(resource.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var summary = new MutableSummary(ResourcesSheet);
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var name = Required(row, "Name", context);
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Key(name);
|
||||||
|
if (!seen.Add(key))
|
||||||
|
{
|
||||||
|
Duplicate(row, "Name", context);
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notes = Optional(row, "Notes");
|
||||||
|
if (byName.TryGetValue(key, out var resource))
|
||||||
|
{
|
||||||
|
summary.Updates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
resource.Name = name;
|
||||||
|
resource.Notes = notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.Creates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
db.ActionResources.Add(new ActionResource
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Notes = notes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSummary(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportLocations(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ImportContext context,
|
||||||
|
bool apply)
|
||||||
|
{
|
||||||
|
if (!TryGetWorksheet(workbook, LocationsSheet, out var worksheet))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = ReadRows(worksheet, LocationsSheet, context, requiredHeaders: ["Name"]);
|
||||||
|
var existing = await db.PlantLocations.ToListAsync();
|
||||||
|
var byName = existing.ToDictionary(location => Key(location.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var summary = new MutableSummary(LocationsSheet);
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var name = Required(row, "Name", context);
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Key(name);
|
||||||
|
if (!seen.Add(key))
|
||||||
|
{
|
||||||
|
Duplicate(row, "Name", context);
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notes = Optional(row, "Notes");
|
||||||
|
if (byName.TryGetValue(key, out var location))
|
||||||
|
{
|
||||||
|
summary.Updates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
location.Name = name;
|
||||||
|
location.Notes = notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.Creates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
db.PlantLocations.Add(new PlantLocation
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Notes = notes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSummary(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportFlags(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ImportContext context,
|
||||||
|
bool apply,
|
||||||
|
string sheetName,
|
||||||
|
string nameHeader)
|
||||||
|
{
|
||||||
|
if (!TryGetWorksheet(workbook, sheetName, out var worksheet))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = ReadRows(worksheet, sheetName, context, requiredHeaders: [nameHeader]);
|
||||||
|
var existing = await db.PlantFlagDefinitions.ToListAsync();
|
||||||
|
var byName = existing.ToDictionary(definition => Key(definition.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var summary = new MutableSummary(sheetName);
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var name = Required(row, nameHeader, context);
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Key(name);
|
||||||
|
if (!seen.Add(key))
|
||||||
|
{
|
||||||
|
Duplicate(row, nameHeader, context);
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = Optional(row, "Color") ?? "#f2f2f2";
|
||||||
|
if (byName.TryGetValue(key, out var definition))
|
||||||
|
{
|
||||||
|
summary.Updates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
definition.Name = name;
|
||||||
|
definition.Color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.Creates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
db.PlantFlagDefinitions.Add(new PlantFlagDefinition
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Color = color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSummary(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportTaxa(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ImportContext context,
|
||||||
|
bool apply)
|
||||||
|
{
|
||||||
|
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]);
|
||||||
|
var existing = await db.PlantTaxa.ToListAsync();
|
||||||
|
var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var summary = new MutableSummary(TaxaSheet);
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var name = Required(row, "Name", context);
|
||||||
|
var genus = Required(row, "Genus", context);
|
||||||
|
var species = Required(row, "Species", context);
|
||||||
|
if (name is null || genus is null || species is null)
|
||||||
|
{
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Key(name);
|
||||||
|
if (!seen.Add(key))
|
||||||
|
{
|
||||||
|
Duplicate(row, "Name", context);
|
||||||
|
summary.Skips++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cultivar = Optional(row, "Cultivar");
|
||||||
|
var variety = Optional(row, "Variety");
|
||||||
|
var authority = Optional(row, "Authority");
|
||||||
|
var family = Optional(row, "Family");
|
||||||
|
var commonName = Optional(row, "Common Name", "CommonName");
|
||||||
|
var externalSource = Optional(row, "External Source", "Source");
|
||||||
|
var externalId = Optional(row, "External ID", "External Id", "GBIF ID");
|
||||||
|
|
||||||
|
if (byName.TryGetValue(key, out var taxon))
|
||||||
|
{
|
||||||
|
summary.Updates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
taxon.Name = name;
|
||||||
|
taxon.Genus = genus;
|
||||||
|
taxon.Species = species;
|
||||||
|
taxon.Cultivar = cultivar;
|
||||||
|
taxon.Variety = variety;
|
||||||
|
taxon.Authority = authority;
|
||||||
|
taxon.Family = family;
|
||||||
|
taxon.CommonName = commonName;
|
||||||
|
taxon.ExternalSource = externalSource;
|
||||||
|
taxon.ExternalId = externalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.Creates++;
|
||||||
|
if (apply)
|
||||||
|
{
|
||||||
|
db.PlantTaxa.Add(new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Genus = genus,
|
||||||
|
Species = species,
|
||||||
|
Cultivar = cultivar,
|
||||||
|
Variety = variety,
|
||||||
|
Authority = authority,
|
||||||
|
Family = family,
|
||||||
|
CommonName = commonName,
|
||||||
|
ExternalSource = externalSource,
|
||||||
|
ExternalId = externalId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSummary(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ImportRow> ReadRows(
|
||||||
|
IXLWorksheet worksheet,
|
||||||
|
string sheetName,
|
||||||
|
ImportContext context,
|
||||||
|
IReadOnlyList<string> requiredHeaders)
|
||||||
|
{
|
||||||
|
var headerRow = worksheet.FirstRowUsed();
|
||||||
|
if (headerRow is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns = headerRow.CellsUsed()
|
||||||
|
.Select(cell => new
|
||||||
|
{
|
||||||
|
Header = NormalizeHeader(cell.GetString()),
|
||||||
|
Column = cell.Address.ColumnNumber
|
||||||
|
})
|
||||||
|
.Where(header => header.Header.Length > 0)
|
||||||
|
.GroupBy(header => header.Header)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().Column);
|
||||||
|
|
||||||
|
foreach (var requiredHeader in requiredHeaders)
|
||||||
|
{
|
||||||
|
if (!columns.ContainsKey(NormalizeHeader(requiredHeader)))
|
||||||
|
{
|
||||||
|
context.Issues.Add(new CatalogImportIssue(
|
||||||
|
sheetName,
|
||||||
|
headerRow.RowNumber(),
|
||||||
|
requiredHeader,
|
||||||
|
$"Missing required column '{requiredHeader}'.",
|
||||||
|
"error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<ImportRow>();
|
||||||
|
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? headerRow.RowNumber();
|
||||||
|
for (var rowNumber = headerRow.RowNumber() + 1; rowNumber <= lastRow; rowNumber++)
|
||||||
|
{
|
||||||
|
var row = worksheet.Row(rowNumber);
|
||||||
|
if (row.Cells().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new ImportRow(sheetName, rowNumber, row, columns));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Required(ImportRow row, string field, ImportContext context)
|
||||||
|
{
|
||||||
|
var value = Optional(row, field);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Issues.Add(new CatalogImportIssue(
|
||||||
|
row.Sheet,
|
||||||
|
row.RowNumber,
|
||||||
|
field,
|
||||||
|
$"{field} is required.",
|
||||||
|
"error"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Optional(ImportRow row, params string[] fields)
|
||||||
|
{
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
if (!row.Columns.TryGetValue(NormalizeHeader(field), out var column))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = row.Row.Cell(column).GetFormattedString().Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Duplicate(ImportRow row, string field, ImportContext context)
|
||||||
|
{
|
||||||
|
context.Issues.Add(new CatalogImportIssue(
|
||||||
|
row.Sheet,
|
||||||
|
row.RowNumber,
|
||||||
|
field,
|
||||||
|
$"Duplicate {field.ToLowerInvariant()} in this spreadsheet.",
|
||||||
|
"error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetWorksheet(XLWorkbook workbook, string sheetName, out IXLWorksheet worksheet) =>
|
||||||
|
workbook.Worksheets.TryGetWorksheet(sheetName, out worksheet!);
|
||||||
|
|
||||||
|
private static string Key(string value) =>
|
||||||
|
value.Trim();
|
||||||
|
|
||||||
|
private static string NormalizeHeader(string header) =>
|
||||||
|
new(header
|
||||||
|
.Where(char.IsLetterOrDigit)
|
||||||
|
.Select(char.ToLowerInvariant)
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
private static void AddSummary(ImportContext context, MutableSummary summary)
|
||||||
|
{
|
||||||
|
context.Sheets.Add(new CatalogImportSheetSummary(
|
||||||
|
summary.Sheet,
|
||||||
|
summary.Creates + summary.Updates + summary.Skips,
|
||||||
|
summary.Creates,
|
||||||
|
summary.Updates,
|
||||||
|
summary.Skips));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImportContext
|
||||||
|
{
|
||||||
|
public List<CatalogImportSheetSummary> Sheets { get; } = [];
|
||||||
|
public List<CatalogImportIssue> Issues { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ImportRow(
|
||||||
|
string Sheet,
|
||||||
|
int RowNumber,
|
||||||
|
IXLRow Row,
|
||||||
|
IReadOnlyDictionary<string, int> Columns);
|
||||||
|
|
||||||
|
private sealed class MutableSummary(string sheet)
|
||||||
|
{
|
||||||
|
public string Sheet { get; } = sheet;
|
||||||
|
public int Creates { get; set; }
|
||||||
|
public int Updates { get; set; }
|
||||||
|
public int Skips { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ app.MapCareTaskEndpoints();
|
|||||||
app.MapActionLogEndpoints();
|
app.MapActionLogEndpoints();
|
||||||
app.MapPlantFlagEndpoints();
|
app.MapPlantFlagEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
|
app.MapImportEndpoints();
|
||||||
app.MapPlantInfoEndpoints();
|
app.MapPlantInfoEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
Reference in New Issue
Block a user