diff --git a/README.md b/README.md index d53685f..e861814 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ BillTrak is a lightweight, web-based application designed to help users manage a 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 ` (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 1. **Adding a Bill**: - Fill out the "Add New Payment" form with date, payee, amount, and optional details diff --git a/data/import.php b/data/import.php index f20eee6..56e1369 100644 --- a/data/import.php +++ b/data/import.php @@ -1,41 +1,192 @@ 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) { - die("Error: Unable to open file - $csvFile"); + throw new RuntimeException("Unable to open file: {$resolvedPath}"); } - // Skip the header row - fgetcsv($handle); + // Explicitly set escape char to avoid deprecation warnings on newer PHP versions + $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 - $stmt = $db->prepare("INSERT INTO bills (billDate, billName, amount, paymentId, year) VALUES (?, ?, ?, ?, ?)"); + $headers = array_map('trim', $headers); + $requiredHeaders = ['Date', 'Payee', 'Reference Number', 'Amount']; + $headerMap = []; - // Process each row - while (($row = fgetcsv($handle)) !== false) { - $date = $row[0]; - $billName = $row[1]; - $amount = floatval($row[2]); - $paymentId = $row[3] ?? null; + foreach ($requiredHeaders as $column) { + $index = array_search($column, $headers); + if ($index === false) { + fclose($handle); + throw new RuntimeException("Missing required column \"{$column}\" in {$csvFile}"); + } + $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); - echo "Data imported successfully from $csvFile.\r\n"; + return [ + 'file' => $csvFile, + 'inserted' => $inserted, + 'skipped' => $skipped, + 'payeesCreated' => $payeesCreated, + 'errors' => $errors + ]; } -// Example usage -importCsvToDatabase('2025.csv', 2025); -importCsvToDatabase('2024.csv', 2024); -importCsvToDatabase('2023.csv', 2023); -importCsvToDatabase('2022.csv', 2022); +if (PHP_SAPI === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) { + $files = array_slice($argv, 1); + + if (empty($files)) { + echo "Usage: php data/import.php [ ...]\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"; + } + } +} diff --git a/includes/api.php b/includes/api.php index c7f3471..54d4c4e 100644 --- a/includes/api.php +++ b/includes/api.php @@ -5,6 +5,7 @@ error_reporting(E_ALL); require_once './bill.php'; require_once './db.php'; +require_once __DIR__.'/../data/import.php'; header('Content-Type: application/json'); @@ -141,9 +142,67 @@ try { break; case 'addPayee': - $stmt = DB::connect()->prepare("INSERT INTO payees (name) VALUES (?)"); - $result = $stmt->execute([$_POST['payeeName']]); - echo json_encode(['success' => $result]); + try { + $name = trim($_POST['payeeName'] ?? ''); + 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; case 'getPayees': @@ -198,6 +257,36 @@ try { ]); 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: throw new InvalidActionException('Invalid action'); } diff --git a/index.php b/index.php index 046eb7b..5bd1da5 100644 --- a/index.php +++ b/index.php @@ -46,7 +46,10 @@ - +
+ + +
@@ -57,13 +60,20 @@ + +
+

Manage Payees

+

No payees yet.

+
    +

    YTD Chart

    - + +
    @@ -164,5 +174,23 @@
    + + + diff --git a/js/app.js b/js/app.js index 98f7dbc..fc7fa77 100644 --- a/js/app.js +++ b/js/app.js @@ -4,12 +4,20 @@ document.addEventListener('DOMContentLoaded', () => { const billList = document.getElementById('billList'); const form = document.getElementById('billForm'); 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 // Populate the year dropdown - async function loadYears() { + async function loadYears(preferredYear) { const response = await fetch('/includes/api.php?action=getYears'); const years = await response.json(); + const yearStrings = years.map(String); yearSelect.innerHTML = ''; // Clear existing options years.forEach(year => { @@ -21,8 +29,9 @@ document.addEventListener('DOMContentLoaded', () => { // Set default year to the most recent one if (years.length > 0) { - yearSelect.value = years[0]; - loadBills(years[0], sortSelect.value); // Load bills for the most recent year with the default sort order + const targetYear = yearStrings.includes(String(preferredYear)) ? preferredYear : years[0]; + yearSelect.value = targetYear; + await loadBills(targetYear, sortSelect.value); // Load bills for the most recent year with the default sort order } else { billList.innerHTML = '

    No data available.

    '; } @@ -50,6 +59,28 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = payee.name; // Display payee name 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 = ` + ${payee.name} +
    + + +
    + `; + payeeList.appendChild(li); + }); + } + } } // Fetch and display bills @@ -160,6 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { // Get the chart canvas const ctx = document.getElementById('ytdPieChart').getContext('2d'); + const legendContainer = document.getElementById('ytdLegend'); // Destroy the existing chart if it exists if (ytdPieChart) { @@ -183,10 +215,14 @@ document.addEventListener('DOMContentLoaded', () => { }, options: { responsive: true, + maintainAspectRatio: true, + layout: { + padding: {top: 8, bottom: 8} + }, plugins: { legend: { - position: 'bottom' - }, + display: false + }, // We render a custom legend below tooltip: { callbacks: { 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(); 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', { method: 'POST', 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 billList.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON' && e.target.textContent === 'Edit') { @@ -291,6 +418,63 @@ document.addEventListener('DOMContentLoaded', () => { 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 const darkModeToggle = document.getElementById('darkModeToggle'); const htmlElement = document.documentElement;