Add Stargas supplier dashboard with performance analytics

This commit is contained in:
2026-03-05 21:37:37 +02:00
parent a9a1263501
commit 23357e09f5
19 changed files with 9625 additions and 43 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ Thumbs.db
# Docker volumes
dbdata/
# Database dump (large file)
kyle.sql

24
add_indexes.sql Normal file
View File

@@ -0,0 +1,24 @@
-- Add indexes for dashboard query performance
ALTER TABLE PL_BILL ADD INDEX idx_pl_bill_refno (REFNO);
ALTER TABLE PL_BILL ADD INDEX idx_pl_bill_accno (ACCNO);
ALTER TABLE PL_BILL ADD INDEX idx_pl_bill_docdtetme (DOCDTETME);
ALTER TABLE PL_BILLTRAN ADD INDEX idx_pl_billtran_refno (REFNO);
ALTER TABLE PL_BILLTRAN ADD INDEX idx_pl_billtran_stockcode (STOCKCODE);
ALTER TABLE PL_SUPPLIERACCOUNT ADD INDEX idx_pl_supplier_suplcde (SUPLCDE);
ALTER TABLE SL_SALESINVOICE ADD INDEX idx_sl_invoice_refno (REFNO);
ALTER TABLE SL_SALESINVOICE ADD INDEX idx_sl_invoice_accno (ACCNO);
ALTER TABLE SL_SALESINVOICE ADD INDEX idx_sl_invoice_docdtetme (DOCDTETME);
ALTER TABLE SL_SALESINVOICETRAN ADD INDEX idx_sl_invoicetran_refno (REFNO);
ALTER TABLE SL_SALESINVOICETRAN ADD INDEX idx_sl_invoicetran_stockcode (STOCKCODE);
ALTER TABLE SL_CUSTOMERACCOUNT ADD INDEX idx_sl_customer_dbtrcde (DBTRCDE);
ALTER TABLE STK_STOCKITEM ADD INDEX idx_stk_stockcode (STOCKCODE);
ALTER TABLE STK_STOCKITEM ADD INDEX idx_stk_category (CATEGORY);
ALTER TABLE STK_STOCKITEM ADD INDEX idx_stk_isactive (ISACTVE);
ALTER TABLE STK_STOCKCATEGORY ADD INDEX idx_stk_category_cde (STCKCTGRYCDE);

View File

@@ -27,7 +27,7 @@ services:
- supplier-network
mysql:
image: mysql:8
image: mariadb:10.11
container_name: supplier-dashboard-mysql
restart: unless-stopped
environment:
@@ -36,7 +36,7 @@ services:
MYSQL_PASSWORD: secret
MYSQL_USER: supplier
ports:
- "3306:3306"
- "3307:3306"
volumes:
- dbdata:/var/lib/mysql
networks:

View File

