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. - 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. - 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. - 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 fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work 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 schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from services.exceptions import EntityConflictError, EntityNotFoundError from services.exceptions import EntityConflictError, EntityNotFoundError
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
@@ -205,12 +205,23 @@ def view_project(
) from exc ) from exc
scenarios = sorted(project.scenarios, key=lambda s: s.created_at) 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( return templates.TemplateResponse(
"projects/detail.html", "projects/detail.html",
{ {
"request": request, "request": request,
"project": project, "project": project,
"scenarios": scenarios, "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.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( return templates.TemplateResponse(
"scenarios/detail.html", "scenarios/detail.html",
{ {
"request": request, "request": request,
"project": project, "project": project,
"scenario": scenario, "scenario": scenario,
"scenario_metrics": scenario_metrics,
"financial_inputs": financial_inputs, "financial_inputs": financial_inputs,
"simulation_parameters": simulation_parameters, "simulation_parameters": simulation_parameters,
}, },

View File

@@ -4,23 +4,59 @@
--hover-highlight: rgba(241, 178, 26, 0.12); --hover-highlight: rgba(241, 178, 26, 0.12);
} }
.projects-table { .header-actions {
width: 100%; display: flex;
border-collapse: collapse; gap: 0.75rem;
border-radius: var(--table-radius); flex-wrap: wrap;
overflow: hidden; justify-content: flex-end;
box-shadow: var(--shadow);
} }
.projects-table th, .project-metrics {
.projects-table td { display: grid;
padding: 0.875rem 1rem; gap: 1.5rem;
border-bottom: 1px solid var(--card-border); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 2rem;
}
.metric-card {
background: var(--card-bg); 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 { .metric-card h2 {
background: var(--hover-highlight); 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 { .definition-list {
@@ -62,6 +98,61 @@
margin: 0; 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 { .alert {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);

View File

@@ -37,44 +37,48 @@
text-decoration: none; text-decoration: none;
} }
.actions { .header-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
} }
.btn { .scenario-metrics {
display: inline-flex; display: grid;
align-items: center; gap: 1.5rem;
justify-content: center; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
padding: 0.6rem 1.1rem; margin-bottom: 2rem;
border-radius: var(--radius-sm);
text-decoration: none;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.btn-primary { .metric-card {
background: linear-gradient(90deg, var(--brand) 0%, var(--brand-2) 100%); background: rgba(21, 27, 35, 0.85);
color: var(--color-text-dark); border-radius: var(--radius);
box-shadow: 0 8px 18px rgba(241, 178, 26, 0.25); padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
} }
.btn-secondary { .metric-card h2 {
background: rgba(148, 197, 255, 0.2); margin: 0;
color: var(--color-text-invert); font-size: 1rem;
border: 1px solid rgba(148, 197, 255, 0.35); color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
} }
.btn-link { .metric-value {
padding: 0.35rem 0.5rem; font-size: 2rem;
color: var(--brand-3); font-weight: 700;
text-decoration: none; margin: 0;
} }
.btn:hover, .metric-caption {
.btn:focus { color: var(--color-text-subtle);
transform: translateY(-1px); font-size: 0.85rem;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
} }
.scenario-filters { .scenario-filters {
@@ -106,6 +110,17 @@
color: var(--text); 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 { .table-responsive {
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
@@ -159,3 +174,24 @@
border-radius: var(--radius-sm); 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> <h1>{{ project.name }}</h1>
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p> <p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
</div> </div>
<div class="actions"> <div class="header-actions">
<a class="btn btn-secondary" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a> <a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
<a class="btn btn-primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a> <a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
</div> </div>
</header> </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"> <section class="card">
<h2>Project Overview</h2> <h2>Project Overview</h2>
<dl class="definition-list"> <dl class="definition-list">
@@ -41,15 +65,20 @@
<dt>Updated</dt> <dt>Updated</dt>
<dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd> <dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
</div> </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> </dl>
</section> </section>
<section class="card"> <section class="card">
<header class="card-header"> <header class="card-header">
<h2>Scenarios</h2> <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> </header>
{% if scenarios %} {% if scenarios %}
<div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -57,7 +86,7 @@
<th>Status</th> <th>Status</th>
<th>Currency</th> <th>Currency</th>
<th>Primary Resource</th> <th>Primary Resource</th>
<th></th> <th class="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -68,15 +97,17 @@
<td>{{ scenario.currency or '—' }}</td> <td>{{ scenario.currency or '—' }}</td>
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td> <td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td>
<td class="text-right"> <td class="text-right">
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a> <a class="table-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.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% 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 %} {% endif %}
</section> </section>
</div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -17,9 +17,36 @@
<h1>{{ scenario.name }}</h1> <h1>{{ scenario.name }}</h1>
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p> <p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
</div> </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> </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"> <section class="card">
<h2>Scenario Details</h2> <h2>Scenario Details</h2>
<dl class="definition-list"> <dl class="definition-list">
@@ -30,17 +57,7 @@
<div> <div>
<dt>Timeline</dt> <dt>Timeline</dt>
<dd> <dd>
{% if scenario.start_date %} {{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}
{{ scenario.start_date }}
{% else %}
{% endif %}
{% if scenario.end_date %}
{{ scenario.end_date }}
{% else %}
{% endif %}
</dd> </dd>
</div> </div>
<div> <div>
@@ -48,12 +65,8 @@
<dd>{{ scenario.discount_rate or '—' }}</dd> <dd>{{ scenario.discount_rate or '—' }}</dd>
</div> </div>
<div> <div>
<dt>Currency</dt> <dt>Last Updated</dt>
<dd>{{ scenario.currency or '—' }}</dd> <dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
</div>
<div>
<dt>Primary Resource</dt>
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
</div> </div>
</dl> </dl>
</section> </section>
@@ -84,7 +97,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No financial inputs recorded.</p> <p class="empty-state">No financial inputs recorded yet.</p>
{% endif %} {% endif %}
</section> </section>
@@ -114,7 +127,8 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No simulation parameters defined.</p> <p class="empty-state">No simulation parameters defined.</p>
{% endif %} {% endif %}
</section> </section>
</div>
{% endblock %} {% endblock %}

View File

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