Implement CSV export for dynamic reports

This commit is contained in:
RunasSudo 2025-06-13 23:39:22 +10:00
parent 50ef94dfee
commit 914cb384b1
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
7 changed files with 96 additions and 7 deletions

View File

@ -15,6 +15,7 @@
"fs:default", "fs:default",
"fs:allow-read-text-file", "fs:allow-read-text-file",
"fs:allow-resource-read-recursive", "fs:allow-resource-read-recursive",
"fs:allow-write-text-file",
"shell:allow-open", "shell:allow-open",
"sql:default", "sql:default",
"sql:allow-execute", "sql:allow-execute",

View File

@ -19,21 +19,48 @@
<template> <template>
<div class="relative print:hidden"> <div class="relative print:hidden">
<button class="text-gray-400 align-middle hover:text-gray-500" @click="isMenuOpen = !isMenuOpen"><EllipsisHorizontalCircleIcon class="size-6" /></button> <button class="text-gray-400 align-middle hover:text-gray-500" @click="isMenuOpen = !isMenuOpen"><EllipsisHorizontalCircleIcon class="size-6" /></button>
<ul class="absolute top-8 right-0 bg-white w-[11rem] shadow-lg ring-1 ring-black/5 focus:outline-hidden" :class="isMenuOpen ? 'block' : 'hidden'"> <ul class="absolute top-8 right-0 py-1 bg-white w-[11rem] shadow-lg ring-1 ring-black/5 focus:outline-hidden" :class="isMenuOpen ? 'block' : 'hidden'">
<li class="group cursor-pointer select-none py-1 px-3 text-gray-900 hover:text-white hover:bg-emerald-600" @click="menuPrint"> <li class="group cursor-pointer select-none py-1 px-3 text-gray-900 hover:text-white hover:bg-emerald-600" @click="menuPrint">
<PrinterIcon class="inline size-5 text-gray-500 group-hover:text-white" /> <PrinterIcon class="inline size-5 text-gray-500 group-hover:text-white" />
Print/Save as PDF Print/Save as PDF
</li> </li>
<li class="group cursor-pointer select-none py-1 px-3 text-gray-900 hover:text-white hover:bg-emerald-600" @click="menuCsv">
<DocumentTextIcon class="inline size-5 text-gray-500 group-hover:text-white" />
Save as CSV
</li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup type="ts"> <script setup lang="ts">
import { EllipsisHorizontalCircleIcon, PrinterIcon } from '@heroicons/vue/24/outline'; import { DocumentTextIcon, EllipsisHorizontalCircleIcon, PrinterIcon } from '@heroicons/vue/24/outline';
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { ref } from 'vue'; import { ref } from 'vue';
import { DynamicReport } from '../reports/base.ts';
const { report, columns, subtitle } = defineProps<{ report: DynamicReport | null, columns?: string[], subtitle?: string }>();
const isMenuOpen = ref(false); const isMenuOpen = ref(false);
async function menuCsv() {
// Export report to CSV
const csv = report!.toCSV(columns, subtitle);
// Save to file
const csvFilename = await save({
filters: [
{ name: 'Comma separated values (CSV)', extensions: ['csv'] }
],
});
if (csvFilename !== null) {
await writeTextFile(csvFilename, csv);
}
isMenuOpen.value = false;
}
function menuPrint() { function menuPrint() {
window.print(); window.print();
isMenuOpen.value = false; isMenuOpen.value = false;

View File

@ -20,7 +20,7 @@
<DynamicReportComponent :report="report"> <DynamicReportComponent :report="report">
<div class="relative"> <div class="relative">
<div class="absolute -top-10 right-0"> <div class="absolute -top-10 right-0">
<DynamicReportMenu /> <DynamicReportMenu :report="report" />
</div> </div>
</div> </div>
</DynamicReportComponent> </DynamicReportComponent>

View File

@ -32,7 +32,7 @@
</select> </select>
</div> </div>
</div> </div>
<DynamicReportMenu /> <DynamicReportMenu :report="report" :columns="reportColumns" :subtitle="'As at ' + dt" />
</div> </div>
</div> </div>
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="!doesBalance"> <div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="!doesBalance">

View File

@ -32,7 +32,7 @@
</select> </select>
</div> </div>
</div> </div>
<DynamicReportMenu /> <DynamicReportMenu :report="report" :columns="reportColumns" :subtitle="dtStart + ' to ' + dt" />
</div> </div>
</DynamicReportComponent> </DynamicReportComponent>
</template> </template>

View File

@ -21,7 +21,7 @@
<div class="my-2 py-2 flex gap-x-2 items-baseline"> <div class="my-2 py-2 flex gap-x-2 items-baseline">
<span class="whitespace-nowrap">As at</span> <span class="whitespace-nowrap">As at</span>
<input type="date" class="bordered-field" v-model.lazy="dt"> <input type="date" class="bordered-field" v-model.lazy="dt">
<DynamicReportMenu /> <DynamicReportMenu :report="report" :subtitle="'As at ' + dt" />
</div> </div>
</DynamicReportComponent> </DynamicReportComponent>
</template> </template>

View File

@ -16,6 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { db, serialiseAmount } from '../db.ts';
import { CriticalError } from '../error.ts';
export class DynamicReport { export class DynamicReport {
title!: string; title!: string;
columns!: string[]; columns!: string[];
@ -28,6 +31,30 @@ export class DynamicReport {
byId(id: string): DynamicReportEntry | null { byId(id: string): DynamicReportEntry | null {
return reportEntryById(this, id); return reportEntryById(this, id);
} }
// Convert to report to CSV
toCSV(columns?: string[], subtitle?: string): string {
let csv = '';
// Title and subtitle
csv += escapeCSV(this.title) + '\n';
if (subtitle) {
csv += escapeCSV(subtitle) + '\n';
}
// Columns
for (const column of columns || this.columns) {
csv += ',' + escapeCSV(column);
}
csv += '\n';
// Entries
for (const entry of this.entries) {
csv += entryToCSV(entry);
}
return csv;
}
} }
// serde_json serialises an enum like this // serde_json serialises an enum like this
@ -73,3 +100,37 @@ export function reportEntryById(report: DynamicReport | Section, id: string): Dy
} }
return null; return null;
} }
// Escape the given text as contents of a single CSV field
function escapeCSV(cell: string): string {
if (cell.indexOf('"') >= 0) {
return '"' + cell.replaceAll('"', '""') + '"';
}
if (cell.indexOf(',') >= 0) {
return '"' + cell + '"';
}
return cell;
}
function entryToCSV(entry: DynamicReportEntry): string {
if (entry === 'Spacer') {
return '\n';
} else if ((entry as { Section: Section }).Section) {
const section = (entry as { Section: Section }).Section;
let csv = '';
for (const sectionEntry of section.entries) {
csv += entryToCSV(sectionEntry);
}
return csv;
} else if ((entry as { Row: Row }).Row) {
const row = (entry as { Row: Row}).Row;
let csv = escapeCSV(row.text);
for (const quantity of row.quantity) {
csv += ',' + escapeCSV(serialiseAmount(quantity, db.metadata.reporting_commodity));
}
csv += '\n';
return csv;
} else {
throw new CriticalError('Unexpected DynamicReportEntry');
}
}