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>
292 lines
7.9 KiB
Vue
292 lines
7.9 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* "🏷 Namen" -- the exclusive UI for creating, viewing, editing and deleting
|
||
* the document's named ranges (a document-wide name standing for a fixed
|
||
* cell/range on a specific table, usable unqualified in any top-level
|
||
* formula -- see src/store/types.ts's `NamedRange`). Mirrors
|
||
* CustomFunctionsModal.vue's list/form structure.
|
||
*/
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import { looksLikeCell, parseA1, toA1 } from '../engine';
|
||
import { useDocument } from '../store/document';
|
||
import type { TableObject } from '../store/types';
|
||
import BaseModal from './BaseModal.vue';
|
||
|
||
const props = defineProps<{ open: boolean }>();
|
||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||
|
||
const store = useDocument();
|
||
|
||
type Mode = 'list' | 'edit';
|
||
const mode = ref<Mode>('list');
|
||
const editingId = ref<string | null>(null);
|
||
const form = reactive({ name: '', tableId: '', start: '', end: '' });
|
||
const formError = ref<string | null>(null);
|
||
|
||
const allTables = computed(() =>
|
||
store.doc.sheets.flatMap((s) =>
|
||
s.objects
|
||
.filter((o): o is TableObject => o.kind === 'table')
|
||
.map((t) => ({ id: t.id, name: t.name, sheetName: s.name })),
|
||
),
|
||
);
|
||
|
||
function tableLabel(tableId: string): string {
|
||
const t = allTables.value.find((x) => x.id === tableId);
|
||
return t ? `${t.name} (${t.sheetName})` : '?';
|
||
}
|
||
|
||
/** e.g. "B2" or "B2:B6" (single cell when start === end). */
|
||
function rangeLabel(r: { start: { col: number; row: number }; end: { col: number; row: number } }): string {
|
||
const a = toA1(r.start.col, r.start.row);
|
||
const b = toA1(r.end.col, r.end.row);
|
||
return a === b ? a : `${a}:${b}`;
|
||
}
|
||
|
||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||
// -progress never lingers into the next open.
|
||
watch(
|
||
() => props.open,
|
||
(o) => {
|
||
if (o) {
|
||
mode.value = 'list';
|
||
formError.value = null;
|
||
}
|
||
},
|
||
);
|
||
|
||
function openNew() {
|
||
editingId.value = null;
|
||
formError.value = null;
|
||
// prefill from the current cell selection, if any -- "create name from
|
||
// selection", same idea as Excel's own shortcut for this.
|
||
const sel = store.doc.selection;
|
||
if (sel) {
|
||
const rect = store.selectionRects(sel)[0];
|
||
form.tableId = sel.tableId;
|
||
form.start = toA1(rect.c0, rect.r0);
|
||
form.end = toA1(rect.c1, rect.r1);
|
||
} else {
|
||
form.tableId = allTables.value[0]?.id ?? '';
|
||
form.start = '';
|
||
form.end = '';
|
||
}
|
||
form.name = '';
|
||
mode.value = 'edit';
|
||
}
|
||
|
||
function openEdit(id: string) {
|
||
const r = store.doc.namedRanges.find((x) => x.id === id);
|
||
if (!r) return;
|
||
editingId.value = id;
|
||
form.name = r.name;
|
||
form.tableId = r.tableId;
|
||
form.start = toA1(r.start.col, r.start.row);
|
||
form.end = toA1(r.end.col, r.end.row);
|
||
formError.value = null;
|
||
mode.value = 'edit';
|
||
}
|
||
|
||
function save() {
|
||
const startTok = form.start.trim();
|
||
const endTok = form.end.trim() || startTok;
|
||
if (!looksLikeCell(startTok)) {
|
||
formError.value = `"${form.start}" ist kein gültiger Zellbezug.`;
|
||
return;
|
||
}
|
||
if (!looksLikeCell(endTok)) {
|
||
formError.value = `"${form.end}" ist kein gültiger Zellbezug.`;
|
||
return;
|
||
}
|
||
const start = parseA1(startTok);
|
||
const end = parseA1(endTok);
|
||
const result = editingId.value
|
||
? store.updateNamedRange(editingId.value, form.name, form.tableId, start, end)
|
||
: store.addNamedRange(form.name, form.tableId, start, end);
|
||
if (result && typeof result === 'object' && 'error' in result) {
|
||
formError.value = result.error;
|
||
return;
|
||
}
|
||
formError.value = null;
|
||
mode.value = 'list';
|
||
}
|
||
|
||
function cancelEdit() {
|
||
formError.value = null;
|
||
mode.value = 'list';
|
||
}
|
||
|
||
function removeRange(id: string, name: string) {
|
||
if (confirm(`Name "${name}" löschen? Formeln, die ihn verwenden, zeigen danach #NAME?.`)) {
|
||
store.deleteNamedRange(id);
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
emit('close');
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<BaseModal
|
||
:open="open"
|
||
:title="mode === 'list' ? 'Benannte Bereiche' : editingId ? 'Namen bearbeiten' : 'Neuer Name'"
|
||
@close="close"
|
||
>
|
||
<!-- ---- list view ---- -->
|
||
<template v-if="mode === 'list'">
|
||
<p v-if="!store.doc.namedRanges.length" class="hint">
|
||
Noch keine benannten Bereiche definiert. Mit „+ Neuer Name" einen anlegen und danach
|
||
wie <code>=Umsatz_Q1</code> in jeder Formel verwenden, ohne Tabellen-Qualifier.
|
||
</p>
|
||
<ul v-else class="nr-list">
|
||
<li v-for="r in store.doc.namedRanges" :key="r.id" class="nr-row">
|
||
<code class="nr-sig">
|
||
{{ r.name }} → {{ tableLabel(r.tableId) }}!{{ rangeLabel(r) }}
|
||
</code>
|
||
<span class="nr-actions">
|
||
<button class="mini" @click="openEdit(r.id)">Bearb.</button>
|
||
<button class="mini danger" title="Löschen" @click="removeRange(r.id, r.name)">🗑</button>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
|
||
<!-- ---- edit / create form ---- -->
|
||
<template v-else>
|
||
<label class="row">
|
||
Name
|
||
<input v-model="form.name" type="text" placeholder="z. B. Umsatz_Q1" />
|
||
</label>
|
||
|
||
<label class="row">
|
||
Tabelle
|
||
<select v-model="form.tableId">
|
||
<option v-for="t in allTables" :key="t.id" :value="t.id">
|
||
{{ t.name }} ({{ t.sheetName }})
|
||
</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="row range-row">
|
||
<span class="range-label">Bereich</span>
|
||
<div class="range-fields">
|
||
<input v-model="form.start" type="text" placeholder="B2" class="cell-input" />
|
||
<span class="range-sep">:</span>
|
||
<input v-model="form.end" type="text" placeholder="B6 (leer = nur B2)" class="cell-input" />
|
||
</div>
|
||
</div>
|
||
<p class="hint">
|
||
Eine leere zweite Zelle bedeutet „nur die erste Zelle" (kein Bereich). Der Name ist
|
||
danach in jeder Formel unqualifiziert nutzbar, z. B. <code>=SUM(Umsatz_Q1)</code> oder
|
||
<code>=Umsatz_Q1 + 1</code> für eine einzelne Zelle. Er überlebt eine spätere Umbenennung
|
||
der Tabelle; wird die Tabelle gelöscht, zeigen Formeln, die den Namen nutzen, danach
|
||
<code>#NAME?</code>.
|
||
</p>
|
||
|
||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<template v-if="mode === 'list'">
|
||
<button class="primary" @click="openNew">+ Neuer Name</button>
|
||
</template>
|
||
<template v-else>
|
||
<button @click="cancelEdit">Abbrechen</button>
|
||
<button class="primary" @click="save">Speichern</button>
|
||
</template>
|
||
</template>
|
||
</BaseModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.hint {
|
||
color: var(--text-dim);
|
||
font-size: 12.5px;
|
||
line-height: 1.5;
|
||
margin: 0;
|
||
}
|
||
.hint code {
|
||
background: var(--surface-2);
|
||
border-radius: 4px;
|
||
padding: 1px 5px;
|
||
}
|
||
.nr-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.nr-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
background: var(--surface-2);
|
||
}
|
||
.nr-sig {
|
||
font-size: 12.5px;
|
||
font-family: inherit;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.nr-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex: none;
|
||
}
|
||
.mini {
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
border-radius: 6px;
|
||
padding: 3px 9px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.mini:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
.mini.danger:hover {
|
||
border-color: #dc2626;
|
||
color: #dc2626;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
margin-bottom: 12px;
|
||
font-size: 12.5px;
|
||
color: var(--text-dim);
|
||
}
|
||
.row input[type='text'],
|
||
.row select {
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 5px 8px;
|
||
font: inherit;
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
}
|
||
.range-fields {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.cell-input {
|
||
width: 120px;
|
||
}
|
||
.range-sep {
|
||
color: var(--text-dim);
|
||
}
|
||
.form-error {
|
||
margin: 4px 0 0;
|
||
font-size: 12.5px;
|
||
color: #dc2626;
|
||
line-height: 1.4;
|
||
}
|
||
</style>
|