initial symfony commit
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
.idea
|
||||
.idea/*
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Lib\Database;
|
||||
use App\Repositories\ExpenseRepository;
|
||||
use App\Services\ExpenseService;
|
||||
|
||||
final class Container
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $params;
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
private array $singletons = [];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function getDatabase(): Database
|
||||
{
|
||||
return $this->singletons[Database::class]
|
||||
??= new Database(
|
||||
(string)$this->params['db.dsn'],
|
||||
(string)$this->params['db.user'],
|
||||
(string)$this->params['db.pass']
|
||||
);
|
||||
}
|
||||
|
||||
public function getExpenseRepository(): ExpenseRepository
|
||||
{
|
||||
return $this->singletons[ExpenseRepository::class]
|
||||
??= new ExpenseRepository($this->getDatabase());
|
||||
}
|
||||
|
||||
public function getExpenseService(): ExpenseService
|
||||
{
|
||||
return $this->singletons[ExpenseService::class]
|
||||
??= new ExpenseService($this->getExpenseRepository());
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// DB config and core
|
||||
require_once __DIR__ . '/DatabaseConnectionConfig.php';
|
||||
require_once __DIR__ . '/container.php';
|
||||
require_once __DIR__ . '/DatabaseConnectionHandler.php';
|
||||
|
||||
// Domain repos/services
|
||||
require_once __DIR__ . '/ExpenseRepository.php';
|
||||
require_once __DIR__ . '/ExpenseService.php';
|
||||
|
||||
// Auth
|
||||
require_once __DIR__ . '/auth_repository.php';
|
||||
require_once __DIR__ . '/auth_service.php';
|
||||
|
||||
// JSON responder
|
||||
require_once __DIR__ . '/JsonResponseHandler.php';
|
||||
|
||||
use App\Container;
|
||||
|
||||
function container(): Container {
|
||||
static $c = null;
|
||||
if ($c === null) {
|
||||
$c = new Container([
|
||||
'db.dsn' => DB_DSN,
|
||||
'db.user' => DB_USER,
|
||||
'db.pass' => DB_PASS,
|
||||
]);
|
||||
}
|
||||
return $c;
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
use App\Services\ExpenseService;
|
||||
|
||||
$service = container()->getExpenseService();
|
||||
$todayExpenses = $service->listByDate(new DateTimeImmutable('today'));
|
||||
$today = (new DateTimeImmutable('today'))->format('Y-m-d');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Daily Expenses</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Flatpickr CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||
<style>
|
||||
/* Highlight dates with expenses in the calendar */
|
||||
.flatpickr-day.has-expense {
|
||||
background: #d1e7dd !important;
|
||||
border-color: #198754 !important;
|
||||
font-weight: bold;
|
||||
color: #0f5132 !important;
|
||||
}
|
||||
.flatpickr-day.has-expense:hover {
|
||||
background: #a3cfbb !important;
|
||||
}
|
||||
.flatpickr-day.has-expense.selected {
|
||||
background: #198754 !important;
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="mb-0">Daily Expenses</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="LogoutRedirect.php">Logout</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="index.php?action=download_csv">Download CSV</a>
|
||||
<a class="btn btn-outline-success btn-sm" href="manage_types_page.php">Manage Types</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form id="expense-form" novalidate>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
<label for="amount" class="form-label">Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">JD</span>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="amount" name="amount" required>
|
||||
</div>
|
||||
<div class="invalid-feedback">Please provide a valid amount.</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label for="type" class="form-label">Type</label>
|
||||
<select class="form-select" id="type" name="type" required>
|
||||
<option value="" selected disabled>Choose...</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Please select a type.</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label for="note" class="form-label">Note (optional)</label>
|
||||
<input type="text" maxlength="255" class="form-control" id="note" name="note" placeholder="e.g., Coffee">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label for="spent_at" class="form-label">Date</label>
|
||||
<input type="date" class="form-control" id="spent_at" name="spent_at" value="<?= $today ?>" required>
|
||||
<div class="invalid-feedback">Please choose a date.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Add Expense</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="alert-placeholder" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<span>Expenses</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label for="filter-date" class="col-form-label">View date:</label>
|
||||
<input type="text" class="form-control" id="filter-date" placeholder="Select date" style="min-width: 150px;" />
|
||||
</div>
|
||||
<small class="text-muted" id="today-date-label"><?= $today ?></small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0" id="expenses-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 120px;">Amount</th>
|
||||
<th style="width: 180px;">Type</th>
|
||||
<th>Note</th>
|
||||
<th style="width: 130px;">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($todayExpenses)): ?>
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">No expenses yet.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($todayExpenses as $e): ?>
|
||||
<tr>
|
||||
<td>JD<?= htmlspecialchars(number_format((float)$e['amount'], 2)) ?></td>
|
||||
<td><?= htmlspecialchars($e['type']) ?></td>
|
||||
<td><?= htmlspecialchars($e['note'] ?? '') ?></td>
|
||||
<td><?= htmlspecialchars($e['spent_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Flatpickr JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function ($) {
|
||||
var datesWithData = [];
|
||||
var filterPicker;
|
||||
|
||||
function showAlert(type, message) {
|
||||
$('#alert-placeholder').html(
|
||||
'<div class="alert alert-' + type + ' alert-dismissible" role="alert">' +
|
||||
message +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
var $tbody = $('#expenses-table tbody');
|
||||
if (!items || !items.length) {
|
||||
$tbody.html('<tr><td colspan="4" class="text-center text-muted py-4">No expenses yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = items.map(function (e) {
|
||||
return '<tr>'
|
||||
+ '<td>JD' + parseFloat(e.amount).toFixed(2) + '</td>'
|
||||
+ '<td>' + $('<div>').text(e.type).html() + '</td>'
|
||||
+ '<td>' + $('<div>').text(e.note || '').html() + '</td>'
|
||||
+ '<td>' + $('<div>').text(e.spent_at).html() + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
$tbody.html(html);
|
||||
}
|
||||
|
||||
function loadTypes() {
|
||||
var $type = $('#type');
|
||||
$type.prop('disabled', true);
|
||||
$.getJSON('index.php?action=types').done(function (res) {
|
||||
if (!res.success) { showAlert('danger', res.error || 'Failed to load types.'); return; }
|
||||
var opts = ['<option value="" selected disabled>Choose...</option>'];
|
||||
(res.data.types || []).forEach(function (t) {
|
||||
var safe = $('<div>').text(t).html();
|
||||
opts.push('<option value="' + safe + '">' + safe + '</option>');
|
||||
});
|
||||
$type.html(opts.join(''));
|
||||
}).fail(function () {
|
||||
showAlert('danger', 'Failed to load types.');
|
||||
}).always(function () {
|
||||
$type.prop('disabled', false);
|
||||
});
|
||||
}
|
||||
|
||||
function loadDatesWithExpenses(callback) {
|
||||
$.getJSON('index.php?action=dates_with_expenses').done(function(res){
|
||||
if (res.success) {
|
||||
datesWithData = res.data.dates || [];
|
||||
console.log('Loaded dates with expenses:', datesWithData);
|
||||
if (callback) callback();
|
||||
}
|
||||
}).fail(function(){
|
||||
console.error('Failed to load dates');
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateLocal(dateObj) {
|
||||
// Format as YYYY-MM-DD in local time, not UTC
|
||||
var year = dateObj.getFullYear();
|
||||
var month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
var day = String(dateObj.getDate()).padStart(2, '0');
|
||||
return year + '-' + month + '-' + day;
|
||||
}
|
||||
|
||||
function initFilterPicker() {
|
||||
if (filterPicker) {
|
||||
filterPicker.destroy();
|
||||
}
|
||||
|
||||
filterPicker = flatpickr('#filter-date', {
|
||||
dateFormat: 'Y-m-d',
|
||||
defaultDate: '<?= $today ?>',
|
||||
onDayCreate: function(dObj, dStr, fp, dayElem) {
|
||||
// Use local date formatting to avoid timezone shift
|
||||
var dateStr = formatDateLocal(dayElem.dateObj);
|
||||
if (datesWithData.indexOf(dateStr) !== -1) {
|
||||
dayElem.classList.add('has-expense');
|
||||
console.log('Highlighting:', dateStr);
|
||||
}
|
||||
},
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
if (dateStr) {
|
||||
loadByDate(dateStr);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadByDate(dateStr) {
|
||||
$.getJSON('index.php?action=list_by_date&date=' + encodeURIComponent(dateStr))
|
||||
.done(function (res) {
|
||||
if (!res.success) { showAlert('danger', res.error || 'Failed to load.'); return; }
|
||||
$('#today-date-label').text(res.data.date);
|
||||
renderRows(res.data.items || []);
|
||||
})
|
||||
.fail(function () { showAlert('danger', 'Failed to load.'); });
|
||||
}
|
||||
|
||||
$(function () {
|
||||
loadTypes();
|
||||
|
||||
// Load dates with expenses, then init picker, then load today's data
|
||||
loadDatesWithExpenses(function(){
|
||||
initFilterPicker();
|
||||
loadByDate('<?= $today ?>');
|
||||
});
|
||||
});
|
||||
|
||||
$('#expense-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var form = this;
|
||||
form.classList.add('was-validated');
|
||||
if (!form.checkValidity()) return;
|
||||
|
||||
var payload = {
|
||||
amount: $('#amount').val(),
|
||||
type: $('#type').val(),
|
||||
note: $('#note').val(),
|
||||
spent_at: $('#spent_at').val()
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: 'index.php?action=create',
|
||||
method: 'POST',
|
||||
data: JSON.stringify(payload),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json'
|
||||
}).done(function (res) {
|
||||
if (res.success) {
|
||||
showAlert('success', 'Expense added.');
|
||||
// Reload dates and reinit picker to show new highlights
|
||||
loadDatesWithExpenses(function(){
|
||||
initFilterPicker();
|
||||
var d = $('#filter-date').val() || payload.spent_at;
|
||||
filterPicker.setDate(d, false); // don't trigger onChange
|
||||
loadByDate(d);
|
||||
});
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
$('#spent_at').val('<?= $today ?>');
|
||||
} else {
|
||||
showAlert('danger', res.error || 'Failed to add expense.');
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var msg = 'Request failed.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
|
||||
showAlert('danger', msg);
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Update these for your environment
|
||||
const DB_DSN = 'mysql:host=localhost;port=3306;dbname=expenses;charset=utf8mb4';
|
||||
const DB_USER = 'expensesuser';
|
||||
const DB_PASS = 't5DDX**L*jTLMs%*';
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(string $dsn, string $user, string $pass)
|
||||
{
|
||||
$opts = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
$this->pdo = new PDO($dsn, $user, $pass, $opts);
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string,mixed> $params
|
||||
*/
|
||||
public function run(string $sql, array $params = []): PDOStatement
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue(is_int($k) ? $k + 1 : (string)$k, $v);
|
||||
}
|
||||
$stmt->execute();
|
||||
return $stmt;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
use App\Http\JsonResponder;
|
||||
use App\Services\ExpenseService;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$action = $_GET['action'] ?? '';
|
||||
$responder = new JsonResponder();
|
||||
$service = container()->getExpenseService();
|
||||
|
||||
// Fetch expenses by date: GET index.php?action=list_by_date&date=YYYY-MM-DD
|
||||
if ($action === 'list_by_date') {
|
||||
$date = isset($_GET['date']) ? (string)$_GET['date'] : '';
|
||||
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
|
||||
$items = $service->listByDate($dt);
|
||||
$responder->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
$input = json_decode(file_get_contents('php://input') ?: '[]', true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$amount = isset($input['amount']) ? (string)$input['amount'] : '';
|
||||
$type = isset($input['type']) ? trim((string)$input['type']) : '';
|
||||
$note = isset($input['note']) ? trim((string)$input['note']) : null;
|
||||
$spentAt = isset($input['spent_at']) ? trim((string)$input['spent_at']) : '';
|
||||
|
||||
$expense = $service->create($amount, $type, $note, $spentAt);
|
||||
|
||||
$responder->success([
|
||||
'amount' => $expense['amount'],
|
||||
'type' => $expense['type'],
|
||||
'note' => $expense['note'],
|
||||
'spent_at' => $expense['spent_at'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'types') {
|
||||
$types = $service->listTypes();
|
||||
$responder->success(['types' => $types]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'add_type' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input') ?: '[]', true, 512, JSON_THROW_ON_ERROR);
|
||||
$name = isset($input['name']) ? (string)$input['name'] : '';
|
||||
$service->addType($name);
|
||||
$responder->success(['message' => 'Type added']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'list_by_date') {
|
||||
$date = isset($_GET['date']) ? (string)$_GET['date'] : '';
|
||||
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
|
||||
$items = $service->listByDate($dt);
|
||||
$responder->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// New: return dates with expenses
|
||||
if ($action === 'dates_with_expenses') {
|
||||
$dates = $service->getDistinctDates();
|
||||
$responder->success(['dates' => $dates]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'download_csv') {
|
||||
$all = $service->listAll();
|
||||
header_remove('Content-Type');
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
$ts = (new DateTimeImmutable('now'))->format('Ymd_His');
|
||||
header('Content-Disposition: attachment; filename="expenses_' . $ts . '.csv"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['ID', 'Amount', 'Type', 'Note', 'Spent At', 'Created At']);
|
||||
foreach ($all as $row) {
|
||||
fputcsv($out, [
|
||||
$row['id'],
|
||||
number_format((float)$row['amount'], 2, '.', ''),
|
||||
$row['type'],
|
||||
$row['note'] ?? '',
|
||||
$row['spent_at'],
|
||||
$row['created_at'],
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Unknown action']);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Lib\Database;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class ExpenseRepository
|
||||
{
|
||||
public function __construct(private Database $db) {}
|
||||
|
||||
/**
|
||||
* @param array{amount:string,type_id:int,note:?(string),spent_at:string} $data
|
||||
*/
|
||||
public function insert(array $data): void
|
||||
{
|
||||
$sql = 'INSERT INTO expenses (amount, type_id, note, spent_at) VALUES (:amount, :type_id, :note, :spent_at)';
|
||||
$this->db->run($sql, [
|
||||
':amount' => $data['amount'],
|
||||
':type_id' => $data['type_id'],
|
||||
':note' => $data['note'],
|
||||
':spent_at' => $data['spent_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function listByDate(DateTimeImmutable $date): array
|
||||
{
|
||||
$sql = 'SELECT e.id, e.amount, et.name AS type, e.note, e.spent_at
|
||||
FROM expenses e
|
||||
INNER JOIN expense_types et ON e.type_id = et.id
|
||||
WHERE e.spent_at = :d
|
||||
ORDER BY e.id DESC';
|
||||
$stmt = $this->db->run($sql, [':d' => $date->format('Y-m-d')]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{id:int,name:string}>
|
||||
*/
|
||||
public function listTypes(): array
|
||||
{
|
||||
$stmt = $this->db->run('SELECT id, name FROM expense_types ORDER BY name ASC');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{id:int,amount:string,type:string,note:?(string),spent_at:string,created_at:string}>
|
||||
*/
|
||||
public function listAll(): array
|
||||
{
|
||||
$sql = 'SELECT e.id, e.amount, et.name AS type, e.note, e.spent_at, e.created_at
|
||||
FROM expenses e
|
||||
INNER JOIN expense_types et ON e.type_id = et.id
|
||||
ORDER BY e.spent_at DESC, e.id DESC';
|
||||
$stmt = $this->db->run($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function addType(string $name): void
|
||||
{
|
||||
$this->db->run('INSERT INTO expense_types (name) VALUES (:n)', [':n' => $name]);
|
||||
}
|
||||
|
||||
public function findTypeIdByName(string $name): ?int
|
||||
{
|
||||
$stmt = $this->db->run('SELECT id FROM expense_types WHERE name = :n LIMIT 1', [':n' => $name]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? (int)$row['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string> Array of YYYY-MM-DD strings
|
||||
*/
|
||||
public function getDistinctDates(): array
|
||||
{
|
||||
$stmt = $this->db->run('SELECT DISTINCT spent_at FROM expenses ORDER BY spent_at DESC');
|
||||
return array_column($stmt->fetchAll(), 'spent_at');
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Repositories\ExpenseRepository;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ExpenseService
|
||||
{
|
||||
public function __construct(private ExpenseRepository $repo) {}
|
||||
|
||||
/**
|
||||
* @return array{amount:string,type:string,note:?(string),spent_at:string}
|
||||
*/
|
||||
public function create(string $amount, string $typeName, ?string $note, string $spentAt): array
|
||||
{
|
||||
$amount = trim($amount);
|
||||
$typeName = trim($typeName);
|
||||
$note = $note !== null ? trim($note) : null;
|
||||
$spentAt = trim($spentAt);
|
||||
|
||||
if ($amount === '' || !is_numeric($amount) || (float)$amount < 0) {
|
||||
throw new InvalidArgumentException('Amount must be a non-negative number.');
|
||||
}
|
||||
if ($typeName === '') {
|
||||
throw new InvalidArgumentException('Type is required.');
|
||||
}
|
||||
if ($note !== null && mb_strlen($note) > 255) {
|
||||
throw new InvalidArgumentException('Note must be at most 255 characters.');
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $spentAt);
|
||||
if (!$date || $date->format('Y-m-d') !== $spentAt) {
|
||||
throw new InvalidArgumentException('Invalid date format. Use YYYY-MM-DD.');
|
||||
}
|
||||
|
||||
// Find type ID by name
|
||||
$typeId = $this->repo->findTypeIdByName($typeName);
|
||||
if ($typeId === null) {
|
||||
throw new InvalidArgumentException('Invalid type.');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'amount' => number_format((float)$amount, 2, '.', ''),
|
||||
'type_id' => $typeId,
|
||||
'note' => $note !== '' ? $note : null,
|
||||
'spent_at' => $spentAt,
|
||||
];
|
||||
|
||||
$this->repo->insert($payload);
|
||||
|
||||
return [
|
||||
'amount' => $payload['amount'],
|
||||
'type' => $typeName,
|
||||
'note' => $payload['note'],
|
||||
'spent_at' => $payload['spent_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function listByDate(DateTimeImmutable $date): array
|
||||
{
|
||||
return $this->repo->listByDate($date);
|
||||
}
|
||||
|
||||
// New: expose types
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function listTypes(): array
|
||||
{
|
||||
return array_map(static fn($t) => $t['name'], $this->repo->listTypes());
|
||||
}
|
||||
|
||||
// New: list all for CSV
|
||||
/**
|
||||
* @return array<int,array{id:int,amount:string,type:string,note:?(string),spent_at:string,created_at:string}>
|
||||
*/
|
||||
public function listAll(): array
|
||||
{
|
||||
return $this->repo->listAll();
|
||||
}
|
||||
|
||||
// New: add type
|
||||
public function addType(string $name): void
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Type name is required.');
|
||||
}
|
||||
if (mb_strlen($name) > 100) {
|
||||
throw new InvalidArgumentException('Type name must be at most 100 characters.');
|
||||
}
|
||||
// prevent duplicates (case-insensitive)
|
||||
$existing = array_map(static fn($t) => mb_strtolower($t['name']), $this->repo->listTypes());
|
||||
if (in_array(mb_strtolower($name), $existing, true)) {
|
||||
throw new InvalidArgumentException('Type already exists.');
|
||||
}
|
||||
$this->repo->addType($name);
|
||||
}
|
||||
|
||||
// New: dates with expenses
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function getDistinctDates(): array
|
||||
{
|
||||
return $this->repo->getDistinctDates();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
final class JsonResponder
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public function success(array $data): void
|
||||
{
|
||||
echo json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
container()->getAuthService()->logout();
|
||||
header('Location: login_page.php');
|
||||
exit;
|
||||
48
README.md
48
README.md
@@ -1,48 +0,0 @@
|
||||
# Expenses App (Symfony 6.4)
|
||||
|
||||
This project has been migrated to use the Symfony 6.4 framework while preserving the original domain logic.
|
||||
|
||||
The new Symfony application structure has been placed under the `symfony/` folder at the project root to keep it separated from legacy files.
|
||||
|
||||
## Requirements
|
||||
- PHP >= 8.1
|
||||
- Composer
|
||||
- Database with PDO (MySQL/PostgreSQL/etc.)
|
||||
|
||||
## Setup
|
||||
1. Copy `.env` variables (or set environment variables in your server):
|
||||
- APP_ENV=dev
|
||||
- APP_DEBUG=1
|
||||
- APP_SECRET=your-secret-here
|
||||
- DB_DSN=your-pdo-dsn (e.g., mysql:host=127.0.0.1;dbname=expenses;charset=utf8mb4)
|
||||
- DB_USER=your-db-user
|
||||
- DB_PASS=your-db-pass
|
||||
2. Install dependencies (run inside the Symfony folder):
|
||||
- cd symfony
|
||||
- composer install
|
||||
3. Run the built-in server (still inside the `symfony` folder):
|
||||
- php -S localhost:8000 -t public
|
||||
4. Open the app:
|
||||
- http://localhost:8000
|
||||
|
||||
## Entry point
|
||||
- New Symfony entry point is `symfony/public/index.php`.
|
||||
|
||||
## Routes (Symfony)
|
||||
- GET / → Daily expenses page (Twig)
|
||||
- GET /login → Login page
|
||||
- POST /login → Perform login
|
||||
- POST /logout → Logout and redirect to /login
|
||||
- GET /expenses/types → JSON list of types
|
||||
- POST /expenses/types/add → JSON add a type
|
||||
- POST /expenses/create → JSON create an expense
|
||||
- GET /expenses/by-date/{date} → JSON list by date (YYYY-MM-DD)
|
||||
- GET /expenses/dates → JSON list of dates with expenses
|
||||
- GET /expenses/download.csv → Download CSV of expenses
|
||||
|
||||
## Legacy files
|
||||
Legacy PHP files have been left in place at the project root for reference. The existing domain classes (repositories, services) continue to be used and are wired via Symfony's DI.
|
||||
|
||||
## Database schema
|
||||
See `expenses_schema.sql` for the table structure.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Lib\Database;
|
||||
|
||||
final class AuthRepository
|
||||
{
|
||||
public function __construct(private Database $db) {}
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$stmt = $this->db->run('SELECT id, email, password_hash FROM users WHERE email = :e LIMIT 1', [':e' => $email]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Repositories\AuthRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class AuthService
|
||||
{
|
||||
public function __construct(private AuthRepository $repo) {}
|
||||
|
||||
public function login(string $email, string $password): void
|
||||
{
|
||||
$email = trim(mb_strtolower($email));
|
||||
if ($email === '' || $password === '') {
|
||||
throw new InvalidArgumentException('Email and password are required.');
|
||||
}
|
||||
$user = $this->repo->findByEmail($email);
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
throw new InvalidArgumentException('Invalid credentials.');
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['uid'] = (int)$user['id'];
|
||||
$_SESSION['email'] = $user['email'];
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
public function ensureAuthenticated(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
if (empty($_SESSION['uid'])) {
|
||||
header('Location: login_page.php');
|
||||
http_response_code(302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
bin/console
Normal file
21
bin/console
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
8
config/bundles.php
Normal file
8
config/bundles.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
];
|
||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
48
config/packages/doctrine.yaml
Normal file
48
config/packages/doctrine.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '15'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
4
config/packages/doctrine_migrations.yaml
Normal file
4
config/packages/doctrine_migrations.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
21
config/packages/framework.yaml
Normal file
21
config/packages/framework.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: auto
|
||||
cookie_samesite: lax
|
||||
storage_factory_id: session.storage.factory.native
|
||||
name: EXPENSES_SESSID
|
||||
router:
|
||||
utf8: true
|
||||
csrf_protection: true
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
12
config/packages/routing.yaml
Normal file
12
config/packages/routing.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
32
config/packages/security.yaml
Normal file
32
config/packages/security.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
security:
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
|
||||
providers:
|
||||
app_user_provider:
|
||||
id: App\Security\UserProvider
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
custom_authenticators:
|
||||
- App\Security\LoginFormAuthenticator
|
||||
entry_point: App\Security\LoginFormAuthenticator
|
||||
logout:
|
||||
path: logout
|
||||
target: login
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
path: /
|
||||
always_remember_me: true
|
||||
|
||||
access_control:
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
@@ -35,6 +35,33 @@ expenses_dates_with_expenses:
|
||||
controller: App\Controller\ExpenseController::datesWithExpenses
|
||||
methods: [GET]
|
||||
|
||||
# Expense Types Management
|
||||
manage_types:
|
||||
path: /types/manage
|
||||
controller: App\Controller\ExpenseTypeController::manage
|
||||
methods: [GET]
|
||||
|
||||
types_list:
|
||||
path: /types/list
|
||||
controller: App\Controller\ExpenseTypeController::list
|
||||
methods: [GET]
|
||||
|
||||
types_add:
|
||||
path: /types/add
|
||||
controller: App\Controller\ExpenseTypeController::add
|
||||
methods: [POST]
|
||||
|
||||
types_delete:
|
||||
path: /types/delete/{id}
|
||||
controller: App\Controller\ExpenseTypeController::delete
|
||||
requirements:
|
||||
id: '\d+'
|
||||
methods: [DELETE]
|
||||
|
||||
register:
|
||||
path: /register
|
||||
controller: App\Controller\RegistrationController::register
|
||||
|
||||
login:
|
||||
path: /login
|
||||
controller: App\Controller\AuthController::login
|
||||
@@ -42,4 +69,3 @@ login:
|
||||
logout:
|
||||
path: /logout
|
||||
controller: App\Controller\AuthController::logout
|
||||
methods: [POST]
|
||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
||||
prefix: /_error
|
||||
31
config/services.yaml
Normal file
31
config/services.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
# Make controllers public
|
||||
App\Controller\:
|
||||
resource: '../src/Controller/'
|
||||
tags: ['controller.service_arguments']
|
||||
public: true
|
||||
|
||||
# Security services
|
||||
App\Security\UserProvider:
|
||||
autowire: true
|
||||
|
||||
App\Security\LoginFormAuthenticator:
|
||||
autowire: true
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Lib\Database;
|
||||
use App\Repositories\ExpenseRepository;
|
||||
use App\Repositories\AuthRepository;
|
||||
use App\Services\ExpenseService;
|
||||
use App\Services\AuthService;
|
||||
|
||||
final class Container
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $params;
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
private array $singletons = [];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function getDatabase(): Database
|
||||
{
|
||||
return $this->singletons[Database::class]
|
||||
??= new Database(
|
||||
(string)$this->params['db.dsn'],
|
||||
(string)$this->params['db.user'],
|
||||
(string)$this->params['db.pass']
|
||||
);
|
||||
}
|
||||
|
||||
public function getExpenseRepository(): ExpenseRepository
|
||||
{
|
||||
return $this->singletons[ExpenseRepository::class]
|
||||
??= new ExpenseRepository($this->getDatabase());
|
||||
}
|
||||
|
||||
public function getExpenseService(): ExpenseService
|
||||
{
|
||||
return $this->singletons[ExpenseService::class]
|
||||
??= new ExpenseService($this->getExpenseRepository());
|
||||
}
|
||||
|
||||
public function getAuthRepository(): AuthRepository
|
||||
{
|
||||
return $this->singletons[AuthRepository::class]
|
||||
??= new AuthRepository($this->getDatabase());
|
||||
}
|
||||
|
||||
public function getAuthService(): AuthService
|
||||
{
|
||||
return $this->singletons[AuthService::class]
|
||||
??= new AuthService($this->getAuthRepository());
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// ... existing code ...
|
||||
require_once __DIR__ . '/AuthRepository.php';
|
||||
require_once __DIR__ . '/AuthService.php';
|
||||
// ... existing code ...
|
||||
|
||||
use App\Container;
|
||||
use App\Repositories\AuthRepository;
|
||||
use App\Services\AuthService;
|
||||
|
||||
// ... existing code ...
|
||||
function container(): Container {
|
||||
static $c = null;
|
||||
if ($c === null) {
|
||||
$c = new Container([
|
||||
'db.dsn' => DB_DSN,
|
||||
'db.user' => DB_USER,
|
||||
'db.pass' => DB_PASS,
|
||||
]);
|
||||
}
|
||||
return $c;
|
||||
}
|
||||
|
||||
// Add these helper getters on the Container if not present (or use closures here)
|
||||
if (!method_exists(Container::class, 'getAuthRepository')) {
|
||||
class_alias(\App\Container::class, 'App_Container_With_Auth');
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
CREATE TABLE expense_types (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO expense_types (name) VALUES
|
||||
('Food'), ('Transport'), ('Groceries'), ('Utilities'), ('Entertainment'), ('Other');
|
||||
|
||||
CREATE TABLE expenses (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
type_id INT UNSIGNED NOT NULL,
|
||||
note VARCHAR(255) NULL,
|
||||
spent_at DATE NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (type_id) REFERENCES expense_types(id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
INDEX idx_spent_at (spent_at)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Add type_id column
|
||||
ALTER TABLE expenses ADD COLUMN type_id INT UNSIGNED NULL AFTER amount;
|
||||
|
||||
-- Migrate existing string types to IDs (assuming types exist in expense_types)
|
||||
UPDATE expenses e
|
||||
INNER JOIN expense_types et ON e.type = et.name
|
||||
SET e.type_id = et.id;
|
||||
|
||||
-- Make type_id NOT NULL after migration
|
||||
ALTER TABLE expenses MODIFY type_id INT UNSIGNED NOT NULL;
|
||||
|
||||
-- Add foreign key constraint
|
||||
ALTER TABLE expenses ADD CONSTRAINT fk_expenses_type
|
||||
FOREIGN KEY (type_id) REFERENCES expense_types(id)
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Drop old type column
|
||||
ALTER TABLE expenses DROP COLUMN type;
|
||||
|
||||
-- Add index on spent_at for performance
|
||||
ALTER TABLE expenses ADD INDEX idx_spent_at (spent_at);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(190) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Seed an initial user (replace with your email; the hash is for password: ChangeMe!2024)
|
||||
INSERT INTO users (email, password_hash) VALUES
|
||||
('admin@example.com', '$2y$10$5E3o0b0iPjW7mQ6b3mV6GukQw8g6n8a8oFJmTgVZk3e7nqzM4TtG6');
|
||||
15
index.php
15
index.php
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
// Require login for any access
|
||||
container()->getAuthService()->ensureAuthenticated();
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if (in_array($action, ['create','types','add_type','download_csv','list_by_date','dates_with_expenses'], true)) {
|
||||
require __DIR__ . '/ExpenseActionHandler.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
require __DIR__ . '/DailyExpensesPage.php';
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
use App\Services\AuthService;
|
||||
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
/** @var AuthService $auth */
|
||||
$auth = container()->getAuthService();
|
||||
$auth->login($_POST['email'] ?? '', $_POST['password'] ?? '');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Login</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-5" style="max-width: 420px;">
|
||||
<h1 class="h3 mb-3">Sign in</h1>
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="post" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/ContainerFactory.php';
|
||||
|
||||
container()->getAuthService()->ensureAuthenticated();
|
||||
$types = container()->getExpenseService()->listTypes();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Manage Types</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4" style="max-width: 640px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Manage Types</h1>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="index.php">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form id="add-type-form" class="row g-2" novalidate>
|
||||
<div class="col-8">
|
||||
<label for="type_name" class="form-label">New Type</label>
|
||||
<input type="text" maxlength="100" class="form-control" id="type_name" name="type_name" required>
|
||||
<div class="invalid-feedback">Enter a type name (max 100 chars).</div>
|
||||
</div>
|
||||
<div class="col-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-success w-100">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alert-placeholder"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Existing Types</div>
|
||||
<ul class="list-group list-group-flush" id="types-list">
|
||||
<?php foreach ($types as $t): ?>
|
||||
<li class="list-group-item"><?= htmlspecialchars($t) ?></li>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($types)): ?>
|
||||
<li class="list-group-item text-muted">No types yet.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function($){
|
||||
function showAlert(type, message) {
|
||||
$('#alert-placeholder').html(
|
||||
'<div class="alert alert-' + type + ' alert-dismissible mt-3" role="alert">' +
|
||||
message +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$('#add-type-form').on('submit', function(e){
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
form.classList.add('was-validated');
|
||||
if (!form.checkValidity()) return;
|
||||
|
||||
var name = $('#type_name').val();
|
||||
|
||||
$.ajax({
|
||||
url: 'index.php?action=add_type',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ name: name }),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json'
|
||||
}).done(function(res){
|
||||
if (res.success) {
|
||||
showAlert('success', 'Type added.');
|
||||
$('#types-list').prepend('<li class="list-group-item">' + $('<div>').text(name).html() + '</li>');
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
} else {
|
||||
showAlert('danger', res.error || 'Failed to add type.');
|
||||
}
|
||||
}).fail(function(xhr){
|
||||
var msg = 'Request failed.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
|
||||
showAlert('danger', msg);
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
39
migrations/Version20251004005440.php
Normal file
39
migrations/Version20251004005440.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251004005440 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY fk_expenses_type');
|
||||
$this->addSql('DROP TABLE expense_types');
|
||||
$this->addSql('DROP TABLE expenses');
|
||||
$this->addSql('ALTER TABLE users ADD roles JSON NOT NULL COMMENT \'(DC2Type:json)\', CHANGE id id INT AUTO_INCREMENT NOT NULL, CHANGE email email VARCHAR(180) NOT NULL, CHANGE created_at created_at DATETIME NOT NULL, CHANGE password_hash password VARCHAR(255) NOT NULL');
|
||||
$this->addSql('ALTER TABLE users RENAME INDEX email TO UNIQ_1483A5E9E7927C74');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE expense_types (id INT UNSIGNED AUTO_INCREMENT NOT NULL, name VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, UNIQUE INDEX name (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('CREATE TABLE expenses (id INT UNSIGNED AUTO_INCREMENT NOT NULL, type_id INT UNSIGNED NOT NULL, amount NUMERIC(10, 2) NOT NULL, note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, spent_at DATE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, INDEX fk_expenses_type (type_id), INDEX idx_spent_at (spent_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT fk_expenses_type FOREIGN KEY (type_id) REFERENCES expense_types (id) ON UPDATE CASCADE');
|
||||
$this->addSql('ALTER TABLE users DROP roles, CHANGE id id INT UNSIGNED AUTO_INCREMENT NOT NULL, CHANGE email email VARCHAR(190) NOT NULL, CHANGE created_at created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE password password_hash VARCHAR(255) NOT NULL');
|
||||
$this->addSql('ALTER TABLE users RENAME INDEX uniq_1483a5e9e7927c74 TO email');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20251004005722.php
Normal file
31
migrations/Version20251004005722.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251004005722 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_1483A5E9E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE users');
|
||||
}
|
||||
}
|
||||
39
migrations/Version20251004011934.php
Normal file
39
migrations/Version20251004011934.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251004011934 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE expense_types (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, name VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_FA382E0AA76ED395 (user_id), UNIQUE INDEX unique_user_type (user_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE expenses (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, type_id INT NOT NULL, amount NUMERIC(10, 2) NOT NULL, note VARCHAR(255) DEFAULT NULL, spent_at DATE NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_2496F35BC54C8C93 (type_id), INDEX idx_spent_at (spent_at), INDEX idx_user_id (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE expense_types ADD CONSTRAINT FK_FA382E0AA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)');
|
||||
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT FK_2496F35BA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)');
|
||||
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT FK_2496F35BC54C8C93 FOREIGN KEY (type_id) REFERENCES expense_types (id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE expense_types DROP FOREIGN KEY FK_FA382E0AA76ED395');
|
||||
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY FK_2496F35BA76ED395');
|
||||
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY FK_2496F35BC54C8C93');
|
||||
$this->addSql('DROP TABLE expense_types');
|
||||
$this->addSql('DROP TABLE expenses');
|
||||
}
|
||||
}
|
||||
7
public/.htaccess
Normal file
7
public/.htaccess
Normal file
@@ -0,0 +1,7 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# If the requested filename exists, serve it directly
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^(.*)$ /index.php [QSA,L]
|
||||
</IfModule>
|
||||
37
public/index.php
Normal file
37
public/index.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
use Symfony\Component\ErrorHandler\Debug;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
// Load cached env vars if the .env.local.php file exists
|
||||
// Run "composer dump-env prod" to create it (requires symfony/flex >=1.2)
|
||||
if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) {
|
||||
foreach ($env as $k => $v) {
|
||||
$_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v);
|
||||
}
|
||||
} elseif (!class_exists(Dotenv::class)) {
|
||||
throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.');
|
||||
} else {
|
||||
// load all the .env files
|
||||
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
|
||||
}
|
||||
|
||||
$_SERVER += $_ENV;
|
||||
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev';
|
||||
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV'];
|
||||
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';
|
||||
|
||||
if ($_SERVER['APP_DEBUG']) {
|
||||
umask(0000);
|
||||
Debug::enable();
|
||||
}
|
||||
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
31
src/Controller/AuthController.php
Normal file
31
src/Controller/AuthController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
use Twig\Environment;
|
||||
|
||||
class AuthController extends AbstractController
|
||||
{
|
||||
public function __construct(private Environment $twig) {}
|
||||
|
||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
$error = $authenticationUtils->getLastAuthenticationError();
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
$html = $this->twig->render('login.html.twig', [
|
||||
'error' => $error ? $error->getMessage() : null,
|
||||
'last_username' => $lastUsername
|
||||
]);
|
||||
|
||||
return new Response($html);
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
// This method can be blank - it will be intercepted by the logout key on your firewall.
|
||||
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,20 @@ namespace App\Controller;
|
||||
|
||||
use App\Services\ExpenseService;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ExpenseController
|
||||
class ExpenseController extends AbstractController
|
||||
{
|
||||
public function __construct(private ExpenseService $service) {}
|
||||
|
||||
private function ensureSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
if (empty($_SESSION['uid'])) {
|
||||
throw new \RuntimeException('Unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
|
||||
try {
|
||||
$expense = $this->service->create(
|
||||
@@ -45,14 +33,16 @@ class ExpenseController
|
||||
|
||||
public function types(): JsonResponse
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$types = $this->service->listTypes();
|
||||
return $this->success(['types' => $types]);
|
||||
}
|
||||
|
||||
public function addType(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
|
||||
try {
|
||||
$this->service->addType((string)($data['name'] ?? ''));
|
||||
@@ -64,7 +54,8 @@ class ExpenseController
|
||||
|
||||
public function listByDate(string $date): JsonResponse
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
|
||||
$items = $this->service->listByDate($dt);
|
||||
return $this->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
|
||||
@@ -72,16 +63,19 @@ class ExpenseController
|
||||
|
||||
public function datesWithExpenses(): JsonResponse
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$dates = $this->service->getDistinctDates();
|
||||
return $this->success(['dates' => $dates]);
|
||||
}
|
||||
|
||||
public function downloadCsv(): Response
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$all = $this->service->listAll();
|
||||
$callback = function() use ($all) {
|
||||
|
||||
$response = new StreamedResponse(function() use ($all) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['ID', 'Amount', 'Type', 'Note', 'Spent At', 'Created At']);
|
||||
foreach ($all as $row) {
|
||||
@@ -95,11 +89,12 @@ class ExpenseController
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
};
|
||||
return new Response(stream_callback: $callback, status: 200, headers: [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="expenses_'.(new \DateTimeImmutable('now'))->format('Ymd_His').'.csv"'
|
||||
]);
|
||||
});
|
||||
|
||||
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
|
||||
$response->headers->set('Content-Disposition', 'attachment; filename="expenses_'.(new \DateTimeImmutable('now'))->format('Ymd_His').'.csv"');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function success(array $data): JsonResponse
|
||||
75
src/Controller/ExpenseTypeController.php
Normal file
75
src/Controller/ExpenseTypeController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\ExpenseService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
|
||||
class ExpenseTypeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private ExpenseService $service
|
||||
) {}
|
||||
|
||||
public function manage(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$types = $this->service->listTypesWithDetails();
|
||||
|
||||
$html = $this->twig->render('expense_type/manage.html.twig', [
|
||||
'types' => $types,
|
||||
]);
|
||||
|
||||
return new Response($html);
|
||||
}
|
||||
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$types = $this->service->listTypesWithDetails();
|
||||
return $this->success(['types' => $types]);
|
||||
}
|
||||
|
||||
public function add(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
|
||||
try {
|
||||
$this->service->addType((string)($data['name'] ?? ''));
|
||||
$types = $this->service->listTypesWithDetails();
|
||||
return $this->success(['message' => 'Type added successfully', 'types' => $types]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
try {
|
||||
$this->service->deleteType($id);
|
||||
$types = $this->service->listTypesWithDetails();
|
||||
return $this->success(['message' => 'Type deleted successfully', 'types' => $types]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private function success(array $data): JsonResponse
|
||||
{
|
||||
return new JsonResponse(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
private function error(string $message, int $status): JsonResponse
|
||||
{
|
||||
return new JsonResponse(['success' => false, 'error' => $message], $status);
|
||||
}
|
||||
}
|
||||
31
src/Controller/PageController.php
Normal file
31
src/Controller/PageController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\ExpenseService;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
|
||||
class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private ExpenseService $expenses
|
||||
) {}
|
||||
|
||||
public function daily(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
$items = $this->expenses->listByDate($today);
|
||||
|
||||
$html = $this->twig->render('daily.html.twig', [
|
||||
'today' => $today->format('Y-m-d'),
|
||||
'items' => $items,
|
||||
]);
|
||||
|
||||
return new Response($html);
|
||||
}
|
||||
}
|
||||
125
src/Entity/Expense.php
Normal file
125
src/Entity/Expense.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ExpenseRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ExpenseRepository::class)]
|
||||
#[ORM\Table(name: 'expenses')]
|
||||
#[ORM\Index(name: 'idx_spent_at', columns: ['spent_at'])]
|
||||
#[ORM\Index(name: 'idx_user_id', columns: ['user_id'])]
|
||||
class Expense
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
|
||||
private ?string $amount = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ExpenseType::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?ExpenseType $type = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
private ?\DateTimeInterface $spentAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private ?\DateTimeInterface $createdAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): self
|
||||
{
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAmount(): ?string
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function setAmount(string $amount): self
|
||||
{
|
||||
$this->amount = $amount;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?ExpenseType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?ExpenseType $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
|
||||
public function setNote(?string $note): self
|
||||
{
|
||||
$this->note = $note;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSpentAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->spentAt;
|
||||
}
|
||||
|
||||
public function setSpentAt(\DateTimeInterface $spentAt): self
|
||||
{
|
||||
$this->spentAt = $spentAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeInterface $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'amount' => $this->amount,
|
||||
'type' => $this->type?->getName(),
|
||||
'note' => $this->note,
|
||||
'spent_at' => $this->spentAt?->format('Y-m-d'),
|
||||
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
src/Entity/ExpenseType.php
Normal file
69
src/Entity/ExpenseType.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ExpenseTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ExpenseTypeRepository::class)]
|
||||
#[ORM\Table(name: 'expense_types')]
|
||||
#[ORM\UniqueConstraint(name: 'unique_user_type', columns: ['user_id', 'name'])]
|
||||
class ExpenseType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private ?\DateTimeInterface $createdAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): self
|
||||
{
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeInterface $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
98
src/Entity/User.php
Normal file
98
src/Entity/User.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = [];
|
||||
|
||||
#[ORM\Column(type: 'string')]
|
||||
private ?string $password = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private ?\DateTimeInterface $createdAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
$this->roles = ['ROLE_USER'];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): self
|
||||
{
|
||||
$this->email = $email;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return (string) $this->email;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
// guarantee every user at least has ROLE_USER
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_unique($roles);
|
||||
}
|
||||
|
||||
public function setRoles(array $roles): self
|
||||
{
|
||||
$this->roles = $roles;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): self
|
||||
{
|
||||
$this->password = $password;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// If you store any temporary, sensitive data on the user, clear it here
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeInterface $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
90
src/Repository/ExpenseRepository.php
Normal file
90
src/Repository/ExpenseRepository.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Expense;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ExpenseRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Expense::class);
|
||||
}
|
||||
|
||||
public function save(Expense $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Expense $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Expense[]
|
||||
*/
|
||||
public function findByUserAndDate(User $user, \DateTimeInterface $date): array
|
||||
{
|
||||
return $this->createQueryBuilder('e')
|
||||
->andWhere('e.user = :user')
|
||||
->andWhere('e.spentAt = :date')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('date', $date->format('Y-m-d'))
|
||||
->orderBy('e.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Expense[]
|
||||
*/
|
||||
public function findByUser(User $user): array
|
||||
{
|
||||
return $this->createQueryBuilder('e')
|
||||
->andWhere('e.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('e.spentAt', 'DESC')
|
||||
->addOrderBy('e.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function findDistinctDatesByUser(User $user): array
|
||||
{
|
||||
$expenses = $this->createQueryBuilder('e')
|
||||
->select('e.spentAt')
|
||||
->andWhere('e.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('e.spentAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
// Extract unique dates and format them
|
||||
$dates = [];
|
||||
foreach ($expenses as $expense) {
|
||||
$date = $expense['spentAt'];
|
||||
if ($date instanceof \DateTimeInterface) {
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
if (!in_array($dateStr, $dates)) {
|
||||
$dates[] = $dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
}
|
||||
73
src/Repository/ExpenseTypeRepository.php
Normal file
73
src/Repository/ExpenseTypeRepository.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ExpenseType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ExpenseTypeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ExpenseType::class);
|
||||
}
|
||||
|
||||
public function save(ExpenseType $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(ExpenseType $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ExpenseType[]
|
||||
*/
|
||||
public function findByUser(User $user): array
|
||||
{
|
||||
return $this->createQueryBuilder('et')
|
||||
->andWhere('et.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('et.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function findOneByUserAndName(User $user, string $name): ?ExpenseType
|
||||
{
|
||||
return $this->createQueryBuilder('et')
|
||||
->andWhere('et.user = :user')
|
||||
->andWhere('et.name = :name')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('name', $name)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function initDefaultTypesForUser(User $user): void
|
||||
{
|
||||
$defaultTypes = ['Food', 'Transport', 'Utilities'];
|
||||
|
||||
foreach ($defaultTypes as $typeName) {
|
||||
if (!$this->findOneByUserAndName($user, $typeName)) {
|
||||
$type = new ExpenseType();
|
||||
$type->setUser($user);
|
||||
$type->setName($typeName);
|
||||
$this->save($type);
|
||||
}
|
||||
}
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
54
src/Repository/UserRepository.php
Normal file
54
src/Repository/UserRepository.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
|
||||
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function save(User $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(User $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to upgrade (rehash) the user's password automatically over time.
|
||||
*/
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
|
||||
}
|
||||
|
||||
$user->setPassword($newHashedPassword);
|
||||
|
||||
$this->save($user, true);
|
||||
}
|
||||
|
||||
public function findOneByEmail(string $email): ?User
|
||||
{
|
||||
return $this->findOneBy(['email' => $email]);
|
||||
}
|
||||
}
|
||||
54
src/Security/LoginFormAuthenticator.php
Normal file
54
src/Security/LoginFormAuthenticator.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(private RouterInterface $router) {}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$email = $request->request->get('email', '');
|
||||
$password = $request->request->get('password', '');
|
||||
|
||||
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($email),
|
||||
new PasswordCredentials($password),
|
||||
[
|
||||
new RememberMeBadge(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
|
||||
return new RedirectResponse($targetPath);
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->router->generate('home'));
|
||||
}
|
||||
|
||||
protected function getLoginUrl(Request $request): string
|
||||
{
|
||||
return $this->router->generate('login');
|
||||
}
|
||||
}
|
||||
57
src/Security/UserProvider.php
Normal file
57
src/Security/UserProvider.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private UserRepository $userRepository) {}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): UserInterface
|
||||
{
|
||||
$user = $this->userRepository->findOneByEmail($identifier);
|
||||
|
||||
if (!$user) {
|
||||
throw new UserNotFoundException(sprintf('User "%s" not found.', $identifier));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user): UserInterface
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
|
||||
}
|
||||
|
||||
$refreshedUser = $this->userRepository->find($user->getId());
|
||||
|
||||
if (!$refreshedUser) {
|
||||
throw new UserNotFoundException(sprintf('User with ID "%s" not found.', $user->getId()));
|
||||
}
|
||||
|
||||
return $refreshedUser;
|
||||
}
|
||||
|
||||
public function supportsClass(string $class): bool
|
||||
{
|
||||
return User::class === $class || is_subclass_of($class, User::class);
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
|
||||
}
|
||||
|
||||
$user->setPassword($newHashedPassword);
|
||||
$this->userRepository->save($user, true);
|
||||
}
|
||||
}
|
||||
174
src/Services/ExpenseService.php
Normal file
174
src/Services/ExpenseService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Entity\Expense;
|
||||
use App\Entity\ExpenseType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\ExpenseRepository;
|
||||
use App\Repository\ExpenseTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
class ExpenseService
|
||||
{
|
||||
public function __construct(
|
||||
private ExpenseRepository $expenseRepository,
|
||||
private ExpenseTypeRepository $expenseTypeRepository,
|
||||
private Security $security
|
||||
) {}
|
||||
|
||||
private function getCurrentUser(): User
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('User must be logged in');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function listTypes(): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$types = $this->expenseTypeRepository->findByUser($user);
|
||||
|
||||
// Initialize default types if none exist
|
||||
if (empty($types)) {
|
||||
$this->expenseTypeRepository->initDefaultTypesForUser($user);
|
||||
$types = $this->expenseTypeRepository->findByUser($user);
|
||||
}
|
||||
|
||||
return array_map(fn(ExpenseType $type) => $type->getName(), $types);
|
||||
}
|
||||
|
||||
public function listTypesWithDetails(): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$types = $this->expenseTypeRepository->findByUser($user);
|
||||
|
||||
// Initialize default types if none exist
|
||||
if (empty($types)) {
|
||||
$this->expenseTypeRepository->initDefaultTypesForUser($user);
|
||||
$types = $this->expenseTypeRepository->findByUser($user);
|
||||
}
|
||||
|
||||
return array_map(fn(ExpenseType $type) => [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'created_at' => $type->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
], $types);
|
||||
}
|
||||
|
||||
public function addType(string $name): void
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$name = trim($name);
|
||||
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('Type name cannot be empty');
|
||||
}
|
||||
|
||||
// Check if type already exists
|
||||
$existingType = $this->expenseTypeRepository->findOneByUserAndName($user, $name);
|
||||
if ($existingType) {
|
||||
throw new \InvalidArgumentException('Type already exists');
|
||||
}
|
||||
|
||||
$type = new ExpenseType();
|
||||
$type->setUser($user);
|
||||
$type->setName($name);
|
||||
|
||||
$this->expenseTypeRepository->save($type, true);
|
||||
}
|
||||
|
||||
public function deleteType(int $typeId): void
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$type = $this->expenseTypeRepository->find($typeId);
|
||||
|
||||
if (!$type) {
|
||||
throw new \InvalidArgumentException('Type not found');
|
||||
}
|
||||
|
||||
if ($type->getUser()->getId() !== $user->getId()) {
|
||||
throw new \RuntimeException('You do not have permission to delete this type');
|
||||
}
|
||||
|
||||
// Check if type is used in any expenses
|
||||
$expenses = $this->expenseRepository->createQueryBuilder('e')
|
||||
->where('e.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (!empty($expenses)) {
|
||||
throw new \InvalidArgumentException('Cannot delete type that is being used in expenses');
|
||||
}
|
||||
|
||||
$this->expenseTypeRepository->remove($type, true);
|
||||
}
|
||||
|
||||
public function create(string $amount, string $typeName, ?string $note, string $spentAt): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
|
||||
$amount = trim($amount);
|
||||
$typeName = trim($typeName);
|
||||
$note = $note !== null ? trim($note) : null;
|
||||
$spentAt = trim($spentAt);
|
||||
|
||||
if ($amount === '' || !is_numeric($amount)) {
|
||||
throw new \InvalidArgumentException('Invalid amount');
|
||||
}
|
||||
if ($typeName === '') {
|
||||
throw new \InvalidArgumentException('Type is required');
|
||||
}
|
||||
|
||||
$spentAtDate = \DateTimeImmutable::createFromFormat('Y-m-d', $spentAt);
|
||||
if (!$spentAtDate) {
|
||||
throw new \InvalidArgumentException('Invalid spent_at date');
|
||||
}
|
||||
|
||||
// Find or create expense type
|
||||
$type = $this->expenseTypeRepository->findOneByUserAndName($user, $typeName);
|
||||
if (!$type) {
|
||||
$type = new ExpenseType();
|
||||
$type->setUser($user);
|
||||
$type->setName($typeName);
|
||||
$this->expenseTypeRepository->save($type, true);
|
||||
}
|
||||
|
||||
$expense = new Expense();
|
||||
$expense->setUser($user);
|
||||
$expense->setAmount(number_format((float)$amount, 2, '.', ''));
|
||||
$expense->setType($type);
|
||||
$expense->setNote($note);
|
||||
$expense->setSpentAt($spentAtDate);
|
||||
|
||||
$this->expenseRepository->save($expense, true);
|
||||
|
||||
return $expense->toArray();
|
||||
}
|
||||
|
||||
public function listByDate(DateTimeImmutable $date): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$expenses = $this->expenseRepository->findByUserAndDate($user, $date);
|
||||
|
||||
return array_map(fn(Expense $expense) => $expense->toArray(), $expenses);
|
||||
}
|
||||
|
||||
public function getDistinctDates(): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
return $this->expenseRepository->findDistinctDatesByUser($user);
|
||||
}
|
||||
|
||||
public function listAll(): array
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
$expenses = $this->expenseRepository->findByUser($user);
|
||||
|
||||
return array_map(fn(Expense $expense) => $expense->toArray(), $expenses);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "appsynchq/expenses",
|
||||
"type": "project",
|
||||
"description": "Expenses app migrated to Symfony 6.4",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-pdo": "*",
|
||||
"symfony/framework-bundle": "^6.4",
|
||||
"symfony/http-foundation": "^6.4",
|
||||
"symfony/routing": "^6.4",
|
||||
"symfony/dependency-injection": "^6.4",
|
||||
"symfony/yaml": "^6.4",
|
||||
"symfony/twig-bundle": "^6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/var-dumper": "^6.4"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"ExpenseService.php",
|
||||
"ExpenseRepository.php",
|
||||
"auth_service.php",
|
||||
"auth_repository.php",
|
||||
"DatabaseConnectionHandler.php",
|
||||
"JsonResponseHandler.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"@php bin/console cache:clear || exit 0"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php bin/console cache:clear || exit 0"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
http_method_override: false
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: auto
|
||||
cookie_samesite: lax
|
||||
router:
|
||||
utf8: true
|
||||
csrf_protection: false
|
||||
php_errors:
|
||||
log: true
|
||||
@@ -1,27 +0,0 @@
|
||||
parameters:
|
||||
env(APP_ENV): 'dev'
|
||||
env(APP_DEBUG): '1'
|
||||
env(APP_SECRET): 'change-me-secret'
|
||||
env(DB_DSN): ''
|
||||
env(DB_USER): ''
|
||||
env(DB_PASS): ''
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
bind:
|
||||
string $dsn: '%env(DB_DSN)%'
|
||||
string $dbUser: '%env(DB_USER)%'
|
||||
string $dbPass: '%env(DB_PASS)%'
|
||||
|
||||
App\:
|
||||
resource: '../src/*'
|
||||
exclude: '../src/{Entity,Tests,Kernel.php}'
|
||||
|
||||
# Register existing DB wrapper as a service
|
||||
App\Lib\Database:
|
||||
arguments:
|
||||
$dsn: '%env(DB_DSN)%'
|
||||
$user: '%env(DB_USER)%'
|
||||
$pass: '%env(DB_PASS)%'
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
use App\Kernel;
|
||||
use Symfony\Component\ErrorHandler\Debug;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
if ($_SERVER['APP_DEBUG'] ?? true) {
|
||||
umask(0000);
|
||||
Debug::enable();
|
||||
}
|
||||
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool)($_SERVER['APP_DEBUG'] ?? true));
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\AuthService;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
public function __construct(private Environment $twig, private AuthService $auth) {}
|
||||
|
||||
private function ensureSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function login(Request $request): Response
|
||||
{
|
||||
$this->ensureSession();
|
||||
$error = null;
|
||||
if ($request->isMethod('POST')) {
|
||||
try {
|
||||
$this->auth->login((string)$request->request->get('email', ''), (string)$request->request->get('password', ''));
|
||||
return new RedirectResponse('/');
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$html = $this->twig->render('login.html.twig', ['error' => $error]);
|
||||
return new Response($html);
|
||||
}
|
||||
|
||||
public function logout(): Response
|
||||
{
|
||||
$this->ensureSession();
|
||||
$this->auth->logout();
|
||||
return new RedirectResponse('/login');
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\ExpenseService;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
class PageController
|
||||
{
|
||||
public function __construct(private Environment $twig, private ExpenseService $expenses) {}
|
||||
|
||||
private function ensureSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function daily(): Response
|
||||
{
|
||||
$this->ensureSession();
|
||||
if (empty($_SESSION['uid'])) {
|
||||
return new RedirectResponse('/login');
|
||||
}
|
||||
$today = (new DateTimeImmutable('today'));
|
||||
$items = $this->expenses->listByDate($today);
|
||||
$html = $this->twig->render('daily.html.twig', [
|
||||
'today' => $today->format('Y-m-d'),
|
||||
'items' => $items,
|
||||
]);
|
||||
return new Response($html);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,11 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="mb-0">Daily Expenses</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" action="/logout">
|
||||
<form method="post" action="{{ path('logout') }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary btn-sm" href="/expenses/download.csv">Download CSV</a>
|
||||
<a class="btn btn-outline-success btn-sm" href="/manage_types_page.php">Manage Types</a>
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ path('manage_types') }}">Manage Types</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
{% else %}
|
||||
{% for e in items %}
|
||||
<tr>
|
||||
<td>JD{{ (e.amount|float)|number_format(2, '.', ',') }}</td>
|
||||
<td>JD{{ (e.amount|number_format(2, '.', ',')) }}</td>
|
||||
<td>{{ e.type|e }}</td>
|
||||
<td>{{ (e.note ?? '')|e }}</td>
|
||||
<td>{{ e.spent_at|e }}</td>
|
||||
227
templates/expense_type/manage.html.twig
Normal file
227
templates/expense_type/manage.html.twig
Normal file
@@ -0,0 +1,227 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Manage Expense Types</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="mb-0">Manage Expense Types</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ path('home') }}">Back to Expenses</a>
|
||||
<form method="post" action="{{ path('logout') }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Add New Type</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="add-type-form" novalidate>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label for="type-name" class="form-label">Type Name</label>
|
||||
<input type="text" class="form-control" id="type-name" name="name" placeholder="e.g., Entertainment" required>
|
||||
<div class="invalid-feedback">Please provide a type name.</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Add Type</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="alert-placeholder" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Your Expense Types</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0" id="types-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created At</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if types is empty %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">No types yet.</td></tr>
|
||||
{% else %}
|
||||
{% for type in types %}
|
||||
<tr data-type-id="{{ type.id }}">
|
||||
<td>{{ type.name|e }}</td>
|
||||
<td>{{ type.created_at|e }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger delete-type-btn" data-type-id="{{ type.id }}" data-type-name="{{ type.name|e }}">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to delete the type "<strong id="delete-type-name"></strong>"?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function ($) {
|
||||
var deleteModal;
|
||||
var typeIdToDelete = null;
|
||||
|
||||
function showAlert(type, message) {
|
||||
$('#alert-placeholder').html(
|
||||
'<div class="alert alert-' + type + ' alert-dismissible" role="alert">' +
|
||||
message +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderTypes(types) {
|
||||
var $tbody = $('#types-table tbody');
|
||||
if (!types || !types.length) {
|
||||
$tbody.html('<tr><td colspan="3" class="text-center text-muted py-4">No types yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = types.map(function (type) {
|
||||
return '<tr data-type-id="' + type.id + '">'
|
||||
+ '<td>' + $('<div>').text(type.name).html() + '</td>'
|
||||
+ '<td>' + $('<div>').text(type.created_at).html() + '</td>'
|
||||
+ '<td>'
|
||||
+ '<button class="btn btn-sm btn-danger delete-type-btn" data-type-id="' + type.id + '" data-type-name="' + $('<div>').text(type.name).html() + '">Delete</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
$tbody.html(html);
|
||||
}
|
||||
|
||||
function loadTypes() {
|
||||
$.getJSON('/types/list')
|
||||
.done(function (res) {
|
||||
if (res.success) {
|
||||
renderTypes(res.data.types);
|
||||
} else {
|
||||
showAlert('danger', res.error || 'Failed to load types.');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
showAlert('danger', 'Failed to load types.');
|
||||
});
|
||||
}
|
||||
|
||||
$(function () {
|
||||
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
|
||||
// Add type form submission
|
||||
$('#add-type-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
form.classList.add('was-validated');
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var typeName = $('#type-name').val();
|
||||
|
||||
$.ajax({
|
||||
url: '/types/add',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ name: typeName }),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json'
|
||||
}).done(function (res) {
|
||||
if (res.success) {
|
||||
showAlert('success', res.data.message || 'Type added successfully.');
|
||||
renderTypes(res.data.types);
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
} else {
|
||||
showAlert('danger', res.error || 'Failed to add type.');
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var msg = 'Request failed.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
msg = xhr.responseJSON.error;
|
||||
}
|
||||
showAlert('danger', msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete type button click
|
||||
$(document).on('click', '.delete-type-btn', function () {
|
||||
typeIdToDelete = $(this).data('type-id');
|
||||
var typeName = $(this).data('type-name');
|
||||
$('#delete-type-name').text(typeName);
|
||||
deleteModal.show();
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
$('#confirm-delete-btn').on('click', function () {
|
||||
if (!typeIdToDelete) return;
|
||||
|
||||
$.ajax({
|
||||
url: '/types/delete/' + typeIdToDelete,
|
||||
method: 'DELETE',
|
||||
dataType: 'json'
|
||||
}).done(function (res) {
|
||||
deleteModal.hide();
|
||||
if (res.success) {
|
||||
showAlert('success', res.data.message || 'Type deleted successfully.');
|
||||
renderTypes(res.data.types);
|
||||
} else {
|
||||
showAlert('danger', res.error || 'Failed to delete type.');
|
||||
}
|
||||
typeIdToDelete = null;
|
||||
}).fail(function (xhr) {
|
||||
deleteModal.hide();
|
||||
var msg = 'Request failed.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
msg = xhr.responseJSON.error;
|
||||
}
|
||||
showAlert('danger', msg);
|
||||
typeIdToDelete = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,10 +13,10 @@
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error|e }}</div>
|
||||
{% endif %}
|
||||
<form method="post" novalidate>
|
||||
<form method="post" action="{{ path('login') }}" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ last_username|default('') }}" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
@@ -24,6 +24,12 @@
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
<p class="mt-3 text-muted small">Demo: Enter any email and password to auto-create an account</p>
|
||||
{% if app.user %}
|
||||
<div class="alert alert-info mt-3">
|
||||
You are logged in as {{ app.user.userIdentifier }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
10
tests/autoload_check.php
Normal file
10
tests/autoload_check.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
echo "autoload OK\n";
|
||||
|
||||
echo function_exists('grapheme_strlen') ? "grapheme OK\n" : "grapheme MISSING\n";
|
||||
echo function_exists('normalizer_normalize') ? "normalizer OK\n" : "normalizer MISSING\n";
|
||||
Reference in New Issue
Block a user