-
-
Notifications
You must be signed in to change notification settings - Fork 0
UI.md
This document covers the user interface system, Tabler framework integration, layouts, views, and frontend architecture.
OpenCashFlow uses Tabler, a premium open-source Bootstrap 5 admin dashboard template. The UI is rendered server-side using Razor views with client-side enhancements via JavaScript.
License: Tabler is MIT licensed and fully open source.
| Technology | Version | Purpose |
|---|---|---|
| Tabler | Latest | Admin dashboard framework |
| Bootstrap | 5.x | CSS framework (bundled with Tabler) |
| jQuery | 3.x | DOM manipulation, AJAX |
| SignalR | Latest | Real-time updates |
| Font Awesome | 6.x | Icons |
src/OpenCashFlow.App/
├── Views/
│ ├── Shared/
│ │ ├── _Layout.cshtml # Main layout
│ │ ├── _LoginLayout.cshtml # Authentication pages layout
│ │ ├── _Navbar.cshtml # Top navigation
│ │ ├── _Sidebar.cshtml # Side navigation
│ │ ├── _Footer.cshtml # Page footer
│ │ └── _ValidationScriptsPartial.cshtml
│ ├── _Partials/
│ │ ├── _PaymentsList.cshtml # Payment list component
│ │ ├── _WidgetCard.cshtml # Dashboard widget
│ │ ├── _Modal.cshtml # Reusable modal
│ │ └── ...
│ ├── Home/
│ │ ├── Login.cshtml
│ │ ├── Register.cshtml
│ │ ├── ForgotPassword.cshtml
│ │ ├── ResetPassword.cshtml
│ │ └── Privacy.cshtml
│ ├── Payment/
│ │ ├── Index.cshtml # Payment list view
│ │ └── Calendar.cshtml # Calendar view
│ ├── Dashboard/
│ │ └── Index.cshtml # Main dashboard
│ ├── Company/
│ ├── Billing/
│ └── Employees/
├── ViewModels/
│ ├── PaymentViewModel.cs
│ ├── DashboardViewModel.cs
│ └── ...
├── ViewComponents/
│ ├── PaymentSummaryViewComponent.cs
│ └── ...
└── wwwroot/
├── css/
│ ├── site.css # Custom styles
│ └── auth.css # Auth pages styles
├── js/
│ ├── site.js # Global JavaScript
│ ├── payment.js # Payment module
│ └── signalr-client.js # SignalR integration
├── libs/
│ ├── jquery/
│ ├── bootstrap/
│ └── signalr/
└── vendor/tabler/
├── css/
├── js/
└── fonts/
The primary layout for authenticated pages:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewData["Title"] - OpenCashFlow</title>
<!-- Tabler CSS -->
<link rel="stylesheet" href="~/vendor/tabler/css/tabler.min.css">
<link rel="stylesheet" href="~/vendor/tabler/css/tabler-vendors.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="~/css/site.css">
@await RenderSectionAsync("Styles", required: false)
</head>
<body class="theme-light">
<div class="page">
<!-- Sidebar -->
@await Html.PartialAsync("_Sidebar")
<div class="page-wrapper">
<!-- Navbar -->
@await Html.PartialAsync("_Navbar")
<!-- Page content -->
<div class="page-body">
<div class="container-xl">
@RenderBody()
</div>
</div>
<!-- Footer -->
@await Html.PartialAsync("_Footer")
</div>
</div>
<!-- Scripts -->
<script src="~/vendor/tabler/js/tabler.min.js"></script>
<script src="~/libs/jquery/jquery.min.js"></script>
<script src="~/js/site.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>A minimal layout for authentication pages:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewData["Title"] - OpenCashFlow</title>
<link rel="stylesheet" href="~/vendor/tabler/css/tabler.min.css">
<link rel="stylesheet" href="~/css/auth.css">
</head>
<body class="d-flex flex-column bg-white">
<div class="page page-center">
<div class="container container-tight py-4">
@RenderBody()
</div>
</div>
<script src="~/vendor/tabler/js/tabler.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>// ViewComponents/PaymentSummaryViewComponent.cs
public class PaymentSummaryViewComponent : ViewComponent
{
private readonly IPaymentService _paymentService;
public PaymentSummaryViewComponent(IPaymentService paymentService)
{
_paymentService = paymentService;
}
public async Task<IViewComponentResult> InvokeAsync(Guid tenantId)
{
var summary = await _paymentService.GetSummaryAsync(tenantId);
return View(summary);
}
}<!-- Views/Shared/Components/PaymentSummary/Default.cshtml -->
@model PaymentSummaryViewModel
<div class="card">
<div class="card-body">
<h4 class="card-title">Payment Summary</h4>
<div class="row">
<div class="col">
<span class="text-muted">Income</span>
<h3 class="text-success">@Model.TotalIncome.ToString("C")</h3>
</div>
<div class="col">
<span class="text-muted">Expenses</span>
<h3 class="text-danger">@Model.TotalExpenses.ToString("C")</h3>
</div>
</div>
</div>
</div>@await Component.InvokeAsync("PaymentSummary", new { tenantId = Model.TenantId })Location: Views/_Partials/
<!-- _WidgetCard.cshtml -->
@model WidgetCardViewModel
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="bg-@Model.Color text-white avatar">
<i class="@Model.Icon"></i>
</span>
</div>
<div class="col">
<div class="font-weight-medium">@Model.Title</div>
<div class="text-muted">@Model.Value</div>
</div>
</div>
</div>
</div>@await Html.PartialAsync("_Partials/_WidgetCard", new WidgetCardViewModel
{
Title = "Total Revenue",
Value = "$12,500",
Color = "green",
Icon = "ti ti-currency-dollar"
})// wwwroot/js/payment.js
const PaymentModule = (function() {
// Private variables
let apiUrl = '/api/v1/Payment';
// Private functions
function showLoading() {
$('#payment-list').addClass('loading');
}
function hideLoading() {
$('#payment-list').removeClass('loading');
}
// Public API
return {
init: function() {
this.bindEvents();
this.loadPayments();
},
bindEvents: function() {
$('#btn-add-payment').on('click', this.showAddModal);
$('#payment-form').on('submit', this.handleSubmit);
},
loadPayments: async function() {
showLoading();
try {
const response = await fetch(apiUrl);
const data = await response.json();
this.renderPayments(data.data);
} catch (error) {
console.error('Failed to load payments:', error);
} finally {
hideLoading();
}
},
renderPayments: function(payments) {
// Render payment list
},
showAddModal: function() {
$('#payment-modal').modal('show');
},
handleSubmit: async function(e) {
e.preventDefault();
// Handle form submission
}
};
})();
// Initialize on document ready
$(document).ready(function() {
PaymentModule.init();
});// Standard AJAX pattern
async function createPayment(paymentData) {
try {
const response = await fetch('/api/v1/Payment', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // Include cookies
body: JSON.stringify(paymentData)
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
if (result.success) {
showNotification('Payment created successfully', 'success');
return result.data;
} else {
showNotification(result.message, 'error');
return null;
}
} catch (error) {
showNotification('An error occurred', 'error');
console.error('Error:', error);
return null;
}
}// Hubs/PaymentHub.cs
public class PaymentHub : Hub
{
public async Task JoinCompanyGroup(string companyId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, companyId);
}
public async Task NotifyPaymentCreated(string companyId, PaymentDto payment)
{
await Clients.Group(companyId).SendAsync("PaymentCreated", payment);
}
}// wwwroot/js/signalr-client.js
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/payment")
.withAutomaticReconnect()
.build();
connection.on("PaymentCreated", function(payment) {
// Add new payment to the list
PaymentModule.addPaymentToList(payment);
showNotification('New payment received', 'info');
});
connection.start()
.then(function() {
// Join company group
const companyId = document.body.dataset.companyId;
connection.invoke("JoinCompanyGroup", companyId);
})
.catch(function(err) {
console.error(err.toString());
});<div class="card">
<div class="card-header">
<h3 class="card-title">Card Title</h3>
<div class="card-actions">
<a href="#" class="btn btn-primary btn-sm">Action</a>
</div>
</div>
<div class="card-body">
Card content
</div>
<div class="card-footer">
Card footer
</div>
</div><div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
@foreach (var payment in Model.Payments)
{
<tr>
<td>@payment.Date.ToString("d")</td>
<td>@payment.Description</td>
<td class="@(payment.Amount >= 0 ? "text-green" : "text-red")">
@payment.Amount.ToString("C")
</td>
<td>
<a href="#" class="btn btn-icon btn-ghost-secondary">
<i class="ti ti-edit"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div><div class="modal modal-blur fade" id="payment-modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Payment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="payment-form">
<div class="mb-3">
<label class="form-label">Amount</label>
<input type="number" class="form-control" name="amount" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="payment-form" class="btn btn-primary">Save</button>
</div>
</div>
</div>
</div><div class="alert alert-success alert-dismissible" role="alert">
<div class="d-flex">
<div><i class="ti ti-check alert-icon"></i></div>
<div>
<h4 class="alert-title">Success!</h4>
<div class="text-muted">Your payment has been saved.</div>
</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
</div>function showNotification(message, type = 'info') {
const toast = `
<div class="toast show" role="alert">
<div class="toast-header">
<span class="avatar avatar-xs bg-${type} me-2"></span>
<strong class="me-auto">Notification</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">${message}</div>
</div>
`;
const container = document.querySelector('.toast-container');
container.insertAdjacentHTML('beforeend', toast);
// Auto-remove after 5 seconds
setTimeout(() => {
container.querySelector('.toast:first-child')?.remove();
}, 5000);
}Tabler supports theme switching:
<!-- Light theme -->
<body class="theme-light">
<!-- Dark theme -->
<body class="theme-dark">function toggleTheme() {
const body = document.body;
if (body.classList.contains('theme-dark')) {
body.classList.remove('theme-dark');
body.classList.add('theme-light');
localStorage.setItem('theme', 'light');
} else {
body.classList.remove('theme-light');
body.classList.add('theme-dark');
localStorage.setItem('theme', 'dark');
}
}
// Apply saved theme on load
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.body.classList.add(`theme-${savedTheme}`);
});/* wwwroot/css/site.css */
/* Custom brand colors */
:root {
--ocf-primary: #206bc4;
--ocf-success: #2fb344;
--ocf-danger: #d63939;
}
/* Override Tabler variables */
.card {
--tblr-card-border-radius: 0.5rem;
}
/* Custom utility classes */
.text-income {
color: var(--ocf-success);
}
.text-expense {
color: var(--ocf-danger);
}
/* Loading overlay */
.loading {
position: relative;
pointer-events: none;
}
.loading::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}// Using jQuery Validation
$('#payment-form').validate({
rules: {
amount: {
required: true,
number: true,
min: 0.01
},
description: {
maxlength: 500
}
},
messages: {
amount: {
required: 'Please enter an amount',
min: 'Amount must be greater than zero'
}
},
submitHandler: function(form) {
// Form is valid, proceed with submission
PaymentModule.handleSubmit(form);
}
});<div class="mb-3">
<label asp-for="Amount" class="form-label"></label>
<input asp-for="Amount" class="form-control" />
<span asp-validation-for="Amount" class="text-danger"></span>
</div>Tabler uses Bootstrap's responsive breakpoints:
| Breakpoint | Size | Class prefix |
|---|---|---|
| Extra small | <576px | (default) |
| Small | ≥576px | sm |
| Medium | ≥768px | md |
| Large | ≥992px | lg |
| Extra large | ≥1200px | xl |
| XXL | ≥1400px | xxl |
<!-- Responsive grid -->
<div class="row">
<div class="col-12 col-md-6 col-lg-4">
<!-- Full width on mobile, half on tablet, third on desktop -->
</div>
</div>
<!-- Responsive visibility -->
<div class="d-none d-md-block">
<!-- Hidden on mobile, visible on tablet+ -->
</div>Project status
OpenCashFlow is under active development.
APIs, database schema, and UI may change until the first stable release.
Built with
.NET · ASP.NET Core · Entity Framework Core · PostgreSQL · Tabler
© 2026 OpenCashFlow
- Developer Preview
- Not production-ready
- First-run setup included