Skip to content
Codewriter90x edited this page Jan 24, 2026 · 1 revision

UI

This document covers the user interface system, Tabler framework integration, layouts, views, and frontend architecture.

Overview

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 Stack

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

Directory Structure

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/

Layouts

Main Layout (_Layout.cshtml)

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>

Login Layout (_LoginLayout.cshtml)

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>

View Components

Creating a View Component

// 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>

Using a View Component

@await Component.InvokeAsync("PaymentSummary", new { tenantId = Model.TenantId })

Partial Views

Reusable Partials

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>

Using Partials

@await Html.PartialAsync("_Partials/_WidgetCard", new WidgetCardViewModel
{
    Title = "Total Revenue",
    Value = "$12,500",
    Color = "green",
    Icon = "ti ti-currency-dollar"
})

JavaScript Architecture

Module Pattern

// 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();
});

AJAX Calls

// 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;
    }
}

SignalR Integration

Hub Configuration

// 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);
    }
}

Client-Side SignalR

// 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());
    });

Tabler Components

Cards

<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>

Tables

<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>

Modals

<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>

Alerts / Notifications

<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>

Toast Notifications (JavaScript)

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);
}

Theming

Light/Dark Mode

Tabler supports theme switching:

<!-- Light theme -->
<body class="theme-light">

<!-- Dark theme -->
<body class="theme-dark">

Theme Toggle

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}`);
});

Custom CSS

/* 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;
}

Form Validation

Client-Side Validation

// 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);
    }
});

Server-Side Validation Display

<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>

Responsive Design

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>

OpenCashFlow

Preview Status

  • Developer Preview
  • Not production-ready
  • First-run setup included

Clone this wiki locally