feat: complete e-commerce dashboard and woocommerce api integration
This commit is contained in:
179
src/app/Http/Controllers/EcommerceController.php
Normal file
179
src/app/Http/Controllers/EcommerceController.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class EcommerceController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the index view.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('ecommerce');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a configured WP HTTP client to easily make requests.
|
||||||
|
*/
|
||||||
|
private function wooClient()
|
||||||
|
{
|
||||||
|
$url = Setting::getValue('ecommerce', 'woo_store_url', '');
|
||||||
|
$key = Setting::getValue('ecommerce', 'woo_consumer_key', '');
|
||||||
|
$secret = Setting::getValue('ecommerce', 'woo_consumer_secret', '');
|
||||||
|
|
||||||
|
if (!$url || !$key || !$secret) {
|
||||||
|
Log::error('Missing WooCommerce API Credentials in settings.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean trailing slashes
|
||||||
|
$baseUrl = rtrim($url, '/');
|
||||||
|
|
||||||
|
return Http::withBasicAuth($key, $secret)->baseUrl($baseUrl . '/wp-json/wc/v3/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start and end dates from the request.
|
||||||
|
*/
|
||||||
|
private function dateRange(Request $request): array
|
||||||
|
{
|
||||||
|
$from = $request->query('from');
|
||||||
|
$to = $request->query('to');
|
||||||
|
|
||||||
|
if (!$from && !$to) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$from ? $from . 'T00:00:00' : null,
|
||||||
|
$to ? $to . 'T23:59:59' : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary metrics (Net Sales, Total Orders, Total Customers).
|
||||||
|
*/
|
||||||
|
public function summary(Request $request)
|
||||||
|
{
|
||||||
|
$client = $this->wooClient();
|
||||||
|
if (!$client) {
|
||||||
|
return response()->json(['error' => 'API credentials missing.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
if ($from) $params['date_min'] = $from;
|
||||||
|
if ($to) $params['date_max'] = $to;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get Sales Report
|
||||||
|
$salesResp = $client->get('reports/sales', $params);
|
||||||
|
$salesData = $salesResp->successful() ? $salesResp->json() : [];
|
||||||
|
$totals = !empty($salesData) ? $salesData[0] : [
|
||||||
|
'total_sales' => 0,
|
||||||
|
'net_sales' => 0,
|
||||||
|
'total_orders' => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
// For total customers, we count users with role customer.
|
||||||
|
$custParams = ['role' => 'customer', 'per_page' => 1];
|
||||||
|
$custResp = $client->get('customers', $custParams);
|
||||||
|
$totalCustomers = 0;
|
||||||
|
if ($custResp->successful() && $custResp->header('X-WP-Total')) {
|
||||||
|
$totalCustomers = (int) $custResp->header('X-WP-Total');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'net_sales' => (float) ($totals['net_sales'] ?? 0),
|
||||||
|
'total_sales' => (float) ($totals['total_sales'] ?? 0),
|
||||||
|
'total_orders' => (int) ($totals['total_orders'] ?? 0),
|
||||||
|
'total_customers' => $totalCustomers,
|
||||||
|
'avg_order_value' => ($totals['total_orders'] ?? 0) > 0 ? ((float) ($totals['net_sales'] ?? 0)) / (int) $totals['total_orders'] : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('WooCommerce API Error (summary): ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Failed to fetch WooCommerce data.'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent Orders list.
|
||||||
|
*/
|
||||||
|
public function recentOrders(Request $request)
|
||||||
|
{
|
||||||
|
$client = $this->wooClient();
|
||||||
|
if (!$client) {
|
||||||
|
return response()->json(['error' => 'API credentials missing.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'per_page' => 10,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'desc',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($from) $params['after'] = $from;
|
||||||
|
if ($to) $params['before'] = $to;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resp = $client->get('orders', $params);
|
||||||
|
$data = $resp->successful() ? $resp->json() : [];
|
||||||
|
|
||||||
|
$orders = array_map(function($order) {
|
||||||
|
return [
|
||||||
|
'id' => $order['id'],
|
||||||
|
'number' => $order['number'],
|
||||||
|
'status' => $order['status'],
|
||||||
|
'total' => (float) $order['total'],
|
||||||
|
'currency' => $order['currency'],
|
||||||
|
'date_created' => $order['date_created'],
|
||||||
|
'customer' => $order['billing']['first_name'] . ' ' . $order['billing']['last_name'],
|
||||||
|
];
|
||||||
|
}, $data);
|
||||||
|
|
||||||
|
return response()->json($orders);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('WooCommerce API Error (orders): ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Failed to fetch WooCommerce data.'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top Selling Products list.
|
||||||
|
*/
|
||||||
|
public function topProducts(Request $request)
|
||||||
|
{
|
||||||
|
$client = $this->wooClient();
|
||||||
|
if (!$client) {
|
||||||
|
return response()->json(['error' => 'API credentials missing.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'per_page' => 10,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($from) $params['date_min'] = $from;
|
||||||
|
if ($to) $params['date_max'] = $to;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resp = $client->get('reports/top_sellers', $params);
|
||||||
|
$data = $resp->successful() ? $resp->json() : [];
|
||||||
|
|
||||||
|
return response()->json($data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('WooCommerce API Error (top_sellers): ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Failed to fetch WooCommerce data.'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/resources/views/ecommerce.blade.php
Normal file
369
src/resources/views/ecommerce.blade.php
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>E-Commerce Dashboard - Stargas</title>
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #0a0a0a 0%, #1a0a0a 30%, #0d0d0d 60%, #0a0a0a 100%); min-height: 100vh; font-family: 'Inter', sans-serif; }
|
||||||
|
.glass { background: rgba(255, 255, 255, 0.04); backdrop-filter: blur(12px); border: 1px solid rgba(227, 25, 55, 0.12); }
|
||||||
|
.stargas-accent { background: linear-gradient(90deg, #E31937, #FFD700, #E31937); height: 2px; }
|
||||||
|
|
||||||
|
/* Shimmer effect for loading states */
|
||||||
|
.loading-shimmer {
|
||||||
|
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 37%, rgba(255,255,255,0.03) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0 50%; }
|
||||||
|
}
|
||||||
|
</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-white/5">
|
||||||
|
<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 text-purple-400">E-Commerce Dashboard</h1>
|
||||||
|
<p class="text-xs text-slate-400">WooCommerce Analytics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/" class="px-3 py-2 text-xs font-medium rounded-lg bg-white/5 hover:bg-white/10 transition-colors border border-white/10 flex items-center gap-1.5 text-slate-300">
|
||||||
|
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
Hub
|
||||||
|
</a>
|
||||||
|
<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-purple-700 hover:bg-purple-600 transition-colors flex items-center gap-2 border border-purple-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>
|
||||||
|
|
||||||
|
<!-- Date Range Filter Bar -->
|
||||||
|
<div class="max-w-[1600px] mx-auto px-6 py-2 flex items-center gap-3 border-t border-white/5">
|
||||||
|
<svg class="w-4 h-4 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
|
||||||
|
<label class="text-xs text-slate-400">Financial Year</label>
|
||||||
|
<select id="filter-fy" onchange="applyFinancialYear()" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/30">
|
||||||
|
<option value="" class="bg-slate-900 text-white">Custom / YTD</option>
|
||||||
|
<script>
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentFYStart = new Date().getMonth() < 2 ? currentYear - 1 : currentYear;
|
||||||
|
for (let y = currentFYStart; y >= 2020; y--) {
|
||||||
|
document.write(`<option value="${y}" class="bg-slate-900 text-white">FY ${y}/${(y+1).toString().substring(2)}</option>`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="h-4 border-l border-white/10 mx-1"></div>
|
||||||
|
|
||||||
|
<label class="text-xs text-slate-400">From</label>
|
||||||
|
<input type="date" id="filter-from" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/30" />
|
||||||
|
<label class="text-xs text-slate-400">To</label>
|
||||||
|
<input type="date" id="filter-to" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/30" />
|
||||||
|
<button onclick="loadAllData()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-700/80 hover:bg-purple-600 transition-colors border border-purple-600/30">Apply</button>
|
||||||
|
<button onclick="clearDateFilter()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white/5 hover:bg-white/10 transition-colors border border-white/10">Clear</button>
|
||||||
|
<span id="filter-status" class="text-xs text-slate-500 ml-auto"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8">
|
||||||
|
|
||||||
|
<div id="api-error" class="hidden glass border-red-500/50 bg-red-500/10 p-4 rounded-xl text-red-400 text-sm font-medium flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
WooCommerce API Connection Error
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-red-400/80 font-normal">Please check your WooCommerce credentials in the <a href="/admin/settings/ecommerce" class="underline hover:text-white">Admin Panel</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<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">Net Sales</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center text-purple-400">
|
||||||
|
<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="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 class="flex items-baseline gap-2">
|
||||||
|
<div id="kpi-sales" class="text-2xl font-bold text-white tracking-tight">R 0.00</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">Total Orders</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-400">
|
||||||
|
<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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<div id="kpi-orders" class="text-2xl font-bold text-white tracking-tight">0</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">Avg Order Value</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-400">
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<div id="kpi-aov" class="text-2xl font-bold text-white tracking-tight">R 0.00</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">Total Customers</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-orange-500/10 flex items-center justify-center text-orange-400">
|
||||||
|
<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="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 class="flex items-baseline gap-2">
|
||||||
|
<div id="kpi-customers" class="text-2xl font-bold text-white tracking-tight">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tables Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
<!-- Recent Orders -->
|
||||||
|
<div class="glass rounded-2xl flex flex-col h-full overflow-hidden">
|
||||||
|
<div class="p-5 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||||||
|
<h3 class="font-bold text-white tracking-tight flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Recent Orders
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-black/20 text-xs text-slate-400 uppercase tracking-wider">
|
||||||
|
<th class="p-3 font-medium">Order #</th>
|
||||||
|
<th class="p-3 font-medium">Customer</th>
|
||||||
|
<th class="p-3 font-medium">Date</th>
|
||||||
|
<th class="p-3 font-medium">Status</th>
|
||||||
|
<th class="p-3 font-medium text-right">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-orders" class="text-sm divide-y divide-white/5">
|
||||||
|
<!-- Populated via JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Selling Products -->
|
||||||
|
<div class="glass rounded-2xl flex flex-col h-full overflow-hidden">
|
||||||
|
<div class="p-5 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||||||
|
<h3 class="font-bold text-white tracking-tight flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg>
|
||||||
|
Top Selling Products
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-black/20 text-xs text-slate-400 uppercase tracking-wider">
|
||||||
|
<th class="p-3 font-medium">Product</th>
|
||||||
|
<th class="p-3 font-medium text-right">Qty Sold</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-products" class="text-sm divide-y divide-white/5">
|
||||||
|
<!-- Populated via JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- JS Logic -->
|
||||||
|
<script>
|
||||||
|
const fmtNum = new Intl.NumberFormat('en-ZA').format;
|
||||||
|
const fmt = new Intl.NumberFormat('en-ZA', { style: 'currency', currency: 'ZAR', minimumFractionDigits: 0 }).format;
|
||||||
|
|
||||||
|
// Date filter helpers
|
||||||
|
function getDateParams() {
|
||||||
|
let from = document.getElementById('filter-from').value;
|
||||||
|
let to = document.getElementById('filter-to').value;
|
||||||
|
|
||||||
|
// Default to YTD
|
||||||
|
if (!from && !to) {
|
||||||
|
const now = new Date();
|
||||||
|
from = `${now.getFullYear()}-01-01`;
|
||||||
|
to = now.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filter-from').value = from;
|
||||||
|
document.getElementById('filter-to').value = to;
|
||||||
|
setTimeout(updateFilterStatus, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
if (to) params.set('to', to);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDateFilter() {
|
||||||
|
document.getElementById('filter-fy').value = '';
|
||||||
|
document.getElementById('filter-from').value = '';
|
||||||
|
document.getElementById('filter-to').value = '';
|
||||||
|
loadAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFinancialYear() {
|
||||||
|
const fyYear = document.getElementById('filter-fy').value;
|
||||||
|
if (!fyYear) return;
|
||||||
|
const startYear = parseInt(fyYear);
|
||||||
|
const endYear = startYear + 1;
|
||||||
|
const from = `${startYear}-03-01`;
|
||||||
|
const isLeapYear = (endYear % 4 === 0 && endYear % 100 !== 0) || (endYear % 400 === 0);
|
||||||
|
const to = `${endYear}-02-${isLeapYear ? '29' : '28'}`;
|
||||||
|
|
||||||
|
document.getElementById('filter-from').value = from;
|
||||||
|
document.getElementById('filter-to').value = to;
|
||||||
|
|
||||||
|
updateFilterStatus();
|
||||||
|
loadAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterStatus() {
|
||||||
|
const from = document.getElementById('filter-from').value;
|
||||||
|
const to = document.getElementById('filter-to').value;
|
||||||
|
const el = document.getElementById('filter-status');
|
||||||
|
if (from || to) {
|
||||||
|
const parts = [];
|
||||||
|
if (from) parts.push('from ' + from);
|
||||||
|
if (to) parts.push('to ' + to);
|
||||||
|
el.textContent = 'Filtered: ' + parts.join(' ');
|
||||||
|
el.classList.add('text-purple-400');
|
||||||
|
el.classList.remove('text-slate-500');
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Showing all time';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch data wrapper
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const qs = getDateParams();
|
||||||
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
|
const fullUrl = qs ? url + separator + qs : url;
|
||||||
|
const res = await fetch(fullUrl);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('api-error').classList.remove('hidden');
|
||||||
|
document.getElementById('api-error').classList.add('flex');
|
||||||
|
throw new Error(data.error || `Failed to fetch ${fullUrl}`);
|
||||||
|
} else {
|
||||||
|
document.getElementById('api-error').classList.add('hidden');
|
||||||
|
document.getElementById('api-error').classList.remove('flex');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKpisLoading() {
|
||||||
|
const ids = ['kpi-sales', 'kpi-orders', 'kpi-aov', 'kpi-customers'];
|
||||||
|
ids.forEach(id => {
|
||||||
|
document.getElementById(id).innerHTML = '<div class="h-8 w-24 rounded loading-shimmer"></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
setKpisLoading();
|
||||||
|
try {
|
||||||
|
const d = await fetchJson('/api/ecommerce/summary');
|
||||||
|
document.getElementById('kpi-sales').textContent = fmt(d.net_sales);
|
||||||
|
document.getElementById('kpi-orders').textContent = fmtNum(d.total_orders);
|
||||||
|
document.getElementById('kpi-aov').textContent = fmt(d.avg_order_value);
|
||||||
|
document.getElementById('kpi-customers').textContent = fmtNum(d.total_customers);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'processing': return `<span class="inline-flex items-center rounded-md bg-blue-500/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20">Processing</span>`;
|
||||||
|
case 'completed': return `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-400 ring-1 ring-inset ring-green-500/20">Completed</span>`;
|
||||||
|
case 'pending': return `<span class="inline-flex items-center rounded-md bg-yellow-500/10 px-2 py-1 text-xs font-medium text-yellow-400 ring-1 ring-inset ring-yellow-500/20">Pending</span>`;
|
||||||
|
case 'cancelled': return `<span class="inline-flex items-center rounded-md bg-red-500/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-500/20">Cancelled</span>`;
|
||||||
|
default: return `<span class="inline-flex items-center rounded-md bg-slate-500/10 px-2 py-1 text-xs font-medium text-slate-400 ring-1 ring-inset ring-slate-500/20">${status}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentOrders() {
|
||||||
|
const tb = document.getElementById('table-orders');
|
||||||
|
tb.innerHTML = Array(5).fill('<tr><td colspan="5" class="p-3"><div class="h-4 w-full rounded loading-shimmer"></div></td></tr>').join('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orders = await fetchJson('/api/ecommerce/recent-orders');
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
tb.innerHTML = '<tr><td colspan="5" class="p-4 text-center text-slate-500">No orders found.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.innerHTML = orders.map(o => `
|
||||||
|
<tr class="hover:bg-white/5 transition-colors">
|
||||||
|
<td class="p-3 font-medium text-white">#${o.number}</td>
|
||||||
|
<td class="p-3 text-slate-300 truncate max-w-[150px]">${o.customer}</td>
|
||||||
|
<td class="p-3 text-slate-500">${new Date(o.date_created).toLocaleDateString()}</td>
|
||||||
|
<td class="p-3">${getStatusBadge(o.status)}</td>
|
||||||
|
<td class="p-3 text-right font-medium text-emerald-400">${fmt(o.total)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopProducts() {
|
||||||
|
const tb = document.getElementById('table-products');
|
||||||
|
tb.innerHTML = Array(5).fill('<tr><td colspan="2" class="p-3"><div class="h-4 w-full rounded loading-shimmer"></div></td></tr>').join('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await fetchJson('/api/ecommerce/top-products');
|
||||||
|
if (!products || products.length === 0) {
|
||||||
|
tb.innerHTML = '<tr><td colspan="2" class="p-4 text-center text-slate-500">No products found for this period.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.innerHTML = products.map(p => `
|
||||||
|
<tr class="hover:bg-white/5 transition-colors">
|
||||||
|
<td class="p-3 font-medium text-white truncate max-w-[200px]" title="${p.name}">${p.name || 'Unknown Product'}</td>
|
||||||
|
<td class="p-3 text-right font-medium text-blue-400">${fmtNum(p.quantity)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllData() {
|
||||||
|
updateFilterStatus();
|
||||||
|
loadSummary();
|
||||||
|
loadRecentOrders();
|
||||||
|
loadTopProducts();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('last-updated').textContent = 'Updated ' + now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
document.addEventListener('DOMContentLoaded', loadAllData);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -78,12 +78,8 @@
|
|||||||
<img src="/images/stargas-logo.svg" alt="Stargas Energies" class="w-full h-auto drop-shadow-lg">
|
<img src="/images/stargas-logo.svg" alt="Stargas Energies" class="w-full h-auto drop-shadow-lg">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
|
||||||
Systems <span class="bg-gradient-to-r from-red-500 to-yellow-500 text-transparent bg-clip-text">Hub</span>
|
|
||||||
</h1>
|
|
||||||
<p class="text-slate-400 max-w-xl mx-auto text-lg">
|
|
||||||
Select a module below to access real-time analytics and powerful management tools for your division.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Grid -->
|
<!-- Dashboard Grid -->
|
||||||
@@ -110,10 +106,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Placeholder: E-Commerce Dashboard -->
|
<!-- Active: E-Commerce Dashboard -->
|
||||||
<a href="/ecommerce" class="dashboard-card glass rounded-2xl p-6 flex flex-col group fade-in relative overflow-hidden block" style="animation-delay: 300ms">
|
<a href="/ecommerce" class="dashboard-card glass rounded-2xl p-6 flex flex-col group fade-in relative overflow-hidden block" style="animation-delay: 300ms">
|
||||||
<div class="absolute top-0 right-0 p-4">
|
<div class="absolute top-0 right-0 p-4">
|
||||||
<span class="inline-flex items-center rounded-md bg-purple-500/10 px-2 py-1 text-xs font-medium text-purple-400 ring-1 ring-inset ring-purple-500/20">Planned</span>
|
<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-400 ring-1 ring-inset ring-green-500/20">Active</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-purple-600 to-purple-900 flex items-center justify-center mb-6 shadow-lg shadow-purple-900/40 group-hover:scale-110 transition-transform duration-300">
|
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-purple-600 to-purple-900 flex items-center justify-center mb-6 shadow-lg shadow-purple-900/40 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\AdminController;
|
use App\Http\Controllers\AdminController;
|
||||||
|
use App\Http\Controllers\EcommerceController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function() {
|
Route::get('/', function() {
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/procurement', [DashboardController::class, 'index']);
|
Route::get('/procurement', [DashboardController::class, 'index']);
|
||||||
|
Route::get('/ecommerce', [EcommerceController::class, 'index']);
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
Route::prefix('admin')->group(function () {
|
Route::prefix('admin')->group(function () {
|
||||||
@@ -28,3 +30,10 @@
|
|||||||
Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']);
|
Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']);
|
||||||
Route::get('/product-details', [DashboardController::class, 'productDetails']);
|
Route::get('/product-details', [DashboardController::class, 'productDetails']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E-Commerce API endpoints
|
||||||
|
Route::prefix('api/ecommerce')->group(function () {
|
||||||
|
Route::get('/summary', [EcommerceController::class, 'summary']);
|
||||||
|
Route::get('/recent-orders', [EcommerceController::class, 'recentOrders']);
|
||||||
|
Route::get('/top-products', [EcommerceController::class, 'topProducts']);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user