feat: enhance project and scenario detail pages with metrics, improved layouts, and updated styles

This commit is contained in:
2025-11-09 19:15:48 +01:00
parent 7f5ed6a42d
commit 400f85c907
9 changed files with 419 additions and 213 deletions

View File

@@ -12,3 +12,4 @@
- Added scenario comparison validator, FastAPI comparison endpoint, and comprehensive unit tests to enforce FR-009 validation rules through API errors.
- Delivered a new dashboard experience with `templates/dashboard.html`, dedicated styling, and a FastAPI route supplying real project/scenario metrics via repository helpers.
- Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging.
- Brought project and scenario detail pages plus their forms in line with the dashboard visuals, adding metric cards, layout grids, and refreshed CTA styles.

View File

@@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work
from models import MiningOperationType, Project
from models import MiningOperationType, Project, ScenarioStatus
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from services.exceptions import EntityConflictError, EntityNotFoundError
from services.unit_of_work import UnitOfWork
@@ -205,12 +205,23 @@ def view_project(
) from exc
scenarios = sorted(project.scenarios, key=lambda s: s.created_at)
scenario_stats = {
"total": len(scenarios),
"active": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE),
"draft": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT),
"archived": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED),
"latest_update": max(
(scenario.updated_at for scenario in scenarios if scenario.updated_at),
default=None,
),
}
return templates.TemplateResponse(
"projects/detail.html",
{
"request": request,
"project": project,
"scenarios": scenarios,
"scenario_stats": scenario_stats,
},
)

View File

