web routing is POG now

This commit is contained in:
Priec
2026-08-12 23:40:14 +02:00
parent 10c8235420
commit f8e483efa8
28 changed files with 1015 additions and 697 deletions

View File

@@ -9,10 +9,9 @@
<div>
<p class="eyebrow">Workspace</p>
<h1>Admin panel</h1>
<p>Browse profiles, tables, and their physical columns.</p>
<p>Browse profiles, tables, and their physical columns. Pick a table to reach what can be done to it.</p>
</div>
<div class="actions">
{% if page.can_manage_tables %}<a href="/admin/table-definition">Table definition</a>{% endif %}
{% if page.can_manage_tables %}<a href="/admin/tables/new">Add table</a>{% endif %}
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}

View File

@@ -50,9 +50,38 @@
</small>
</button>
</form>
{#
The actions for the table you are pointed at, next to the table
itself. Each one is a page of its own; this pane is where you
choose what to act on, so it is also where you say what to do.
#}
{% if page.selected_table.as_deref() == Some(table.name.as_str()) %}
<div class="browser-actions">
{% if page.can_manage_tables && !table.is_system() %}
<a href="/admin/tables/columns?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">Add / rename columns</a>
<a class="danger-action" href="/admin/tables/delete?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">Delete table</a>
{% endif %}
<a href="/permissions/grants">Who may use it</a>
</div>
{% endif %}
{% endfor %}
{% if page.selected_table.is_none() %}
<p class="empty">Pick a table to add columns to it, delete it, or say who may use it.</p>
{% endif %}
{% endif %}
</div>
{% if page.can_manage_tables && page.selected_profile.is_some() %}
<div class="browser-actions pane-footer">
{% if page.is_global() %}
<a href="/admin/tables/new?global=true">+ New global table</a>
{% else %}
<a href="/admin/tables/new?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">+ New table</a>
<a href="/admin/tables/from-template?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">From template</a>
<a href="/admin/profiles/copy?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Copy profile</a>
<a href="/admin/profiles/history?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Rename history</a>
{% endif %}
</div>
{% endif %}
</section>
<section class="pane">

View File

@@ -29,7 +29,7 @@
</label>
<label>Column type
<select name="column_type_input"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
hx-trigger="change" hx-include="#column-form"
hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
@@ -134,7 +134,7 @@
</div>
<button type="button" class="secondary"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "add-column"}'>Stage column</button>
@@ -153,7 +153,7 @@
there is no column of its own name to index. #}
{% if page.columns.is_indexable(*loop.index0) %}
<button type="button" class="toggle"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
@@ -168,7 +168,7 @@
</td>
<td>
<button type="button" class="danger"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
</td>

View File

