1240 lines
57 KiB
HTML
1240 lines
57 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SimpleLedger - Your Personal Bookkeeper</title>
|
|
<!-- 1. Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- 2. Chart.js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script>
|
|
|
|
<style>
|
|
body { font-family: 'Inter', sans-serif; }
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
.spinner {
|
|
border: 4px solid rgba(0, 0, 0, .1);
|
|
border-left-color: #2563eb;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
/* Modal base */
|
|
.modal {
|
|
transition: opacity 0.25s ease;
|
|
}
|
|
.modal-content {
|
|
transition: transform 0.25s ease;
|
|
}
|
|
/* Sortable table header */
|
|
.sortable-header {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.sortable-header:hover {
|
|
background-color: #f9fafb;
|
|
}
|
|
.sortable-header.sort-asc::after {
|
|
content: ' ▲';
|
|
font-size: 0.8em;
|
|
}
|
|
.sortable-header.sort-desc::after {
|
|
content: ' ▼';
|
|
font-size: 0.8em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-100">
|
|
|
|
<!-- Auth View -->
|
|
<div id="authView" class="flex items-center justify-center min-h-screen">
|
|
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
|
|
<h2 id="authTitle" class="text-2xl font-bold mb-6 text-center">Login</h2>
|
|
<div id="authError" class="text-red-500 text-sm mb-4 hidden"></div>
|
|
|
|
<!-- Login Form -->
|
|
<form id="loginForm">
|
|
<div class="mb-4">
|
|
<label for="loginUsername" class="block text-sm font-medium text-gray-700">Username</label>
|
|
<input type="text" id="loginUsername" name="username" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div class="mb-6">
|
|
<label for="loginPassword" class="block text-sm font-medium text-gray-700">Password</label>
|
|
<input type="password" id="loginPassword" name="password" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Login
|
|
</button>
|
|
</form>
|
|
|
|
<!-- Register Form (hidden by default) -->
|
|
<form id="registerForm" class="hidden">
|
|
<div class="mb-4">
|
|
<label for="regUsername" class="block text-sm font-medium text-gray-700">Username</label>
|
|
<input type="text" id="regUsername" name="username" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div class="mb-6">
|
|
<label for="regPassword" class="block text-sm font-medium text-gray-700">Password</label>
|
|
<input type="password" id="regPassword" name="password" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<button type="submit" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-green-700">
|
|
Register
|
|
</button>
|
|
</form>
|
|
|
|
<p class="text-center text-sm text-gray-600 mt-6">
|
|
<a href="#" id="toggleAuth" class="text-blue-600 hover:underline">Need an account? Register</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading Modal (Overlay) -->
|
|
<div id="loadingModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div class="bg-white p-6 rounded-lg shadow-xl flex items-center">
|
|
<div class="spinner mr-4"></div>
|
|
<span id="loadingModalText" class="text-lg">Loading...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Transaction Modal -->
|
|
<div id="editTxModal" class="modal hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-40">
|
|
<div class="modal-content bg-white p-6 rounded-lg shadow-xl w-full max-w-lg">
|
|
<h3 class="text-xl font-semibold mb-4">Edit Transaction</h3>
|
|
<form id="editTxForm">
|
|
<input type="hidden" id="editTxId">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="editTxDate" class="block text-sm font-medium text-gray-700">Date</label>
|
|
<input type="date" id="editTxDate" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div>
|
|
<label for="editTxAmount" class="block text-sm font-medium text-gray-700">Amount</label>
|
|
<input type="number" step="0.01" id="editTxAmount" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
</div>
|
|
<div class="mt-4">
|
|
<label for="editTxDescription" class="block text-sm font-medium text-gray-700">Description</label>
|
|
<input type="text" id="editTxDescription" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
|
<div>
|
|
<label for="editTxCategory" class="block text-sm font-medium text-gray-700">Category</label>
|
|
<select id="editTxCategory" class="category-select mt-1 w-full p-2 border rounded-md shadow-sm"></select>
|
|
</div>
|
|
<div>
|
|
<label for="editTxVendor" class="block text-sm font-medium text-gray-700">Vendor</label>
|
|
<select id="editTxVendor" class="vendor-select mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
<option value="">-- None --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-between items-center mt-6">
|
|
<button type="button" id="createRuleFromTx" class="bg-green-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-green-700">
|
|
Create Rule
|
|
</button>
|
|
<div class="flex space-x-3">
|
|
<button type="button" id="closeTxModalBtn" class="bg-gray-300 text-gray-800 py-2 px-4 rounded-lg font-semibold hover:bg-gray-400">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Main App Container -->
|
|
<div id="appContainer" class="hidden flex h-screen">
|
|
<!-- Sidebar Navigation -->
|
|
<nav class="w-64 bg-blue-800 text-white flex flex-col p-4">
|
|
<h1 class="text-2xl font-bold mb-6">SimpleLedger</h1>
|
|
<a href="#" data-view="dashboard" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Dashboard</a>
|
|
<a href="#" data-view="transactions" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Transactions</a>
|
|
<a href="#" data-view="upload" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Upload</a>
|
|
<a href="#" data-view="categories" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Categories</a>
|
|
<a href="#" data-view="vendors" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Vendors</a>
|
|
<a href="#" data-view="rules" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Rules</a>
|
|
<a href="#" data-view="reports" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600">Reports</a>
|
|
<div class="mt-auto">
|
|
<a href="#" id="logoutButton" class="p-3 rounded-lg text-blue-100 hover:bg-blue-600 block">Logout</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main Content Area -->
|
|
<main class="flex-1 p-8 overflow-y-auto">
|
|
|
|
<!-- View: Dashboard -->
|
|
<section id="dashboard">
|
|
<h2 class="text-3xl font-bold mb-6">Dashboard</h2>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-6 mb-6">
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-sm font-medium text-gray-500">Gross Income</h3>
|
|
<p id="statGrossIncome" class="text-2xl font-semibold text-green-600">$0.00</p>
|
|
</div>
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-sm font-medium text-gray-500">Returns & Discounts</h3>
|
|
<p id="statReturns" class="text-2xl font-semibold text-yellow-600">$0.00</p>
|
|
</div>
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-sm font-medium text-gray-500">Net Income</h3>
|
|
<p id="statNetIncome" class="text-2xl font-semibold text-green-700">$0.00</p>
|
|
</div>
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-sm font-medium text-gray-500">Total Expenses</h3>
|
|
<p id="statTotalExpenses" class="text-2xl font-semibold text-red-600">$0.00</p>
|
|
</div>
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-sm font-medium text-gray-500">Net Profit / (Loss)</h3>
|
|
<p id="statNetProfit" class="text-2xl font-semibold">$0.00</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md h-96">
|
|
<h3 class="text-xl font-semibold mb-4">Top 10 Expenses</h3>
|
|
<div class="relative h-full w-full max-h-[300px]">
|
|
<canvas id="expensePieChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- View: Transactions -->
|
|
<section id="transactions" class="hidden">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-3xl font-bold">Transactions</h2>
|
|
<div class="flex items-center space-x-4">
|
|
<input type="search" id="transactionSearch" placeholder="Search descriptions..." class="p-2 border rounded-md shadow-sm">
|
|
<select id="transactionFilter" class="p-2 border rounded-md shadow-sm">
|
|
<option value="all">All</option>
|
|
<option value="uncategorized">Uncategorized</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white p-4 rounded-lg shadow-md">
|
|
<table class="w-full">
|
|
<thead class="border-b">
|
|
<tr>
|
|
<th class="p-3 text-left sortable-header" data-sort="date">Date</th>
|
|
<th class="p-3 text-left sortable-header" data-sort="description">Description</th>
|
|
<th class="p-3 text-left sortable-header" data-sort="category">Category</th>
|
|
<th class="p-3 text-left sortable-header" data-sort="vendorName">Vendor</th>
|
|
<th class="p-3 text-right sortable-header" data-sort="amount">Amount</th>
|
|
<th class="p-3 text-center">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="transactionsTableBody">
|
|
<!-- JS will render transactions here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- View: Upload -->
|
|
<section id="upload" class="hidden">
|
|
<h2 class="text-3xl font-bold mb-6">Upload Files</h2>
|
|
<form id="uploadForm" class="bg-white p-6 rounded-lg shadow-md max-w-lg">
|
|
<div class="mb-4">
|
|
<label for="bankFile" class="block text-sm font-medium text-gray-700 mb-2">1. Bank Transactions CSV</label>
|
|
<p class="text-xs text-gray-500 mb-2">(e.g., your `transactions.csv` file. Must have 'Date', 'Description', 'Debit', 'Credit' columns)</p>
|
|
<input type="file" id="bankFile" accept=".csv" class="w-full p-2 border rounded-md">
|
|
</div>
|
|
|
|
<div class="mb-6">
|
|
<label for="salesFile" class="block text-sm font-medium text-gray-700 mb-2">2. Sales Summary CSV</label>
|
|
<p class="text-xs text-gray-500 mb-2">(e.g., your `sales-summary.csv` file. Used to find 'Net sales' and 'Square payment processing fees')</p>
|
|
<input type="file" id="salesFile" accept=".csv" class="w-full p-2 border rounded-md">
|
|
</div>
|
|
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Process Files
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
<!-- View: Categories -->
|
|
<section id="categories" class="hidden">
|
|
<h2 class="text-3xl font-bold mb-6">Manage Categories</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Add Category Form -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Add New Category</h3>
|
|
<form id="categoryForm">
|
|
<div class="mb-4">
|
|
<label for="categoryName" class="block text-sm font-medium text-gray-700">Name</label>
|
|
<input type="text" id="categoryName" name="categoryName" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="categoryType" class="block text-sm font-medium text-gray-700">Type</label>
|
|
<select id="categoryType" name="categoryType" class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
<option value="income">Income</option>
|
|
<option value="expense">Expense</option>
|
|
<option value="liability">Liability</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Add Category
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<!-- Category List -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Existing Categories</h3>
|
|
<div id="categoryList" class="space-y-2 max-h-96 overflow-y-auto">
|
|
<!-- JS will render categories here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- View: Vendors -->
|
|
<section id="vendors" class="hidden">
|
|
<h2 class="text-3xl font-bold mb-6">Manage Vendors</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Add Vendor Form -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Add New Vendor</h3>
|
|
<form id="vendorForm">
|
|
<div class="mb-4">
|
|
<label for="vendorName" class="block text-sm font-medium text-gray-700">Name</label>
|
|
<input type="text" id="vendorName" name="vendorName" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Add Vendor
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<!-- Vendor List -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Existing Vendors</h3>
|
|
<div id="vendorList" class="space-y-2 max-h-96 overflow-y-auto">
|
|
<!-- JS will render vendors here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- View: Rules -->
|
|
<section id="rules" class="hidden">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-3xl font-bold">Categorization Rules</h2>
|
|
<button id="applyAllRulesBtn" class="bg-green-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-green-700">
|
|
Apply Rules to Uncategorized
|
|
</button>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Add Rule Form -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Add New Rule</h3>
|
|
<form id="ruleForm">
|
|
<div class="mb-4">
|
|
<label for="ruleKeyword" class="block text-sm font-medium text-gray-700">If 'Description' contains:</label>
|
|
<input type="text" id="ruleKeyword" name="ruleKeyword" placeholder="e.g., ADP PAYROLL" required class="mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="ruleCategory" class="block text-sm font-medium text-gray-700">Set Category to:</label>
|
|
<select id="ruleCategory" name="ruleCategory" class="category-select mt-1 w-full p-2 border rounded-md shadow-sm">
|
|
<!-- JS will render categories here -->
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Add Rule
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<!-- Rules List -->
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Existing Rules</h3>
|
|
<div id="rulesList" class="space-y-2 max-h-96 overflow-y-auto">
|
|
<!-- JS will render rules here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- View: Reports -->
|
|
<section id="reports" class="hidden">
|
|
<h2 class="text-3xl font-bold mb-6">Reports</h2>
|
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
<h3 class="text-xl font-semibold mb-4">Profit & Loss Statement</h3>
|
|
<div class="flex items-center space-x-4 mb-4">
|
|
<div>
|
|
<label for="reportStartDate" class="block text-sm font-medium text-gray-700">Start Date</label>
|
|
<input type="date" id="reportStartDate" class="mt-1 p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<div>
|
|
<label for="reportEndDate" class="block text-sm font-medium text-gray-700">End Date</label>
|
|
<input type="date" id="reportEndDate" class="mt-1 p-2 border rounded-md shadow-sm">
|
|
</div>
|
|
<button id="generateReportBtn" class="self-end bg-blue-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-blue-700">
|
|
Generate
|
|
</button>
|
|
<button id="exportReportBtn" class="hidden self-end bg-green-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-green-700">
|
|
Export to CSV
|
|
</button>
|
|
</div>
|
|
|
|
<!-- P&L Table -->
|
|
<table class="w-full max-w-2xl mt-6">
|
|
<tbody id="plReportBody">
|
|
<!-- JS will render report here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
// --- App State ---
|
|
let categories = [], rules = [], transactions = [], vendors = [];
|
|
let expensePieChart = null;
|
|
let jwtToken = localStorage.getItem('simpleLedgerToken');
|
|
let transactionSort = { column: 'date', direction: 'desc' };
|
|
|
|
// --- API Base ---
|
|
const API_URL = '/api';
|
|
|
|
// --- API Fetch Helper ---
|
|
async function apiFetch(endpoint, options = {}) {
|
|
const headers = {
|
|
'Authorization': `Bearer ${jwtToken}`,
|
|
...options.headers
|
|
};
|
|
|
|
if (!(options.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const res = await fetch(`${API_URL}${endpoint}`, { ...options, headers });
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
// Token is invalid or expired
|
|
logout();
|
|
throw new Error('Authentication error. Please log in again.');
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
throw new Error(errorText || 'API request failed');
|
|
}
|
|
|
|
const contentType = res.headers.get("content-type");
|
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
return res.json();
|
|
} else {
|
|
return res.text();
|
|
}
|
|
}
|
|
|
|
// --- Auth Functions ---
|
|
function showAuthError(message) {
|
|
const el = document.getElementById('authError');
|
|
el.textContent = message;
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
function hideAuthError() {
|
|
document.getElementById('authError').classList.add('hidden');
|
|
}
|
|
|
|
function toggleAuthForm(showLogin) {
|
|
hideAuthError();
|
|
if (showLogin) {
|
|
document.getElementById('authTitle').textContent = 'Login';
|
|
document.getElementById('loginForm').classList.remove('hidden');
|
|
document.getElementById('registerForm').classList.add('hidden');
|
|
document.getElementById('toggleAuth').textContent = 'Need an account? Register';
|
|
} else {
|
|
document.getElementById('authTitle').textContent = 'Register';
|
|
document.getElementById('loginForm').classList.add('hidden');
|
|
document.getElementById('registerForm').classList.remove('hidden');
|
|
document.getElementById('toggleAuth').textContent = 'Have an account? Login';
|
|
}
|
|
}
|
|
|
|
async function handleLogin(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const username = form.username.value;
|
|
const password = form.password.value;
|
|
|
|
try {
|
|
const data = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
|
|
if (!data.ok) {
|
|
const errorText = await data.text();
|
|
throw new Error(errorText);
|
|
}
|
|
|
|
const { accessToken } = await data.json();
|
|
jwtToken = accessToken;
|
|
localStorage.setItem('simpleLedgerToken', jwtToken);
|
|
|
|
document.getElementById('authView').classList.add('hidden');
|
|
document.getElementById('appContainer').classList.remove('hidden');
|
|
showView('dashboard');
|
|
await loadAllData();
|
|
|
|
} catch (err) {
|
|
showAuthError(err.message);
|
|
}
|
|
}
|
|
|
|
async function handleRegister(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const username = form.username.value;
|
|
const password = form.password.value;
|
|
|
|
try {
|
|
const data = await fetch('/api/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
|
|
if (!data.ok) {
|
|
const errorText = await data.text();
|
|
throw new Error(errorText);
|
|
}
|
|
|
|
alert('Registration successful! Please log in.');
|
|
toggleAuthForm(true); // Show login form
|
|
|
|
} catch (err) {
|
|
showAuthError(err.message);
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
jwtToken = null;
|
|
localStorage.removeItem('simpleLedgerToken');
|
|
document.getElementById('authView').classList.remove('hidden');
|
|
document.getElementById('appContainer').classList.add('hidden');
|
|
}
|
|
|
|
// --- Data Loading ---
|
|
async function loadAllData() {
|
|
showLoadingModal(true, "Loading your data...");
|
|
try {
|
|
await Promise.all([
|
|
loadCategories(),
|
|
loadVendors(),
|
|
loadRules(),
|
|
loadTransactions()
|
|
]);
|
|
updateDashboard(); // Update dashboard after all data is loaded
|
|
} catch (error) {
|
|
console.error("Error loading data:", error);
|
|
alert(error.message);
|
|
} finally {
|
|
showLoadingModal(false);
|
|
}
|
|
}
|
|
|
|
async function loadCategories() {
|
|
categories = await apiFetch('/categories');
|
|
renderCategories();
|
|
renderCategoryDropdowns();
|
|
}
|
|
|
|
async function loadVendors() {
|
|
vendors = await apiFetch('/vendors');
|
|
renderVendors();
|
|
renderVendorDropdowns();
|
|
}
|
|
|
|
async function loadRules() {
|
|
rules = await apiFetch('/rules');
|
|
renderRules();
|
|
}
|
|
|
|
async function loadTransactions() {
|
|
transactions = await apiFetch('/transactions');
|
|
// Dates are already strings from server, convert to Date objects
|
|
transactions.forEach(tx => tx.date = new Date(tx.date));
|
|
renderTransactions(); // Will sort and render
|
|
}
|
|
|
|
// --- UI Rendering ---
|
|
|
|
function renderCategories() {
|
|
const list = document.getElementById('categoryList');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
categories.sort((a,b) => a.name.localeCompare(b.name)).forEach(cat => {
|
|
const el = document.createElement('div');
|
|
el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm';
|
|
el.innerHTML = `
|
|
<span>${cat.name} (${cat.type})</span>
|
|
<button data-id="${cat.id}" class="text-red-500 hover:text-red-700">×</button>
|
|
`;
|
|
el.querySelector('button').onclick = () => deleteCategory(cat.id);
|
|
list.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderCategoryDropdowns() {
|
|
const selects = document.querySelectorAll('.category-select');
|
|
const options = categories
|
|
.sort((a,b) => a.name.localeCompare(b.name))
|
|
.map(c => `<option value="${c.name}">${c.name}</option>`)
|
|
.join('');
|
|
selects.forEach(sel => {
|
|
const currentVal = sel.value;
|
|
sel.innerHTML = options;
|
|
sel.value = currentVal;
|
|
});
|
|
}
|
|
|
|
function renderVendors() {
|
|
const list = document.getElementById('vendorList');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
vendors.forEach(v => {
|
|
const el = document.createElement('div');
|
|
el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm';
|
|
el.innerHTML = `
|
|
<span>${v.name}</span>
|
|
<button data-id="${v.id}" class="text-red-500 hover:text-red-700">×</button>
|
|
`;
|
|
el.querySelector('button').onclick = () => deleteVendor(v.id);
|
|
list.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderVendorDropdowns() {
|
|
const selects = document.querySelectorAll('.vendor-select');
|
|
const options = vendors
|
|
.sort((a,b) => a.name.localeCompare(b.name))
|
|
.map(c => `<option value="${c.id}">${c.name}</option></span>`)
|
|
.join('');
|
|
selects.forEach(sel => {
|
|
const currentVal = sel.value;
|
|
sel.innerHTML = '<option value="">-- None --</option>' + options;
|
|
sel.value = currentVal || "";
|
|
});
|
|
}
|
|
|
|
function renderRules() {
|
|
const list = document.getElementById('rulesList');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
rules.forEach(rule => {
|
|
const el = document.createElement('div');
|
|
el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm';
|
|
el.innerHTML = `
|
|
<span>If description contains "<strong>${rule.keyword}</strong>", set category to <strong>${rule.category}</strong></span>
|
|
<button data-id="${rule.id}" class="text-red-500 hover:text-red-700">×</button>
|
|
`;
|
|
el.querySelector('button').onclick = () => deleteRule(rule.id);
|
|
list.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderTransactions() {
|
|
const tbody = document.getElementById('transactionsTableBody');
|
|
const filter = document.getElementById('transactionFilter').value;
|
|
const search = document.getElementById('transactionSearch').value.toLowerCase();
|
|
if (!tbody) return;
|
|
|
|
let txsToRender = [...transactions];
|
|
|
|
// 1. Sort
|
|
txsToRender.sort((a, b) => {
|
|
let valA = a[transactionSort.column];
|
|
let valB = b[transactionSort.column];
|
|
|
|
// Handle special cases
|
|
if (transactionSort.column === 'date') {
|
|
valA = valA.getTime();
|
|
valB = valB.getTime();
|
|
} else if (transactionSort.column === 'amount') {
|
|
valA = valA;
|
|
valB = valB;
|
|
} else {
|
|
valA = (valA || '').toLowerCase();
|
|
valB = (valB || '').toLowerCase();
|
|
}
|
|
|
|
if (valA < valB) {
|
|
return transactionSort.direction === 'asc' ? -1 : 1;
|
|
}
|
|
if (valA > valB) {
|
|
return transactionSort.direction === 'asc' ? 1 : -1;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
// 2. Filter by Category
|
|
if (filter === 'uncategorized') {
|
|
txsToRender = txsToRender.filter(tx => tx.category.toLowerCase() === 'expense - uncategorized' || tx.category.toLowerCase() === 'uncategorized');
|
|
}
|
|
|
|
// 3. Filter by Search
|
|
if (search) {
|
|
txsToRender = txsToRender.filter(tx => tx.description.toLowerCase().includes(search));
|
|
}
|
|
|
|
// 4. Update Header styles
|
|
document.querySelectorAll('.sortable-header').forEach(th => {
|
|
th.classList.remove('sort-asc', 'sort-desc');
|
|
if (th.dataset.sort === transactionSort.column) {
|
|
th.classList.add(transactionSort.direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
});
|
|
|
|
// 5. Render
|
|
tbody.innerHTML = '';
|
|
if (txsToRender.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center p-4">No transactions found.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
txsToRender.forEach(tx => {
|
|
const txDate = new Date(tx.date);
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'border-b hover:bg-gray-50';
|
|
tr.innerHTML = `
|
|
<td class="p-3">${txDate.toLocaleDateString()}</td>
|
|
<td class="p-3 truncate max-w-xs">${tx.description}</td>
|
|
<td class="p-3">${tx.category}</td>
|
|
<td class="p-3">${tx.vendorName || 'N/A'}</td>
|
|
<td class="p-3 text-right font-medium ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}">
|
|
${tx.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
|
|
</td>
|
|
<td class="p-3 text-center">
|
|
<button data-id="${tx.id}" class="edit-tx-btn text-blue-600 hover:underline text-sm">Edit</button>
|
|
<button data-id="${tx.id}" class="delete-tx-btn text-red-600 hover:underline text-sm ml-2">Del</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
tbody.querySelectorAll('.edit-tx-btn').forEach(btn => {
|
|
btn.onclick = (e) => showEditTransactionModal(e.target.dataset.id);
|
|
});
|
|
tbody.querySelectorAll('.delete-tx-btn').forEach(btn => {
|
|
btn.onclick = (e) => deleteTransaction(e.target.dataset.id);
|
|
});
|
|
}
|
|
|
|
function handleSort(e) {
|
|
const column = e.target.dataset.sort;
|
|
if (!column) return;
|
|
|
|
if (transactionSort.column === column) {
|
|
transactionSort.direction = transactionSort.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
transactionSort.column = column;
|
|
transactionSort.direction = 'desc'; // Default to desc for new columns
|
|
}
|
|
renderTransactions();
|
|
}
|
|
|
|
async function updateDashboard() {
|
|
try {
|
|
const summary = await apiFetch('/dashboard-summary');
|
|
const { totalIncome, totalReturns, totalExpenses } = summary.stats;
|
|
|
|
const netIncome = totalIncome + totalReturns; // Returns are negative
|
|
const netProfit = netIncome + totalExpenses; // Expenses are negative
|
|
|
|
document.getElementById('statGrossIncome').textContent = totalIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
// Sum of returns (negative) and discounts (negative)
|
|
const returnsAndDiscounts = totalReturns; // We'll add discounts later if parsed
|
|
document.getElementById('statReturns').textContent = returnsAndDiscounts.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
document.getElementById('statNetIncome').textContent = netIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
document.getElementById('statTotalExpenses').textContent = totalExpenses.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
document.getElementById('statNetProfit').textContent = netProfit.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
|
|
if (netProfit >= 0) {
|
|
document.getElementById('statNetProfit').classList.remove('text-red-600');
|
|
document.getElementById('statNetProfit').classList.add('text-green-600');
|
|
} else {
|
|
document.getElementById('statNetProfit').classList.remove('green-red-600');
|
|
document.getElementById('statNetProfit').classList.add('text-red-600');
|
|
}
|
|
|
|
// Render Pie Chart
|
|
const ctx = document.getElementById('expensePieChart').getContext('2d');
|
|
if (expensePieChart) {
|
|
expensePieChart.destroy();
|
|
}
|
|
expensePieChart = new Chart(ctx, {
|
|
type: 'pie',
|
|
data: {
|
|
labels: summary.pieData.labels,
|
|
datasets: [{
|
|
label: 'Expenses',
|
|
data: summary.pieData.data,
|
|
backgroundColor: [
|
|
'rgba(239, 68, 68, 0.7)',
|
|
'rgba(59, 130, 246, 0.7)',
|
|
'rgba(234, 179, 8, 0.7)',
|
|
'rgba(34, 197, 94, 0.7)',
|
|
'rgba(168, 85, 247, 0.7)',
|
|
'rgba(236, 72, 153, 0.7)',
|
|
'rgba(249, 115, 22, 0.7)',
|
|
'rgba(13, 148, 136, 0.7)',
|
|
'rgba(107, 114, 128, 0.7)',
|
|
'rgba(217, 119, 6, 0.7)'
|
|
],
|
|
borderColor: '#fff',
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'right',
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("Error updating dashboard:", error);
|
|
alert("Could not load dashboard summary.");
|
|
}
|
|
}
|
|
|
|
function generateReport() {
|
|
const startVal = document.getElementById('reportStartDate').value;
|
|
const endVal = document.getElementById('reportEndDate').value;
|
|
|
|
// Fix for date parsing: ensure dates are treated as local
|
|
const start = new Date(startVal + 'T00:00:00');
|
|
const end = new Date(endVal + 'T23:59:59');
|
|
|
|
if (!startVal || !endVal) {
|
|
alert("Please select valid start and end dates.");
|
|
return;
|
|
}
|
|
|
|
const txs = transactions.filter(tx => {
|
|
const txDate = new Date(tx.date);
|
|
return txDate >= start && txDate <= end;
|
|
});
|
|
|
|
let incomeByCat = {};
|
|
let expensesByCat = {};
|
|
let totalIncome = 0;
|
|
let totalExpenses = 0;
|
|
|
|
txs.forEach(tx => {
|
|
const category = categories.find(c => c.name === tx.category);
|
|
const type = category ? category.type : (tx.amount > 0 ? 'income' : 'expense');
|
|
|
|
if (type === 'income') {
|
|
totalIncome += tx.amount;
|
|
incomeByCat[tx.category] = (incomeByCat[tx.category] || 0) + tx.amount;
|
|
} else if (type === 'expense') {
|
|
totalExpenses += tx.amount;
|
|
expensesByCat[tx.category] = (expensesByCat[tx.category] || 0) + tx.amount;
|
|
}
|
|
// 'liability' types are ignored
|
|
});
|
|
|
|
const netProfit = totalIncome + totalExpenses;
|
|
const reportBody = document.getElementById('plReportBody');
|
|
reportBody.innerHTML = '';
|
|
|
|
let incomeHtml = '<tr class="font-bold bg-gray-100"><td class="p-2" colspan="2">Income</td></tr>';
|
|
const incomeCategories = Object.keys(incomeByCat).sort();
|
|
for (const cat of incomeCategories) {
|
|
const amt = incomeByCat[cat];
|
|
incomeHtml += `<tr><td class="p-2 pl-6">${cat}</td><td class="p-2 text-right">${amt.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td></tr>`;
|
|
}
|
|
incomeHtml += `<tr class="font-semibold border-t"><td class="p-2">Total Income (Net Sales)</td><td class="p-2 text-right">${totalIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td></tr>`;
|
|
|
|
let expensesHtml = '<tr class="font-bold bg-gray-100 mt-4"><td class="p-2" colspan="2">Expenses</td></tr>';
|
|
const expenseCategories = Object.keys(expensesByCat).sort();
|
|
for (const cat of expenseCategories) {
|
|
const amt = expensesByCat[cat];
|
|
expensesHtml += `<tr><td class="p-2 pl-6">${cat}</td><td class="p-2 text-right">${amt.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td></tr>`;
|
|
}
|
|
expensesHtml += `<tr class="font-semibold border-t"><td class="p-2">Total Expenses</td><td class="p-2 text-right">${totalExpenses.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td></tr>`;
|
|
|
|
let netHtml = `<tr class="font-bold text-lg border-t-2 ${netProfit >= 0 ? 'text-green-700' : 'text-red-700'}">
|
|
<td class="p-2">Net Profit / (Loss)</td>
|
|
<td class="p-2 text-right">${netProfit.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
|
|
</tr>`;
|
|
|
|
reportBody.innerHTML = incomeHtml + expensesHtml + netHtml;
|
|
document.getElementById('exportReportBtn').classList.remove('hidden');
|
|
}
|
|
|
|
function exportReport() {
|
|
let csvContent = "data:text/csv;charset=utf-8,";
|
|
csvContent += "Item,Category,Amount\n";
|
|
|
|
const reportBody = document.getElementById('plReportBody');
|
|
reportBody.querySelectorAll('tr').forEach(tr => {
|
|
const cells = tr.querySelectorAll('td');
|
|
if (cells.length === 2) {
|
|
let col1 = cells[0].textContent.replace(/,/g, '');
|
|
let col2 = cells[1].textContent.replace(/[$(),]/g, '');
|
|
if (cells[0].classList.contains('pl-6')) {
|
|
csvContent += ` ,${col1},${col2}\n`;
|
|
} else {
|
|
csvContent += `${col1}, ,${col2}\n`;
|
|
}
|
|
}
|
|
});
|
|
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "profit_loss_report.csv");
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
// --- Data Actions (Now use apiFetch) ---
|
|
|
|
async function addCategory(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const name = form.categoryName.value;
|
|
const type = form.categoryType.value;
|
|
if (!name) return;
|
|
|
|
try {
|
|
await apiFetch('/categories', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, type })
|
|
});
|
|
form.reset();
|
|
await loadCategories(); // Refresh
|
|
} catch (error) {
|
|
console.error("Error adding category: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteCategory(id) {
|
|
if (confirm("Are you sure you want to delete this category?")) {
|
|
try {
|
|
await apiFetch(`/categories/${id}`, { method: 'DELETE' });
|
|
await loadCategories(); // Refresh
|
|
} catch (error) {
|
|
console.error("Error deleting category: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addVendor(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const name = form.vendorName.value;
|
|
if (!name) return;
|
|
|
|
try {
|
|
await apiFetch('/vendors', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name })
|
|
});
|
|
form.reset();
|
|
await loadVendors(); // Refresh
|
|
} catch (error) {
|
|
console.error("Error adding vendor: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteVendor(id) {
|
|
if (confirm("Are you sure you want to delete this vendor?")) {
|
|
try {
|
|
await apiFetch(`/vendors/${id}`, { method: 'DELETE' });
|
|
await loadVendors(); // Refresh
|
|
} catch (error) {
|
|
console.error("Error deleting vendor: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addRule(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const keyword = form.ruleKeyword.value;
|
|
const category = form.ruleCategory.value;
|
|
if (!keyword || !category) return;
|
|
|
|
try {
|
|
await apiFetch('/rules', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ keyword, category })
|
|
});
|
|
form.reset();
|
|
await loadRules(); // Refresh
|
|
|
|
// New: Ask to apply rules
|
|
if (confirm("Rule added. Apply all rules to uncategorized transactions now?")) {
|
|
applyAllRules();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("Error adding rule: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteRule(id) {
|
|
if (confirm("Are you sure you want to delete this rule?")) {
|
|
try {
|
|
await apiFetch(`/rules/${id}`, { method: 'DELETE' });
|
|
await loadRules(); // Refresh
|
|
} catch (error) {
|
|
console.error("Error deleting rule: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function applyAllRules() {
|
|
showLoadingModal(true, "Applying rules...");
|
|
try {
|
|
const message = await apiFetch('/rules/apply-all', { method: 'POST' });
|
|
alert(message);
|
|
await loadTransactions(); // Refresh transactions
|
|
} catch (error) {
|
|
console.error("Error applying rules:", error);
|
|
alert(error.message);
|
|
} finally {
|
|
showLoadingModal(false);
|
|
}
|
|
}
|
|
|
|
function showEditTransactionModal(txId) {
|
|
const tx = transactions.find(t => t.id == txId);
|
|
if (!tx) return;
|
|
|
|
document.getElementById('editTxId').value = tx.id;
|
|
// Dates need to be in 'YYYY-MM-DD' format for the input
|
|
document.getElementById('editTxDate').value = new Date(tx.date).toISOString().split('T')[0];
|
|
document.getElementById('editTxDescription').value = tx.description;
|
|
document.getElementById('editTxAmount').value = tx.amount;
|
|
document.getElementById('editTxCategory').value = tx.category;
|
|
document.getElementById('editTxVendor').value = tx.vendorId || "";
|
|
|
|
document.getElementById('editTxModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeEditTransactionModal() {
|
|
document.getElementById('editTxModal').classList.add('hidden');
|
|
}
|
|
|
|
async function handleEditTransactionSubmit(e) {
|
|
e.preventDefault();
|
|
const id = document.getElementById('editTxId').value;
|
|
const payload = {
|
|
id: id,
|
|
date: document.getElementById('editTxDate').value,
|
|
description: document.getElementById('editTxDescription').value,
|
|
amount: parseFloat(document.getElementById('editTxAmount').value),
|
|
category: document.getElementById('editTxCategory').value,
|
|
vendorId: document.getElementById('editTxVendor').value || null
|
|
};
|
|
|
|
try {
|
|
await apiFetch(`/transactions/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
closeEditTransactionModal();
|
|
await loadTransactions(); // Full refresh
|
|
await updateDashboard();
|
|
} catch (error) {
|
|
console.error("Error updating transaction: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function handleCreateRuleFromTx() {
|
|
const description = document.getElementById('editTxDescription').value;
|
|
const category = document.getElementById('editTxCategory').value;
|
|
|
|
let keyword = prompt(`Create rule for "${description}"?\n\nEnter the part of the description to use as a keyword:`, description);
|
|
|
|
if (keyword) {
|
|
try {
|
|
await apiFetch('/rules', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ keyword, category })
|
|
});
|
|
await loadRules();
|
|
alert(`Rule created: If description contains "${keyword}", set to "${category}".`);
|
|
} catch (error) {
|
|
console.error("Error creating rule:", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteTransaction(txId) {
|
|
if (confirm("Are you sure you want to delete this transaction permanently?")) {
|
|
try {
|
|
await apiFetch(`/transactions/${txId}`, { method: 'DELETE' });
|
|
await loadTransactions(); // Full refresh
|
|
await updateDashboard();
|
|
} catch (error) {
|
|
console.error("Error deleting transaction: ", error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- File Processing ---
|
|
|
|
async function handleFiles(e) {
|
|
e.preventDefault();
|
|
const bankFile = document.getElementById('bankFile').files[0];
|
|
const salesFile = document.getElementById('salesFile').files[0];
|
|
|
|
if (!bankFile && !salesFile) {
|
|
alert("Please select at least one file to upload.");
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
if (bankFile) formData.append('bankFile', bankFile);
|
|
if (salesFile) formData.append('salesFile', salesFile);
|
|
|
|
showLoadingModal(true, "Uploading and processing files...");
|
|
|
|
try {
|
|
const result = await apiFetch('/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
alert(result); // Show success message from server
|
|
document.getElementById('uploadForm').reset();
|
|
await loadTransactions(); // Refresh data
|
|
await updateDashboard(); // Refresh dashboard
|
|
showView('transactions');
|
|
|
|
} catch (error) {
|
|
console.error("Error uploading files:", error);
|
|
alert(error.message);
|
|
} finally {
|
|
showLoadingModal(false);
|
|
}
|
|
}
|
|
|
|
|
|
// --- UI Navigation ---
|
|
function showView(viewId) {
|
|
document.querySelectorAll('main > section').forEach(section => {
|
|
section.classList.add('hidden');
|
|
});
|
|
document.querySelectorAll('nav a').forEach(a => {
|
|
a.classList.remove('bg-blue-700', 'text-white');
|
|
a.classList.add('text-blue-100', 'hover:bg-blue-600');
|
|
});
|
|
|
|
const activeView = document.getElementById(viewId);
|
|
const activeLink = document.querySelector(`nav a[data-view="${viewId}"]`);
|
|
|
|
if (activeView) activeView.classList.remove('hidden');
|
|
if (activeLink) {
|
|
activeLink.classList.add('bg-blue-700', 'text-white');
|
|
activeLink.classList.remove('text-blue-100', 'hover:bg-blue-600');
|
|
}
|
|
|
|
// Refresh dashboard data only when switching to it
|
|
if (viewId === 'dashboard') {
|
|
updateDashboard();
|
|
}
|
|
}
|
|
|
|
function showLoadingModal(show, text = 'Loading...') {
|
|
const modal = document.getElementById('loadingModal');
|
|
const textEl = document.getElementById('loadingModalText');
|
|
if (show) {
|
|
textEl.textContent = text;
|
|
modal.classList.remove('hidden');
|
|
} else {
|
|
modal.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// --- Initial App Load ---
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Auth form toggling
|
|
document.getElementById('toggleAuth').onclick = (e) => {
|
|
e.preventDefault();
|
|
const isLogin = document.getElementById('loginForm').classList.contains('hidden');
|
|
toggleAuthForm(isLogin);
|
|
};
|
|
|
|
// Auth form submission
|
|
document.getElementById('loginForm').onsubmit = handleLogin;
|
|
document.getElementById('registerForm').onsubmit = handleRegister;
|
|
|
|
// App Navigation
|
|
document.querySelectorAll('nav a').forEach(a => {
|
|
a.onclick = (e) => {
|
|
e.preventDefault();
|
|
if(e.target.id === 'logoutButton') return logout();
|
|
showView(e.target.dataset.view);
|
|
}
|
|
});
|
|
|
|
// --- Event Listeners ---
|
|
document.getElementById('uploadForm').onsubmit = handleFiles;
|
|
document.getElementById('categoryForm').onsubmit = addCategory;
|
|
document.getElementById('vendorForm').onsubmit = addVendor;
|
|
document.getElementById('ruleForm').onsubmit = addRule;
|
|
document.getElementById('transactionFilter').onchange = renderTransactions;
|
|
document.getElementById('transactionSearch').oninput = renderTransactions;
|
|
document.getElementById('generateReportBtn').onclick = generateReport;
|
|
document.getElementById('exportReportBtn').onclick = exportReport;
|
|
document.getElementById('applyAllRulesBtn').onclick = applyAllRules;
|
|
|
|
// Transaction table sorting
|
|
document.querySelectorAll('.sortable-header').forEach(th => {
|
|
th.onclick = handleSort;
|
|
});
|
|
|
|
// Edit Tx Modal
|
|
document.getElementById('closeTxModalBtn').onclick = closeEditTransactionModal;
|
|
document.getElementById('editTxForm').onsubmit = handleEditTransactionSubmit;
|
|
document.getElementById('createRuleFromTx').onclick = handleCreateRuleFromTx;
|
|
|
|
// Check if already logged in
|
|
if (jwtToken) {
|
|
document.getElementById('authView').classList.add('hidden');
|
|
document.getElementById('appContainer').classList.remove('hidden');
|
|
showView('dashboard');
|
|
loadAllData();
|
|
} else {
|
|
document.getElementById('authView').classList.remove('hidden');
|
|
document.getElementById('appContainer').classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|
|
|