@@ -14,6 +14,9 @@ server {
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
}
location ~ /\.ht {

View File

@@ -1,59 +1,115 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Stargas Energies — Supplier Dashboard
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
A performance analytics dashboard for Stargas Energies, providing real-time insights into supplier spend, product sales, margins, and category breakdowns.
## About Laravel
Built with **Laravel 12**, **Chart.js 4**, **Tailwind CSS**, and **MariaDB**, running in Docker.
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
![Stargas Logo](public/images/star-gas-logo.jfif)
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
## Features
Laravel is accessible, powerful, and provides tools required for large, robust applications.
- **KPI Cards** — Total suppliers, products, customers, purchase spend, sales revenue, gross profit, margin %, and invoice count with animated counters
- **Purchase Spend Over Time** — Monthly line chart tracking supplier costs
- **Sales Revenue & Profit Over Time** — Dual-line chart showing revenue vs gross profit
- **Top 10 Suppliers** — Ranked list with progress bars, click to drill down into per-supplier product breakdown and spend timeline
- **Top 10 Products** — Ranked by revenue with margin indicators
- **Category Breakdown** — Doughnut chart of revenue by stock category
- **Supplier Spend Comparison** — Horizontal bar chart comparing top suppliers
## Learning Laravel
## Tech Stack
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
| Layer | Technology |
|------------|---------------------------|
| Backend | Laravel 12 (PHP 8.2) |
| Database | MariaDB 10.11 |
| Frontend | Tailwind CSS, Chart.js 4 |
| Web Server | Nginx (Alpine) |
| Container | Docker Compose |
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Database Schema
## Laravel Sponsors
Data originates from an Omni Accounts Firebird database, exported to MariaDB. Eight tables:
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
| Table | Description |
|--------------------------|--------------------------------|
| `PL_SUPPLIERACCOUNT` | Supplier master records |
| `PL_BILL` | Supplier invoices (headers) |
| `PL_BILLTRAN` | Supplier invoice line items |
| `SL_CUSTOMERACCOUNT` | Customer master records |
| `SL_SALESINVOICE` | Sales invoices (headers) |
| `SL_SALESINVOICETRAN` | Sales invoice line items |
| `STK_STOCKITEM` | Product/stock items |
| `STK_STOCKCATEGORY` | Product categories |
### Premium Partners
**Important**: Omni Accounts stores prices with a "per X units" divisor. The correct unit price formula is `COSTPRICE / COSTPRICEPER` (and `SELLINGPRICE / SELLINGPRICEPER` for sales).
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Getting Started
## Contributing
### Prerequisites
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
- Docker & Docker Compose
- The `kyle.sql` database dump file
## Code of Conduct
### Setup
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
```bash
# 1. Clone the repository
git clone <repo-url> supplier-dashboard
cd supplier-dashboard
## Security Vulnerabilities
# 2. Start the containers
docker-compose up -d --build
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
# 3. Import the database (first time only)
docker cp kyle.sql supplier-dashboard-mysql:/tmp/kyle.sql
docker exec -it supplier-dashboard-mysql mariadb -u root -proot supplier_dashboard -e "source /tmp/kyle.sql"
## License
# 4. Generate application key
docker exec supplier-dashboard-app php artisan key:generate
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
# 5. Set storage permissions
docker exec supplier-dashboard-app chmod -R 777 /var/www/storage /var/www/bootstrap/cache
# 6. Add performance indexes
docker cp add_indexes.sql supplier-dashboard-mysql:/tmp/add_indexes.sql
docker exec -it supplier-dashboard-mysql mariadb -u root -proot supplier_dashboard -e "source /tmp/add_indexes.sql"
```
### Access
- **Dashboard**: [http://localhost:8000](http://localhost:8000)
- **MariaDB**: `localhost:3307` (user: `supplier`, password: `secret`)
## API Endpoints
| Method | Endpoint | Description |
|--------|---------------------------------------|------------------------------------|
| GET | `/` | Dashboard page |
| GET | `/api/dashboard/summary` | KPI summary totals |
| GET | `/api/dashboard/top-suppliers` | Top 10 suppliers by spend |
| GET | `/api/dashboard/top-products` | Top 10 products by revenue |
| GET | `/api/dashboard/spend-over-time` | Monthly purchase spend trend |
| GET | `/api/dashboard/sales-over-time` | Monthly revenue & profit trend |
| GET | `/api/dashboard/category-breakdown` | Revenue grouped by category |
| GET | `/api/dashboard/supplier/{code}` | Supplier drill-down (products + timeline) |
## Project Structure
```
supplier-dashboard/
├── docker-compose.yml # Container orchestration
├── Dockerfile # PHP-FPM app image
├── nginx/default.conf # Nginx reverse proxy config
├── add_indexes.sql # Performance indexes
├── kyle.sql # Database dump (not committed)
└── src/ # Laravel application
├── app/
│ ├── Http/Controllers/
│ │ └── DashboardController.php # All API endpoints
│ └── Models/ # Eloquent models (8)
├── resources/views/
│ └── dashboard.blade.php # Dashboard UI
├── routes/web.php # Route definitions
└── public/images/
└── star-gas-logo.jfif # Stargas logo
```

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index()
{
return view('dashboard');
}
public function summary(): JsonResponse
{
$totalSuppliers = DB::table('PL_SUPPLIERACCOUNT')->count();
$totalProducts = DB::table('STK_STOCKITEM')->where('ISACTVE', 'Y')->count();
$totalCustomers = DB::table('SL_CUSTOMERACCOUNT')->count();
$purchaseSpend = DB::table('PL_BILLTRAN')
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total')
->value('total');
$salesRevenue = DB::table('SL_SALESINVOICETRAN')
->selectRaw('COALESCE(SUM(QTYTOINVOICE * SELLINGPRICE / SELLINGPRICEPER), 0) as total')
->value('total');
$salesCost = DB::table('SL_SALESINVOICETRAN')
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total')
->value('total');
$grossProfit = $salesRevenue - $salesCost;
$margin = $salesRevenue > 0 ? round(($grossProfit / $salesRevenue) * 100, 1) : 0;
$totalInvoices = DB::table('PL_BILL')->count();
return response()->json([
'total_suppliers' => $totalSuppliers,
'total_products' => $totalProducts,
'total_customers' => $totalCustomers,
'purchase_spend' => round($purchaseSpend, 2),
'sales_revenue' => round($salesRevenue, 2),
'gross_profit' => round($grossProfit, 2),
'margin_percent' => $margin,
'total_invoices' => $totalInvoices,
]);
}
public function topSuppliers(): JsonResponse
{
$suppliers = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->selectRaw('i.ACCNO as code, i.SUPLNME as name, COUNT(DISTINCT i.REFNO) as invoice_count, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend, SUM(il.QTYTOINVOICE) as total_qty')
->groupBy('i.ACCNO', 'i.SUPLNME')
->orderByDesc('total_spend')
->limit(10)
->get();
return response()->json($suppliers);
}
public function topProducts(): JsonResponse
{
$products = DB::table('SL_SALESINVOICETRAN as sl')
->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE')
->selectRaw('sl.STOCKCODE as code, p.DESCRIPTION as name, SUM(sl.QTYTOINVOICE) as total_qty_sold, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost')
->groupBy('sl.STOCKCODE', 'p.DESCRIPTION')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(function ($item) {
$item->margin = $item->total_revenue > 0
? round((($item->total_revenue - $item->total_cost) / $item->total_revenue) * 100, 1)
: 0;
return $item;
});
return response()->json($products);
}
public function spendOverTime(): JsonResponse
{
$data = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->selectRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m') as month, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend")
->whereNotNull('i.DOCDTETME')
->groupByRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m')")
->orderBy('month')
->get();
return response()->json($data);
}
public function salesOverTime(): JsonResponse
{
$data = DB::table('SL_SALESINVOICETRAN as sl')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->selectRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m') as month, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost")
->whereNotNull('si.DOCDTETME')
->groupByRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m')")
->orderBy('month')
->get()
->map(function ($item) {
$item->gross_profit = round($item->total_revenue - $item->total_cost, 2);
return $item;
});
return response()->json($data);
}
public function categoryBreakdown(): JsonResponse
{
$data = DB::table('SL_SALESINVOICETRAN as sl')
->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE')
->leftJoin('STK_STOCKCATEGORY as c', 'p.CATEGORY', '=', 'c.STCKCTGRYCDE')
->selectRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized') as category, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as cost, SUM(sl.QTYTOINVOICE) as qty")
->groupByRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized')")
->orderByDesc('revenue')
->get();
return response()->json($data);
}
public function supplierProducts(string $supplierCode): JsonResponse
{
$products = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->join('STK_STOCKITEM as p', 'il.STOCKCODE', '=', 'p.STOCKCODE')
->where('i.ACCNO', $supplierCode)
->selectRaw('il.STOCKCODE as code, p.DESCRIPTION as name, SUM(il.QTYTOINVOICE) as total_qty, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend, COUNT(DISTINCT i.REFNO) as invoice_count')
->groupBy('il.STOCKCODE', 'p.DESCRIPTION')
->orderByDesc('total_spend')
->limit(20)
->get();
$timeline = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->where('i.ACCNO', $supplierCode)
->selectRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m') as month, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend")
->whereNotNull('i.DOCDTETME')
->groupByRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m')")
->orderBy('month')
->get();
$supplierName = DB::table('PL_SUPPLIERACCOUNT')
->where('SUPLCDE', $supplierCode)
->value('SUPLNME');
return response()->json([
'supplier_code' => $supplierCode,
'supplier_name' => $supplierName,
'products' => $products,
'timeline' => $timeline,
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
protected $table = 'STK_STOCKCATEGORY';
public $timestamps = false;
public function products()
{
return $this->hasMany(Product::class, 'CATEGORY', 'STCKCTGRYCDE');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
protected $table = 'SL_CUSTOMERACCOUNT';
public $timestamps = false;
public function invoices()
{
return $this->hasMany(CustomerInvoice::class, 'ACCNO', 'DBTRCDE');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CustomerInvoice extends Model
{
protected $table = 'SL_SALESINVOICE';
public $timestamps = false;
protected $casts = [
'DOCDTETME' => 'datetime',
];
public function customer()
{
return $this->belongsTo(Customer::class, 'ACCNO', 'DBTRCDE');
}
public function lines()
{
return $this->hasMany(CustomerInvoiceLine::class, 'REFNO', 'REFNO');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CustomerInvoiceLine extends Model
{
protected $table = 'SL_SALESINVOICETRAN';
public $timestamps = false;
protected $casts = [
'QTYTOINVOICE' => 'decimal:2',
'SELLINGPRICE' => 'decimal:2',
'COSTPRICE' => 'decimal:2',
];
public function invoice()
{
return $this->belongsTo(CustomerInvoice::class, 'REFNO', 'REFNO');
}
public function product()
{
return $this->belongsTo(Product::class, 'STOCKCODE', 'STOCKCODE');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $table = 'STK_STOCKITEM';
public $timestamps = false;
protected $casts = [
'COSTPRICE' => 'decimal:2',
'SELLINGPRICE1' => 'decimal:2',
];
public function category()
{
return $this->belongsTo(Category::class, 'CATEGORY', 'STCKCTGRYCDE');
}
public function supplierInvoiceLines()
{
return $this->hasMany(SupplierInvoiceLine::class, 'STOCKCODE', 'STOCKCODE');
}
public function customerInvoiceLines()
{
return $this->hasMany(CustomerInvoiceLine::class, 'STOCKCODE', 'STOCKCODE');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Supplier extends Model
{
protected $table = 'PL_SUPPLIERACCOUNT';
public $timestamps = false;
public function invoices()
{
return $this->hasMany(SupplierInvoice::class, 'ACCNO', 'SUPLCDE');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SupplierInvoice extends Model
{
protected $table = 'PL_BILL';
public $timestamps = false;
protected $casts = [
'DOCDTETME' => 'datetime',
];
public function supplier()
{
return $this->belongsTo(Supplier::class, 'ACCNO', 'SUPLCDE');
}
public function lines()
{
return $this->hasMany(SupplierInvoiceLine::class, 'REFNO', 'REFNO');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SupplierInvoiceLine extends Model
{
protected $table = 'PL_BILLTRAN';
public $timestamps = false;
protected $casts = [
'QTYTOINVOICE' => 'decimal:2',
'COSTPRICE' => 'decimal:2',
];
public function invoice()
{
return $this->belongsTo(SupplierInvoice::class, 'REFNO', 'REFNO');
}
public function product()
{
return $this->belongsTo(Product::class, 'STOCKCODE', 'STOCKCODE');
}
}

8397
src/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 160">
<defs>
<linearGradient id="starYellow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFD700"/>
<stop offset="100%" stop-color="#FFC300"/>
</linearGradient>
<linearGradient id="starRed" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#E31937"/>
<stop offset="50%" stop-color="#CC1530"/>
</linearGradient>
<filter id="shadow" x="-5%" y="-5%" width="115%" height="115%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.4"/>
</filter>
</defs>
<!-- Star -->
<g transform="translate(80,80)" filter="url(#shadow)">
<!-- White outer border -->
<polygon points="0,-72 20,-26 70,-26 30,6 46,54 0,26 -46,54 -30,6 -70,-26 -20,-26"
fill="#fff"/>
<!-- Black star body -->
<polygon points="0,-65 18,-23 63,-23 27,5 42,49 0,24 -42,49 -27,5 -63,-23 -18,-23"
fill="#1a1a1a"/>
<!-- Red outline star -->
<polygon points="0,-54 15,-19 52,-19 23,4 35,41 0,20 -35,41 -23,4 -52,-19 -15,-19"
fill="none" stroke="#E31937" stroke-width="4"/>
<!-- Yellow/Gold inner star -->
<polygon points="0,-46 12,-16 44,-16 19,3 30,35 0,17 -30,35 -19,3 -44,-16 -12,-16"
fill="url(#starYellow)" stroke="#E31937" stroke-width="1.5"/>
<!-- Inner black star -->
<polygon points="0,-34 9,-12 33,-12 14,2 22,26 0,13 -22,26 -14,2 -33,-12 -9,-12"
fill="#1a1a1a"/>
</g>
<!-- "stargas" text — bold italic red with black outline -->
<g filter="url(#shadow)">
<text x="138" y="112" font-family="Impact, 'Arial Black', Haettenschweiler, sans-serif"
font-size="80" font-weight="900" font-style="italic" letter-spacing="-2"
fill="#E31937" stroke="#1a1a1a" stroke-width="4" stroke-linejoin="round"
paint-order="stroke fill">stargas</text>
</g>
<!-- ® symbol -->
<text x="468" y="72" font-family="Arial, sans-serif" font-size="18" font-weight="700"
fill="#1a1a1a">®</text>
<!-- "ENERGIES" text -->
<text x="368" y="138" font-family="Impact, 'Arial Black', Haettenschweiler, sans-serif"
font-size="28" font-weight="700" font-style="italic" letter-spacing="1"
fill="#E31937" stroke="#1a1a1a" stroke-width="2" stroke-linejoin="round"
paint-order="stroke fill">ENERGIES</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,698 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Supplier Dashboard Stargas</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700,800" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; }
/* Animated gradient background — Stargas dark theme */
body {
background: linear-gradient(135deg, #0a0a0a 0%, #1a0a0a 30%, #0d0d0d 60%, #0a0a0a 100%);
min-height: 100vh;
}
/* Glass card effect */
.glass {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
border: 1px solid rgba(227, 25, 55, 0.12);
}
/* KPI card hover glow */
.kpi-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(227, 25, 55, 0.2);
border-color: rgba(227, 25, 55, 0.3);
}
/* Animated counter */
.counter-value {
transition: all 0.6s ease-out;
}
/* Chart container subtle glow */
.chart-card {
transition: all 0.3s ease;
}
.chart-card:hover {
border-color: rgba(227, 25, 55, 0.3);
}
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, rgba(227,25,55,0.05) 25%, rgba(227,25,55,0.12) 50%, rgba(227,25,55,0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.5rem;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Fade in animation */
.fade-in {
animation: fadeIn 0.6s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
to { opacity: 1; }
}
/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(227,25,55,0.3); border-radius: 3px; }
/* Supplier modal */
.modal-backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Rank badge colors — Gold, Silver, Bronze */
.rank-1 { background: linear-gradient(135deg, #FFD700, #FFA500); }
.rank-2 { background: linear-gradient(135deg, #C0C0C0, #8a8a8a); }
.rank-3 { background: linear-gradient(135deg, #CD7F32, #a0522d); }
/* Stargas accent line */
.stargas-accent {
background: linear-gradient(90deg, #E31937, #FFD700, #E31937);
height: 2px;
}
</style>
</head>
<body class="text-white antialiased">
<!-- Stargas Accent Line -->
<div class="stargas-accent"></div>
<!-- Header -->
<header class="glass sticky top-0 z-50 border-b border-red-900/20">
<div class="max-w-[1600px] mx-auto px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<img src="/images/stargas-logo.svg" alt="Stargas Energies" class="h-12">
<div class="border-l border-white/10 pl-4">
<h1 class="text-lg font-bold tracking-tight">Supplier Dashboard</h1>
<p class="text-xs text-slate-400">Performance Analytics</p>
</div>
</div>
<div class="flex items-center gap-4">
<span id="last-updated" class="text-xs text-slate-500"></span>
<button onclick="loadAllData()" class="px-4 py-2 text-xs font-medium rounded-lg bg-red-700 hover:bg-red-600 transition-colors flex items-center gap-2 border border-red-600/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Refresh
</button>
</div>
</div>
</header>
<main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8">
<!-- KPI Cards Row -->
<section id="kpi-section" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 gap-4">
<div class="kpi-card glass rounded-2xl p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Suppliers</span>
<div class="w-8 h-8 rounded-lg bg-red-500/10 flex items-center justify-center">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</div>
</div>
<div id="kpi-suppliers" class="text-3xl font-bold counter-value">
<div class="skeleton h-9 w-20"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Active Products</span>
<div class="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
</div>
</div>
<div id="kpi-products" class="text-3xl font-bold counter-value">
<div class="skeleton h-9 w-20"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Purchase Spend</span>
<div class="w-8 h-8 rounded-lg bg-red-600/10 flex items-center justify-center">
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
</div>
<div id="kpi-spend" class="text-3xl font-bold counter-value">
<div class="skeleton h-9 w-32"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Gross Margin</span>
<div class="w-8 h-8 rounded-lg bg-yellow-600/10 flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</div>
</div>
<div id="kpi-margin" class="text-3xl font-bold counter-value">
<div class="skeleton h-9 w-24"></div>
</div>
</div>
</section>
<!-- Secondary KPI row -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="kpi-card glass rounded-2xl p-5">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Sales Revenue</span>
<div id="kpi-revenue" class="text-2xl font-bold mt-2 text-red-400">
<div class="skeleton h-8 w-28"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Gross Profit</span>
<div id="kpi-profit" class="text-2xl font-bold mt-2 text-yellow-400">
<div class="skeleton h-8 w-28"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Customers</span>
<div id="kpi-customers" class="text-2xl font-bold mt-2 text-orange-400">
<div class="skeleton h-8 w-16"></div>
</div>
</div>
<div class="kpi-card glass rounded-2xl p-5">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Supplier Invoices</span>
<div id="kpi-invoices" class="text-2xl font-bold mt-2 text-red-300">
<div class="skeleton h-8 w-16"></div>
</div>
</div>
</section>
<!-- Charts Row 1: Time Series -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="chart-card glass rounded-2xl p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Purchase Spend Over Time
</h3>
<div class="relative h-72">
<canvas id="chart-spend-time"></canvas>
</div>
</div>
<div class="chart-card glass rounded-2xl p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
Sales Revenue & Profit Over Time
</h3>
<div class="relative h-72">
<canvas id="chart-sales-time"></canvas>
</div>
</div>
</section>
<!-- Charts Row 2: Rankings -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Top Suppliers -->
<div class="chart-card glass rounded-2xl p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Top 10 Suppliers by Spend
</h3>
<div id="top-suppliers-list" class="space-y-2 max-h-96 overflow-y-auto">
<div class="skeleton h-12 w-full mb-2"></div>
<div class="skeleton h-12 w-full mb-2"></div>
<div class="skeleton h-12 w-full mb-2"></div>
</div>
</div>
<!-- Top Products -->
<div class="chart-card glass rounded-2xl p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
Top 10 Products by Revenue
</h3>
<div id="top-products-list" class="space-y-2 max-h-96 overflow-y-auto">
<div class="skeleton h-12 w-full mb-2"></div>
<div class="skeleton h-12 w-full mb-2"></div>
<div class="skeleton h-12 w-full mb-2"></div>
</div>
</div>
</section>
<!-- Charts Row 3: Category + Bar Charts -->
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="chart-card glass rounded-2xl p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"/></svg>
Revenue by Category
</h3>
<div class="relative h-72">
<canvas id="chart-category"></canvas>
</div>
</div>
<div class="chart-card glass rounded-2xl p-6 lg:col-span-2">
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Supplier Spend Comparison
</h3>
<div class="relative h-72">
<canvas id="chart-supplier-bar"></canvas>
</div>
</div>
</section>
</main>
<!-- Supplier Detail Modal -->
<div id="supplier-modal" class="fixed inset-0 z-50 hidden">
<div class="modal-backdrop absolute inset-0" onclick="closeModal()"></div>
<div class="absolute right-0 top-0 h-full w-full max-w-2xl glass bg-slate-900/95 border-l border-white/10 overflow-y-auto transform transition-transform duration-300 translate-x-full" id="modal-panel">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-bold" id="modal-supplier-name">Supplier Details</h2>
<button onclick="closeModal()" class="w-8 h-8 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div id="modal-content">
<div class="space-y-3">
<div class="skeleton h-8 w-48"></div>
<div class="skeleton h-64 w-full"></div>
<div class="skeleton h-12 w-full"></div>
<div class="skeleton h-12 w-full"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// ── Helpers ──
const fmt = (n) => new Intl.NumberFormat('en-ZA', { style: 'currency', currency: 'ZAR', maximumFractionDigits: 0 }).format(n);
const fmtNum = (n) => new Intl.NumberFormat('en-ZA').format(n);
const fmtMonth = (ym) => {
const [y, m] = ym.split('-');
return new Date(y, m - 1).toLocaleDateString('en-ZA', { month: 'short', year: '2-digit' });
};
// Color palette — Stargas theme
const colors = {
red: 'rgba(227, 25, 55, 1)',
redFaded: 'rgba(227, 25, 55, 0.15)',
gold: 'rgba(255, 215, 0, 1)',
goldFaded: 'rgba(255, 215, 0, 0.15)',
orange: 'rgba(255, 165, 0, 1)',
orangeFaded: 'rgba(255, 165, 0, 0.15)',
crimson: 'rgba(180, 20, 45, 1)',
amber: 'rgba(245, 158, 11, 1)',
amberFaded: 'rgba(245, 158, 11, 0.15)',
};
const chartPalette = [
'rgba(227, 25, 55, 0.85)',
'rgba(255, 215, 0, 0.85)',
'rgba(255, 165, 0, 0.85)',
'rgba(180, 20, 45, 0.85)',
'rgba(245, 158, 11, 0.85)',
'rgba(255, 99, 71, 0.85)',
'rgba(204, 51, 0, 0.85)',
'rgba(255, 200, 50, 0.85)',
'rgba(200, 80, 80, 0.85)',
'rgba(210, 150, 50, 0.85)',
];
// ── Chart.js Global Defaults ──
Chart.defaults.color = 'rgba(148, 163, 184, 0.8)';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.06)';
Chart.defaults.font.family = "'Inter', sans-serif";
Chart.defaults.font.size = 11;
Chart.defaults.plugins.legend.labels.usePointStyle = true;
Chart.defaults.plugins.legend.labels.pointStyleWidth = 8;
Chart.defaults.animation.duration = 800;
// ── Chart instances ──
let chartSpendTime, chartSalesTime, chartCategory, chartSupplierBar, chartModalTimeline;
// ── Data loading ──
async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}`);
return res.json();
}
async function loadSummary() {
const d = await fetchJson('/api/dashboard/summary');
animateValue('kpi-suppliers', d.total_suppliers, fmtNum);
animateValue('kpi-products', d.total_products, fmtNum);
animateValue('kpi-spend', d.purchase_spend, fmt);
animateValue('kpi-margin', d.margin_percent, (v) => v.toFixed(1) + '%');
animateValue('kpi-revenue', d.sales_revenue, fmt);
animateValue('kpi-profit', d.gross_profit, fmt);
animateValue('kpi-customers', d.total_customers, fmtNum);
animateValue('kpi-invoices', d.total_invoices, fmtNum);
}
function animateValue(id, end, formatter) {
const el = document.getElementById(id);
el.classList.add('fade-in');
const duration = 1000;
const start = 0;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
const current = start + (end - start) * eased;
el.textContent = formatter(current);
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
async function loadSpendOverTime() {
const data = await fetchJson('/api/dashboard/spend-over-time');
const ctx = document.getElementById('chart-spend-time').getContext('2d');
if (chartSpendTime) chartSpendTime.destroy();
chartSpendTime = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => fmtMonth(d.month)),
datasets: [{
label: 'Purchase Spend',
data: data.map(d => d.total_spend),
borderColor: colors.red,
backgroundColor: colors.redFaded,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) } }
}
}
});
}
async function loadSalesOverTime() {
const data = await fetchJson('/api/dashboard/sales-over-time');
const ctx = document.getElementById('chart-sales-time').getContext('2d');
if (chartSalesTime) chartSalesTime.destroy();
chartSalesTime = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => fmtMonth(d.month)),
datasets: [
{
label: 'Revenue',
data: data.map(d => d.total_revenue),
borderColor: colors.gold,
backgroundColor: colors.goldFaded,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
},
{
label: 'Gross Profit',
data: data.map(d => d.gross_profit),
borderColor: colors.orange,
backgroundColor: colors.orangeFaded,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
},
plugins: {
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmt(ctx.parsed.y) } }
}
}
});
}
async function loadTopSuppliers() {
const data = await fetchJson('/api/dashboard/top-suppliers');
const container = document.getElementById('top-suppliers-list');
const maxSpend = data.length > 0 ? Math.max(...data.map(d => d.total_spend)) : 1;
container.innerHTML = data.map((s, i) => `
<div class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.06] transition-colors cursor-pointer fade-in"
style="animation-delay: ${i * 60}ms"
onclick="openSupplierModal('${encodeURIComponent(s.code)}')">
<div class="w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${i < 3 ? 'rank-' + (i+1) : 'bg-white/10'}">
${i + 1}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">${s.name}</div>
<div class="mt-1 h-1.5 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-red-600 to-red-500 transition-all duration-1000"
style="width: ${(s.total_spend / maxSpend * 100).toFixed(1)}%"></div>
</div>
</div>
<div class="text-right shrink-0">
<div class="text-sm font-semibold">${fmt(s.total_spend)}</div>
<div class="text-xs text-slate-500">${fmtNum(s.invoice_count)} inv</div>
</div>
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
`).join('');
// Also build bar chart
const ctx = document.getElementById('chart-supplier-bar').getContext('2d');
if (chartSupplierBar) chartSupplierBar.destroy();
chartSupplierBar = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.name.length > 18 ? d.name.substring(0, 18) + '…' : d.name),
datasets: [{
label: 'Total Spend',
data: data.map(d => d.total_spend),
backgroundColor: chartPalette,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } },
y: { grid: { display: false } }
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.x) } }
}
}
});
}
async function loadTopProducts() {
const data = await fetchJson('/api/dashboard/top-products');
const container = document.getElementById('top-products-list');
const maxRev = data.length > 0 ? Math.max(...data.map(d => d.total_revenue)) : 1;
container.innerHTML = data.map((p, i) => `
<div class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.06] transition-colors fade-in"
style="animation-delay: ${i * 60}ms">
<div class="w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${i < 3 ? 'rank-' + (i+1) : 'bg-white/10'}">
${i + 1}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">${p.name}</div>
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 transition-all duration-1000"
style="width: ${(p.total_revenue / maxRev * 100).toFixed(1)}%"></div>
</div>
<span class="text-xs font-medium ${p.margin >= 20 ? 'text-yellow-400' : p.margin >= 10 ? 'text-orange-400' : 'text-red-400'}">${p.margin}%</span>
</div>
</div>
<div class="text-right shrink-0">
<div class="text-sm font-semibold">${fmt(p.total_revenue)}</div>
<div class="text-xs text-slate-500">${fmtNum(p.total_qty_sold)} units</div>
</div>
</div>
`).join('');
}
async function loadCategoryBreakdown() {
const data = await fetchJson('/api/dashboard/category-breakdown');
const ctx = document.getElementById('chart-category').getContext('2d');
if (chartCategory) chartCategory.destroy();
chartCategory = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.map(d => d.category),
datasets: [{
data: data.map(d => d.revenue),
backgroundColor: chartPalette,
borderWidth: 0,
hoverOffset: 8,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: { padding: 12, font: { size: 10 } }
},
tooltip: {
callbacks: {
label: ctx => ctx.label + ': ' + fmt(ctx.parsed)
}
}
}
}
});
}
// ── Supplier Modal ──
function openSupplierModal(code) {
const modal = document.getElementById('supplier-modal');
const panel = document.getElementById('modal-panel');
modal.classList.remove('hidden');
setTimeout(() => panel.classList.remove('translate-x-full'), 10);
document.getElementById('modal-content').innerHTML = `
<div class="space-y-3">
<div class="skeleton h-8 w-48"></div>
<div class="skeleton h-64 w-full"></div>
<div class="skeleton h-12 w-full"></div>
<div class="skeleton h-12 w-full"></div>
</div>
`;
fetchJson('/api/dashboard/supplier/' + code).then(data => {
document.getElementById('modal-supplier-name').textContent = data.supplier_name || code;
let html = '';
// Timeline chart
html += `<div class="mb-6"><h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Spend Over Time</h4><div class="h-48"><canvas id="chart-modal-timeline"></canvas></div></div>`;
// Products table
html += `<h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Products Purchased</h4>`;
html += `<div class="space-y-2">`;
data.products.forEach((p, i) => {
const maxSpend = Math.max(...data.products.map(x => x.total_spend));
html += `
<div class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03]">
<span class="text-xs font-mono text-slate-500 w-6">${i + 1}.</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">${p.name}</div>
<div class="mt-1 h-1 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-red-600 to-orange-500"
style="width: ${(p.total_spend / maxSpend * 100).toFixed(1)}%"></div>
</div>
</div>
<div class="text-right shrink-0">
<div class="text-sm font-semibold">${fmt(p.total_spend)}</div>
<div class="text-xs text-slate-500">${fmtNum(p.total_qty)} units · ${p.invoice_count} inv</div>
</div>
</div>
`;
});
html += `</div>`;
document.getElementById('modal-content').innerHTML = html;
// Render timeline chart
if (data.timeline.length > 0) {
const tCtx = document.getElementById('chart-modal-timeline').getContext('2d');
if (chartModalTimeline) chartModalTimeline.destroy();
chartModalTimeline = new Chart(tCtx, {
type: 'bar',
data: {
labels: data.timeline.map(d => fmtMonth(d.month)),
datasets: [{
label: 'Spend',
data: data.timeline.map(d => d.total_spend),
backgroundColor: colors.redFaded,
borderColor: colors.red,
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) } }
}
}
});
}
});
}
function closeModal() {
const panel = document.getElementById('modal-panel');
panel.classList.add('translate-x-full');
setTimeout(() => document.getElementById('supplier-modal').classList.add('hidden'), 300);
}
// ── Load everything ──
async function loadAllData() {
document.getElementById('last-updated').textContent = 'Loading...';
await Promise.all([
loadSummary(),
loadSpendOverTime(),
loadSalesOverTime(),
loadTopSuppliers(),
loadTopProducts(),
loadCategoryBreakdown(),
]);
document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString('en-ZA');
}
// Init
loadAllData();
</script>
</body>
</html>

View File

@@ -1,7 +1,17 @@
<?php
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
Route::get('/', [DashboardController::class, 'index']);
// Dashboard API endpoints
Route::prefix('api/dashboard')->group(function () {
Route::get('/summary', [DashboardController::class, 'summary']);
Route::get('/top-suppliers', [DashboardController::class, 'topSuppliers']);
Route::get('/top-products', [DashboardController::class, 'topProducts']);
Route::get('/spend-over-time', [DashboardController::class, 'spendOverTime']);
Route::get('/sales-over-time', [DashboardController::class, 'salesOverTime']);
Route::get('/category-breakdown', [DashboardController::class, 'categoryBreakdown']);
Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']);
});