@@ -0,0 +1,23 @@
{# GET /admin/tables/columns — crate::pages::admin::table_definition::ui::ColumnsPage #}
{% extends "ui/base.html" %}
{% block title %}Columns{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
{#
One swap target for the page's body. Both writes answer with this markup
re-read from the backend, so the screen after a change is the definition
as it now is, not the form that was submitted.
#}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/columns_panel.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{#
The columns page's body — crate::pages::admin::table_definition::ui::ColumnsFragment.
Both writes on this page swap it, so it carries the outcome as well as the
forms.
#}
{% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %}
{#
Append columns. The panel stages columns without writing anything; the
selection travels in the URL so the posted fields are exactly the ones the
Add-table builder posts, and both are decoded by the same code.
#}
<section class="panel">
<h2>Add columns to <code>{{ page.selection.table }}</code></h2>
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
<form id="column-form"
hx-post="/admin/tables/columns{{ page.selection.query() }}"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>Rename a column</h2>
<p class="hint">Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.</p>
<form hx-post="/admin/tables/rename"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label>Column
<select name="old_column_name">
<option value="">Choose a column</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>New name
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
</div>
<div class="form-actions">
<button type="submit">Rename column</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>Columns it has now <span class="count">{{ detail.columns.len() }}</span></h2>
<table class="builder-table">
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
<td>
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if !detail.scripts.is_empty() %}
<h3 class="panel-subhead">Scripts</h3>
<table class="builder-table">
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
{% for script in detail.scripts %}
<tr>
<td><code>{{ script.target_column }}</code></td>
<td>{{ script.target_column_type }}</td>
<td>
{{ script.description }}
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}

View File

@@ -0,0 +1,76 @@
{#
The heading and action switcher shared by the five table-definition pages.
Every one of them carries a `page` (crate::pages::admin::table_definition::
state::TableDefinitionPageState), whose `active` says which is open.
These are deliberately separate pages, for the same reason the permission
sections are: adding a column, dropping a table, copying a profile and
reading its rename history are four different decisions, and stacking them
on one screen was what made the old workspace impossible to find anything
in. Which links appear follows how much has been selected — the table-wide
ones need a table, the profile-wide ones only a profile.
#}
<section class="heading">
<div>
<p class="eyebrow">
{%- if page.selection.has_table() %}{{ page.selection.scope_label() }}{% else %}Table definition{% endif -%}
</p>
<h1>
{%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label() }}{% endif -%}
</h1>
<p>
{%- if let Some(detail) = page.detail -%}
{{ detail.columns.len() }} columns · identified by
{%- if detail.row_display_columns.is_empty() %} its id
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
{%- if let Some(summary) = page.selected_table() -%}
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
{%- endif -%}
.
{%- else -%}
Everything on this page acts on this profile.
{%- endif -%}
</p>
</div>
<div class="actions">
<a href="/admin{% if page.selection.has_table() %}{{ page.selection.query() }}{% else %}{{ page.selection.profile_query() }}{% endif %}">← Admin panel</a>
</div>
</section>
<nav class="tabs" aria-label="table actions">
{% if page.selection.has_table() %}
{% if page.table_is_writable() %}
<a href="/admin/tables/columns{{ page.selection.query() }}" class="tab {% if page.is("columns") %}selected{% endif %}" {% if page.is("columns") %}aria-current="page"{% endif %}>
<span>Columns</span><small>Append columns and rename them</small>
</a>
<a href="/admin/tables/delete{{ page.selection.query() }}" class="tab {% if page.is("delete") %}selected{% endif %}" {% if page.is("delete") %}aria-current="page"{% endif %}>
<span>Delete</span><small>Drop this table</small>
</a>
{% endif %}
<a href="/permissions/grants" class="tab">
<span>Access</span><small>Which roles may read and write it</small>
</a>
{% endif %}
{% if page.selection.has_profile() && !page.selection.is_global() %}
<a href="/admin/profiles/copy{{ page.selection.profile_query() }}" class="tab {% if page.is("copy") %}selected{% endif %}" {% if page.is("copy") %}aria-current="page"{% endif %}>
<span>Copy profile</span><small>Duplicate its structure</small>
</a>
<a href="/admin/tables/from-template{{ page.selection.profile_query() }}" class="tab {% if page.is("template") %}selected{% endif %}" {% if page.is("template") %}aria-current="page"{% endif %}>
<span>From template</span><small>Create tables from a Typst invoice</small>
</a>
<a href="/admin/profiles/history{{ page.selection.profile_query() }}" class="tab {% if page.is("history") %}selected{% endif %}" {% if page.is("history") %}aria-current="page"{% endif %}>
<span>Rename history</span><small>How columns got their names</small>
</a>
{% endif %}
</nav>
{% if page.selection.has_table() && !page.table_is_writable() %}
<section class="panel">
<h2>Backend-managed</h2>
<p class="hint">
This table is the backend's own. Its definition is visible in the
<a href="/admin{{ page.selection.query() }}">admin panel</a>, but it can only be
changed through the backend's own APIs, so none of the write actions apply to it.
</p>
</section>
{% endif %}

View File

@@ -0,0 +1,14 @@
{# GET /admin/profiles/copy — crate::pages::admin::table_definition::ui::CopyPage #}
{% extends "ui/base.html" %}
{% block title %}Copy profile{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/copy_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{#
The copy-profile page's body —
crate::pages::admin::table_definition::ui::CopyFragment.
#}
{% include "pages/admin/table_definition/feedback.html" %}
<section class="panel">
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
<form hx-post="/admin/profiles/copy"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>New profile name
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
</label>
</div>
{% if !page.copy_candidates().is_empty() %}
<div class="check-group">
{% for table in page.copy_candidates() %}
<label class="check">
<input type="checkbox" name="table_names" value="{{ table.name }}"
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
{{ table.name }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="form-actions">
<button type="submit">Copy profile</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,14 @@
{# GET /admin/tables/delete — crate::pages::admin::table_definition::ui::DeletePage #}
{% extends "ui/base.html" %}
{% block title %}Delete table{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/delete_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{#
The delete page's body — crate::pages::admin::table_definition::ui::DeleteFragment.
Only a refused delete swaps this: a successful one redirects to the admin
panel, because the table this page was about no longer exists.
#}
{% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %}
<section class="panel danger-panel">
<h2>Delete <code>{{ page.selection.table }}</code></h2>
<p class="hint">
Drops the table and its definition, and the profile too when this was its
last table. The backend refuses to delete a table that still has rows.
</p>
{% if let Some(summary) = page.selected_table() %}
{% if !summary.depends_on.is_empty() %}
<p class="hint">It links to {{ summary.depends_on|join(", ") }}.</p>
{% endif %}
{% endif %}
<form hx-post="/admin/tables/delete"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="danger-submit">Delete table</button>
</div>
</form>
</section>
{% else if !page.selection.has_table() %}
<section class="panel">
<h2>No table chosen</h2>
<p class="hint">Pick the table to delete in the <a href="/admin{{ page.selection.profile_query() }}">admin panel</a> first.</p>
</section>
{% endif %}

View File

@@ -0,0 +1,24 @@
{#
The outcome of the last write, reported the way every page here reports it:
inline as an alert, as a modal that has to be dismissed, and — on success —
as a toast that outlives the swap.
Included at the top of each page's panel fragment, because the fragment is
what a write swaps, so this is the markup that has to carry the reason.
#}
{% import "ui/alert.html" as feedback_alert %}
{% import "ui/dialog.html" as feedback_dialog %}
{% import "ui/toast.html" as feedback_toast %}
{%- if let Some(message) = page.error %}{% call feedback_alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call feedback_toast::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call feedback_dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %}
{% if !sql.is_empty() %}
<section class="panel">
<h2>SQL the backend ran</h2>
<pre class="sql-preview">{{ sql }}</pre>
</section>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,14 @@
{# GET /admin/tables/from-template — crate::pages::admin::table_definition::ui::TemplatePage #}
{% extends "ui/base.html" %}
{% block title %}Create from template{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/from_template_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{#
The invoice-template page's body —
crate::pages::admin::table_definition::ui::TemplateFragment.
#}
{% include "pages/admin/table_definition/feedback.html" %}
{% if !page.generated.is_empty() %}
<section class="panel">
<h2>Tables created from the template</h2>
<table class="builder-table">
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
<tbody>
{% for generated in page.generated %}
<tr>
<td><code>{{ generated.table_name }}</code></td>
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint"></span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="panel">
<h2>Create tables from an invoice template</h2>
<p class="hint">
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
template. A path that matches an existing table and column becomes a
reference to it; anything else becomes a TEXT column to refine later, and
every <code>[]</code> becomes a child table.
</p>
<form hx-post="/admin/tables/from-template"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>Table name
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
</label>
<label>Row display columns
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
<small>Comma-separated, and only for the root table.</small>
</label>
<label class="wide">Template source
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = (&#10; &quot;sidlo.nazov&quot;,&#10; &quot;people[].name&quot;,&#10;)">{{ page.invoice.typst_source }}</textarea>
</label>
</div>
<div class="form-actions">
<button type="submit">Create from template</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,39 @@
{#
GET /admin/profiles/history — crate::pages::admin::table_definition::ui::HistoryPage
Read-only, so there is nothing here to swap and no panel fragment beside it.
A table in the selection narrows the history to that table; the loader is
what applies that, by passing its id to the backend.
#}
{% extends "ui/base.html" %}
{% block title %}Rename history{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<section class="panel">
<h2>Column rename history</h2>
{% if page.history.is_empty() %}
<p class="hint">
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
<tbody>
{% for entry in page.history %}
<tr>
<td><code>{{ entry.table_name }}</code></td>
<td><code>{{ entry.old_column_name }}</code></td>
<td><code>{{ entry.new_column_name }}</code></td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
{% endblock %}

View File

@@ -1,32 +0,0 @@
{# GET /admin/table-definition — crate::pages::admin::table_definition::ui::TableDefinitionPage #}
{% extends "ui/base.html" %}
{% block title %}Table definition{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Table definition</p>
<h1>Table definition</h1>
<p>Choose Global to manage tables shared by every profile, or choose a profile to manage only its own tables.</p>
</div>
<div class="actions">
<a href="/admin">← Admin panel</a>
</div>
</section>
{#
One swap target for the whole workspace. Every write answers with this
markup re-read from the backend, so the screen after a change is the
definition as it now is, not the form that was submitted.
#}
<div id="table-definition-workspace" aria-live="polite">
{% include "pages/admin/table_definition/workspace.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -1,342 +0,0 @@
{#
The whole workspace — crate::pages::admin::table_definition::ui::WorkspaceFragment,
and what table_definition.html embeds on first load.
Which panels appear follows how much has been selected: the copy and invoice
template panels need a profile, everything else needs a table, and a
backend-managed ("system") table gets none of the write panels at all,
because the server refuses every one of them for it.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call toast::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %}
{% if !sql.is_empty() %}
<section class="panel">
<h2>SQL the backend ran</h2>
<pre class="sql-preview">{{ sql }}</pre>
</section>
{% endif %}
{% endif %}
{% if !page.generated.is_empty() %}
<section class="panel">
<h2>Tables created from the template</h2>
<table class="builder-table">
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
<tbody>
{% for generated in page.generated %}
<tr>
<td><code>{{ generated.table_name }}</code></td>
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint"></span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="panel">
<h2>Selection</h2>
<div class="form-grid">
<label>Scope
<select name="profile" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<option value="">Choose a scope</option>
<option value="__global" {% if page.selection.is_global() %}selected{% endif %}>Global — all profiles</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selection.profile == *profile %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
</select>
</label>
{% if page.selection.has_profile() %}
<label>Table
<select name="table" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-include="[name='profile']">
<option value="">Choose a table</option>
{% for table in page.tables %}
<option value="{{ table.name }}" {% if page.selection.table == table.name %}selected{% endif %}>
{{ table.name }}{% if table.is_system() %} (system){% endif %}
</option>
{% endfor %}
</select>
</label>
{% endif %}
</div>
{% if page.selection.has_profile() %}
<div class="actions panel-actions">
{% if page.selection.is_global() %}
<a href="/admin/tables/new?global=true">+ Create a global table</a>
{% else %}
<a href="/admin/tables/new?profile={{ page.selection.profile }}">+ Create a table in this profile</a>
{% endif %}
</div>
{% if page.tables.is_empty() %}
<p class="hint">This profile has no tables yet.</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Kind</th><th>Depends on</th><th>Row display</th></tr></thead>
<tbody>
{% for table in page.tables %}
<tr>
<td><code>{{ table.name }}</code></td>
<td>{{ table.table_kind }}</td>
<td>{% if table.depends_on.is_empty() %}<span class="hint"></span>{% else %}{{ table.depends_on|join(", ") }}{% endif %}</td>
<td>{% if table.row_display_columns.is_empty() %}<span class="hint">id</span>{% else %}{{ table.row_display_columns|join(", ") }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="hint">Nothing is selected yet. Every panel below acts on one profile, and most of them on one table inside it.</p>
{% endif %}
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>{{ detail.name }} <span class="count">{{ detail.columns.len() }} columns</span></h2>
<p class="hint">
Identified by
{%- if detail.row_display_columns.is_empty() %} its id
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
{%- if let Some(summary) = page.selected_table() -%}
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
{%- endif -%}
.
</p>
{% if detail.is_system() %}
<p class="hint">This table is backend-managed. Its definition is shown here, but it can only be changed through the backend's own APIs.</p>
{% endif %}
<table class="builder-table">
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
<td>
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if !detail.scripts.is_empty() %}
<h3 class="panel-subhead">Scripts</h3>
<table class="builder-table">
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
{% for script in detail.scripts %}
<tr>
<td><code>{{ script.target_column }}</code></td>
<td>{{ script.target_column_type }}</td>
<td>
{{ script.description }}
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}
{% if !page.permission_object.is_empty() %}
<section class="panel">
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
<p class="hint">
These grants cover this table family and take effect on subsequent requests without replacing the
current session. Wider access — a whole profile, or every table at once — is on the
<a href="/permissions/grants">Permissions page</a>.
</p>
<table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr>
<td>{{ role.role }}</td>
<td><div class="actions">{% for permission in role.actions %}
{% if permission.direct %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="danger">{{ permission.action }} ✓</button>
</form>
{% else if permission.effective %}
<span class="tag">{{ permission.action }} · inherited</span>
{% else %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
</form>
{% endif %}
{% endfor %}</div></td>
</tr>{% endfor %}</tbody>
</table>
<div id="permission-status" aria-live="polite"></div>
</section>
{% endif %}
{% if page.table_is_writable() %}
{#
Append columns. The panel stages columns without writing anything; the
selection travels in the URL so the posted fields are exactly the ones the
Add-table builder posts, and both are decoded by the same code.
#}
<section class="panel">
<h2>Add columns to <code>{{ page.selection.table }}</code></h2>
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
<form id="column-form"
hx-post="/admin/table-definition/columns{{ page.selection.query() }}"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>Rename a column</h2>
<p class="hint">Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.</p>
<form hx-post="/admin/table-definition/rename"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label>Column
<select name="old_column_name">
<option value="">Choose a column</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>New name
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
</div>
<div class="form-actions">
<button type="submit">Rename column</button>
</div>
</form>
</section>
{% endif %}
<section class="panel danger-panel">
<h2>Delete <code>{{ page.selection.table }}</code></h2>
<p class="hint">
Drops the table and its definition, and the profile too when this was its
last table. The backend refuses to delete a table that still has rows.
</p>
<form hx-post="/admin/table-definition/delete"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="danger-submit">Delete table</button>
</div>
</form>
</section>
{% endif %}
{% if page.selection.has_profile() && !page.selection.is_global() %}
<section class="panel">
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
<form hx-post="/admin/table-definition/copy"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>New profile name
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
</label>
</div>
{% if !page.copy_candidates().is_empty() %}
<div class="check-group">
{% for table in page.copy_candidates() %}
<label class="check">
<input type="checkbox" name="table_names" value="{{ table.name }}"
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
{{ table.name }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="form-actions">
<button type="submit">Copy profile</button>
</div>
</form>
</section>
<section class="panel">
<h2>Create tables from an invoice template</h2>
<p class="hint">
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
template. A path that matches an existing table and column becomes a
reference to it; anything else becomes a TEXT column to refine later, and
every <code>[]</code> becomes a child table.
</p>
<form hx-post="/admin/table-definition/invoice-template"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>Table name
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
</label>
<label>Row display columns
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
<small>Comma-separated, and only for the root table.</small>
</label>
<label class="wide">Template source
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = (&#10; &quot;sidlo.nazov&quot;,&#10; &quot;people[].name&quot;,&#10;)">{{ page.invoice.typst_source }}</textarea>
</label>
</div>
<div class="form-actions">
<button type="submit">Create from template</button>
</div>
</form>
</section>
<section class="panel">
<h2>Column rename history</h2>
{% if page.history.is_empty() %}
<p class="hint">
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
<tbody>
{% for entry in page.history %}
<tr>
<td><code>{{ entry.table_name }}</code></td>
<td><code>{{ entry.old_column_name }}</code></td>
<td><code>{{ entry.new_column_name }}</code></td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}