webs unified
This commit is contained in:
37
web/templates/pages/add_logic/add_logic.html
Normal file
37
web/templates/pages/add_logic/add_logic.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{# GET /admin/logic/new — crate::pages::add_logic::ui::AddLogicPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add logic{% endblock %}
|
||||
{% block eyebrow %}Computed column{% endblock %}
|
||||
{% block heading %}Add logic{% endblock %}
|
||||
{% block lead %}<p>Create or update a Steel script through the existing table-script service.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/logic" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label class="wide">Table
|
||||
<select name="table_definition_id" required>
|
||||
<option value="0">Choose a table</option>
|
||||
{% for table in page.tables %}
|
||||
<option value="{{ table.id }}" {% if page.form.table_definition_id == table.id %}selected{% endif %}>{{ table.profile_name }}.{{ table.table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Logic name<input name="logic_name" value="{{ page.form.logic_name }}" placeholder="invoice total"></label>
|
||||
<label>Target column<input name="target_column" value="{{ page.form.target_column }}" required placeholder="total"></label>
|
||||
<label class="wide">Steel expression
|
||||
<textarea name="script" rows="12" required placeholder="(+ (get-var "subtotal") (get-var "tax"))">{{ page.form.script }}</textarea>
|
||||
</label>
|
||||
<label class="wide">Description<input name="description" value="{{ page.form.description }}"></label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save the logic", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save logic</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
42
web/templates/pages/add_table/add_table.html
Normal file
42
web/templates/pages/add_table/add_table.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add table{% endblock %}
|
||||
{% block eyebrow %}Table definition{% endblock %}
|
||||
{% block heading %}Add table{% endblock %}
|
||||
{% block lead %}<p>Create a table through the existing gRPC table-definition service.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/tables" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.profiles %}
|
||||
<option value="{{ profile }}" {% if page.form.profile_name == *profile %}selected{% endif %}>{{ profile }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Table name<input name="table_name" value="{{ page.form.table_name }}" required placeholder="invoices"></label>
|
||||
<label class="wide">Columns
|
||||
<textarea name="columns" rows="9" required
|
||||
placeholder="number: text:indexed issued_on: date stock: int:quantity-ledger">{{ page.form.columns }}</textarea>
|
||||
<small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small>
|
||||
</label>
|
||||
<label>Additional indexed columns<input name="indexed_columns" value="{{ page.form.indexed_columns }}" placeholder="number, issued_on"></label>
|
||||
<label>Base currency<input name="base_currency" value="{{ page.form.base_currency }}" maxlength="3" placeholder="EUR"></label>
|
||||
<label>Required links<input name="required_links" value="{{ page.form.required_links }}" placeholder="customer, address"></label>
|
||||
<label>Optional links<input name="optional_links" value="{{ page.form.optional_links }}" placeholder="project"></label>
|
||||
<label>Row display columns<input name="row_display_columns" value="{{ page.form.row_display_columns }}" placeholder="name, ico"></label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Create table</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
32
web/templates/pages/add_validation/field.html
Normal file
32
web/templates/pages/add_validation/field.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{# GET /admin/validation/new — crate::pages::add_validation::ui::FieldValidationPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add field validation{% endblock %}
|
||||
{% block eyebrow %}Validation{% endblock %}
|
||||
{% block heading %}Add field validation{% endblock %}
|
||||
{% block lead %}<p>Attach validation to one column of one table.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Profile<input name="profile_name" list="profiles" value="{{ page.form.profile_name }}" required></label>
|
||||
<label>Table<input name="table_name" list="tables" value="{{ page.form.table_name }}" required></label>
|
||||
<label>Column<input name="data_key" value="{{ page.form.data_key }}" required></label>
|
||||
<label class="wide">Apply named validation set instead
|
||||
<input name="validation_set_name" value="{{ page.form.validation_set_name }}"
|
||||
placeholder="Leave empty to save the rules below">
|
||||
</label>
|
||||
{% include "pages/add_validation/shared_fields.html" %}
|
||||
</div>
|
||||
<datalist id="profiles">{% for profile in page.profiles %}<option value="{{ profile }}"></option>{% endfor %}</datalist>
|
||||
<datalist id="tables">{% for table in page.tables %}<option value="{{ table }}"></option>{% endfor %}</datalist>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save validation</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
25
web/templates/pages/add_validation/rule.html
Normal file
25
web/templates/pages/add_validation/rule.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{# GET /admin/validation/rules/new — crate::pages::add_validation::ui::ValidationRulePage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add reusable validation rule{% endblock %}
|
||||
{% block eyebrow %}Global validation library{% endblock %}
|
||||
{% block heading %}Add reusable validation rule{% endblock %}
|
||||
{% block lead %}<p>A named rule that validation sets can compose, independent of any table.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation/rules" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Rule name<input name="rule_name" value="{{ page.form.rule_name }}" required></label>
|
||||
<label>Description<input name="description" value="{{ page.form.description }}"></label>
|
||||
{% include "pages/add_validation/shared_fields.html" %}
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save rule</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
28
web/templates/pages/add_validation/set.html
Normal file
28
web/templates/pages/add_validation/set.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{# GET /admin/validation/sets/new — crate::pages::add_validation::ui::ValidationSetPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add validation set{% endblock %}
|
||||
{% block eyebrow %}Global validation library{% endblock %}
|
||||
{% block heading %}Add validation set{% endblock %}
|
||||
{% block lead %}<p>Compose an ordered set from existing reusable rule names.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation/sets" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Set name<input name="name" value="{{ form.name }}" required></label>
|
||||
<label>Description<input name="description" value="{{ form.description }}"></label>
|
||||
<label class="wide">Reusable rules
|
||||
<input name="global_rules" value="{{ form.global_rules }}" required placeholder="required, invoice-number, max-length">
|
||||
<small>Comma-separated and applied in this order.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save set</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
34
web/templates/pages/add_validation/shared_fields.html
Normal file
34
web/templates/pages/add_validation/shared_fields.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{#
|
||||
Validation inputs shared by the field-validation and reusable-rule pages.
|
||||
Reads `page.form` (crate::pages::add_validation::state::ValidationForm).
|
||||
#}
|
||||
<label class="wide">Position rules
|
||||
<textarea name="pattern_rules" rows="6"
|
||||
placeholder="0-3 | alphabetic 4,5 | one-of=-,+ 6+ | regex=[0-9]">{{ page.form.pattern_rules }}</textarea>
|
||||
<small>One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5.
|
||||
Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=…</small>
|
||||
</label>
|
||||
<label class="wide">Pattern description<input name="pattern_description" value="{{ page.form.pattern_description }}"></label>
|
||||
|
||||
<label>Minimum length<input name="minimum" type="number" min="0" value="{{ page.form.minimum }}"></label>
|
||||
<label>Maximum length<input name="maximum" type="number" min="0" value="{{ page.form.maximum }}"></label>
|
||||
<label>Warning threshold<input name="warn_at" type="number" min="0" value="{{ page.form.warn_at }}"></label>
|
||||
<label>Count mode
|
||||
<select name="count_mode">
|
||||
<option value="chars">Characters</option>
|
||||
<option value="bytes" {% if page.form.count_mode == "bytes" %}selected{% endif %}>Bytes</option>
|
||||
<option value="display-width" {% if page.form.count_mode == "display-width" %}selected{% endif %}>Display width</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">Allowed values<input name="allowed_values" value="{{ page.form.allowed_values }}" placeholder="draft, issued, paid"></label>
|
||||
<label>Display mask<input name="mask_pattern" value="{{ page.form.mask_pattern }}" placeholder="####-##-##"></label>
|
||||
<label>Mask input character<input name="mask_input_char" value="{{ page.form.mask_input_char }}" placeholder="#"></label>
|
||||
<label>Mask template character<input name="mask_template_char" value="{{ page.form.mask_template_char }}" placeholder="_"></label>
|
||||
|
||||
<div class="check-group">
|
||||
<label class="check"><input type="checkbox" name="required" value="true" {% if page.form.required %}checked{% endif %}>Required</label>
|
||||
<label class="check"><input type="checkbox" name="allow_empty" value="true" {% if page.form.allow_empty %}checked{% endif %}>Allow empty</label>
|
||||
<label class="check"><input type="checkbox" name="case_insensitive" value="true" {% if page.form.case_insensitive %}checked{% endif %}>Case insensitive</label>
|
||||
<label class="check"><input type="checkbox" name="external_validation_enabled" value="true" {% if page.form.external_validation_enabled %}checked{% endif %}>External validation</label>
|
||||
<label class="check"><input type="checkbox" name="locked" value="true" {% if page.form.locked %}checked{% endif %}>Lock configuration</label>
|
||||
</div>
|
||||
25
web/templates/pages/admin/admin/admin.html
Normal file
25
web/templates/pages/admin/admin/admin.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{# GET /admin — crate::pages::admin::admin::ui::AdminPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Admin panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>Admin panel</h1>
|
||||
<p>Browse profiles, tables, and their physical columns.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/admin/tables/new">Add table</a>
|
||||
<a href="/admin/logic/new">Add logic</a>
|
||||
<a href="/admin/validation/new">Add validation</a>
|
||||
<a href="/admin/validation/sets/new">Add rule</a>
|
||||
<a href="/admin/import">Import</a>
|
||||
<a href="/admin/export">Export</a>
|
||||
</div>
|
||||
</section>
|
||||
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
69
web/templates/pages/admin/admin/workspace.html
Normal file
69
web/templates/pages/admin/admin/workspace.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{#
|
||||
GET /admin/workspace — crate::pages::admin::admin::ui::AdminWorkspace
|
||||
Also included by pages/admin.html for the first paint. Both structs expose
|
||||
the same `page: AdminPageState` field.
|
||||
#}
|
||||
<div class="workspace" aria-live="polite">
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Profiles</h2><span>{{ page.profiles.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.profiles.is_empty() %}
|
||||
<p class="empty">No profiles available.</p>
|
||||
{% else %}
|
||||
{% for profile in page.profiles %}
|
||||
<form hx-get="/admin/workspace" hx-target="#admin-workspace" hx-swap="innerHTML">
|
||||
<button class="browser-item {% if page.selected_profile.as_deref() == Some(profile.name.as_str()) %}selected{% endif %}"
|
||||
type="submit" name="profile" value="{{ profile.name }}">
|
||||
<span>{{ profile.name }}</span>
|
||||
<small>{{ profile.table_count }} tables</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Tables</h2><span>{{ page.tables.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.selected_profile.is_none() %}
|
||||
<p class="empty">Select a profile to see its tables.</p>
|
||||
{% else if page.tables.is_empty() %}
|
||||
<p class="empty">This profile has no tables.</p>
|
||||
{% else %}
|
||||
{% for table in page.tables %}
|
||||
<form hx-get="/admin/workspace" hx-target="#admin-workspace" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selected_profile.as_deref().unwrap_or_default() }}">
|
||||
<button class="browser-item {% if page.selected_table.as_deref() == Some(table.name.as_str()) %}selected{% endif %}"
|
||||
type="submit" name="table" value="{{ table.name }}">
|
||||
<span>{{ table.name }}</span>
|
||||
<small>
|
||||
{%- if table.depends_on.is_empty() %}No dependencies{% else %}Depends on {{ table.depends_on|join(", ") }}{% endif %}
|
||||
· display: {{ table.row_display_columns|join(", ") }}
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Columns</h2><span>{{ page.columns.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.selected_table.is_none() %}
|
||||
<p class="empty">Select a table to inspect its columns.</p>
|
||||
{% else if page.columns.is_empty() %}
|
||||
<p class="empty">This table has no visible columns.</p>
|
||||
{% else %}
|
||||
{% for column in page.columns %}
|
||||
<div class="column">
|
||||
<span>{{ column.name }}</span>
|
||||
<code>{{ column.data_type }}</code>
|
||||
<small>{{ column.flags()|join(" · ") }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
68
web/templates/pages/analytics/analytics.html
Normal file
68
web/templates/pages/analytics/analytics.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{# GET / — crate::pages::analytics::ui::AnalyticsPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Analytics{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main x-data="{ sql: '', copied: false }">
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Reporting</p>
|
||||
<h1>Analytics</h1>
|
||||
<p>Query the read-only analytics API and chart the result.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="analytics">
|
||||
<aside class="panel sidebar">
|
||||
<h2>Available data</h2>
|
||||
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
|
||||
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
|
||||
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<label>Profile
|
||||
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change"
|
||||
hx-target="#catalog" hx-swap="innerHTML">
|
||||
<option value="">Loading profiles…</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Load schema</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<form id="query-form" class="panel query-form" hx-post="/api/query" hx-include="#profile"
|
||||
hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<div class="query-row">
|
||||
<label>Chart
|
||||
<select name="chart_type">
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
<option value="scatter">Scatter</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
||||
</div>
|
||||
<label>SQL
|
||||
<textarea id="sql" x-ref="sql" x-model="sql" name="sql" required spellcheck="false"
|
||||
placeholder="Load the schema, then choose a starter query or write a SELECT query"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Run query</button>
|
||||
<span class="htmx-indicator hint">Running…</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="output" aria-live="polite"></section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
45
web/templates/pages/analytics/catalog.html
Normal file
45
web/templates/pages/analytics/catalog.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{# POST /api/catalog — crate::pages::analytics::ui::CatalogFragment #}
|
||||
{% if tables.is_empty() %}
|
||||
<p class="hint">This profile has no analytics tables.</p>
|
||||
{% else %}
|
||||
<p class="schema-summary"><strong>{{ tables.len() }}</strong> tables available</p>
|
||||
<div class="catalog-tables">
|
||||
{% for table in tables %}
|
||||
<details class="catalog-table">
|
||||
<summary>
|
||||
<code>{{ table.name }}</code>
|
||||
{%- if !table.base_currency.is_empty() %}<span class="currency">{{ table.base_currency }}</span>{% endif -%}
|
||||
</summary>
|
||||
<button type="button" class="starter-query" data-sql="{{ table.starter_query }}"
|
||||
x-on:click="sql = $el.dataset.sql; $refs.sql.focus()">Use starter query</button>
|
||||
<ul class="columns">
|
||||
{% for column in table.columns %}
|
||||
<li>
|
||||
<button type="button" class="column-name" data-insert="{{ column.insert_text }}"
|
||||
x-on:click="$refs.sql.setRangeText($el.dataset.insert, $refs.sql.selectionStart, $refs.sql.selectionEnd, 'end'); sql = $refs.sql.value; $refs.sql.focus()">{{ column.name }}</button>
|
||||
<span>{{ column.details }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if !table.links.is_empty() %}
|
||||
<div class="links">
|
||||
<span>Links</span>
|
||||
<ul>
|
||||
{% for link in table.links %}
|
||||
<li><code>{{ link.source_column }}</code> → <code>{{ link.linked_table }}</code>{% if link.required %} (required){% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<details class="llm-context">
|
||||
<summary>LLM schema context</summary>
|
||||
<p class="hint">Copy this entire text and include it when asking an LLM to write a query.</p>
|
||||
<textarea x-ref="llmSchema" readonly>{{ llm_context }}</textarea>
|
||||
<button type="button" class="secondary"
|
||||
x-on:click="navigator.clipboard.writeText($refs.llmSchema.value); copied = true">Copy context</button>
|
||||
<span class="hint" x-show="copied" x-cloak>Copied</span>
|
||||
</details>
|
||||
{% endif %}
|
||||
12
web/templates/pages/analytics/profile_options.html
Normal file
12
web/templates/pages/analytics/profile_options.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{#
|
||||
GET /api/profiles — crate::pages::analytics::ui::ProfileOptions
|
||||
The swap target is a <select>, so even the failure case is an <option>.
|
||||
#}
|
||||
{%- if let Some(message) = error %}
|
||||
<option value="">Could not load profiles: {{ message }}</option>
|
||||
{%- else %}
|
||||
<option value="">{% if profiles.is_empty() %}No profiles available{% else %}Choose a profile…{% endif %}</option>
|
||||
{%- for profile in profiles %}
|
||||
<option value="{{ profile.name }}">{{ profile.name }} ({{ profile.table_count }} tables)</option>
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
22
web/templates/pages/analytics/query_result.html
Normal file
22
web/templates/pages/analytics/query_result.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{#
|
||||
POST /api/query — crate::pages::analytics::ui::QueryResult
|
||||
Either a scrollable table or an ECharts container, depending on the chart
|
||||
type the form asked for. `chart_option` is JSON built in Rust.
|
||||
#}
|
||||
<p class="result-meta">{{ row_count }} rows in {{ elapsed_ms }} ms{% if truncated %} (truncated){% endif %}</p>
|
||||
{% match chart_option %}
|
||||
{% when Some(option) %}
|
||||
<div class="chart" data-option="{{ option }}"
|
||||
x-init="echarts.init($el).setOption(JSON.parse($el.dataset.option))"></div>
|
||||
{% when None %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>{% for column in columns %}<th>{{ column.name }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{%- for row in rows %}
|
||||
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmatch %}
|
||||
34
web/templates/pages/import_export/export/export.html
Normal file
34
web/templates/pages/import_export/export/export.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{# GET /admin/export — crate::pages::import_export::export::ui::ExportPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
|
||||
{% block title %}Export CSV{% endblock %}
|
||||
{% block eyebrow %}Data transfer{% endblock %}
|
||||
{% block heading %}Export CSV{% endblock %}
|
||||
{% block lead %}<p>The download is generated from live table data through gRPC.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
{# A real form post, not HTMX: the response is a file download. #}
|
||||
<form method="post" action="/admin/export.csv">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.catalog.profiles %}
|
||||
<option value="{{ profile.name }}">{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tables
|
||||
<input name="table_names" list="export-tables" required placeholder="invoices, customers">
|
||||
<small>Separate multiple tables with commas.</small>
|
||||
</label>
|
||||
</div>
|
||||
<datalist id="export-tables">
|
||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
||||
</datalist>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Download CSV</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
40
web/templates/pages/import_export/import/import.html
Normal file
40
web/templates/pages/import_export/import/import.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Import CSV{% endblock %}
|
||||
{% block eyebrow %}Data transfer{% endblock %}
|
||||
{% block heading %}Import CSV{% endblock %}
|
||||
{% block lead %}<p>Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.catalog.profiles %}
|
||||
<option value="{{ profile.name }}" {% if page.form.profile_name == profile.name %}selected{% endif %}>{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Target tables<input name="table_names" list="import-tables" value="{{ page.form.table_names }}" required placeholder="invoices, customers"></label>
|
||||
<label class="wide">CSV file
|
||||
<input type="file" accept=".csv,text/csv"
|
||||
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
||||
</label>
|
||||
<label class="wide">CSV data<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
|
||||
</div>
|
||||
<datalist id="import-tables">
|
||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
||||
</datalist>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not import CSV", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Import rows</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
web/templates/pages/login/login.html
Normal file
17
web/templates/pages/login/login.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{# GET /login — crate::pages::login::ui::LoginPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Sign in{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="login-main">
|
||||
<form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button" novalidate>
|
||||
<h1>Sign in</h1>
|
||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-status" aria-live="polite"></div>
|
||||
</form>
|
||||
</main>
|
||||
{% endblock %}
|
||||
13
web/templates/ui/alert.html
Normal file
13
web/templates/ui/alert.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{#
|
||||
Inline status blocks. Imported wherever a form reports its outcome:
|
||||
|
||||
{% import "ui/alert.html" as alert %}
|
||||
{% call alert::error("Could not create the table", message) %}{% endcall %}
|
||||
#}
|
||||
{% macro error(title, message) %}
|
||||
<div class="form-error"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro success(title, message) %}
|
||||
<div class="form-success"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
||||
{% endmacro %}
|
||||
6
web/templates/ui/alert_fragment.html
Normal file
6
web/templates/ui/alert_fragment.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{#
|
||||
Standalone swap target for every form POST — crate::ui::Alert.
|
||||
Rendered into the page's #submission-status / #login-status div.
|
||||
#}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
{%- if success %}{% call alert::success(title, message) %}{% endcall %}{% else %}{% call alert::error(title, message) %}{% endcall %}{% endif -%}
|
||||
22
web/templates/ui/base.html
Normal file
22
web/templates/ui/base.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{#
|
||||
The shell every full page extends. A page template overrides `title`,
|
||||
optionally `head` (extra scripts) and `body_class`, and always `content`.
|
||||
|
||||
Every page struct must carry a `nav: crate::ui::Nav` field, because the
|
||||
navbar component below reads it.
|
||||
#}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Komp Accounting{% endblock %} · Komp Accounting</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include "ui/navbar.html" %}
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
14
web/templates/ui/error.html
Normal file
14
web/templates/ui/error.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{# Load failures that are not worth a redirect — crate::ui::ErrorPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}{{ heading }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="error-page">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a href="/admin">Back to the admin panel</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
17
web/templates/ui/form_page.html
Normal file
17
web/templates/ui/form_page.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{#
|
||||
Card layout shared by every admin form page. A page supplies `eyebrow`,
|
||||
`heading`, an optional `lead` paragraph, and the `form` itself.
|
||||
#}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="form-main">
|
||||
<a class="back-link" href="/admin">← Admin panel</a>
|
||||
<section class="form-card">
|
||||
<p class="eyebrow">{% block eyebrow %}{% endblock %}</p>
|
||||
<h1>{% block heading %}{% endblock %}</h1>
|
||||
{% block lead %}{% endblock %}
|
||||
{% block form %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
21
web/templates/ui/navbar.html
Normal file
21
web/templates/ui/navbar.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{#
|
||||
Shared navbar, rendered on every page by base.html.
|
||||
Reads the `nav` field of the surrounding page struct (crate::ui::Nav).
|
||||
#}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>Komp Accounting</strong>
|
||||
{% if !nav.role.is_empty() %}<span class="role">{{ nav.role }}</span>{% endif %}
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/admin" {% if nav.active == "admin" %}class="active"{% endif %}>Admin</a>
|
||||
<a href="/" {% if nav.active == "analytics" %}class="active"{% endif %}>Analytics</a>
|
||||
{% if nav.authenticated %}
|
||||
<form hx-post="/logout" hx-swap="none">
|
||||
<button class="link-button" type="submit">Log out</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
5
web/templates/ui/notice.html
Normal file
5
web/templates/ui/notice.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{#
|
||||
One-line inline notice — crate::ui::Notice. Used where a full alert card is
|
||||
too heavy: analytics errors and the "log in first" prompts.
|
||||
#}
|
||||
<p class="{% if error %}error{% else %}hint{% endif %}">{{ message }}{% if login_link %} Please <a href="/login">log in</a> first.{% endif %}</p>
|
||||
Reference in New Issue
Block a user