17dc5f12f6
* fix(articles): stop losing and mislabeling non-SEK article prices Support report (odinaero.se): EUR article prices did not stick and the register showed every price in kr. Three concrete defects, one cause: articles.currency existed in the DB and API but the UI dropped it. - Edit dialog omitted currency from initialData, so ArticleForm fell back to SEK and every save silently reset an EUR article to SEK. - Register list and detail page formatted prices without the article's currency, rendering EUR amounts as "kr". - "Spara som artikel" in the invoice editor posted the line price without the invoice's currency, so lines from EUR invoices became SEK articles. - The xlsx/csv register export stamped the kr-suffixed currency format on every price; prices now use a new suffix-free decimalColumn and a Valuta column carries the per-article code. Follow-ups (not in this diff): the article importer does not detect a Valuta column yet, and the MCP create/update_article staged schemas have no currency param (agent-created articles stay SEK). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(articles): reinstate deactivate/activate on the article detail page Support report (odinaero.se): no button to set an article inactive. Commit 8a9a930f turned DELETE into a hard delete and removed the deactivate action, but hard delete is refused for articles referenced by invoice lines (ARTICLE_IN_USE), leaving used articles with no retire path even though the API, the list badge and the i18n keys for deactivation all still exist. Adds an Inaktivera/Aktivera button next to Redigera that PATCHes the active flag (confirm dialog on deactivate, none on reactivate) and stays on the page so the status badge reflects the change. Reuses the orphaned deactivate_* keys; adds the three missing activate_* keys in both locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): stop resetting customer language to Swedish on every edit Same defect class as the article currency reset in this branch: the customer edit dialog's initialData omits language, CustomerForm defaults it to 'sv' and submits every field, and the PATCH route applies it. Editing any detail on an English-language customer silently flipped their invoice PDFs and emails back to Swedish. Found by a repo-wide sweep for hand-picked initialData edit dialogs; customers, suppliers and articles are the only three such call sites, and suppliers passes every form field already. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import * as XLSX from 'xlsx'
|
|
|
|
/**
|
|
* Generic xlsx workbook builder for reports.
|
|
*
|
|
* The helper is intentionally declarative: callers describe one or more sheets
|
|
* via `SheetSpec`, each with a header row and a row mapper. Column-level number
|
|
* formatting hints (currency, date, integer, percent) are applied per-cell via
|
|
* the `z` (number format) field on the cell object.
|
|
*
|
|
* Currency format follows the Swedish accounting convention used in formatCurrency
|
|
* (`lib/utils.ts`): `#,##0.00 " kr"`. Dates use ISO `yyyy-mm-dd` to match
|
|
* `formatDate(x)`. Both align with how figures are displayed in-app.
|
|
*
|
|
* Bolding the header row would require `xlsx-style` or `cellStyles: true` which
|
|
* is not supported in the base `xlsx` distribution we ship. Instead we freeze
|
|
* the first row so the headers stay visible while scrolling: visually distinct
|
|
* without depending on optional packages.
|
|
*
|
|
* Column widths are computed automatically from the maximum content length per
|
|
* column. This keeps the produced file legible in Excel/Numbers without any
|
|
* post-processing by the caller.
|
|
*/
|
|
|
|
export type CellValue = string | number | Date | null | undefined
|
|
export type ColumnFormat = 'text' | 'currency' | 'decimal' | 'date' | 'integer' | 'percent'
|
|
|
|
export interface ColumnSpec {
|
|
/** Human-readable header label rendered in row 1. */
|
|
header: string
|
|
/** Excel number-format hint applied to every body cell in this column. */
|
|
format: ColumnFormat
|
|
}
|
|
|
|
export interface SheetSpec<TRow> {
|
|
/** Sheet tab name. Excel limits this to 31 characters; longer names are truncated. */
|
|
name: string
|
|
/** Column definitions (header + format), one per column. */
|
|
columns: ColumnSpec[]
|
|
/** Array of rows the sheet should render. */
|
|
rows: TRow[]
|
|
/**
|
|
* Maps a single row to an array of cell values. The returned array length
|
|
* must match `columns.length`. Use `null`/`undefined` for blank cells.
|
|
*/
|
|
mapRow: (row: TRow) => CellValue[]
|
|
}
|
|
|
|
const CURRENCY_FORMAT = '#,##0.00 " kr"'
|
|
// Money amount WITHOUT the " kr" suffix: for columns whose currency varies per
|
|
// row (e.g. article prices with a separate Valuta column).
|
|
const DECIMAL_FORMAT = '#,##0.00'
|
|
const DATE_FORMAT = 'yyyy-mm-dd'
|
|
const INTEGER_FORMAT = '#,##0'
|
|
const PERCENT_FORMAT = '0.00%'
|
|
|
|
function formatToZ(format: ColumnFormat): string | undefined {
|
|
switch (format) {
|
|
case 'currency':
|
|
return CURRENCY_FORMAT
|
|
case 'decimal':
|
|
return DECIMAL_FORMAT
|
|
case 'date':
|
|
return DATE_FORMAT
|
|
case 'integer':
|
|
return INTEGER_FORMAT
|
|
case 'percent':
|
|
return PERCENT_FORMAT
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute display length for column-width sizing. For numbers and dates we
|
|
* approximate the formatted width (currency picks up the " kr" suffix; dates
|
|
* are always 10 chars; integers add thousand separators). For strings we use
|
|
* actual length. Null/undefined cells contribute 0.
|
|
*/
|
|
function displayLength(value: CellValue, format: ColumnFormat): number {
|
|
if (value === null || value === undefined) return 0
|
|
if (value instanceof Date) return 10 // yyyy-mm-dd
|
|
if (typeof value === 'number') {
|
|
switch (format) {
|
|
case 'currency': {
|
|
// "12 345 678,90 kr" ≈ integer-with-thousands + 6 (decimals, separator, suffix)
|
|
const formatted = Math.abs(value)
|
|
.toFixed(2)
|
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
|
return formatted.length + 3 + (value < 0 ? 1 : 0) // +3 for " kr"
|
|
}
|
|
case 'decimal': {
|
|
const formatted = Math.abs(value)
|
|
.toFixed(2)
|
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
|
return formatted.length + (value < 0 ? 1 : 0)
|
|
}
|
|
case 'integer': {
|
|
const formatted = Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
|
return formatted.length + (value < 0 ? 1 : 0)
|
|
}
|
|
case 'percent':
|
|
return (value * 100).toFixed(2).length + 1
|
|
default:
|
|
return value.toString().length
|
|
}
|
|
}
|
|
return String(value).length
|
|
}
|
|
|
|
/**
|
|
* Build a workbook buffer from one or more sheet specs.
|
|
*
|
|
* @returns A Node Buffer containing the serialized xlsx file.
|
|
*/
|
|
// The generic `_T` is preserved for source-compatibility with callers that
|
|
// pass an explicit type argument (e.g. `reportToWorkbook<FlatRow>([...])`).
|
|
// Internally we accept heterogeneous sheet types via `SheetSpec<any>` because
|
|
// TypeScript cannot unify multiple sheets with different row types via a
|
|
// single type parameter. Per-sheet type safety still applies inside each
|
|
// `SheetSpec<TRow>` declaration.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
|
export function reportToWorkbook<_T = unknown>(spec: ReadonlyArray<SheetSpec<any>>, options: { bookType?: 'xlsx' | 'csv' } = {}): Buffer {
|
|
if (spec.length === 0) {
|
|
throw new Error('reportToWorkbook: at least one sheet spec is required')
|
|
}
|
|
|
|
const workbook = XLSX.utils.book_new()
|
|
|
|
for (const sheet of spec) {
|
|
// Build AOA (array of arrays): row 0 = headers, rows 1..n = body.
|
|
const headerRow = sheet.columns.map((c) => c.header)
|
|
const bodyRows = sheet.rows.map((row) => {
|
|
const mapped = sheet.mapRow(row)
|
|
if (mapped.length !== sheet.columns.length) {
|
|
throw new Error(
|
|
`reportToWorkbook: row length ${mapped.length} does not match column count ${sheet.columns.length} on sheet "${sheet.name}"`,
|
|
)
|
|
}
|
|
return mapped.map((v) => (v === undefined ? null : v))
|
|
})
|
|
|
|
const aoa: CellValue[][] = [headerRow, ...bodyRows]
|
|
// `cellDates: true` tells xlsx to write Date values as Excel date cells
|
|
// (type 'd', not type 'n'), so callers can use native Date objects and get
|
|
// real date typing in the file.
|
|
const worksheet = XLSX.utils.aoa_to_sheet(aoa as unknown[][], { cellDates: true })
|
|
|
|
// Apply per-column number format on body cells (skip header row at r=0).
|
|
if (bodyRows.length > 0) {
|
|
for (let colIdx = 0; colIdx < sheet.columns.length; colIdx++) {
|
|
const fmt = formatToZ(sheet.columns[colIdx].format)
|
|
if (!fmt) continue
|
|
for (let rowIdx = 1; rowIdx <= bodyRows.length; rowIdx++) {
|
|
const ref = XLSX.utils.encode_cell({ r: rowIdx, c: colIdx })
|
|
const cell = worksheet[ref]
|
|
if (cell) {
|
|
cell.z = fmt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-size columns based on max content length per column. Header counts
|
|
// too: a short numeric column with a long header still needs to fit the
|
|
// label. Min 8, max 60 chars to avoid degenerate widths.
|
|
const colWidths = sheet.columns.map((col, colIdx) => {
|
|
let maxLen = col.header.length
|
|
for (const row of bodyRows) {
|
|
const cellValue = row[colIdx]
|
|
const len = displayLength(cellValue as CellValue, col.format)
|
|
if (len > maxLen) maxLen = len
|
|
}
|
|
const width = Math.min(Math.max(maxLen + 2, 8), 60)
|
|
return { wch: width }
|
|
})
|
|
worksheet['!cols'] = colWidths
|
|
|
|
// Freeze the header row so users can scroll the body without losing
|
|
// column titles. Compensates for not being able to bold them in base xlsx.
|
|
worksheet['!freeze'] = { xSplit: 0, ySplit: 1 }
|
|
// Excel format: top row stays visible
|
|
worksheet['!views'] = [{ state: 'frozen', ySplit: 1 }]
|
|
|
|
// Truncate sheet name to Excel's 31-char limit.
|
|
const truncatedName = sheet.name.length > 31 ? sheet.name.slice(0, 31) : sheet.name
|
|
XLSX.utils.book_append_sheet(workbook, worksheet, truncatedName)
|
|
}
|
|
|
|
// `XLSX.write` with `type: 'buffer'` returns a Node Buffer. `bookType: 'csv'`
|
|
// emits only the first sheet (CSV is single-sheet): fine for the flat,
|
|
// single-sheet register exports that use this option.
|
|
const bookType = options.bookType ?? 'xlsx'
|
|
const out = XLSX.write(workbook, { type: 'buffer', bookType }) as Buffer
|
|
return out
|
|
}
|
|
|
|
/** UTF-8 byte-order mark (U+FEFF) so Excel opens CSV exports with åäö intact. */
|
|
export const UTF8_BOM = '\uFEFF'
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Column helpers: small declarative builders so route files read cleanly.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function textColumn(header: string): ColumnSpec {
|
|
return { header, format: 'text' }
|
|
}
|
|
|
|
export function currencyColumn(header: string): ColumnSpec {
|
|
return { header, format: 'currency' }
|
|
}
|
|
|
|
/** Two-decimal amount without the " kr" suffix: pair with a per-row currency column. */
|
|
export function decimalColumn(header: string): ColumnSpec {
|
|
return { header, format: 'decimal' }
|
|
}
|
|
|
|
export function dateColumn(header: string): ColumnSpec {
|
|
return { header, format: 'date' }
|
|
}
|
|
|
|
export function integerColumn(header: string): ColumnSpec {
|
|
return { header, format: 'integer' }
|
|
}
|
|
|
|
export function percentColumn(header: string): ColumnSpec {
|
|
return { header, format: 'percent' }
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Filename helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Slugify a company name for use in download filenames.
|
|
*
|
|
* - Lowercases everything.
|
|
* - Replaces Swedish characters (åäö) with their ASCII fallbacks.
|
|
* - Strips anything that's not alphanumeric.
|
|
* - Collapses runs of separators to a single dash and trims edges.
|
|
* - Returns `'foretag'` if the input slugifies to empty (e.g. only emoji).
|
|
*/
|
|
export function slugifyCompanyName(name: string): string {
|
|
if (!name) return 'foretag'
|
|
const lowered = name.toLowerCase()
|
|
const ascii = lowered
|
|
.replace(/å/g, 'a')
|
|
.replace(/ä/g, 'a')
|
|
.replace(/ö/g, 'o')
|
|
.replace(/é/g, 'e')
|
|
.replace(/è/g, 'e')
|
|
.replace(/ü/g, 'u')
|
|
.replace(/ß/g, 'ss')
|
|
const slug = ascii
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
return slug.length > 0 ? slug : 'foretag'
|
|
}
|
|
|
|
/**
|
|
* Build a filename in the form `<reportSlug>-<companySlug>-<periodYYYYMMDD>.xlsx`.
|
|
*
|
|
* @param reportSlug Static report identifier (e.g. `"trial-balance"`)
|
|
* @param companyName Raw company name (will be slugified)
|
|
* @param period ISO date string (`YYYY-MM-DD`); date separators are stripped
|
|
*/
|
|
export function xlsxFilename(reportSlug: string, companyName: string, period: string): string {
|
|
const companySlug = slugifyCompanyName(companyName)
|
|
const periodCompact = (period || '').replace(/-/g, '')
|
|
const parts = [reportSlug, companySlug, periodCompact].filter(Boolean)
|
|
return `${parts.join('-')}.xlsx`
|
|
}
|
|
|
|
/**
|
|
* Build a download filename `<slug>-<companySlug>-<dateYYYYMMDD>.<ext>`.
|
|
* Like `xlsxFilename` but with a caller-chosen extension (`'xlsx'` | `'csv'`),
|
|
* for register exports that offer both formats.
|
|
*/
|
|
export function exportFilename(
|
|
slug: string,
|
|
companyName: string,
|
|
date: string,
|
|
ext: 'xlsx' | 'csv',
|
|
): string {
|
|
const companySlug = slugifyCompanyName(companyName)
|
|
const dateCompact = (date || '').replace(/-/g, '')
|
|
const parts = [slug, companySlug, dateCompact].filter(Boolean)
|
|
return `${parts.join('-')}.${ext}`
|
|
}
|