Files
komp_ac/web/templates/pages/add_table/builder.html
2026-08-15 15:29:15 +02:00

450 lines
24 KiB
HTML

{#
POST /admin/tables/builder — crate::pages::add_table::ui::BuilderFragment.
The whole builder, which is also what `add_table.html` embeds on first load.
Every control posts the entire draft back to /admin/tables/builder and swaps
this markup in again, so the rules live on the server exactly as they do in
the TUI client. The hidden inputs in the columns, relations and display
sections are what carry the draft across requests.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{#
A failure is a dialog, not an inline alert: the builder is taller than the
viewport, so an alert at the top is off screen for anyone who has scrolled
down to the column list or the Create table button. The alert stays too, as
what is left on the page after the dialog is dismissed.
#}
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("builder-could-not-continue"), message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call toast::success(nav.tr("builder-draft-updated"), message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error(nav.locale, nav.tr("builder-could-not-continue"), message) %}{% endcall %}{% endif -%}
<section class="builder-section">
<h2>{{ nav.tr("builder-table-details") }}</h2>
<div class="form-grid">
{# Penguin UI custom radio, adapted from radio/custom-radio.html with this form's values and htmx behaviour. #}
<fieldset class="col-span-full m-0 border-0 p-0">
<legend class="mb-2 text-xs text-on-surface">{{ nav.tr("builder-scope-question") }}</legend>
<div class="grid grid-cols-1 gap-2">
<label class="relative flex items-center gap-3 rounded-radius border border-outline bg-surface-alt px-3 py-2.5 text-on-surface transition-colors has-checked:border-primary has-checked:bg-primary/5 has-checked:text-on-surface-strong has-focus:outline-2 has-focus:outline-offset-2 has-focus:outline-primary">
<input id="scope-profile" type="radio" name="global" value="false" class="peer sr-only"
aria-describedby="scope-profile-description"
{% if !page.draft.global %}checked{% endif %}
hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="invisible size-5 shrink-0 peer-checked:visible">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path>
</svg>
<span class="flex flex-col"><strong class="text-sm font-medium">{{ nav.tr("builder-scope-profile") }}</strong><small id="scope-profile-description">{{ nav.tr("builder-scope-profile-hint") }}</small></span>
</label>
<label class="relative flex items-center gap-3 rounded-radius border border-outline bg-surface-alt px-3 py-2.5 text-on-surface transition-colors has-checked:border-primary has-checked:bg-primary/5 has-checked:text-on-surface-strong has-focus:outline-2 has-focus:outline-offset-2 has-focus:outline-primary">
<input id="scope-global" type="radio" name="global" value="true" class="peer sr-only"
aria-describedby="scope-global-description"
{% if page.draft.global %}checked{% endif %}
hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="invisible size-5 shrink-0 peer-checked:visible">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path>
</svg>
<span class="flex flex-col"><strong class="text-sm font-medium">{{ nav.tr("builder-scope-global") }}</strong><small id="scope-global-description">{{ nav.tr("builder-scope-global-hint") }}</small></span>
</label>
</div>
</fieldset>
{% if !page.draft.global %}
<label>{{ nav.tr("builder-which-profile") }}
<select name="profile_name" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">{{ nav.tr("builder-choose-profile") }}</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selected_profile() == profile.as_str() %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
<option value="__new__" {% if page.draft.creating_new_profile %}selected{% endif %}>+ {{ nav.tr("builder-new-profile") }}…</option>
</select>
</label>
{% else %}
<input type="hidden" name="profile_name" value="{{ page.draft.profile_name }}">
{% endif %}
{% if page.draft.show_profile_name_input() %}
<label>{{ nav.tr("builder-new-profile-name") }}
<input name="profile_name_input" value="{{ page.draft.profile_name_input }}" placeholder="bookkeeping">
</label>
{% endif %}
{% if page.draft.show_accounting_currency() %}
<label>{{ nav.tr("builder-accounting-currency") }}
<input name="accounting_currency" value="{{ page.draft.accounting_currency }}" list="currency-codes"
maxlength="3" placeholder="EUR">
{#
The list stays complete: EUR-only books are the server's current
limitation, not the shape of the field. When it accepts more, this
hint goes and nothing else here has to change.
#}
<small>{{ nav.tr("builder-accounting-currency-hint") }}</small>
</label>
{% else %}
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
{% endif %}
{#
No hx-trigger here. A text input fires `change` on blur, so mousing into
the next field would swap the whole builder out from under the click that
is still in flight: the click lands on a detached node and the field only
takes focus on a second click. Nothing rendered depends on the table name
on its own — it filters the table out of its own link-target list, and
that list is only rendered after a column-type `change`, which posts the
current name along with the rest of the form.
#}
<label>{{ nav.tr("builder-table-name") }}
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice">
</label>
</div>
</section>
<section class="builder-section">
<h2>{{ nav.tr("builder-add-column-heading") }}</h2>
<div class="form-grid">
{#
A compound choice is a definition row, not a column: it is named after
its own type and expands into companions the server creates. There is
nothing to name, so the field is replaced by what the choice will add.
#}
{% if !page.draft.columns.pending_is_compound() %}
<label>{% if page.draft.columns.show_link_target() %}{{ nav.tr("td-link-alias") }}{% else %}{{ nav.tr("td-column-name") }}{% endif %}
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
{% if page.draft.columns.show_link_target() %}<small>{{ nav.tr("td-link-alias-hint") }}</small>{% endif %}
</label>
{% endif %}
<label>{{ nav.tr("td-column-type") }}
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">{{ nav.tr("td-choose-type") }}</option>
{% for column_type in column_types %}
<option value="{{ column_type }}" {% if page.draft.columns.type_input == *column_type %}selected{% endif %}>{% if *column_type == "link" %}{{ nav.tr("td-fk-link") }}{% else %}{{ column_type }}{% endif %}</option>
{% endfor %}
</select>
</label>
{% if page.draft.columns.show_temporal_type() %}
<label>{{ nav.tr("td-temporal-type") }}
<select name="temporal_type_input">
<option value="">{{ nav.tr("td-choose-temporal-type") }}</option>
{% for temporal_type in temporal_types %}
<option value="{{ temporal_type }}" {% if page.draft.columns.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.columns.show_gtin_type() %}
<label>{{ nav.tr("td-gtin-type") }}
<select name="gtin_type_input">
<option value="">{{ nav.tr("td-choose-gtin-length") }}</option>
{% for gtin_type in gtin_types %}
<option value="{{ gtin_type }}" {% if page.draft.columns.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.columns.show_decimal_arguments() %}
<label>{{ nav.tr("td-precision") }}
<input name="decimal_precision_input" value="{{ page.draft.columns.decimal_precision_input }}"
inputmode="numeric" placeholder="12">
<small>{{ nav.tr("td-precision-hint") }}</small>
</label>
<label>{{ nav.tr("td-scale") }}
<input name="decimal_scale_input" value="{{ page.draft.columns.decimal_scale_input }}"
inputmode="numeric" placeholder="3">
<small>{{ nav.tr("td-scale-hint") }}</small>
</label>
{% endif %}
{% if page.draft.columns.show_link_target() %}
<label>{{ nav.tr("td-referenced-table") }}
<select name="link_table_input">
<option value="">{{ nav.tr("td-choose-table") }}</option>
{% if !page.draft.global_relation_tables().is_empty() %}
<optgroup label="{{ nav.tr("td-group-global") }}">
{% for table in page.draft.global_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if !page.draft.user_relation_tables().is_empty() %}
<optgroup label="{{ nav.tr("td-group-user") }}">
{% for table in page.draft.user_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if !page.draft.system_relation_tables().is_empty() %}
<optgroup label="{{ nav.tr("td-group-system") }}">
{% for table in page.draft.system_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
{% if page.draft.relation_tables.is_empty() %}<small>{{ nav.tr("td-no-eligible-tables") }}</small>{% endif %}
</label>
{% endif %}
{% if page.draft.columns.show_money_options() %}
<label>{{ nav.tr("td-currency") }}
<input name="column_currency_input" value="{{ page.draft.columns.currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
<label>{{ nav.tr("td-rounding") }}
<select name="column_rounding_input">
<option value="none" {% if page.draft.columns.rounding_input != "half-up" %}selected{% endif %}>{{ nav.tr("td-rounding-none") }}</option>
<option value="half-up" {% if page.draft.columns.rounding_input == "half-up" %}selected{% endif %}>{{ nav.tr("td-rounding-half-up") }}</option>
</select>
</label>
{% endif %}
{# None of the three applies to a definition row: it leaves no column to
index, nothing to keep a quantity ledger on, and no value to require. #}
{% if !page.draft.columns.pending_is_compound() %}
{#
A link is left out rather than defaulted to "no": the server builds an
index for every foreign key as it creates the table, and refuses a
definition that asks for one on top — so there is no choice to offer.
#}
{% if page.draft.columns.show_indexing() %}
<label>{{ nav.tr("td-indexing") }}
<select name="column_indexing_input">
<option value="no" {% if page.draft.columns.indexing_input != "yes" %}selected{% endif %}>{{ nav.tr("common-no") }}</option>
<option value="yes" {% if page.draft.columns.indexing_input == "yes" %}selected{% endif %}>{{ nav.tr("common-yes") }}</option>
</select>
</label>
{% else if page.draft.columns.pending_is_auto_indexed() %}
<div class="field-note">
<span class="field-label">{{ nav.tr("td-indexing") }}</span>
<p class="hint">{{ nav.tr("td-indexed-automatically") }}</p>
</div>
{% endif %}
<label>{{ nav.tr("td-required") }}
<select name="column_required_input">
<option value="no" {% if page.draft.columns.required_input != "yes" %}selected{% endif %}>{{ nav.tr("common-no") }}</option>
<option value="yes" {% if page.draft.columns.required_input == "yes" %}selected{% endif %}>{{ nav.tr("common-yes") }}</option>
</select>
<small>{{ nav.tr("td-required-hint") }}</small>
</label>
{% if page.draft.columns.show_quantity_ledger() %}
<label>{{ nav.tr("td-quantity-ledger") }}
<select name="column_quantity_ledger_input">
<option value="no" {% if page.draft.columns.quantity_ledger_input != "yes" %}selected{% endif %}>{{ nav.tr("common-no") }}</option>
<option value="yes" {% if page.draft.columns.quantity_ledger_input == "yes" %}selected{% endif %}>{{ nav.tr("common-yes") }}</option>
</select>
<small>{{ nav.tr_args("td-ql-only", [("types", page.draft.columns.quantity_ledger_types())]) }}</small>
</label>
{% endif %}
{% endif %}
</div>
{% if page.draft.global %}
<p class="hint">{{ nav.tr("builder-shared-table-hint") }}</p>
{% endif %}
{% if page.draft.columns.pending_is_compound() %}
<div class="compound-note">
<p><code>{{ page.draft.columns.pending_compound_name() }}</code> {{ nav.tr("builder-compound-intro") }}</p>
<ul>
{% for generated in page.draft.columns.pending_generated_columns() %}
{# The currency and rounding are named rather than shown: those two
inputs sit above with no `change` trigger of their own, so their
current values are only known here on the next render. #}
<li><code>{{ generated.name }}</code> <span class="hint">{{ generated.data_type }}{% if generated.inherits_currency %} — {{ nav.tr("builder-inherits-currency-hint") }}{% endif %}</span></li>
{% endfor %}
{% if page.draft.columns.pending_compound_name() == "accounting" %}
<li><code>account_id</code> <span class="hint">{{ nav.tr("builder-account-system-hint")|safe }}</span></li>
{% endif %}
</ul>
<p class="hint">{{ nav.tr("builder-rename-generated-hint")|safe }}</p>
<p class="hint">{{ nav.tr_args_html("builder-reserved-hint", [("type", page.draft.columns.pending_compound_name())])|safe }}</p>
</div>
{% endif %}
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>{{ nav.tr("builder-add-column-button") }}</button>
</section>
<section class="builder-section">
<h2>{{ nav.tr("builder-columns-heading") }} <span class="count">{{ page.draft.columns.added.len() }}</span></h2>
{% if page.draft.columns.is_empty() %}
<p class="hint">{{ nav.tr("builder-no-columns") }} <em>{{ nav.tr("builder-add-column-button") }}</em>.</p>
{% else %}
<p class="hint">{{ nav.tr("builder-columns-order-hint") }}</p>
<table class="builder-table">
<thead><tr><th>{{ nav.tr("builder-th-order") }}</th><th>{{ nav.tr("builder-th-name") }}</th><th>{{ nav.tr("builder-th-type") }}</th><th>{{ nav.tr("builder-th-indexed") }}</th><th>{{ nav.tr("builder-th-options") }}</th><th></th></tr></thead>
<tbody>
{#
Every declared column, each followed by the columns the server will
generate from it. `row.index` is what says which is which: a
generated column has none, because it is not the user's to move,
index or remove — it belongs to the definition row above it.
#}
{% for row in page.column_rows() %}
<tr {% if row.index.is_none() %}class="generated-row"{% endif %}>
<td class="order">
{% if let Some(index) = row.index %}
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("builder-move-up", [("name", row.name.clone())]) }}"
{% if row.first %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "move-column-up", "index": "{{ index }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("builder-move-down", [("name", row.name.clone())]) }}"
{% if row.last %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "move-column-down", "index": "{{ index }}"}'{% endif %}>&darr;</button>
{% else %}
<span class="hint">&#8627;</span>
{% endif %}
</td>
<td>
<code>{{ row.name }}</code>
{# The list only reports a rename; it is asked for below, so that
this column stays something to read. #}
{% if !row.alias.is_empty() %}<span class="tag">{{ nav.tr_args("builder-renamed-to", [("alias", row.alias.clone())]) }}</span>{% endif %}
</td>
<td>{{ row.data_type }}</td>
<td>
{# A compound column expands into schema-managed companions, so
there is no column of its own name to index. #}
{% if row.indexable %}
{% if let Some(index) = row.index %}
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-index", "index": "{{ index }}"}'>
{% if row.indexed %}[x]{% else %}[ ]{% endif %}
</button>
{% endif %}
{% else if row.indexed %}
{# Not a choice anyone made: a link and the account foreign key
are indexed by the server as it creates the table. #}
<span class="tag">{{ nav.tr("td-indexed-automatically-tag") }}</span>
{% else %}
<span class="hint">&mdash;</span>
{% endif %}
</td>
<td>{% for tag in row.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
<td>
{% if let Some(index) = row.index %}
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ index }}"}'>{{ nav.tr("common-remove") }}</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# The draft itself: one set of fields per column, in order. #}
{% for column in page.draft.columns.added %}
<input type="hidden" name="column_names" value="{{ column.name }}">
<input type="hidden" name="column_types" value="{{ column.data_type }}">
<input type="hidden" name="column_indexed" value="{% if column.indexed %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_quantity_ledger" value="{% if column.quantity_ledger %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_required" value="{% if column.required %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_rounding" value="{{ column.money_mode.label() }}">
<input type="hidden" name="column_currencies" value="{{ column.currency }}">
{% endfor %}
</section>
{#
Renaming what a definition row generates. A generated column is named by the
backend, so it cannot be named in the request that creates the table — but the
name is a display name, and an alias here is applied as a rename as soon as
the table exists. Leaving a field empty keeps the backend's name.
It is a closed `<details>`, so the default — the backend's names — costs
nothing to read past. Fields inside a closed `<details>` are still part of the
form, so the aliases travel with every builder post either way.
#}
{% if !page.alias_rows().is_empty() %}
<section class="builder-section">
{# Open once a name has been asked for: the section is where those names are
read back, and a swap that closed it would hide what was just applied. #}
<details class="rename-generated" {% if page.draft.has_generated_aliases() %}open{% endif %}>
<summary>{{ nav.tr("builder-rename-generated") }}</summary>
<p class="hint">{{ nav.tr("builder-rename-generated-hint2") }}</p>
<ul class="rename-list">
{% for row in page.alias_rows() %}
<li>
<code>{{ row.source }}</code>
<span class="hint">{{ row.data_type }}</span>
<input type="hidden" name="generated_alias_sources" value="{{ row.source }}">
<input name="generated_alias_names" value="{{ row.alias }}"
aria-label="{{ nav.tr_args("builder-rename-aria", [("name", row.source.clone())]) }}" placeholder="{{ row.source }}">
</li>
{% endfor %}
</ul>
{#
A text field posts nothing on its own here, so the names typed above are
only carried by the next request. This button is that request: it applies
them, so the column list and the preview show the renamed columns.
#}
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "apply-generated-names"}'>{{ nav.tr("builder-apply-names") }}</button>
</details>
</section>
{% endif %}
<section class="builder-section">
{% for table in page.draft.relation_tables %}
<input type="hidden" name="relation_tables" value="{{ table }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>{{ nav.tr("builder-row-display") }}</h2>
<p class="hint">{{ nav.tr("builder-row-display-hint")|safe }}</p>
<ul class="builder-list">
{% for candidate in page.row_display_candidates() %}
<li>
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-display", "index": "{{ candidate.index }}"}'>{{ candidate.mark() }}</button>
<code>{{ candidate.name }}</code>
</li>
{% endfor %}
</ul>
{% for display in page.draft.row_display_columns %}
<input type="hidden" name="row_display_columns" value="{{ display }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>{{ nav.tr("builder-preview") }}</h2>
<table class="builder-table preview">
<thead><tr><th></th><th>{{ nav.tr("builder-preview-th-column") }}</th><th>{{ nav.tr("builder-preview-th-type") }}</th><th>{{ nav.tr("builder-preview-th-options") }}</th><th>{{ nav.tr("builder-preview-th-source") }}</th></tr></thead>
<tbody>
{% for row in page.draft.preview_rows(nav.locale) %}
<tr class="source-{{ row.source }}">
<td class="mark">{{ row.mark }}</td>
<td><code>{{ row.column }}</code></td>
<td>{{ row.data_type }}</td>
<td>{{ row.option }}</td>
<td>{{ row.source }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<div class="form-actions">
<a href="/admin">{{ nav.tr("common-cancel") }}</a>
<button type="submit">{{ nav.tr("builder-create-table") }}</button>
</div>