The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
/**
|
||
* Document templates: `createBlankDocument()` (Datei › Neu, and what a first
|
||
* start opens) and `createSeedDocument()` (Datei › Muster).
|
||
*
|
||
* The pre-filled document ("roter Faden" of the study), in the v2 canvas model:
|
||
* one sheet ("Analyse") carrying three table objects and a chart object, plus a
|
||
* second empty sheet to try things out.
|
||
*
|
||
* Note on the brief: the acceptance list mentions "Übersicht!B2 = 6070" as the
|
||
* yearly Nord revenue, but the given Nord row is 1200 + 1450 + 1600 + 1720 =
|
||
* 5970. The explicit table wins -- the exact number is deterministic and the
|
||
* propagation behaviour it demonstrates is unchanged.
|
||
*/
|
||
|
||
import { parseA1 } from '../engine';
|
||
import type { ChartObject, DocumentState, TableObject } from '../store/types';
|
||
import { defaultTableView } from '../store/types';
|
||
|
||
let uid = 0;
|
||
const nextId = (p: string) => `${p}-seed-${(uid++).toString(36)}`;
|
||
|
||
function makeTable(
|
||
name: string,
|
||
x: number,
|
||
y: number,
|
||
rows: number,
|
||
cols: number,
|
||
entries: Array<[string, string | number]>,
|
||
colWidths: Record<number, number> = {},
|
||
viewOverride: Partial<TableObject['view']> = {},
|
||
): TableObject {
|
||
const cells: Record<string, string> = {};
|
||
for (const [a1, value] of entries) {
|
||
const { col, row } = parseA1(a1);
|
||
cells[`${col},${row}`] = String(value);
|
||
}
|
||
return {
|
||
kind: 'table',
|
||
id: nextId('tbl'),
|
||
name,
|
||
x,
|
||
y,
|
||
rows,
|
||
cols,
|
||
colWidths,
|
||
cells,
|
||
view: { ...defaultTableView(), ...viewOverride },
|
||
};
|
||
}
|
||
|
||
export function createSeedDocument(): DocumentState {
|
||
const umsatz = makeTable('Umsatz', 40, 40, 20, 8, [
|
||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||
['A2', 'Nord'], ['B2', 1200], ['C2', 1450], ['D2', 1600], ['E2', 1720],
|
||
['A3', 'Süd'], ['B3', 980], ['C3', 1100], ['D3', 1250], ['E3', 1310],
|
||
['A4', 'Ost'], ['B4', 1550], ['C4', 1480], ['D4', 1620], ['E4', 1700],
|
||
['A5', 'West'], ['B5', 1340], ['C5', 1390], ['D5', 1500], ['E5', 1580],
|
||
['A6', 'Zentral'], ['B6', 1105], ['C6', 1205], ['D6', 1298], ['E6', 1402],
|
||
['A8', 'Summe'],
|
||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'], ['D8', '=SUM(D2:D6)'], ['E8', '=SUM(E2:E6)'],
|
||
['A9', 'Mittelwert'],
|
||
['B9', '=AVG(B2:B6)'], ['C9', '=AVG(C2:C6)'], ['D9', '=AVG(D2:D6)'], ['E9', '=AVG(E2:E6)'],
|
||
['A10', 'Minimum'],
|
||
['B10', '=MIN(B2:B6)'], ['C10', '=MIN(C2:C6)'], ['D10', '=MIN(D2:D6)'], ['E10', '=MIN(E2:E6)'],
|
||
['A11', 'Maximum'],
|
||
['B11', '=MAX(B2:B6)'], ['C11', '=MAX(C2:C6)'], ['D11', '=MAX(D2:D6)'], ['E11', '=MAX(E2:E6)'],
|
||
['A12', 'Anzahl'],
|
||
['B12', '=COUNT(B2:B6)'], ['C12', '=COUNT(C2:C6)'], ['D12', '=COUNT(D2:D6)'], ['E12', '=COUNT(E2:E6)'],
|
||
['G2', 'Jahr Nord'], ['H2', '=SUM(B2:E2)'],
|
||
['G3', 'Schnitt/Q'], ['H3', '=SUM(B2:E2)/4'],
|
||
['G4', 'Delta Q4-Q1'], ['H4', '=E2-B2'],
|
||
], { 0: 92, 6: 96 }, { mode: 'fixed', width: 545, height: 360, frozenRows: 1, frozenCols: 1 });
|
||
|
||
const kosten = makeTable('Kosten', 40, 700, 12, 8, [
|
||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||
['A2', 'Nord'], ['B2', 800], ['C2', 820], ['D2', 900], ['E2', 880],
|
||
['A3', 'Süd'], ['B3', 700], ['C3', 690], ['D3', 710], ['E3', 720],
|
||
['A4', 'Ost'], ['B4', 900], ['C4', 910], ['D4', 950], ['E4', 980],
|
||
['A5', 'West'], ['B5', 760], ['C5', 770], ['D5', 780], ['E5', 790],
|
||
['A6', 'Zentral'], ['B6', 640], ['C6', 650], ['D6', 660], ['E6', 670],
|
||
], { 0: 92 });
|
||
|
||
const regions = ['Nord', 'Süd', 'Ost', 'West', 'Zentral'];
|
||
const overview: Array<[string, string | number]> = [
|
||
['A1', 'Region'], ['B1', 'Umsatz Jahr'], ['C1', 'Kosten Jahr'],
|
||
['D1', 'Deckungsbeitrag'], ['E1', 'Marge %'],
|
||
];
|
||
regions.forEach((region, i) => {
|
||
const r = i + 2;
|
||
overview.push(
|
||
[`A${r}`, region],
|
||
[`B${r}`, `=SUM(Umsatz!B${r}:E${r})`],
|
||
[`C${r}`, `=SUM(Kosten!B${r}:E${r})`],
|
||
[`D${r}`, `=B${r}-C${r}`],
|
||
[`E${r}`, `=D${r}/B${r}*100`],
|
||
);
|
||
});
|
||
overview.push(
|
||
['A8', 'Gesamt'],
|
||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'],
|
||
['D8', '=SUM(D2:D6)'], ['E8', '=AVG(E2:E6)'],
|
||
);
|
||
const uebersicht = makeTable('Übersicht', 1000, 40, 12, 6, overview, {
|
||
0: 84, 3: 118,
|
||
});
|
||
|
||
const chart: ChartObject = {
|
||
kind: 'chart',
|
||
id: nextId('cht'),
|
||
name: 'Umsatz je Region',
|
||
x: 1000,
|
||
y: 460,
|
||
width: 560,
|
||
height: 340,
|
||
view: 'bar',
|
||
sourceTableId: umsatz.id,
|
||
};
|
||
|
||
const analyse = {
|
||
id: nextId('sheet'),
|
||
name: 'Analyse',
|
||
objects: [umsatz, kosten, uebersicht, chart],
|
||
};
|
||
// second sheet demos the per-sheet backdrop colour + hidden grid
|
||
const blatt2 = {
|
||
id: nextId('sheet'),
|
||
name: 'Blatt 2',
|
||
objects: [],
|
||
background: '#f4f1ff',
|
||
showGrid: false,
|
||
};
|
||
|
||
return {
|
||
version: 2,
|
||
sheets: [analyse, blatt2],
|
||
activeSheetId: analyse.id,
|
||
activeObjectId: umsatz.id,
|
||
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||
locale: 'de-DE',
|
||
csvDelimiter: ',',
|
||
customFunctions: [],
|
||
namedRanges: [
|
||
// demos "🏷 Namen": =Umsatz_Q1 anywhere resolves to Umsatz!B2:B6, even
|
||
// from a different sheet, without a table qualifier.
|
||
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
||
],
|
||
macros: [],
|
||
};
|
||
}
|
||
|
||
/** An empty document: one sheet with one empty table, ready to type into. */
|
||
export function createBlankDocument(): DocumentState {
|
||
const table = makeTable('Tabelle 1', 40, 40, 12, 6, []);
|
||
const sheet = { id: nextId('sheet'), name: 'Blatt 1', objects: [table] };
|
||
return {
|
||
version: 2,
|
||
sheets: [sheet],
|
||
activeSheetId: sheet.id,
|
||
activeObjectId: table.id,
|
||
selection: { tableId: table.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||
locale: 'de-DE',
|
||
csvDelimiter: ',',
|
||
customFunctions: [],
|
||
namedRanges: [],
|
||
macros: [],
|
||
};
|
||
}
|