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..00dbaaf 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'); @@ -198,6 +199,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..e64d659 100644 --- a/index.php +++ b/index.php @@ -17,7 +17,8 @@

Bill Tracker

-
+
+
+ + + diff --git a/js/app.js b/js/app.js index 98f7dbc..c21f155 100644 --- a/js/app.js +++ b/js/app.js @@ -4,12 +4,18 @@ document.addEventListener('DOMContentLoaded', () => { const billList = document.getElementById('billList'); const form = document.getElementById('billForm'); const currentYear = new Date().getFullYear(); + 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 +27,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.

'; } @@ -291,6 +298,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;