697 lines
39 KiB
HTML
697 lines
39 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tonjum Technologies - Dashboard</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
<style>
|
|
body { background: #0f172a; min-height: 100vh; }
|
|
.glass { background: rgba(255,255,255,0.03); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.06); }
|
|
.tab-btn.active { border-bottom: 2px solid #6366f1; color: #818cf8; }
|
|
.modal-overlay { background: rgba(0,0,0,0.6); }
|
|
input, select, textarea { color-scheme: dark; }
|
|
.toast { animation: slideIn 0.3s ease; }
|
|
@keyframes slideIn { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
|
</style>
|
|
</head>
|
|
<body class="text-gray-100">
|
|
<nav class="border-b border-gray-800 bg-gray-900/80 sticky top-0 z-40">
|
|
<div class="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
|
|
<span class="font-semibold text-lg text-indigo-400">TonjumTech Records</span>
|
|
<button onclick="logout()" class="text-sm text-gray-400 hover:text-white transition-colors">Sign Out</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 py-6">
|
|
<div id="toast" class="hidden fixed top-4 right-4 z-50 px-5 py-3 rounded-xl shadow-lg toast text-sm font-medium"></div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-6 border-b border-gray-800 mb-6">
|
|
<button class="tab-btn active pb-3 text-sm font-medium text-gray-400" data-tab="dashboard">Dashboard</button>
|
|
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="transactions">Transactions</button>
|
|
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="subscriptions">Subscriptions</button>
|
|
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="clients">Clients</button>
|
|
</div>
|
|
|
|
<!-- ========== TAB: DASHBOARD ========== -->
|
|
<div id="tab-dashboard" class="tab-content">
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<div class="glass rounded-xl p-5">
|
|
<p class="text-xs text-gray-500 uppercase tracking-wider">Total Profit</p>
|
|
<p id="stat-total" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
|
</div>
|
|
<div class="glass rounded-xl p-5">
|
|
<p class="text-xs text-gray-500 uppercase tracking-wider">Last 365 Days</p>
|
|
<p id="stat-365" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
|
</div>
|
|
<div class="glass rounded-xl p-5">
|
|
<p class="text-xs text-gray-500 uppercase tracking-wider">Last 30 Days</p>
|
|
<p id="stat-30" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
|
</div>
|
|
<div class="glass rounded-xl p-5">
|
|
<p class="text-xs text-gray-500 uppercase tracking-wider">Projected Year</p>
|
|
<p id="stat-projected" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
|
<div class="lg:col-span-3 glass rounded-xl p-5">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-4">Monthly Income vs Expenses</h3>
|
|
<div style="height:250px"><canvas id="monthlyChart"></canvas></div>
|
|
</div>
|
|
<div class="glass rounded-xl p-5">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-3">Subscriptions</h3>
|
|
<p class="text-xs text-gray-500">Active Subscriptions</p>
|
|
<p id="sub-count" class="text-2xl font-bold text-white mt-1">0</p>
|
|
<p class="text-xs text-gray-500 mt-3">Monthly Recurring</p>
|
|
<p id="sub-mrr" class="text-xl font-bold text-emerald-400 mt-1">$0.00</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== TAB: TRANSACTIONS ========== -->
|
|
<div id="tab-transactions" class="tab-content hidden">
|
|
<button onclick="toggleForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addTxBtn">+ Add Transaction</button>
|
|
<button onclick="exportCSV()" class="mb-4 ml-3 text-sm text-emerald-400 hover:text-emerald-300 transition-colors">Export CSV</button>
|
|
|
|
<div id="txForm" class="hidden glass rounded-xl p-5 mb-6">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-4">New Transaction</h3>
|
|
<form id="transactionForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Type</label>
|
|
<select name="type" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
<option value="Income">Income</option>
|
|
<option value="Expense">Expense</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Date</label>
|
|
<input type="date" name="date" required value="{{ today }}"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Client</label>
|
|
<div class="flex gap-1">
|
|
<select name="client_id" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
<option value="">Select...</option>
|
|
</select>
|
|
<button type="button" onclick="showAddClient()" class="px-3 py-2 bg-indigo-600/20 text-indigo-400 rounded-lg text-sm hover:bg-indigo-600/30">+</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Amount</label>
|
|
<input type="number" step="0.01" name="amount" required placeholder="0.00"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Payment Method</label>
|
|
<select name="payment_method" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
<option value="Cash">Cash</option>
|
|
<option value="Venmo">Venmo</option>
|
|
<option value="Check">Check</option>
|
|
<option value="Card">Card</option>
|
|
<option value="Stripe">Stripe</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
|
<input type="text" name="description" placeholder="Optional"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Image (optional)</label>
|
|
<input type="file" name="image" accept="image/*"
|
|
class="w-full text-sm text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-indigo-600/20 file:text-indigo-400 hover:file:bg-indigo-600/30">
|
|
</div>
|
|
<div class="flex items-end">
|
|
<button type="submit" class="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Add Record</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="glass rounded-xl overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
|
<th class="text-left px-4 py-3 font-medium">Date</th>
|
|
<th class="text-left px-4 py-3 font-medium">Type</th>
|
|
<th class="text-left px-4 py-3 font-medium">Client</th>
|
|
<th class="text-right px-4 py-3 font-medium">Amount</th>
|
|
<th class="text-left px-4 py-3 font-medium">Method</th>
|
|
<th class="text-left px-4 py-3 font-medium">Description</th>
|
|
<th class="text-center px-4 py-3 font-medium">Image</th>
|
|
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="txTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="txEmpty" class="hidden text-center py-12 text-gray-500">No transactions yet</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== TAB: SUBSCRIPTIONS ========== -->
|
|
<div id="tab-subscriptions" class="tab-content hidden">
|
|
<button onclick="toggleSubForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addSubBtn">+ Add Subscription</button>
|
|
|
|
<div id="subForm" class="hidden glass rounded-xl p-5 mb-6">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-4">New Subscription</h3>
|
|
<form id="subscriptionForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Client</label>
|
|
<div class="flex gap-1">
|
|
<select name="client_id" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
<option value="">Select...</option>
|
|
</select>
|
|
<button type="button" onclick="showAddClient()" class="px-3 py-2 bg-indigo-600/20 text-indigo-400 rounded-lg text-sm hover:bg-indigo-600/30">+</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Service Name</label>
|
|
<input type="text" name="service_name" required placeholder="e.g. Web Hosting"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Amount</label>
|
|
<input type="number" step="0.01" name="amount" required placeholder="0.00"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Interval</label>
|
|
<select name="interval" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
<option value="Weekly">Weekly</option>
|
|
<option value="Monthly">Monthly</option>
|
|
<option value="Quarterly">Quarterly</option>
|
|
<option value="Annually">Annually</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Start Date</label>
|
|
<input type="date" name="start_date" required
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">End Date (optional)</label>
|
|
<input type="date" name="end_date"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div class="flex items-end">
|
|
<button type="submit" class="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Add Subscription</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="glass rounded-xl overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
|
<th class="text-left px-4 py-3 font-medium">Client</th>
|
|
<th class="text-left px-4 py-3 font-medium">Service</th>
|
|
<th class="text-right px-4 py-3 font-medium">Amount</th>
|
|
<th class="text-left px-4 py-3 font-medium">Interval</th>
|
|
<th class="text-left px-4 py-3 font-medium">Next Billing</th>
|
|
<th class="text-left px-4 py-3 font-medium">Status</th>
|
|
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="subTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="subEmpty" class="hidden text-center py-12 text-gray-500">No subscriptions yet</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Client Modal -->
|
|
<div id="clientModal" class="hidden fixed inset-0 z-50 modal-overlay flex items-center justify-center p-4">
|
|
<div class="glass rounded-xl p-6 w-full max-w-sm">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-4">Add Client</h3>
|
|
<input type="text" id="newClientName" placeholder="Client name"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm mb-4 focus:outline-none focus:border-indigo-500">
|
|
<div class="flex gap-2 justify-end">
|
|
<button onclick="closeClientModal()" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
|
<button onclick="saveClient()" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== TAB: CLIENTS ========== -->
|
|
<div id="tab-clients" class="tab-content hidden">
|
|
<button onclick="toggleClientForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addClientBtn">+ Add Client</button>
|
|
|
|
<div id="clientForm" class="hidden glass rounded-xl p-5 mb-6">
|
|
<h3 class="text-sm font-medium text-gray-300 mb-4">Client</h3>
|
|
<input type="hidden" name="client_id" value="">
|
|
<form id="clientDetailForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Name *</label>
|
|
<input type="text" name="name" required
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Contact Name</label>
|
|
<input type="text" name="contact_name" placeholder="Leave blank if same"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Phone</label>
|
|
<input type="text" name="phone"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Email</label>
|
|
<input type="email" name="email"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div class="lg:col-span-2">
|
|
<label class="block text-xs text-gray-500 mb-1">Address</label>
|
|
<input type="text" name="address"
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
|
</div>
|
|
<div class="flex items-end gap-2">
|
|
<button type="submit" class="py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
|
<button type="button" onclick="cancelClientForm()" class="py-2 px-4 text-sm text-gray-400 hover:text-white">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="glass rounded-xl overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
|
<th class="text-left px-4 py-3 font-medium">Name</th>
|
|
<th class="text-left px-4 py-3 font-medium">Contact</th>
|
|
<th class="text-left px-4 py-3 font-medium">Phone</th>
|
|
<th class="text-left px-4 py-3 font-medium">Email</th>
|
|
<th class="text-left px-4 py-3 font-medium">Address</th>
|
|
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="clientTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="clientEmpty" class="hidden text-center py-12 text-gray-500">No clients yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Modal -->
|
|
<div id="imageModal" class="hidden fixed inset-0 z-50 modal-overlay flex items-center justify-center p-4" onclick="this.classList.add('hidden')">
|
|
<img id="imageModalContent" class="max-w-full max-h-[90vh] rounded-xl" src="" alt="Image">
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = '';
|
|
const token = localStorage.getItem('token');
|
|
if (!token) window.location.href = '/';
|
|
const headers = {'Authorization': 'Bearer ' + token};
|
|
let monthlyChart = null;
|
|
|
|
function getHeaders() { return {'Authorization': 'Bearer ' + localStorage.getItem('token')}; }
|
|
function getFormHeaders() { const h = getHeaders(); return h; }
|
|
|
|
function showToast(msg, type) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.className = 'fixed top-4 right-4 z-50 px-5 py-3 rounded-xl shadow-lg toast text-sm font-medium ' +
|
|
(type === 'error' ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white');
|
|
t.classList.remove('hidden');
|
|
setTimeout(() => t.classList.add('hidden'), 3000);
|
|
}
|
|
|
|
function logout() { localStorage.removeItem('token'); window.location.href = '/'; }
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
|
|
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
|
|
if (btn.dataset.tab === 'dashboard') loadDashboard();
|
|
if (btn.dataset.tab === 'transactions') loadTransactions();
|
|
if (btn.dataset.tab === 'clients') loadClientsTab();
|
|
if (btn.dataset.tab === 'subscriptions') loadSubscriptions();
|
|
});
|
|
});
|
|
|
|
// === DASHBOARD ===
|
|
async function loadDashboard() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/dashboard/summary', {headers: getHeaders()});
|
|
const d = await res.json();
|
|
document.getElementById('stat-total').textContent = '$' + d.totalProfit.toFixed(2);
|
|
document.getElementById('stat-365').textContent = '$' + d.last365.toFixed(2);
|
|
document.getElementById('stat-30').textContent = '$' + d.last30.toFixed(2);
|
|
document.getElementById('stat-projected').textContent = '$' + d.projected.toFixed(2);
|
|
document.getElementById('sub-count').textContent = d.activeSubscriptions;
|
|
document.getElementById('sub-mrr').textContent = '$' + d.activeSubscriptionsTotal.toFixed(2);
|
|
|
|
const ctx = document.getElementById('monthlyChart').getContext('2d');
|
|
if (monthlyChart) monthlyChart.destroy();
|
|
const labels = d.monthlyHistory.map(m => m.month);
|
|
monthlyChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{label: 'Income', data: d.monthlyHistory.map(m => m.income), backgroundColor: '#22c55e', borderRadius: 4},
|
|
{label: 'Expenses', data: d.monthlyHistory.map(m => m.expense), backgroundColor: '#ef4444', borderRadius: 4}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {legend: {labels: {color: '#9ca3af', boxWidth: 12, padding: 16}}},
|
|
scales: {
|
|
x: {ticks: {color: '#6b7280'}, grid: {color: '#1f2937'}},
|
|
y: {ticks: {color: '#6b7280', callback: v => '$' + v}, grid: {color: '#1f2937'}}
|
|
}
|
|
}
|
|
});
|
|
} catch(e) { showToast('Failed to load dashboard', 'error'); }
|
|
}
|
|
|
|
// === CLIENTS (quick-add modal for dropdowns) ===
|
|
let clientSelects = [];
|
|
|
|
async function loadClients() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
|
const clients = await res.json();
|
|
clientSelects = document.querySelectorAll('select[name="client_id"]');
|
|
const opts = '<option value="">Select...</option>' + clients.map(c =>
|
|
'<option value="' + c.Id + '">' + c.Name + '</option>').join('');
|
|
clientSelects.forEach(s => { const v = s.value; s.innerHTML = opts; s.value = v; });
|
|
} catch(e) {}
|
|
}
|
|
|
|
function showAddClient() {
|
|
document.getElementById('clientModal').classList.remove('hidden');
|
|
document.getElementById('newClientName').value = '';
|
|
document.getElementById('newClientName').focus();
|
|
}
|
|
function closeClientModal() { document.getElementById('clientModal').classList.add('hidden'); }
|
|
async function saveClient() {
|
|
const name = document.getElementById('newClientName').value.trim();
|
|
if (!name) return;
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients', {
|
|
method: 'POST',
|
|
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
|
body: JSON.stringify({name})
|
|
});
|
|
if (res.ok) {
|
|
showToast('Client added', 'success');
|
|
closeClientModal();
|
|
loadClients();
|
|
} else {
|
|
const d = await res.json();
|
|
showToast(d.message || 'Error', 'error');
|
|
}
|
|
} catch(e) { showToast('Error adding client', 'error'); }
|
|
}
|
|
|
|
// === CLIENTS TAB ===
|
|
function toggleClientForm() {
|
|
const f = document.getElementById('clientForm');
|
|
f.classList.toggle('hidden');
|
|
document.getElementById('addClientBtn').textContent = f.classList.contains('hidden') ? '+ Add Client' : 'Cancel';
|
|
if (!f.classList.contains('hidden')) {
|
|
document.querySelector('#clientDetailForm [name="client_id"]').value = '';
|
|
document.getElementById('clientDetailForm').reset();
|
|
}
|
|
}
|
|
function cancelClientForm() {
|
|
document.getElementById('clientForm').classList.add('hidden');
|
|
document.getElementById('addClientBtn').textContent = '+ Add Client';
|
|
}
|
|
|
|
async function loadClientsTab() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
|
const clients = await res.json();
|
|
const tbody = document.getElementById('clientTableBody');
|
|
const empty = document.getElementById('clientEmpty');
|
|
if (!clients.length) {
|
|
tbody.innerHTML = '';
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
tbody.innerHTML = clients.map(c => {
|
|
const contact = c.ContactName || c.Name;
|
|
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
|
'<td class="px-4 py-3 text-gray-300 font-medium">' + (c.Name || '') + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-400">' + contact + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-400">' + (c.Phone || '') + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-400">' + (c.Email || '') + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-400 max-w-[200px] truncate">' + (c.Address || '') + '</td>' +
|
|
'<td class="px-4 py-3 text-center">' +
|
|
'<button onclick="editClient(' + c.Id + ')" class="text-indigo-400 hover:text-indigo-300 text-xs mr-2">Edit</button>' +
|
|
'<button onclick="deleteClient(' + c.Id + ')" class="text-red-400 hover:text-red-300 text-xs">Delete</button>' +
|
|
'</td></tr>';
|
|
}).join('');
|
|
} catch(e) { showToast('Failed to load clients', 'error'); }
|
|
}
|
|
|
|
async function editClient(id) {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
|
const clients = await res.json();
|
|
const c = clients.find(x => x.Id === id);
|
|
if (!c) return;
|
|
const f = document.getElementById('clientForm');
|
|
f.classList.remove('hidden');
|
|
document.getElementById('addClientBtn').textContent = 'Cancel';
|
|
document.querySelector('#clientDetailForm [name="client_id"]').value = c.Id;
|
|
document.querySelector('#clientDetailForm [name="name"]').value = c.Name || '';
|
|
document.querySelector('#clientDetailForm [name="contact_name"]').value = c.ContactName || '';
|
|
document.querySelector('#clientDetailForm [name="phone"]').value = c.Phone || '';
|
|
document.querySelector('#clientDetailForm [name="email"]').value = c.Email || '';
|
|
document.querySelector('#clientDetailForm [name="address"]').value = c.Address || '';
|
|
} catch(e) { showToast('Error loading client', 'error'); }
|
|
}
|
|
|
|
async function deleteClient(id) {
|
|
if (!confirm('Delete this client?')) return;
|
|
try {
|
|
await fetch(API_BASE + '/api/clients/' + id, {method: 'DELETE', headers: getHeaders()});
|
|
showToast('Deleted', 'success');
|
|
loadClientsTab();
|
|
loadClients();
|
|
} catch(e) { showToast('Error deleting', 'error'); }
|
|
}
|
|
|
|
document.getElementById('clientDetailForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const data = {};
|
|
fd.forEach((v, k) => data[k] = v);
|
|
const clientId = data.client_id;
|
|
delete data.client_id;
|
|
try {
|
|
const url = clientId
|
|
? API_BASE + '/api/clients/' + clientId
|
|
: API_BASE + '/api/clients';
|
|
const method = clientId ? 'PUT' : 'POST';
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (res.ok) {
|
|
showToast(clientId ? 'Client updated' : 'Client created', 'success');
|
|
e.target.reset();
|
|
document.getElementById('clientForm').classList.add('hidden');
|
|
document.getElementById('addClientBtn').textContent = '+ Add Client';
|
|
loadClientsTab();
|
|
loadClients();
|
|
} else {
|
|
const d = await res.json();
|
|
showToast(d.message || 'Error', 'error');
|
|
}
|
|
} catch(e) { showToast('Error saving client', 'error'); }
|
|
});
|
|
|
|
// === TRANSACTIONS ===
|
|
function toggleForm() {
|
|
const f = document.getElementById('txForm');
|
|
f.classList.toggle('hidden');
|
|
document.getElementById('addTxBtn').textContent = f.classList.contains('hidden') ? '+ Add Transaction' : 'Cancel';
|
|
if (!f.classList.contains('hidden')) loadClients();
|
|
}
|
|
|
|
async function loadTransactions() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/transactions?limit=200', {headers: getHeaders()});
|
|
const d = await res.json();
|
|
const tbody = document.getElementById('txTableBody');
|
|
const empty = document.getElementById('txEmpty');
|
|
if (!d.transactions.length) {
|
|
tbody.innerHTML = '';
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
tbody.innerHTML = d.transactions.map(tx => {
|
|
const amt = tx.Type === 'Income'
|
|
? '<span class="text-emerald-400">+$' + tx.Amount.toFixed(2) + '</span>'
|
|
: '<span class="text-red-400">-$' + tx.Amount.toFixed(2) + '</span>';
|
|
const imgHtml = tx.HasImage
|
|
? '<button onclick="showImage(' + tx.Id + ')" class="text-indigo-400 hover:text-indigo-300 text-xs">View</button>'
|
|
: '<span class="text-gray-600 text-xs">—</span>';
|
|
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
|
'<td class="px-4 py-3 text-gray-300">' + tx.TransactionDate + '</td>' +
|
|
'<td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded-full ' +
|
|
(tx.Type === 'Income' ? 'bg-emerald-600/20 text-emerald-400' : 'bg-red-600/20 text-red-400') + '">' + tx.Type + '</span></td>' +
|
|
'<td class="px-4 py-3 text-gray-300">' + (tx.ClientName || '—') + '</td>' +
|
|
'<td class="px-4 py-3 text-right font-medium">' + amt + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-300">' + tx.PaymentMethod + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-400 max-w-[200px] truncate">' + (tx.Description || '') + '</td>' +
|
|
'<td class="px-4 py-3 text-center">' + imgHtml + '</td>' +
|
|
'<td class="px-4 py-3 text-center">' +
|
|
'<button onclick="deleteTx(' + tx.Id + ')" class="text-red-400 hover:text-red-300 text-xs ml-2">Delete</button>' +
|
|
'</td></tr>';
|
|
}).join('');
|
|
} catch(e) { showToast('Failed to load transactions', 'error'); }
|
|
}
|
|
|
|
document.getElementById('transactionForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/transactions', {
|
|
method: 'POST',
|
|
headers: getFormHeaders(),
|
|
body: fd
|
|
});
|
|
if (res.ok) {
|
|
showToast('Transaction added', 'success');
|
|
e.target.reset();
|
|
document.getElementById('txForm').classList.add('hidden');
|
|
document.getElementById('addTxBtn').textContent = '+ Add Transaction';
|
|
loadTransactions();
|
|
loadDashboard();
|
|
} else {
|
|
const d = await res.json();
|
|
showToast(d.message || 'Error', 'error');
|
|
}
|
|
} catch(e) { showToast('Error adding transaction', 'error'); }
|
|
});
|
|
|
|
async function deleteTx(id) {
|
|
if (!confirm('Delete this transaction?')) return;
|
|
try {
|
|
await fetch(API_BASE + '/api/transactions/' + id, {method: 'DELETE', headers: getHeaders()});
|
|
showToast('Deleted', 'success');
|
|
loadTransactions();
|
|
loadDashboard();
|
|
} catch(e) { showToast('Error deleting', 'error'); }
|
|
}
|
|
|
|
function showImage(id) {
|
|
const img = document.getElementById('imageModalContent');
|
|
img.src = API_BASE + '/api/transactions/' + id + '/image';
|
|
document.getElementById('imageModal').classList.remove('hidden');
|
|
}
|
|
|
|
async function exportCSV() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/transactions/export/csv', {headers: getHeaders()});
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'transactions.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast('CSV exported', 'success');
|
|
} catch(e) { showToast('Error exporting CSV', 'error'); }
|
|
}
|
|
|
|
// === SUBSCRIPTIONS ===
|
|
function toggleSubForm() {
|
|
const f = document.getElementById('subForm');
|
|
f.classList.toggle('hidden');
|
|
document.getElementById('addSubBtn').textContent = f.classList.contains('hidden') ? '+ Add Subscription' : 'Cancel';
|
|
if (!f.classList.contains('hidden')) loadClients();
|
|
}
|
|
|
|
async function loadSubscriptions() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/subscriptions', {headers: getHeaders()});
|
|
const subs = await res.json();
|
|
const tbody = document.getElementById('subTableBody');
|
|
const empty = document.getElementById('subEmpty');
|
|
if (!subs.length) {
|
|
tbody.innerHTML = '';
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
tbody.innerHTML = subs.map(s => {
|
|
const statusHtml = s.IsActive
|
|
? '<span class="text-emerald-400 text-xs">Active</span>'
|
|
: '<span class="text-gray-500 text-xs">Inactive</span>';
|
|
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
|
'<td class="px-4 py-3 text-gray-300">' + (s.ClientName || '—') + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-300">' + s.ServiceName + '</td>' +
|
|
'<td class="px-4 py-3 text-right font-medium">$' + s.Amount.toFixed(2) + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-300">' + s.Interval + '</td>' +
|
|
'<td class="px-4 py-3 text-gray-300">' + s.NextBillingDate + '</td>' +
|
|
'<td class="px-4 py-3">' + statusHtml + '</td>' +
|
|
'<td class="px-4 py-3 text-center">' +
|
|
'<button onclick="deleteSub(' + s.Id + ')" class="text-red-400 hover:text-red-300 text-xs">Deactivate</button>' +
|
|
'</td></tr>';
|
|
}).join('');
|
|
} catch(e) { showToast('Failed to load subscriptions', 'error'); }
|
|
}
|
|
|
|
document.getElementById('subscriptionForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const data = {};
|
|
fd.forEach((v, k) => data[k] = v);
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/subscriptions', {
|
|
method: 'POST',
|
|
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (res.ok) {
|
|
showToast('Subscription created', 'success');
|
|
e.target.reset();
|
|
document.getElementById('subForm').classList.add('hidden');
|
|
document.getElementById('addSubBtn').textContent = '+ Add Subscription';
|
|
loadSubscriptions();
|
|
loadDashboard();
|
|
} else {
|
|
const d = await res.json();
|
|
showToast(d.message || 'Error', 'error');
|
|
}
|
|
} catch(e) { showToast('Error creating subscription', 'error'); }
|
|
});
|
|
|
|
async function deleteSub(id) {
|
|
if (!confirm('Deactivate this subscription?')) return;
|
|
try {
|
|
await fetch(API_BASE + '/api/subscriptions/' + id, {method: 'DELETE', headers: getHeaders()});
|
|
showToast('Deactivated', 'success');
|
|
loadSubscriptions();
|
|
loadDashboard();
|
|
} catch(e) { showToast('Error', 'error'); }
|
|
}
|
|
|
|
// Initial load
|
|
loadDashboard();
|
|
loadClients();
|
|
</script>
|
|
</body>
|
|
</html>
|