@@ -339,12 +339,20 @@ def view_scenario(
scenario.simulation_parameters, key=lambda item: item.created_at
)
scenario_metrics = {
"financial_count": len(financial_inputs),
"parameter_count": len(simulation_parameters),
"currency": scenario.currency,
"primary_resource": scenario.primary_resource.value.replace('_', ' ').title() if scenario.primary_resource else None,
}
return templates.TemplateResponse(
"scenarios/detail.html",
{
"request": request,
"project": project,
"scenario": scenario,
"scenario_metrics": scenario_metrics,
"financial_inputs": financial_inputs,
"simulation_parameters": simulation_parameters,
},

View File

@@ -4,23 +4,59 @@
--hover-highlight: rgba(241, 178, 26, 0.12);
}
.projects-table {
width: 100%;
border-collapse: collapse;
border-radius: var(--table-radius);
overflow: hidden;
box-shadow: var(--shadow);
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.projects-table th,
.projects-table td {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--card-border);
.project-metrics {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 2rem;
}
.metric-card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--card-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.projects-table tbody tr:hover {
background: var(--hover-highlight);
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.project-form {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.definition-list {
@@ -62,6 +98,61 @@
margin: 0;
}
.project-layout {
display: grid;
gap: 1.5rem;
}
.table-responsive {
overflow-x: auto;
border-radius: var(--table-radius);
}
.table {
width: 100%;
border-collapse: collapse;
border-radius: var(--table-radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--card-border);
background: rgba(21, 27, 35, 0.85);
}
.table tbody tr:hover {
background: var(--hover-highlight);
}
.table-link {
color: var(--brand-2);
text-decoration: none;
margin-left: 0.5rem;
}
.table-link:hover,
.table-link:focus {
text-decoration: underline;
}
.text-right {
text-align: right;
}
@media (min-width: 960px) {
.project-layout {
grid-template-columns: 1.1fr 1.9fr;
align-items: start;
}
.header-actions {
justify-content: flex-start;
}
}
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);

View File

@@ -37,44 +37,48 @@
text-decoration: none;
}
.actions {
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.6rem 1.1rem;
border-radius: var(--radius-sm);
text-decoration: none;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
.scenario-metrics {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 2rem;
}
.btn-primary {
background: linear-gradient(90deg, var(--brand) 0%, var(--brand-2) 100%);
color: var(--color-text-dark);
box-shadow: 0 8px 18px rgba(241, 178, 26, 0.25);
.metric-card {
background: rgba(21, 27, 35, 0.85);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.btn-secondary {
background: rgba(148, 197, 255, 0.2);
color: var(--color-text-invert);
border: 1px solid rgba(148, 197, 255, 0.35);
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.btn-link {
padding: 0.35rem 0.5rem;
color: var(--brand-3);
text-decoration: none;
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.scenario-filters {
@@ -106,6 +110,17 @@
color: var(--text);
}
.scenario-form {
background: rgba(21, 27, 35, 0.85);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.table-responsive {
width: 100%;
overflow-x: auto;
@@ -159,3 +174,24 @@
border-radius: var(--radius-sm);
}
}
.scenario-layout {
display: grid;
gap: 1.5rem;
}
.empty-state {
color: var(--muted);
font-style: italic;
}
@media (min-width: 960px) {
.header-actions {
justify-content: flex-start;
}
.scenario-layout {
grid-template-columns: 1.1fr 1.9fr;
align-items: start;
}
}

View File

@@ -16,12 +16,36 @@
<h1>{{ project.name }}</h1>
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
</div>
<div class="actions">
<a class="btn btn-secondary" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
<a class="btn btn-primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
<div class="header-actions">
<a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
</div>
</header>
<section class="project-metrics">
<article class="metric-card">
<h2>Total Scenarios</h2>
<p class="metric-value">{{ scenario_stats.total }}</p>
<span class="metric-caption">Across this project</span>
</article>
<article class="metric-card">
<h2>Active</h2>
<p class="metric-value">{{ scenario_stats.active }}</p>
<span class="metric-caption">Currently live analyses</span>
</article>
<article class="metric-card">
<h2>Draft</h2>
<p class="metric-value">{{ scenario_stats.draft }}</p>
<span class="metric-caption">Awaiting validation</span>
</article>
<article class="metric-card">
<h2>Archived</h2>
<p class="metric-value">{{ scenario_stats.archived }}</p>
<span class="metric-caption">Historical references</span>
</article>
</section>
<div class="project-layout">
<section class="card">
<h2>Project Overview</h2>
<dl class="definition-list">
@@ -41,15 +65,20 @@
<dt>Updated</dt>
<dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
</div>
<div>
<dt>Latest Scenario Update</dt>
<dd>{{ scenario_stats.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_stats.latest_update else '—' }}</dd>
</div>
</dl>
</section>
<section class="card">
<header class="card-header">
<h2>Scenarios</h2>
<a class="btn btn-link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
</header>
{% if scenarios %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
@@ -57,7 +86,7 @@
<th>Status</th>
<th>Currency</th>
<th>Primary Resource</th>
<th></th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@@ -68,15 +97,17 @@
<td>{{ scenario.currency or '—' }}</td>
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td>
<td class="text-right">
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
<a class="table-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="table-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No scenarios yet.</p>
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
{% endif %}
</section>
</div>
{% endblock %}

View File

@@ -21,13 +21,22 @@
<h1>{% if project %}Edit Project{% else %}Create Project{% endif %}</h1>
<p class="text-muted">Provide core information about the mining project.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
</div>
</header>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form class="form" method="post" action="{{ form_action }}">
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form class="form project-form" method="post" action="{{ form_action }}">
<div class="form-grid">
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" required value="{{ project.name if project else '' }}" />
@@ -46,15 +55,16 @@
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="4">{{ project.description if project else '' }}</textarea>
<textarea id="description" name="description" rows="5">{{ project.description if project else '' }}</textarea>
</div>
<div class="form-actions">
<a class="btn btn-secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
</div>
</form>
{% endblock %}

View File

@@ -17,9 +17,36 @@
<h1>{{ scenario.name }}</h1>
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
</div>
<a class="btn btn-secondary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
<div class="header-actions">
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div>
</header>
<section class="scenario-metrics">
<article class="metric-card">
<h2>Financial Inputs</h2>
<p class="metric-value">{{ scenario_metrics.financial_count }}</p>
<span class="metric-caption">Line items captured</span>
</article>
<article class="metric-card">
<h2>Simulation Parameters</h2>
<p class="metric-value">{{ scenario_metrics.parameter_count }}</p>
<span class="metric-caption">Inputs driving forecasts</span>
</article>
<article class="metric-card">
<h2>Currency</h2>
<p class="metric-value">{{ scenario_metrics.currency or '—' }}</p>
<span class="metric-caption">Financial reporting</span>
</article>
<article class="metric-card">
<h2>Primary Resource</h2>
<p class="metric-value">{{ scenario_metrics.primary_resource or '—' }}</p>
<span class="metric-caption">Scenario focus</span>
</article>
</section>
<div class="scenario-layout">
<section class="card">
<h2>Scenario Details</h2>
<dl class="definition-list">
@@ -30,17 +57,7 @@
<div>
<dt>Timeline</dt>
<dd>
{% if scenario.start_date %}
{{ scenario.start_date }}
{% else %}
{% endif %}
{% if scenario.end_date %}
{{ scenario.end_date }}
{% else %}
{% endif %}
{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}
</dd>
</div>
<div>
@@ -48,12 +65,8 @@
<dd>{{ scenario.discount_rate or '—' }}</dd>
</div>
<div>
<dt>Currency</dt>
<dd>{{ scenario.currency or '—' }}</dd>
</div>
<div>
<dt>Primary Resource</dt>
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
<dt>Last Updated</dt>
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
</div>
</dl>
</section>
@@ -84,7 +97,7 @@
</table>
</div>
{% else %}
<p>No financial inputs recorded.</p>
<p class="empty-state">No financial inputs recorded yet.</p>
{% endif %}
</section>
@@ -114,7 +127,8 @@
</table>
</div>
{% else %}
<p>No simulation parameters defined.</p>
<p class="empty-state">No simulation parameters defined.</p>
{% endif %}
</section>
</div>
{% endblock %}

View File

@@ -21,13 +21,17 @@
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
<p class="text-muted">Configure assumptions and metadata for this scenario.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Scenario</button>
</div>
</header>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form class="form" method="post" action="{{ form_action }}">
<form class="form scenario-form" method="post" action="{{ form_action }}">
<div class="form-grid">
<div class="form-group">
<label for="name">Name</label>
@@ -76,12 +80,12 @@
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="4">{{ scenario.description if scenario else '' }}</textarea>
<textarea id="description" name="description" rows="5">{{ scenario.description if scenario else '' }}</textarea>
</div>
<div class="form-actions">
<a class="btn btn-secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Scenario</button>
</div>
</form>
{% endblock %}