add spreadsheet export feature
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
deletePlantLocation,
|
deletePlantLocation,
|
||||||
deletePlantTaxon,
|
deletePlantTaxon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
|
downloadSpreadsheetExport,
|
||||||
getActionResources,
|
getActionResources,
|
||||||
getCareActivities,
|
getCareActivities,
|
||||||
getCareActions,
|
getCareActions,
|
||||||
@@ -117,6 +118,7 @@ export function App() {
|
|||||||
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
|
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
|
||||||
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
|
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
|
||||||
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
|
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
|
||||||
@@ -673,6 +675,18 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportSpreadsheet() {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await downloadSpreadsheetExport();
|
||||||
|
} catch {
|
||||||
|
setError('Could not export the spreadsheet.');
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTaxon() {
|
async function saveTaxon() {
|
||||||
if (!taxonForm.name.trim() || !taxonForm.genus.trim() || !taxonForm.species.trim()) {
|
if (!taxonForm.name.trim() || !taxonForm.genus.trim() || !taxonForm.species.trim()) {
|
||||||
setError('Name, genus, and species are required.');
|
setError('Name, genus, and species are required.');
|
||||||
@@ -1304,6 +1318,7 @@ 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}
|
||||||
@@ -1312,6 +1327,7 @@ 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}
|
||||||
|
|||||||
@@ -46,6 +46,31 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadSpreadsheetExport() {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/export/spreadsheet`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get('Content-Disposition') ?? '';
|
||||||
|
const encodedFileNameMatch = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
|
||||||
|
const fileNameMatch = /filename="?([^";]+)"?/i.exec(disposition);
|
||||||
|
const fileName = encodedFileNameMatch
|
||||||
|
? decodeURIComponent(encodedFileNameMatch[1])
|
||||||
|
: fileNameMatch?.[1] ?? 'plant-man-export.xlsx';
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPlants() {
|
export async function getPlants() {
|
||||||
return request<Plant[]>('/api/plants');
|
return request<Plant[]>('/api/plants');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
import { Download, 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,6 +7,7 @@ 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[];
|
||||||
@@ -15,6 +16,7 @@ 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;
|
||||||
@@ -26,6 +28,7 @@ export function PlantsView({
|
|||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isExporting,
|
||||||
isPlantEditorOpen,
|
isPlantEditorOpen,
|
||||||
isSaving,
|
isSaving,
|
||||||
plants,
|
plants,
|
||||||
@@ -34,6 +37,7 @@ export function PlantsView({
|
|||||||
onCloseDetail,
|
onCloseDetail,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onExport,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onNew,
|
onNew,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
@@ -49,10 +53,16 @@ export function PlantsView({
|
|||||||
</h2>
|
</h2>
|
||||||
<p>{error ?? 'Create and maintain plant objects.'}</p>
|
<p>{error ?? 'Create and maintain plant objects.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-action" type="button" onClick={onNew}>
|
<div className="summary-actions">
|
||||||
<Plus size={18} />
|
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
|
||||||
New plant
|
<Download size={18} />
|
||||||
</button>
|
{isExporting ? 'Exporting' : 'Export'}
|
||||||
|
</button>
|
||||||
|
<button className="primary-action" type="button" onClick={onNew}>
|
||||||
|
<Plus size={18} />
|
||||||
|
New plant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isPlantEditorOpen ? (
|
{isPlantEditorOpen ? (
|
||||||
|
|||||||
@@ -345,6 +345,13 @@ p {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.section,
|
.section,
|
||||||
.editor-panel {
|
.editor-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using plant_manager.Data;
|
||||||
|
|
||||||
|
namespace plant_manager.Endpoints
|
||||||
|
{
|
||||||
|
public static class ExportEndpoints
|
||||||
|
{
|
||||||
|
private const string SpreadsheetContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
|
||||||
|
public static void MapExportEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/export/spreadsheet", async (ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
|
||||||
|
await AddPlantsSheet(workbook, db);
|
||||||
|
await AddCareSchedulesSheet(workbook, db);
|
||||||
|
await AddActionLogsSheet(workbook, db);
|
||||||
|
await AddFlagsSheet(workbook, db);
|
||||||
|
await AddGroupsSheet(workbook, db);
|
||||||
|
await AddTaxaSheet(workbook, db);
|
||||||
|
await AddLocationsSheet(workbook, db);
|
||||||
|
await AddActivitiesSheet(workbook, db);
|
||||||
|
await AddResourcesSheet(workbook, db);
|
||||||
|
await AddRecipesSheet(workbook, db);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
|
||||||
|
var fileName = $"plant-man-export-{DateTime.UtcNow:yyyyMMdd-HHmm}.xlsx";
|
||||||
|
return Results.File(stream.ToArray(), SpreadsheetContentType, fileName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddPlantsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var plants = await db.Plants
|
||||||
|
.Include(plant => plant.Taxon)
|
||||||
|
.Include(plant => plant.Location)
|
||||||
|
.Include(plant => plant.Flags)
|
||||||
|
.ThenInclude(flag => flag.Definition)
|
||||||
|
.Include(plant => plant.GroupMemberships)
|
||||||
|
.ThenInclude(membership => membership.PlantGroup)
|
||||||
|
.Include(plant => plant.CareSchedules)
|
||||||
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
|
.Include(plant => plant.ActionLogs)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.OrderBy(plant => plant.Nickname)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = plants
|
||||||
|
.Select(plant => new object?[]
|
||||||
|
{
|
||||||
|
plant.Id,
|
||||||
|
plant.Nickname,
|
||||||
|
FormatDate(plant.Birthday),
|
||||||
|
plant.Taxon == null ? "" : plant.Taxon.Name,
|
||||||
|
plant.Taxon == null ? "" : $"{plant.Taxon.Genus} {plant.Taxon.Species}",
|
||||||
|
plant.Location == null ? "" : plant.Location.Name,
|
||||||
|
string.Join(", ", plant.GroupMemberships
|
||||||
|
.OrderBy(membership => membership.PlantGroup.Name)
|
||||||
|
.Select(membership => membership.PlantGroup.Name)),
|
||||||
|
string.Join(", ", plant.Flags
|
||||||
|
.Where(flag => flag.ResolvedOn == null)
|
||||||
|
.OrderBy(flag => flag.Definition.Name)
|
||||||
|
.Select(flag => flag.Definition.Name)),
|
||||||
|
plant.CareSchedules.Count,
|
||||||
|
plant.ActionLogs.Count
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Plants", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Birthday",
|
||||||
|
"Taxon",
|
||||||
|
"Scientific Name",
|
||||||
|
"Location",
|
||||||
|
"Groups",
|
||||||
|
"Active Flags",
|
||||||
|
"Care Schedules",
|
||||||
|
"Action Logs"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var schedules = await db.PlantCareSchedules
|
||||||
|
.Include(schedule => schedule.Plant)
|
||||||
|
.Include(schedule => schedule.CareActivity)
|
||||||
|
.Include(schedule => schedule.CareAction)
|
||||||
|
.OrderBy(schedule => schedule.Plant.Nickname)
|
||||||
|
.ThenBy(schedule => schedule.CareActivity.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = schedules
|
||||||
|
.Select(schedule => new object?[]
|
||||||
|
{
|
||||||
|
schedule.Id,
|
||||||
|
schedule.PlantId,
|
||||||
|
schedule.Plant.Nickname,
|
||||||
|
schedule.CareActivity.Name,
|
||||||
|
schedule.CareAction.Name,
|
||||||
|
schedule.EveryDays,
|
||||||
|
FormatDate(schedule.ScheduledFor),
|
||||||
|
schedule.RecurrenceMode,
|
||||||
|
schedule.RepeatEvery,
|
||||||
|
schedule.RepeatUnit,
|
||||||
|
schedule.RepeatOnDays ?? "",
|
||||||
|
schedule.EndsMode,
|
||||||
|
FormatDate(schedule.EndsOn),
|
||||||
|
schedule.EndsAfterOccurrences
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Care Schedules", [
|
||||||
|
"ID",
|
||||||
|
"Plant ID",
|
||||||
|
"Plant",
|
||||||
|
"Activity",
|
||||||
|
"Primary Action",
|
||||||
|
"Every Days",
|
||||||
|
"Scheduled For",
|
||||||
|
"Recurrence Mode",
|
||||||
|
"Repeat Every",
|
||||||
|
"Repeat Unit",
|
||||||
|
"Repeat On Days",
|
||||||
|
"Ends Mode",
|
||||||
|
"Ends On",
|
||||||
|
"Ends After Occurrences"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddActionLogsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var logs = await db.ActionLogs
|
||||||
|
.Include(log => log.Plant)
|
||||||
|
.Include(log => log.CareActivity)
|
||||||
|
.Include(log => log.CareAction)
|
||||||
|
.Include(log => log.Resources)
|
||||||
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.OrderByDescending(log => log.PerformedOn)
|
||||||
|
.ThenBy(log => log.Plant.Nickname)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = logs
|
||||||
|
.Select(log => new object?[]
|
||||||
|
{
|
||||||
|
log.Id,
|
||||||
|
log.PlantId,
|
||||||
|
log.Plant.Nickname,
|
||||||
|
FormatDate(log.PerformedOn),
|
||||||
|
log.CareActivity.Name,
|
||||||
|
log.CareAction.Name,
|
||||||
|
log.ActionNameSnapshot,
|
||||||
|
string.Join(", ", log.Resources
|
||||||
|
.OrderBy(resource => resource.ActionResource.Name)
|
||||||
|
.Select(resource => FormatQuantity(resource.ActionResource.Name, resource.Quantity, resource.Unit))),
|
||||||
|
log.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Action Logs", [
|
||||||
|
"ID",
|
||||||
|
"Plant ID",
|
||||||
|
"Plant",
|
||||||
|
"Performed On",
|
||||||
|
"Activity",
|
||||||
|
"Primary Action",
|
||||||
|
"Action Snapshot",
|
||||||
|
"Resources",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddFlagsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var flags = await db.PlantFlags
|
||||||
|
.Include(flag => flag.Plant)
|
||||||
|
.Include(flag => flag.Definition)
|
||||||
|
.OrderBy(flag => flag.ResolvedOn != null)
|
||||||
|
.ThenBy(flag => flag.Plant.Nickname)
|
||||||
|
.ThenBy(flag => flag.Definition.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = flags
|
||||||
|
.Select(flag => new object?[]
|
||||||
|
{
|
||||||
|
flag.Id,
|
||||||
|
flag.PlantId,
|
||||||
|
flag.Plant.Nickname,
|
||||||
|
flag.Definition.Name,
|
||||||
|
flag.Definition.Color,
|
||||||
|
FormatDate(flag.StartedOn),
|
||||||
|
FormatDate(flag.ResolvedOn),
|
||||||
|
flag.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Flags", [
|
||||||
|
"ID",
|
||||||
|
"Plant ID",
|
||||||
|
"Plant",
|
||||||
|
"Flag",
|
||||||
|
"Color",
|
||||||
|
"Started On",
|
||||||
|
"Resolved On",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddGroupsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var groups = await db.PlantGroups
|
||||||
|
.Include(group => group.Memberships)
|
||||||
|
.ThenInclude(membership => membership.Plant)
|
||||||
|
.OrderBy(group => group.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = groups
|
||||||
|
.Select(group => new object?[]
|
||||||
|
{
|
||||||
|
group.Id,
|
||||||
|
group.Name,
|
||||||
|
group.Memberships.Count,
|
||||||
|
string.Join(", ", group.Memberships
|
||||||
|
.OrderBy(membership => membership.Plant.Nickname)
|
||||||
|
.Select(membership => membership.Plant.Nickname)),
|
||||||
|
group.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Groups", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Plant Count",
|
||||||
|
"Plants",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddTaxaSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var rows = await db.PlantTaxa
|
||||||
|
.OrderBy(taxon => taxon.Name)
|
||||||
|
.Select(taxon => new object?[]
|
||||||
|
{
|
||||||
|
taxon.Id,
|
||||||
|
taxon.Name,
|
||||||
|
taxon.Genus,
|
||||||
|
taxon.Species,
|
||||||
|
taxon.Cultivar ?? "",
|
||||||
|
taxon.Variety ?? "",
|
||||||
|
taxon.Authority ?? ""
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Taxa", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Genus",
|
||||||
|
"Species",
|
||||||
|
"Cultivar",
|
||||||
|
"Variety",
|
||||||
|
"Authority"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddLocationsSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var locations = await db.PlantLocations
|
||||||
|
.Include(location => location.Plants)
|
||||||
|
.OrderBy(location => location.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = locations
|
||||||
|
.Select(location => new object?[]
|
||||||
|
{
|
||||||
|
location.Id,
|
||||||
|
location.Name,
|
||||||
|
location.Plants.Count,
|
||||||
|
location.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Locations", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Plant Count",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddActivitiesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var activities = await db.CareActivities
|
||||||
|
.Include(activity => activity.Actions)
|
||||||
|
.ThenInclude(action => action.CareAction)
|
||||||
|
.Include(activity => activity.Actions)
|
||||||
|
.ThenInclude(action => action.Resources)
|
||||||
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.OrderBy(activity => activity.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = activities
|
||||||
|
.Select(activity => new object?[]
|
||||||
|
{
|
||||||
|
activity.Id,
|
||||||
|
activity.Name,
|
||||||
|
string.Join(", ", activity.Actions
|
||||||
|
.OrderBy(action => action.SortOrder)
|
||||||
|
.Select(action => action.CareAction.Name)),
|
||||||
|
string.Join(", ", activity.Actions
|
||||||
|
.SelectMany(action => action.Resources)
|
||||||
|
.OrderBy(resource => resource.ActionResource.Name)
|
||||||
|
.Select(resource => FormatQuantity(resource.ActionResource.Name, resource.Quantity, resource.Unit))),
|
||||||
|
activity.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Activities", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Actions",
|
||||||
|
"Resources",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddResourcesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var rows = await db.ActionResources
|
||||||
|
.Include(resource => resource.ProducedByRecipe)
|
||||||
|
.OrderBy(resource => resource.Name)
|
||||||
|
.Select(resource => new object?[]
|
||||||
|
{
|
||||||
|
resource.Id,
|
||||||
|
resource.Name,
|
||||||
|
resource.ProducedByRecipe == null ? "" : resource.ProducedByRecipe.Name,
|
||||||
|
resource.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Resources", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Produced By Recipe",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddRecipesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var recipes = await db.Recipes
|
||||||
|
.Include(recipe => recipe.OutputResource)
|
||||||
|
.Include(recipe => recipe.Components)
|
||||||
|
.ThenInclude(component => component.ActionResource)
|
||||||
|
.OrderBy(recipe => recipe.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
var rows = recipes
|
||||||
|
.Select(recipe => new object?[]
|
||||||
|
{
|
||||||
|
recipe.Id,
|
||||||
|
recipe.Name,
|
||||||
|
recipe.Type,
|
||||||
|
recipe.MeasurementMode,
|
||||||
|
recipe.OutputResource == null ? "" : recipe.OutputResource.Name,
|
||||||
|
string.Join(", ", recipe.Components
|
||||||
|
.OrderBy(component => component.SortOrder)
|
||||||
|
.Select(component => FormatQuantity(component.ActionResource.Name, component.Quantity, component.Unit))),
|
||||||
|
recipe.Notes ?? ""
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddSheet(workbook, "Recipes", [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Type",
|
||||||
|
"Measurement Mode",
|
||||||
|
"Output Resource",
|
||||||
|
"Components",
|
||||||
|
"Notes"
|
||||||
|
], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
string name,
|
||||||
|
IReadOnlyList<string> headers,
|
||||||
|
IReadOnlyList<object?[]> rows)
|
||||||
|
{
|
||||||
|
var worksheet = workbook.Worksheets.Add(name);
|
||||||
|
for (var column = 0; column < headers.Count; column++)
|
||||||
|
{
|
||||||
|
worksheet.Cell(1, column + 1).Value = headers[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var row = 0; row < rows.Count; row++)
|
||||||
|
{
|
||||||
|
for (var column = 0; column < headers.Count; column++)
|
||||||
|
{
|
||||||
|
var value = column < rows[row].Length ? rows[row][column] : null;
|
||||||
|
worksheet.Cell(row + 2, column + 1).Value = XLCellValue.FromObject(value ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var usedRange = worksheet.Range(1, 1, Math.Max(rows.Count + 1, 1), headers.Count);
|
||||||
|
usedRange.SetAutoFilter();
|
||||||
|
worksheet.SheetView.FreezeRows(1);
|
||||||
|
|
||||||
|
var headerRange = worksheet.Range(1, 1, 1, headers.Count);
|
||||||
|
headerRange.Style.Font.Bold = true;
|
||||||
|
headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#DBE7D2");
|
||||||
|
|
||||||
|
worksheet.Columns().AdjustToContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateOnly? date) =>
|
||||||
|
date?.ToString("yyyy-MM-dd") ?? "";
|
||||||
|
|
||||||
|
private static string FormatQuantity(string name, decimal? quantity, string? unit)
|
||||||
|
{
|
||||||
|
if (quantity is null)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = string.IsNullOrWhiteSpace(unit) ? "" : $" {unit}";
|
||||||
|
return $"{name} ({quantity:0.##}{suffix})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ namespace plant_manager.Endpoints
|
|||||||
"/api/action-resources",
|
"/api/action-resources",
|
||||||
"/api/care-activities",
|
"/api/care-activities",
|
||||||
"/api/care-tasks/upcoming",
|
"/api/care-tasks/upcoming",
|
||||||
"/api/action-logs"
|
"/api/action-logs",
|
||||||
|
"/api/export/spreadsheet"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -52,5 +52,6 @@ app.MapPlantCareScheduleEndpoints();
|
|||||||
app.MapCareTaskEndpoints();
|
app.MapCareTaskEndpoints();
|
||||||
app.MapActionLogEndpoints();
|
app.MapActionLogEndpoints();
|
||||||
app.MapPlantFlagEndpoints();
|
app.MapPlantFlagEndpoints();
|
||||||
|
app.MapExportEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
Reference in New Issue
Block a user