mirror of
https://github.com/Solo-Web-Works/BillTrak.git
synced 2026-01-29 05:10:33 +00:00
✨feature: Add CSV import
This commit is contained in:
@@ -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 <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
|
||||
1. **Adding a Bill**:
|
||||
- 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
|
||||
declare(strict_types=1);
|
||||
|
||||
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)) {
|
||||
die("Error: File not found - $csvFile");
|
||||
foreach ($formats as $format) {
|
||||
$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) {
|
||||
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 <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 './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');
|
||||
}
|
||||
|
||||
21
index.php
21
index.php
@@ -17,7 +17,8 @@
|
||||
<div class="flex justify-between">
|
||||
<h1 class="text-4xl font-bold p-4">Bill Tracker</h1>
|
||||
|
||||
<div class="flex justify-end p-4">
|
||||
<div class="flex justify-end p-4 gap-2">
|
||||
<button id="openImportModal" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Import CSV</button>
|
||||
<button id="darkModeToggle" class="flex items-center focus:outline-none p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<svg id="sunIcon" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-500 hidden" fill="none" viewBox="0 0 50 50" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M 24.90625 3.96875 C 24.863281 3.976563 24.820313 3.988281 24.78125 4 C 24.316406 4.105469 23.988281 4.523438 24 5 L 24 11 C 23.996094 11.359375 24.183594 11.695313 24.496094 11.878906 C 24.808594 12.058594 25.191406 12.058594 25.503906 11.878906 C 25.816406 11.695313 26.003906 11.359375 26 11 L 26 5 C 26.011719 4.710938 25.894531 4.433594 25.6875 4.238281 C 25.476563 4.039063 25.191406 3.941406 24.90625 3.96875 Z M 10.65625 9.84375 C 10.28125 9.910156 9.980469 10.183594 9.875 10.546875 C 9.769531 10.914063 9.878906 11.304688 10.15625 11.5625 L 14.40625 15.8125 C 14.648438 16.109375 15.035156 16.246094 15.410156 16.160156 C 15.78125 16.074219 16.074219 15.78125 16.160156 15.410156 C 16.246094 15.035156 16.109375 14.648438 15.8125 14.40625 L 11.5625 10.15625 C 11.355469 9.933594 11.054688 9.820313 10.75 9.84375 C 10.71875 9.84375 10.6875 9.84375 10.65625 9.84375 Z M 39.03125 9.84375 C 38.804688 9.875 38.59375 9.988281 38.4375 10.15625 L 34.1875 14.40625 C 33.890625 14.648438 33.753906 15.035156 33.839844 15.410156 C 33.925781 15.78125 34.21875 16.074219 34.589844 16.160156 C 34.964844 16.246094 35.351563 16.109375 35.59375 15.8125 L 39.84375 11.5625 C 40.15625 11.265625 40.246094 10.800781 40.0625 10.410156 C 39.875 10.015625 39.460938 9.789063 39.03125 9.84375 Z M 24.90625 15 C 24.875 15.007813 24.84375 15.019531 24.8125 15.03125 C 24.75 15.035156 24.6875 15.046875 24.625 15.0625 C 24.613281 15.074219 24.605469 15.082031 24.59375 15.09375 C 19.289063 15.320313 15 19.640625 15 25 C 15 30.503906 19.496094 35 25 35 C 30.503906 35 35 30.503906 35 25 C 35 19.660156 30.746094 15.355469 25.46875 15.09375 C 25.433594 15.09375 25.410156 15.0625 25.375 15.0625 C 25.273438 15.023438 25.167969 15.003906 25.0625 15 C 25.042969 15 25.019531 15 25 15 C 24.96875 15 24.9375 15 24.90625 15 Z M 24.9375 17 C 24.957031 17 24.980469 17 25 17 C 25.03125 17 25.0625 17 25.09375 17 C 29.46875 17.050781 33 20.613281 33 25 C 33 29.421875 29.421875 33 25 33 C 20.582031 33 17 29.421875 17 25 C 17 20.601563 20.546875 17.035156 24.9375 17 Z M 4.71875 24 C 4.167969 24.078125 3.78125 24.589844 3.859375 25.140625 C 3.9375 25.691406 4.449219 26.078125 5 26 L 11 26 C 11.359375 26.003906 11.695313 25.816406 11.878906 25.503906 C 12.058594 25.191406 12.058594 24.808594 11.878906 24.496094 C 11.695313 24.183594 11.359375 23.996094 11 24 L 5 24 C 4.96875 24 4.9375 24 4.90625 24 C 4.875 24 4.84375 24 4.8125 24 C 4.78125 24 4.75 24 4.71875 24 Z M 38.71875 24 C 38.167969 24.078125 37.78125 24.589844 37.859375 25.140625 C 37.9375 25.691406 38.449219 26.078125 39 26 L 45 26 C 45.359375 26.003906 45.695313 25.816406 45.878906 25.503906 C 46.058594 25.191406 46.058594 24.808594 45.878906 24.496094 C 45.695313 24.183594 45.359375 23.996094 45 24 L 39 24 C 38.96875 24 38.9375 24 38.90625 24 C 38.875 24 38.84375 24 38.8125 24 C 38.78125 24 38.75 24 38.71875 24 Z M 15 33.875 C 14.773438 33.90625 14.5625 34.019531 14.40625 34.1875 L 10.15625 38.4375 C 9.859375 38.679688 9.722656 39.066406 9.808594 39.441406 C 9.894531 39.8125 10.1875 40.105469 10.558594 40.191406 C 10.933594 40.277344 11.320313 40.140625 11.5625 39.84375 L 15.8125 35.59375 C 16.109375 35.308594 16.199219 34.867188 16.039063 34.488281 C 15.882813 34.109375 15.503906 33.867188 15.09375 33.875 C 15.0625 33.875 15.03125 33.875 15 33.875 Z M 34.6875 33.875 C 34.3125 33.941406 34.011719 34.214844 33.90625 34.578125 C 33.800781 34.945313 33.910156 35.335938 34.1875 35.59375 L 38.4375 39.84375 C 38.679688 40.140625 39.066406 40.277344 39.441406 40.191406 C 39.8125 40.105469 40.105469 39.8125 40.191406 39.441406 C 40.277344 39.066406 40.140625 38.679688 39.84375 38.4375 L 35.59375 34.1875 C 35.40625 33.988281 35.148438 33.878906 34.875 33.875 C 34.84375 33.875 34.8125 33.875 34.78125 33.875 C 34.75 33.875 34.71875 33.875 34.6875 33.875 Z M 24.90625 37.96875 C 24.863281 37.976563 24.820313 37.988281 24.78125 38 C 24.316406 38.105469 23.988281 38.523438 24 39 L 24 45 C 23.996094 45.359375 24.183594 45.695313 24.496094 45.878906 C 24.808594 46.058594 25.191406 46.058594 25.503906 45.878906 C 25.816406 45.695313 26.003906 45.359375 26 45 L 26 39 C 26.011719 38.710938 25.894531 38.433594 25.6875 38.238281 C 25.476563 38.039063 25.191406 37.941406 24.90625 37.96875 Z"></path>
|
||||
@@ -164,5 +165,23 @@
|
||||
</form>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
70
js/app.js
70
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 = '<p class="text-gray-500">No data available.</p>';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user