Files
numolus/app/frontend/components/NamedRangesModal.vue
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
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>
2026-09-28 20:09:06 +02:00

292 lines
7.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>