mirror of
https://github.com/Solo-Web-Works/BillTrak.git
synced 2026-01-29 12:10:33 +00:00
Compare commits
3 Commits
6cbfe765ef
...
Updates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44c76544fc | ||
|
|
4504d4415b | ||
|
|
f048e1e5f4 |
@@ -54,6 +54,12 @@ BillTrak is a lightweight, web-based application designed to help users manage a
|
|||||||
http://localhost:8888
|
http://localhost:8888
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## CSV Import
|
||||||
|
- Prepare a CSV file with the headers `Date, Payee, Reference Number, Amount` (see `2025.csv` for an example layout).
|
||||||
|
- From the project root, run `php data/import.php <csv-file>` (you can pass multiple files to import them in one go).
|
||||||
|
- The importer creates missing payees automatically and skips duplicate bills that match date + payee + amount + reference number.
|
||||||
|
- Data is written into `data/bills.db`, so be sure that file exists (copy `data/bills-sample.db` to `data/bills.db` if you need a starting point).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
1. **Adding a Bill**:
|
1. **Adding a Bill**:
|
||||||
- Fill out the "Add New Payment" form with date, payee, amount, and optional details
|
- Fill out the "Add New Payment" form with date, payee, amount, and optional details
|
||||||
|
|||||||
197
data/import.php
197
data/import.php
@@ -1,41 +1,192 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__.'/../includes/db.php';
|
require_once __DIR__.'/../includes/db.php';
|
||||||
|
|
||||||
function importCsvToDatabase($csvFile, $year) {
|
/**
|
||||||
$db = DB::connect();
|
* Normalize incoming dates to YYYY-MM-DD.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
function normalizeDate(string $rawDate): string {
|
||||||
|
$rawDate = trim($rawDate);
|
||||||
|
$formats = ['M d, Y', 'M j, Y', 'Y-m-d', 'Y/m/d'];
|
||||||
|
|
||||||
if (!file_exists($csvFile)) {
|
foreach ($formats as $format) {
|
||||||
die("Error: File not found - $csvFile");
|
$date = DateTime::createFromFormat($format, $rawDate);
|
||||||
|
if ($date instanceof DateTime) {
|
||||||
|
return $date->format('Y-m-d');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$handle = fopen($csvFile, 'r');
|
$timestamp = strtotime($rawDate);
|
||||||
|
if ($timestamp !== false) {
|
||||||
|
return date('Y-m-d', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException("Unable to parse date: {$rawDate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a CSV file with the columns: Date, Payee, Reference Number, Amount.
|
||||||
|
*
|
||||||
|
* - Creates payees on the fly if they don't exist.
|
||||||
|
* - Skips duplicate bills that match date + payee + amount + paymentId.
|
||||||
|
*
|
||||||
|
* @return array Summary of the import run.
|
||||||
|
*/
|
||||||
|
function importCsvToDatabase(string $csvFile, ?PDO $db = null): array {
|
||||||
|
$db = $db ?: DB::connect();
|
||||||
|
|
||||||
|
// Allow relative paths from the project root or current working directory
|
||||||
|
$resolvedPath = $csvFile;
|
||||||
|
if (!is_readable($resolvedPath)) {
|
||||||
|
$altPath = __DIR__.'/../'.$csvFile;
|
||||||
|
if (is_readable($altPath)) {
|
||||||
|
$resolvedPath = $altPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($resolvedPath)) {
|
||||||
|
throw new InvalidArgumentException("File not found or unreadable: {$csvFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($resolvedPath, 'r');
|
||||||
if ($handle === false) {
|
if ($handle === false) {
|
||||||
die("Error: Unable to open file - $csvFile");
|
throw new RuntimeException("Unable to open file: {$resolvedPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the header row
|
// Explicitly set escape char to avoid deprecation warnings on newer PHP versions
|
||||||
fgetcsv($handle);
|
$headers = fgetcsv($handle, 0, ',', '"', '\\');
|
||||||
|
if ($headers === false) {
|
||||||
|
fclose($handle);
|
||||||
|
throw new RuntimeException("CSV file appears to be empty: {$csvFile}");
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare SQL statement for inserting data
|
$headers = array_map('trim', $headers);
|
||||||
$stmt = $db->prepare("INSERT INTO bills (billDate, billName, amount, paymentId, year) VALUES (?, ?, ?, ?, ?)");
|
$requiredHeaders = ['Date', 'Payee', 'Reference Number', 'Amount'];
|
||||||
|
$headerMap = [];
|
||||||
|
|
||||||
// Process each row
|
foreach ($requiredHeaders as $column) {
|
||||||
while (($row = fgetcsv($handle)) !== false) {
|
$index = array_search($column, $headers);
|
||||||
$date = $row[0];
|
if ($index === false) {
|
||||||
$billName = $row[1];
|
fclose($handle);
|
||||||
$amount = floatval($row[2]);
|
throw new RuntimeException("Missing required column \"{$column}\" in {$csvFile}");
|
||||||
$paymentId = $row[3] ?? null;
|
}
|
||||||
|
$headerMap[$column] = $index;
|
||||||
|
}
|
||||||
|
|
||||||
$stmt->execute([$date, $billName, $amount, $paymentId, $year]);
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$payeesCreated = 0;
|
||||||
|
$errors = [];
|
||||||
|
$lineNumber = 1; // Start after header
|
||||||
|
|
||||||
|
$findPayeeStmt = $db->prepare("SELECT id FROM payees WHERE name = ?");
|
||||||
|
$insertPayeeStmt = $db->prepare("INSERT OR IGNORE INTO payees (name) VALUES (?)");
|
||||||
|
$findBillStmt = $db->prepare("
|
||||||
|
SELECT id FROM bills
|
||||||
|
WHERE billDate = ? AND payeeId = ? AND amount = ? AND IFNULL(paymentId, '') = IFNULL(?, '')
|
||||||
|
");
|
||||||
|
$insertBillStmt = $db->prepare("
|
||||||
|
INSERT INTO bills (billDate, payeeId, amount, paymentId, comment, year)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (($row = fgetcsv($handle, 0, ',', '"', '\\')) !== false) {
|
||||||
|
$lineNumber++;
|
||||||
|
|
||||||
|
// Skip rows that are completely empty
|
||||||
|
if (count(array_filter($row, fn($value) => trim((string)$value) !== '')) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = normalizeDate($row[$headerMap['Date']] ?? '');
|
||||||
|
$payeeName = trim((string)($row[$headerMap['Payee']] ?? ''));
|
||||||
|
$paymentId = trim((string)($row[$headerMap['Reference Number']] ?? '')) ?: null;
|
||||||
|
$amountRaw = (string)($row[$headerMap['Amount']] ?? '0');
|
||||||
|
$amount = (float)str_replace([',', '$'], '', $amountRaw);
|
||||||
|
$year = (int)substr($date, 0, 4);
|
||||||
|
|
||||||
|
if ($payeeName === '' || $amount === 0.0) {
|
||||||
|
throw new RuntimeException('Missing payee or zero amount.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch or create payee
|
||||||
|
$findPayeeStmt->execute([$payeeName]);
|
||||||
|
$payeeId = $findPayeeStmt->fetchColumn();
|
||||||
|
|
||||||
|
if (!$payeeId) {
|
||||||
|
$insertPayeeStmt->execute([$payeeName]);
|
||||||
|
if ($insertPayeeStmt->rowCount() > 0) {
|
||||||
|
$payeesCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$findPayeeStmt->execute([$payeeName]);
|
||||||
|
$payeeId = $findPayeeStmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$payeeId) {
|
||||||
|
throw new RuntimeException("Could not resolve payee ID for \"{$payeeName}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip duplicates
|
||||||
|
$findBillStmt->execute([$date, $payeeId, $amount, $paymentId]);
|
||||||
|
if ($findBillStmt->fetchColumn()) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertBillStmt->execute([$date, $payeeId, $amount, $paymentId, '', $year]);
|
||||||
|
$inserted++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$errors[] = "Line {$lineNumber}: {$e->getMessage()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
fclose($handle);
|
||||||
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|
||||||
echo "Data imported successfully from $csvFile.\r\n";
|
return [
|
||||||
|
'file' => $csvFile,
|
||||||
|
'inserted' => $inserted,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'payeesCreated' => $payeesCreated,
|
||||||
|
'errors' => $errors
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example usage
|
if (PHP_SAPI === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
|
||||||
importCsvToDatabase('2025.csv', 2025);
|
$files = array_slice($argv, 1);
|
||||||
importCsvToDatabase('2024.csv', 2024);
|
|
||||||
importCsvToDatabase('2023.csv', 2023);
|
if (empty($files)) {
|
||||||
importCsvToDatabase('2022.csv', 2022);
|
echo "Usage: php data/import.php <csv-file> [<csv-file> ...]\n";
|
||||||
|
echo "The file must contain the columns: Date, Payee, Reference Number, Amount.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
$result = importCsvToDatabase($file);
|
||||||
|
|
||||||
|
echo "Imported {$result['inserted']} bills from {$file} ";
|
||||||
|
echo "(skipped {$result['skipped']} duplicates, {$result['payeesCreated']} new payees).\n";
|
||||||
|
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
echo "Warnings:\n - ".implode("\n - ", $result['errors'])."\n";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "Failed to import {$file}: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ error_reporting(E_ALL);
|
|||||||
|
|
||||||
require_once './bill.php';
|
require_once './bill.php';
|
||||||
require_once './db.php';
|
require_once './db.php';
|
||||||
|
require_once __DIR__.'/../data/import.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
@@ -141,9 +142,67 @@ try {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'addPayee':
|
case 'addPayee':
|
||||||
$stmt = DB::connect()->prepare("INSERT INTO payees (name) VALUES (?)");
|
try {
|
||||||
$result = $stmt->execute([$_POST['payeeName']]);
|
$name = trim($_POST['payeeName'] ?? '');
|
||||||
echo json_encode(['success' => $result]);
|
if ($name === '') {
|
||||||
|
throw new MissingRequiredException('Payee name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = DB::connect()->prepare("INSERT INTO payees (name) VALUES (?)");
|
||||||
|
$result = $stmt->execute([$name]);
|
||||||
|
echo json_encode(['success' => $result]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'editPayee':
|
||||||
|
try {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
$name = trim($_POST['payeeName'] ?? '');
|
||||||
|
|
||||||
|
if (empty($id) || $name === '') {
|
||||||
|
throw new MissingRequiredException('Missing payee id or name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = DB::connect()->prepare("UPDATE payees SET name = ? WHERE id = ?");
|
||||||
|
$result = $stmt->execute([$name, $id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => $result]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'deletePayee':
|
||||||
|
try {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
|
||||||
|
if (empty($id)) {
|
||||||
|
throw new MissingRequiredException('Missing payee id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = DB::connect();
|
||||||
|
|
||||||
|
// Block deletion if payee is still referenced by bills
|
||||||
|
$usage = $db->prepare("SELECT COUNT(*) FROM bills WHERE payeeId = ?");
|
||||||
|
$usage->execute([$id]);
|
||||||
|
$billCount = (int)$usage->fetchColumn();
|
||||||
|
|
||||||
|
if ($billCount > 0) {
|
||||||
|
throw new RuntimeException('Cannot delete payee while bills reference it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("DELETE FROM payees WHERE id = ?");
|
||||||
|
$result = $stmt->execute([$id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => $result]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'getPayees':
|
case 'getPayees':
|
||||||
@@ -198,6 +257,36 @@ try {
|
|||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'importCsv':
|
||||||
|
try {
|
||||||
|
if (!isset($_FILES['file'])) {
|
||||||
|
throw new MissingRequiredException('No file uploaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Upload failed with error code '.$_FILES['file']['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpPath = tempnam(sys_get_temp_dir(), 'billtrak_csv_');
|
||||||
|
if (!$tmpPath || !move_uploaded_file($_FILES['file']['tmp_name'], $tmpPath)) {
|
||||||
|
throw new RuntimeException('Could not process uploaded file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = importCsvToDatabase($tmpPath);
|
||||||
|
} finally {
|
||||||
|
if (is_file($tmpPath)) {
|
||||||
|
unlink($tmpPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'result' => $result]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new InvalidActionException('Invalid action');
|
throw new InvalidActionException('Invalid action');
|
||||||
}
|
}
|
||||||
|
|||||||
32
index.php
32
index.php
@@ -46,7 +46,10 @@
|
|||||||
<input type="text" name="comment" class="p-2 border rounded bg-gray-50" placeholder="Add a comment (optional)"></input>
|
<input type="text" name="comment" class="p-2 border rounded bg-gray-50" placeholder="Add a comment (optional)"></input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="mt-4 bg-blue-500 text-white px-4 py-2 rounded">Add Bill</button>
|
<div class="flex items-center gap-4 mt-4">
|
||||||
|
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Add Bill</button>
|
||||||
|
<button type="button" id="openImportModal" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Import CSV</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 bg-white p-4 mb-6 rounded shadow">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 bg-white p-4 mb-6 rounded shadow">
|
||||||
@@ -57,13 +60,20 @@
|
|||||||
<input type="text" id="newPayeeName" name="payeeName" class="bg-white border rounded p-2 w-5/6" placeholder="New Payee Name">
|
<input type="text" id="newPayeeName" name="payeeName" class="bg-white border rounded p-2 w-5/6" placeholder="New Payee Name">
|
||||||
|
|
||||||
<button type="submit" class="mt-4 bg-blue-500 text-white px-4 py-2 rounded">Add Payee</button>
|
<button type="submit" class="mt-4 bg-blue-500 text-white px-4 py-2 rounded">Add Payee</button>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Manage Payees</h3>
|
||||||
|
<p id="payeeListEmpty" class="text-gray-500 text-sm">No payees yet.</p>
|
||||||
|
<ul id="payeeList" class="list-none pl-0 space-y-2 text-sm"></ul>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Chart & Totals -->
|
<!-- Chart & Totals -->
|
||||||
<div class="border p-4 rounded shadow bg-gray-100 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="border p-4 rounded shadow bg-gray-100 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div id="ytdPieChartSection" class="bg-white p-4 rounded shadow">
|
<div id="ytdPieChartSection" class="bg-white p-4 rounded shadow">
|
||||||
<h3 class="text-xl font-bold mb-4">YTD Chart</h3>
|
<h3 class="text-xl font-bold mb-4">YTD Chart</h3>
|
||||||
<canvas id="ytdPieChart"></canvas>
|
<canvas id="ytdPieChart" height="320"></canvas>
|
||||||
|
<div id="ytdLegend" class="mt-4 flex flex-wrap gap-3 text-sm text-gray-500"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ytdSection" class="bg-white p-4 rounded shadow">
|
<div id="ytdSection" class="bg-white p-4 rounded shadow">
|
||||||
@@ -164,5 +174,23 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Modal -->
|
||||||
|
<div id="importModal" class="fixed inset-0 bg-black bg-opacity-50 items-center justify-center hidden">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg w-96">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Import CSV</h2>
|
||||||
|
<form id="importForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="importFile" class="block text-sm font-medium">Select CSV File</label>
|
||||||
|
<input type="file" id="importFile" name="file" accept=".csv,text/csv" class="border rounded px-3 py-2 w-full bg-gray-50">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button type="button" id="importCancel" class="bg-red-300 px-4 py-2 rounded hover:bg-red-400">Cancel</button>
|
||||||
|
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Upload & Import</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
194
js/app.js
194
js/app.js
@@ -4,12 +4,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const billList = document.getElementById('billList');
|
const billList = document.getElementById('billList');
|
||||||
const form = document.getElementById('billForm');
|
const form = document.getElementById('billForm');
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const payeeList = document.getElementById('payeeList');
|
||||||
|
const payeeListEmpty = document.getElementById('payeeListEmpty');
|
||||||
|
const importModal = document.getElementById('importModal');
|
||||||
|
const openImportButton = document.getElementById('openImportModal');
|
||||||
|
const importCancelButton = document.getElementById('importCancel');
|
||||||
|
const importForm = document.getElementById('importForm');
|
||||||
|
const importFileInput = document.getElementById('importFile');
|
||||||
let ytdPieChart; // To hold the chart instance
|
let ytdPieChart; // To hold the chart instance
|
||||||
|
|
||||||
// Populate the year dropdown
|
// Populate the year dropdown
|
||||||
async function loadYears() {
|
async function loadYears(preferredYear) {
|
||||||
const response = await fetch('/includes/api.php?action=getYears');
|
const response = await fetch('/includes/api.php?action=getYears');
|
||||||
const years = await response.json();
|
const years = await response.json();
|
||||||
|
const yearStrings = years.map(String);
|
||||||
|
|
||||||
yearSelect.innerHTML = ''; // Clear existing options
|
yearSelect.innerHTML = ''; // Clear existing options
|
||||||
years.forEach(year => {
|
years.forEach(year => {
|
||||||
@@ -21,8 +29,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Set default year to the most recent one
|
// Set default year to the most recent one
|
||||||
if (years.length > 0) {
|
if (years.length > 0) {
|
||||||
yearSelect.value = years[0];
|
const targetYear = yearStrings.includes(String(preferredYear)) ? preferredYear : years[0];
|
||||||
loadBills(years[0], sortSelect.value); // Load bills for the most recent year with the default sort order
|
yearSelect.value = targetYear;
|
||||||
|
await loadBills(targetYear, sortSelect.value); // Load bills for the most recent year with the default sort order
|
||||||
} else {
|
} else {
|
||||||
billList.innerHTML = '<p class="text-gray-500">No data available.</p>';
|
billList.innerHTML = '<p class="text-gray-500">No data available.</p>';
|
||||||
}
|
}
|
||||||
@@ -50,6 +59,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
option.textContent = payee.name; // Display payee name
|
option.textContent = payee.name; // Display payee name
|
||||||
payeeSelect.appendChild(option);
|
payeeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (payeeList) {
|
||||||
|
payeeList.innerHTML = '';
|
||||||
|
|
||||||
|
if (payees.length === 0) {
|
||||||
|
if (payeeListEmpty) payeeListEmpty.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
if (payeeListEmpty) payeeListEmpty.classList.add('hidden');
|
||||||
|
payees.forEach((payee) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'flex items-center justify-between border rounded px-3 py-2 bg-white shadow-sm';
|
||||||
|
li.innerHTML = `
|
||||||
|
<span>${payee.name}</span>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<button type="button" class="text-white px-3 py-1 rounded text-xs shadow hover:opacity-90" style="background-color:#16a34a" data-action="edit-payee" data-id="${payee.id}" data-name="${payee.name}">Edit</button>
|
||||||
|
<button type="button" class="text-white px-3 py-1 rounded text-xs shadow hover:opacity-90" style="background-color:#dc2626" data-action="delete-payee" data-id="${payee.id}" data-name="${payee.name}">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
payeeList.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and display bills
|
// Fetch and display bills
|
||||||
@@ -160,6 +191,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Get the chart canvas
|
// Get the chart canvas
|
||||||
const ctx = document.getElementById('ytdPieChart').getContext('2d');
|
const ctx = document.getElementById('ytdPieChart').getContext('2d');
|
||||||
|
const legendContainer = document.getElementById('ytdLegend');
|
||||||
|
|
||||||
// Destroy the existing chart if it exists
|
// Destroy the existing chart if it exists
|
||||||
if (ytdPieChart) {
|
if (ytdPieChart) {
|
||||||
@@ -183,10 +215,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
layout: {
|
||||||
|
padding: {top: 8, bottom: 8}
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom'
|
display: false
|
||||||
},
|
}, // We render a custom legend below
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (tooltipItem) {
|
label: function (tooltipItem) {
|
||||||
@@ -199,6 +235,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom legend rendering in two columns, lighter text
|
||||||
|
if (legendContainer) {
|
||||||
|
legendContainer.innerHTML = '';
|
||||||
|
legendContainer.className = 'mt-4 grid grid-cols-2 gap-3 text-sm text-gray-500';
|
||||||
|
|
||||||
|
const colors = ytdPieChart.data.datasets[0].backgroundColor;
|
||||||
|
labels.forEach((label, idx) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'flex items-center gap-3';
|
||||||
|
|
||||||
|
const swatch = document.createElement('span');
|
||||||
|
swatch.style.display = 'inline-block';
|
||||||
|
swatch.style.width = '12px';
|
||||||
|
swatch.style.height = '12px';
|
||||||
|
swatch.style.borderRadius = '9999px';
|
||||||
|
swatch.style.backgroundColor = colors[idx % colors.length];
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = label;
|
||||||
|
text.className = 'text-gray-200';
|
||||||
|
|
||||||
|
item.appendChild(swatch);
|
||||||
|
item.appendChild(text);
|
||||||
|
legendContainer.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +304,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
|
const payeeName = (formData.get('payeeName') || '').toString().trim();
|
||||||
|
if (payeeName === '') {
|
||||||
|
alert('Please enter a payee name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.set('payeeName', payeeName);
|
||||||
|
|
||||||
const response = await fetch('/includes/api.php?action=addPayee', {
|
const response = await fetch('/includes/api.php?action=addPayee', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -254,6 +324,63 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit/Delete payees
|
||||||
|
if (payeeList) {
|
||||||
|
payeeList.addEventListener('click', async (e) => {
|
||||||
|
const action = e.target.getAttribute('data-action');
|
||||||
|
const payeeId = e.target.getAttribute('data-id');
|
||||||
|
const currentName = e.target.getAttribute('data-name');
|
||||||
|
|
||||||
|
if (!action || !payeeId) return;
|
||||||
|
|
||||||
|
if (action === 'edit-payee') {
|
||||||
|
const newName = prompt('Enter the new payee name:', currentName || '');
|
||||||
|
if (!newName || newName.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', payeeId);
|
||||||
|
formData.append('payeeName', newName.trim());
|
||||||
|
|
||||||
|
const response = await fetch('/includes/api.php?action=editPayee', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
alert(data.error || 'Failed to update payee.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPayees('payeeId');
|
||||||
|
await loadBills(yearSelect.value, sortSelect.value); // refresh displayed names
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete-payee') {
|
||||||
|
const confirmed = confirm(`Delete payee "${currentName}"? This cannot be undone.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', payeeId);
|
||||||
|
|
||||||
|
const response = await fetch('/includes/api.php?action=deletePayee', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
alert(data.error || 'Failed to delete payee.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPayees('payeeId');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Event listener for edit buttons within bill items
|
// Event listener for edit buttons within bill items
|
||||||
billList.addEventListener('click', (e) => {
|
billList.addEventListener('click', (e) => {
|
||||||
if (e.target.tagName === 'BUTTON' && e.target.textContent === 'Edit') {
|
if (e.target.tagName === 'BUTTON' && e.target.textContent === 'Edit') {
|
||||||
@@ -291,6 +418,63 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('editModal').classList.remove('flex');
|
document.getElementById('editModal').classList.remove('flex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Import modal
|
||||||
|
function openImportModal() {
|
||||||
|
importModal.classList.add('flex');
|
||||||
|
importModal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImportModal() {
|
||||||
|
importForm.reset();
|
||||||
|
importModal.classList.add('hidden');
|
||||||
|
importModal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
openImportButton.addEventListener('click', openImportModal);
|
||||||
|
importCancelButton.addEventListener('click', closeImportModal);
|
||||||
|
|
||||||
|
importForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!importFileInput.files.length) {
|
||||||
|
alert('Please choose a CSV file to import.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', importFileInput.files[0]);
|
||||||
|
|
||||||
|
const response = await fetch('/includes/api.php?action=importCsv', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
alert(data.error || 'Failed to import CSV.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data.result || {};
|
||||||
|
const summaryLines = [
|
||||||
|
`Imported ${result.inserted ?? 0} bills.`,
|
||||||
|
`Skipped ${result.skipped ?? 0} duplicates.`,
|
||||||
|
`Added ${result.payeesCreated ?? 0} new payees.`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length) {
|
||||||
|
summaryLines.push('Warnings:', result.errors.map((msg) => `- ${msg}`).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(summaryLines.join('\n'));
|
||||||
|
|
||||||
|
closeImportModal();
|
||||||
|
await loadYears(yearSelect.value);
|
||||||
|
await loadYtdAmounts(yearSelect.value);
|
||||||
|
await renderYtdPieChart(yearSelect.value);
|
||||||
|
});
|
||||||
|
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
const htmlElement = document.documentElement;
|
const htmlElement = document.documentElement;
|
||||||
|
|||||||
Reference in New Issue
Block a user