This commit is contained in:
lubiana 2025-06-09 19:56:08 +02:00
parent 66c4c1fe4f
commit 2c2e34b71e
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
42 changed files with 910 additions and 939 deletions

View file

@ -1,10 +1,32 @@
<?php <?php
declare(strict_types=1);
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Symfony\Bundle\MakerBundle\MakerBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], FrameworkBundle::class => [
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 'all' => true,
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], ],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], TwigBundle::class => [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 'all' => true,
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], ],
DoctrineBundle::class => [
'all' => true,
],
DoctrineMigrationsBundle::class => [
'all' => true,
],
MakerBundle::class => [
'dev' => true,
],
WebProfilerBundle::class => [
'dev' => true,
'test' => true,
],
]; ];

View file

@ -6,10 +6,12 @@ use App\Service\Config\AppName;
use Symfony\Config\TwigConfig; use Symfony\Config\TwigConfig;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return static function (ContainerConfigurator $containerConfigurator, TwigConfig $twig): void { return static function (ContainerConfigurator $containerConfigurator, TwigConfig $twig): void {
$twig->fileNamePattern('*.twig'); $twig->fileNamePattern('*.twig');
$twig->formThemes(['bootstrap_5_layout.html.twig']); $twig->formThemes(['form/theme.html.twig']);
$twig->global('appName', \Symfony\Component\DependencyInjection\Loader\Configurator\service(AppName::class)); $twig->global('appName', service(AppName::class));
if ($containerConfigurator->env() === 'test') { if ($containerConfigurator->env() === 'test') {
$twig->strictVariables(true); $twig->strictVariables(true);
} }

View file

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void { return static function (ContainerConfigurator $containerConfigurator): void {
@ -15,5 +16,4 @@ return static function (ContainerConfigurator $containerConfigurator): void {
$services->load('App\\Service\\', __DIR__ . '/../src/Service') $services->load('App\\Service\\', __DIR__ . '/../src/Service')
->public(); ->public();
}; };

View file

@ -13,785 +13,115 @@ document.addEventListener('mousemove', function (e) {
}, 1000); }, 1000);
}); });
// Dashboard, Order Management, Inventory Management, Settings, and Drink Type functionality // Function to initialize number input buttons
function initNumberInputs(container = document) {
container.querySelectorAll('.number-input-wrapper').forEach(function(wrapper) {
const input = wrapper.querySelector('input[type="number"]');
const decreaseBtn = wrapper.querySelector('[data-action="decrease"]');
const increaseBtn = wrapper.querySelector('[data-action="increase"]');
if (!input || !decreaseBtn || !increaseBtn) return;
// Skip if already initialized
if (decreaseBtn.hasAttribute('data-initialized')) return;
const step = parseFloat(input.getAttribute('step')) || 1;
const min = 0;
const max = input.getAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
decreaseBtn.addEventListener('click', function() {
const currentValue = parseFloat(input.value) || 0;
const newValue = currentValue - step;
if (min === null || newValue >= min) {
input.value = newValue;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
increaseBtn.addEventListener('click', function() {
const currentValue = parseFloat(input.value) || 0;
const newValue = currentValue + step;
if (max === null || newValue <= max) {
input.value = newValue;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// Validate input on change
input.addEventListener('input', function() {
const value = parseFloat(this.value);
if (min !== null && value < min) {
this.value = min;
}
if (max !== null && value > max) {
this.value = max;
}
});
// Mark as initialized
decreaseBtn.setAttribute('data-initialized', 'true');
increaseBtn.setAttribute('data-initialized', 'true');
});
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Stock update form handling // Bootstrap Modal handling
initStockUpdateForms(); const htmxModal = document.getElementById('htmxModal');
if (htmxModal) {
// Edit button handling htmxModal.addEventListener('show.bs.modal', function(event) {
initEditButtons(); // Get the button that triggered the modal
const button = event.relatedTarget;
// Order form handling // Extract the drink name from data-* attributes
initOrderForms(); const drinkName = button.getAttribute('data-drink-name');
// Update the modal title
// Order status form handling if (drinkName) {
initOrderStatusForms(); const modalTitle = htmxModal.querySelector('.modal-title');
if (modalTitle) {
// Inventory form handling modalTitle.textContent = 'Update Stock for ' + drinkName;
initInventoryForm();
// Settings form handling
initSettingsForm();
// Drink Type form handling
initDrinkTypeForm();
});
/**
* Initialize stock update forms with AJAX submission and validation
*/
function initStockUpdateForms() {
// Get all stock update forms
const stockForms = document.querySelectorAll('.stock-update-form');
// Add event listener to each form
stockForms.forEach(form => {
// Add validation to quantity input
const quantityInput = form.querySelector('input[name="quantity"]');
if (quantityInput) {
quantityInput.addEventListener('input', function() {
validateQuantityInput(this);
});
} }
// Add submit handler
form.addEventListener('submit', function(e) {
e.preventDefault();
// Validate form before submission
if (!validateStockForm(form)) {
return;
}
// Get form data
const formData = new FormData(form);
// Show loading state
const submitButton = form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(form.parentNode, 'success', 'Stock updated successfully.');
} else {
// Show error message
showAlert(form.parentNode, 'danger', `Error updating stock: ${data.message}`);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(form.parentNode, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
});
}
/**
* Initialize edit buttons
*/
function initEditButtons() {
const editButtons = document.querySelectorAll('.btn-edit-drink-type');
editButtons.forEach(button => {
button.addEventListener('click', function() {
const drinkTypeId = this.dataset.drinkTypeId;
window.location.href = `/drink-types/edit/${drinkTypeId}`;
});
});
}
/**
* Validate a stock update form
* @param {HTMLFormElement} form - The form to validate
* @returns {boolean} - Whether the form is valid
*/
function validateStockForm(form) {
const quantityInput = form.querySelector('input[name="quantity"]');
return validateQuantityInput(quantityInput);
}
/**
* Validate a quantity input
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateQuantityInput(input) {
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a valid number');
return false;
}
// Check if value is non-negative
if (value < 0) {
input.classList.add('is-invalid');
showInputError(input, 'Quantity cannot be negative');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Show an error message for an input
* @param {HTMLInputElement} input - The input with the error
* @param {string} message - The error message
*/
function showInputError(input, message) {
// Remove any existing error message
const existingError = input.parentNode.querySelector('.invalid-feedback');
if (existingError) {
existingError.remove();
}
// Create and add new error message
const errorDiv = document.createElement('div');
errorDiv.className = 'invalid-feedback';
errorDiv.textContent = message;
input.parentNode.appendChild(errorDiv);
}
/**
* Show an alert message
* @param {HTMLElement} container - The container to add the alert to
* @param {string} type - The type of alert (success, danger, warning, info)
* @param {string} message - The alert message
*/
function showAlert(container, type, message) {
// Remove any existing alerts
const existingAlerts = container.querySelectorAll('.alert');
existingAlerts.forEach(alert => alert.remove());
// Create and add new alert
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show mt-3`;
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
container.appendChild(alert);
// Remove alert after 3 seconds
setTimeout(() => {
alert.remove();
}, 3000);
}
/**
* Initialize order forms with validation and dynamic item handling
*/
function initOrderForms() {
const orderForm = document.getElementById('orderForm');
if (!orderForm) return;
// Validate all quantity inputs
const quantityInputs = orderForm.querySelectorAll('input[type="number"]');
quantityInputs.forEach(input => {
input.addEventListener('input', function() {
validateQuantityInput(this);
});
});
// Add form submission handler
orderForm.addEventListener('submit', function(e) {
// Validate all inputs before submission
let isValid = true;
quantityInputs.forEach(input => {
if (!validateQuantityInput(input)) {
isValid = false;
}
});
if (!isValid) {
e.preventDefault();
showAlert(orderForm, 'danger', 'Please correct the errors in the form before submitting.');
}
});
// Add "Add Item" button functionality if it exists
const addItemButton = document.getElementById('addOrderItem');
if (addItemButton) {
addItemButton.addEventListener('click', function() {
addOrderItem();
});
}
// Add "Remove Item" button functionality
const removeButtons = orderForm.querySelectorAll('.remove-order-item');
removeButtons.forEach(button => {
button.addEventListener('click', function() {
removeOrderItem(this);
});
});
}
/**
* Add a new order item row to the order form
*/
function addOrderItem() {
const orderItemsTable = document.querySelector('#orderForm table tbody');
const drinkTypeSelect = document.getElementById('drinkTypeSelect');
if (!orderItemsTable || !drinkTypeSelect) return;
// Get the selected drink type
const selectedOption = drinkTypeSelect.options[drinkTypeSelect.selectedIndex];
const drinkTypeId = selectedOption.value;
const drinkTypeName = selectedOption.text;
// Check if this drink type is already in the table
const existingRow = orderItemsTable.querySelector(`input[value="${drinkTypeId}"]`);
if (existingRow) {
showAlert(orderItemsTable.parentNode, 'warning', `${drinkTypeName} is already in the order.`);
return;
}
// Create a new row
const newRow = document.createElement('tr');
const rowCount = orderItemsTable.querySelectorAll('tr').length;
newRow.innerHTML = `
<td>${drinkTypeName}</td>
<td>
<input type="hidden" name="items[${rowCount}][drinkTypeId]" value="${drinkTypeId}">
<input type="number" name="items[${rowCount}][quantity]"
class="form-control form-control-sm order-quantity"
value="1" min="0">
</td>
<td>
<button type="button" class="btn btn-sm btn-danger remove-order-item">Remove</button>
</td>
`;
// Add event listeners to the new row
const quantityInput = newRow.querySelector('input[type="number"]');
quantityInput.addEventListener('input', function() {
validateQuantityInput(this);
});
const removeButton = newRow.querySelector('.remove-order-item');
removeButton.addEventListener('click', function() {
removeOrderItem(this);
});
// Add the row to the table
orderItemsTable.appendChild(newRow);
}
/**
* Remove an order item row from the order form
* @param {HTMLElement} button - The remove button that was clicked
*/
function removeOrderItem(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
}
/**
* Initialize order status forms with AJAX submission
*/
function initOrderStatusForms() {
const statusForm = document.querySelector('form[action^="/orders/update-status/"]');
if (!statusForm) return;
statusForm.addEventListener('submit', function(e) {
e.preventDefault();
// Get form data
const formData = new FormData(statusForm);
// Show loading state
const submitButton = statusForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(statusForm.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(statusForm.parentNode, 'success', 'Order status updated successfully.');
// Update the status badge
const statusBadge = document.querySelector('.badge');
if (statusBadge) {
// Remove old status classes
statusBadge.classList.remove('bg-primary', 'bg-warning', 'bg-info', 'bg-success');
// Add new status class
const newStatus = formData.get('status');
if (newStatus === 'new') {
statusBadge.classList.add('bg-primary');
} else if (newStatus === 'in_work') {
statusBadge.classList.add('bg-warning');
} else if (newStatus === 'ordered') {
statusBadge.classList.add('bg-info');
} else if (newStatus === 'fulfilled') {
statusBadge.classList.add('bg-success');
}
// Update the text
statusBadge.textContent = newStatus.charAt(0).toUpperCase() + newStatus.slice(1);
}
} else {
// Show error message
showAlert(statusForm.parentNode, 'danger', `Error updating status: ${data.message}`);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(statusForm.parentNode, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
}
/**
* Initialize inventory form with validation, highlighting for changed values, and AJAX submission
*/
function initInventoryForm() {
const inventoryForm = document.getElementById('inventoryForm');
if (!inventoryForm) return;
// Get all quantity inputs
const quantityInputs = inventoryForm.querySelectorAll('.inventory-quantity');
// Add event listener to each input for validation and highlighting
quantityInputs.forEach(input => {
// Store original value for comparison
const originalValue = parseInt(input.dataset.originalValue);
// Add input event listener for validation
input.addEventListener('input', function() {
// Validate input
validateQuantityInput(this);
// Highlight changed values
const currentValue = parseInt(this.value);
if (currentValue !== originalValue) {
this.classList.add('bg-warning', 'text-dark');
} else {
this.classList.remove('bg-warning', 'text-dark');
}
});
});
// Add submit handler
inventoryForm.addEventListener('submit', function(e) {
e.preventDefault();
// Validate all inputs before submission
let isValid = true;
quantityInputs.forEach(input => {
if (!validateQuantityInput(input)) {
isValid = false;
}
});
if (!isValid) {
showAlert(inventoryForm, 'danger', 'Please correct the errors in the form before submitting.');
return;
}
// Check if any values have changed
let hasChanges = false;
quantityInputs.forEach(input => {
const originalValue = parseInt(input.dataset.originalValue);
const currentValue = parseInt(input.value);
if (currentValue !== originalValue) {
hasChanges = true;
}
});
if (!hasChanges) {
showAlert(inventoryForm, 'warning', 'No changes detected. Nothing to update.');
return;
}
// Get form data
const formData = new FormData(inventoryForm);
// Add X-Requested-With header to indicate AJAX request
const headers = new Headers({
'X-Requested-With': 'XMLHttpRequest'
});
// Show loading state
const submitButton = inventoryForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(inventoryForm.action, {
method: 'POST',
headers: headers,
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(inventoryForm, 'success', 'Inventory updated successfully.');
// Update original values and remove highlighting
quantityInputs.forEach(input => {
const drinkTypeId = input.previousElementSibling.value;
const updatedItem = data.updatedItems.find(item => item.drinkTypeId == drinkTypeId);
if (updatedItem) {
input.dataset.originalValue = updatedItem.newQuantity;
input.classList.remove('bg-warning', 'text-dark');
}
});
// Show update results
const updateResults = document.getElementById('updateResults');
const updateResultsContent = document.getElementById('updateResultsContent');
if (updateResults && updateResultsContent) {
updateResults.classList.remove('d-none');
let resultsHtml = '<h6>The following items were updated:</h6><ul>';
data.updatedItems.forEach(item => {
resultsHtml += `<li>${item.name}: ${item.oldQuantity}${item.newQuantity}</li>`;
});
resultsHtml += '</ul>';
updateResultsContent.innerHTML = resultsHtml;
}
} else {
// Show error message
let errorMessage = 'Error updating inventory.';
if (data.errors && data.errors.length > 0) {
errorMessage += ' ' + data.errors.join(' ');
}
showAlert(inventoryForm, 'danger', errorMessage);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(inventoryForm, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
}
/**
* Initialize settings form with validation and AJAX submission
*/
function initSettingsForm() {
const settingsForm = document.getElementById('settings-form');
if (!settingsForm) return;
// Get all inputs
const textInputs = settingsForm.querySelectorAll('input[type="text"]');
const numberInputs = settingsForm.querySelectorAll('input[type="number"]');
// Add event listeners for validation
textInputs.forEach(input => {
input.addEventListener('input', function() {
validateSettingTextInput(this);
});
});
numberInputs.forEach(input => {
input.addEventListener('input', function() {
validateSettingNumberInput(this);
});
});
// Add submit handler
settingsForm.addEventListener('submit', function(e) {
// Validate all inputs before submission
let isValid = true;
textInputs.forEach(input => {
if (!validateSettingTextInput(input)) {
isValid = false;
}
});
numberInputs.forEach(input => {
if (!validateSettingNumberInput(input)) {
isValid = false;
}
});
if (!isValid) {
e.preventDefault();
showAlert(settingsForm, 'danger', 'Please correct the errors in the form before submitting.');
}
});
// Reset to defaults button
const resetButton = document.getElementById('reset-defaults');
if (resetButton) {
resetButton.addEventListener('click', function() {
if (confirm('Are you sure you want to reset all settings to their default values?')) {
window.location.href = '/settings/reset';
}
});
}
}
/**
* Validate a text input for settings
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateSettingTextInput(input) {
// Check if value is empty
if (input.value.trim() === '') {
input.classList.add('is-invalid');
showInputError(input, 'This field cannot be empty');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Validate a number input for settings
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateSettingNumberInput(input) {
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a valid number');
return false;
}
// Check if value is positive
if (value < 1) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a positive number');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Initialize drink type form with validation and AJAX name uniqueness check
*/
function initDrinkTypeForm() {
const drinkTypeForm = document.getElementById('drink-type-form');
if (!drinkTypeForm) return;
// Get form inputs
const nameInput = drinkTypeForm.querySelector('input[name="name"]');
const descriptionInput = drinkTypeForm.querySelector('textarea[name="description"]');
const desiredStockInput = drinkTypeForm.querySelector('input[name="desired_stock"]');
// Store original name for edit mode
const originalName = nameInput ? nameInput.value : '';
// Add validation for name input
if (nameInput) {
nameInput.addEventListener('input', function() {
validateDrinkTypeName(this, originalName);
});
// Check name uniqueness on blur
nameInput.addEventListener('blur', function() {
checkDrinkTypeNameUniqueness(this, originalName);
});
}
// Add validation for desired stock input
if (desiredStockInput) {
desiredStockInput.addEventListener('input', function() {
validateDesiredStock(this);
});
}
// Add form submission handler
drinkTypeForm.addEventListener('submit', function(e) {
let isValid = true;
// Validate name
if (nameInput && !validateDrinkTypeName(nameInput, originalName)) {
isValid = false;
}
// Validate desired stock
if (desiredStockInput && !validateDesiredStock(desiredStockInput)) {
isValid = false;
}
if (!isValid) {
e.preventDefault();
showAlert(drinkTypeForm, 'danger', 'Please correct the errors in the form before submitting.');
} }
}); });
} }
/** // HTMX Modal handling
* Validate a drink type name document.body.addEventListener('htmx:afterSwap', function(event) {
* @param {HTMLInputElement} input - The input to validate // If the target is the modal body, initialize any form elements inside it
* @param {string} originalName - The original name (for edit mode) if (event.detail.target.id === 'htmxModalBody') {
* @returns {boolean} - Whether the input is valid // The modal content has been loaded
*/ console.log('Modal content loaded');
function validateDrinkTypeName(input, originalName) { // Initialize number inputs in the modal
const value = input.value.trim(); initNumberInputs(event.detail.target);
// Check if value is empty
if (value === '') {
input.classList.add('is-invalid');
showInputError(input, 'Name is required');
return false;
} }
// Check if value is too long
if (value.length > 255) {
input.classList.add('is-invalid');
showInputError(input, 'Name cannot be longer than 255 characters');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Check if a drink type name is unique
* @param {HTMLInputElement} input - The input to check
* @param {string} originalName - The original name (for edit mode)
*/
function checkDrinkTypeNameUniqueness(input, originalName) {
const value = input.value.trim();
// Skip check if name hasn't changed or is empty
if (value === '' || value === originalName) {
return;
}
// Show loading state
input.classList.add('is-loading');
// Check if name already exists
fetch(`/api/drink-types/check-name?name=${encodeURIComponent(value)}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Remove loading state
input.classList.remove('is-loading');
if (data.exists) {
input.classList.add('is-invalid');
showInputError(input, 'A drink type with this name already exists');
}
})
.catch(error => {
// Remove loading state
input.classList.remove('is-loading');
console.error('Error checking name uniqueness:', error);
}); });
}
/** // Handle form submissions in the modal
* Validate a desired stock input document.body.addEventListener('htmx:beforeSend', function(event) {
* @param {HTMLInputElement} input - The input to validate // If the event is from a form inside the modal
* @returns {boolean} - Whether the input is valid if (event.detail.elt.closest('#htmxModalBody')) {
*/ // Add the HX-Request header to the request
function validateDesiredStock(input) { event.detail.requestConfig.headers['HX-Request'] = 'true';
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Desired stock must be a number');
return false;
} }
});
// Check if value is non-negative // Handle redirects from HTMX
if (value < 0) { document.body.addEventListener('htmx:responseError', function(event) {
input.classList.add('is-invalid'); if (event.detail.xhr.status === 303) {
showInputError(input, 'Desired stock cannot be negative'); const redirectUrl = event.detail.xhr.getResponseHeader('HX-Redirect');
return false; if (redirectUrl) {
// Close the modal
const modal = bootstrap.Modal.getInstance(document.getElementById('htmxModal'));
if (modal) {
modal.hide();
} }
// Redirect to the specified URL
window.location.href = redirectUrl;
}
}
});
// Input is valid // Initialize number inputs on page load
input.classList.remove('is-invalid'); initNumberInputs();
input.classList.add('is-valid'); });
return true;
}

1
public/assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -25,4 +25,5 @@ return RectorConfig::configure()
->withSkip([ ->withSkip([
FirstClassCallableRector::class, FirstClassCallableRector::class,
]) ])
->withImportNames(removeUnusedImports: true)
; ;

View file

@ -6,7 +6,7 @@ namespace App\Controller;
use App\Entity\DrinkType; use App\Entity\DrinkType;
use App\Form\DrinkTypeForm; use App\Form\DrinkTypeForm;
use App\Repository\DrinkTypeRepository; use App\Repository\PropertyChangeLogRepository;
use App\Service\InventoryService; use App\Service\InventoryService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -21,7 +21,7 @@ final class DrinkTypeController extends AbstractController
public function index(InventoryService $inventoryService): Response public function index(InventoryService $inventoryService): Response
{ {
return $this->render('drink_type/index.html.twig', [ return $this->render('drink_type/index.html.twig', [
'drink_stocks' => $inventoryService->getAllDrinkTypesWithStockLevels(true) 'drink_stocks' => $inventoryService->getAllDrinkTypesWithStockLevels(true),
]); ]);
} }
@ -46,10 +46,26 @@ final class DrinkTypeController extends AbstractController
} }
#[Route(path: '/{id}', name: 'app_drink_type_show', methods: ['GET'])] #[Route(path: '/{id}', name: 'app_drink_type_show', methods: ['GET'])]
public function show(DrinkType $drinkType): Response public function show(DrinkType $drinkType, PropertyChangeLogRepository $propertyChangeLogRepository): Response
{ {
// Get orders that contain this drink type
$orderItems = $drinkType->getOrderItems();
// Get inventory history for this drink type
$inventoryRecords = $drinkType->getInventoryRecords();
// Get desired stock history from PropertyChangeLog
$desiredStockHistory = $propertyChangeLogRepository->findBy([
'entityClass' => DrinkType::class,
'propertyName' => 'desiredStock',
'entityId' => $drinkType->getId()
], ['changeDate' => 'DESC']);
return $this->render('drink_type/show.html.twig', [ return $this->render('drink_type/show.html.twig', [
'drink_type' => $drinkType, 'drink_type' => $drinkType,
'order_items' => $orderItems,
'inventory_records' => $inventoryRecords,
'desired_stock_history' => $desiredStockHistory,
]); ]);
} }

View file

@ -29,7 +29,7 @@ final class Index extends AbstractController
return $this->render('index.html.twig', [ return $this->render('index.html.twig', [
'drinkStocks' => $drinkStocks, 'drinkStocks' => $drinkStocks,
'low' => $low 'low' => $low,
]); ]);
} }
} }

View file

@ -1,10 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\DrinkType;
use App\Entity\InventoryRecord; use App\Entity\InventoryRecord;
use App\Form\InventoryRecordForm; use App\Form\InventoryRecordForm;
use App\Repository\InventoryRecordRepository; use App\Repository\InventoryRecordRepository;
use App\Service\InventoryService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -22,10 +26,11 @@ final class InventoryRecordController extends AbstractController
]); ]);
} }
#[Route('/new', name: 'app_inventory_record_new', methods: ['GET', 'POST'])] #[Route('/new/{drinkType}', name: 'app_inventory_record_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response public function new(Request $request, EntityManagerInterface $entityManager, DrinkType $drinkType): Response
{ {
$inventoryRecord = new InventoryRecord(); $inventoryRecord = new InventoryRecord();
$inventoryRecord->setDrinkType($drinkType);
$form = $this->createForm(InventoryRecordForm::class, $inventoryRecord); $form = $this->createForm(InventoryRecordForm::class, $inventoryRecord);
$form->handleRequest($request); $form->handleRequest($request);
@ -33,7 +38,22 @@ final class InventoryRecordController extends AbstractController
$entityManager->persist($inventoryRecord); $entityManager->persist($inventoryRecord);
$entityManager->flush(); $entityManager->flush();
return $this->redirectToRoute('app_inventory_record_index', [], Response::HTTP_SEE_OTHER); // If it's an HTMX request, return a redirect that HTMX will follow
if ($request->headers->has('HX-Request')) {
$response = new Response('', Response::HTTP_SEE_OTHER);
$response->headers->set('HX-Redirect', $this->generateUrl('app_index'));
return $response;
}
return $this->redirectToRoute('app_index', [], Response::HTTP_SEE_OTHER);
}
// Check if it's an HTMX request
if ($request->headers->has('HX-Request')) {
return $this->render('inventory_record/_modal_form.html.twig', [
'inventory_record' => $inventoryRecord,
'form' => $form,
]);
} }
return $this->render('inventory_record/new.html.twig', [ return $this->render('inventory_record/new.html.twig', [
@ -42,6 +62,26 @@ final class InventoryRecordController extends AbstractController
]); ]);
} }
#[Route('/modal/{drinkType}', name: 'app_inventory_record_modal', methods: ['GET'])]
public function modal(DrinkType $drinkType, InventoryService $inventoryService): Response
{
$inventoryRecord = new InventoryRecord();
$inventoryRecord->setDrinkType($drinkType);
$inventoryRecord->setQuantity(
$inventoryService->getLatestInventoryRecord($drinkType)->getQuantity() ?? 0
);
$form = $this->createForm(InventoryRecordForm::class, $inventoryRecord, [
'action' => $this->generateUrl('app_inventory_record_new', [
'drinkType' => $drinkType->getId(),
]),
]);
return $this->render('inventory_record/_modal_form.html.twig', [
'inventory_record' => $inventoryRecord,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_inventory_record_show', methods: ['GET'])] #[Route('/{id}', name: 'app_inventory_record_show', methods: ['GET'])]
public function show(InventoryRecord $inventoryRecord): Response public function show(InventoryRecord $inventoryRecord): Response
{ {

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Order;
use App\Form\OrderForm;
use App\Repository\OrderRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/order')]
final class OrderController extends AbstractController
{
#[Route(name: 'app_order_index', methods: ['GET'])]
public function index(OrderRepository $orderRepository): Response
{
return $this->render('order/index.html.twig', [
'orders' => $orderRepository->findAll(),
]);
}
#[Route('/new', name: 'app_order_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$order = new Order();
$form = $this->createForm(OrderForm::class, $order);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($order);
$entityManager->flush();
return $this->redirectToRoute('app_order_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('order/new.html.twig', [
'order' => $order,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_order_show', methods: ['GET'])]
public function show(Order $order): Response
{
return $this->render('order/show.html.twig', [
'order' => $order,
]);
}
#[Route('/{id}/edit', name: 'app_order_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Order $order, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(OrderForm::class, $order);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_order_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('order/edit.html.twig', [
'order' => $order,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_order_delete', methods: ['POST'])]
public function delete(Request $request, Order $order, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete' . $order->getId(), $request->getPayload()->getString('_token'))) {
$entityManager->remove($order);
$entityManager->flush();
}
return $this->redirectToRoute('app_order_index', [], Response::HTTP_SEE_OTHER);
}
}

View file

@ -9,6 +9,7 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use App\Entity\PropertyChangeLog;
#[ORM\Entity(repositoryClass: DrinkTypeRepository::class)] #[ORM\Entity(repositoryClass: DrinkTypeRepository::class)]
#[ORM\Table(name: 'drink_type')] #[ORM\Table(name: 'drink_type')]

View file

@ -32,7 +32,7 @@ class OrderItem
#[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'orderItems')] #[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'orderItems')]
#[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: false)] #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: false)]
private null|Order $order; private null|Order $order = null;
public function __construct( public function __construct(
) { ) {
@ -53,7 +53,7 @@ class OrderItem
public function setOrder(null|Order $order): self public function setOrder(null|Order $order): self
{ {
// Remove from old order if exists // Remove from old order if exists
if (isset($this->order) && $this->order instanceof Order && $this->order !== $order) { if ($this->order instanceof Order && $this->order instanceof Order && $this->order !== $order) {
$this->order->removeOrderItem($this); $this->order->removeOrderItem($this);
} }

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\PropertyChangeLogRepository;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\GeneratedValue;
use DateTimeImmutable;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PropertyChangeLogRepository::class)]
#[ORM\Table(name: 'property_change_log')]
final class PropertyChangeLog
{
#[Id]
#[GeneratedValue]
#[Column(type: 'integer')]
private int|null $id = null;
#[Column(type: 'string', length: 255)]
private string $propertyName;
#[Column(type: 'string', length: 255)]
private string $entityClass;
#[Column(type: 'integer', nullable: true)]
private ?int $entityId = null;
#[Column(type: 'string', length: 255)]
private string $newValue;
#[Column(type: 'datetime_immutable')]
private DateTimeImmutable $changeDate;
public function __construct()
{
$this->changeDate = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getPropertyName(): string
{
return $this->propertyName;
}
public function setPropertyName(string $propertyName): void
{
$this->propertyName = $propertyName;
}
public function getEntityClass(): string
{
return $this->entityClass;
}
public function setEntityClass(string $entityClass): void
{
$this->entityClass = $entityClass;
}
public function getEntityId(): ?int
{
return $this->entityId;
}
public function setEntityId(?int $entityId): void
{
$this->entityId = $entityId;
}
public function getNewValue(): string
{
return $this->newValue;
}
public function setNewValue(string $newValue): void
{
$this->newValue = $newValue;
}
public function getChangeDate(): DateTimeImmutable
{
return $this->changeDate;
}
public function setChangeDate(DateTimeImmutable $changeDate): void
{
$this->changeDate = $changeDate;
}
}

View file

@ -6,6 +6,7 @@ namespace App\Form;
use App\Entity\DrinkType; use App\Entity\DrinkType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -13,7 +14,7 @@ class DrinkTypeForm extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder->add('name')->add('description')->add('desiredStock'); $builder->add('name')->add('description')->add('desiredStock', NumberType::class);
} }
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void

View file

@ -1,11 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Form; namespace App\Form;
use App\Entity\DrinkType; use App\Entity\DrinkType;
use App\Entity\InventoryRecord; use App\Entity\InventoryRecord;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -14,19 +17,14 @@ class InventoryRecordForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('quantity') ->add('quantity', NumberType::class)
->add('timestamp', null, [
'widget' => 'single_text',
])
->add('createdAt', null, [
'widget' => 'single_text',
])
->add('updatedAt', null, [
'widget' => 'single_text',
])
->add('drinkType', EntityType::class, [ ->add('drinkType', EntityType::class, [
'class' => DrinkType::class, 'class' => DrinkType::class,
'choice_label' => 'id', 'choice_label' => 'id',
'attr' => [
'style' => 'display: none;',
],
'label' => false,
]) ])
; ;
} }

27
src/Form/OrderForm.php Normal file
View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Order;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OrderForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('status')
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Order::class,
]);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\PropertyChangeLog;
use Doctrine\ORM\EntityManagerInterface;
/**
* @extends AbstractRepository<PropertyChangeLog>
*/
final class PropertyChangeLogRepository extends AbstractRepository
{
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct($entityManager, $entityManager->getClassMetadata(PropertyChangeLog::class));
}
}

View file

@ -12,6 +12,7 @@
{% block javascripts %} {% block javascripts %}
<script src="/assets/js/app.js"></script> <script src="/assets/js/app.js"></script>
<script src="/assets/js/bootstrap.bundle.min.js"></script> <script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/htmx.min.js"></script>
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
@ -34,5 +35,20 @@
<div class="container mt-4"> <div class="container mt-4">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
<!-- Modal container for HTMX -->
<div class="modal fade" id="htmxModal" tabindex="-1" aria-labelledby="htmxModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="htmxModalLabel">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="htmxModalBody">
<!-- Content will be loaded here by HTMX -->
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View file

@ -8,9 +8,6 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Id</th>
<th>CreatedAt</th>
<th>UpdatedAt</th>
<th>Name</th> <th>Name</th>
<th>Description</th> <th>Description</th>
<th>DesiredStock</th> <th>DesiredStock</th>
@ -22,9 +19,6 @@
{% for drink_stock in drink_stocks %} {% for drink_stock in drink_stocks %}
{% set drink_type = drink_stock.record.drinkType %} {% set drink_type = drink_stock.record.drinkType %}
<tr> <tr>
<td>{{ drink_type.id }}</td>
<td>{{ drink_type.createdAt ? drink_type.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ drink_type.updatedAt ? drink_type.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ drink_type.name }}</td> <td>{{ drink_type.name }}</td>
<td>{{ drink_type.description }}</td> <td>{{ drink_type.description }}</td>
<td>{{ drink_type.desiredStock }}</td> <td>{{ drink_type.desiredStock }}</td>
@ -36,7 +30,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7">no records found</td> <td colspan="5">no records found</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -1,10 +1,18 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}DrinkType{% endblock %} {% block title %}{{ drink_type.name }}{% endblock %}
{% block body %} {% block body %}
<h1>DrinkType</h1> <div class="container">
<h1>{{ drink_type.name }}</h1>
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">Drink Details</h5>
</div>
<div class="card-body">
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
@ -33,10 +41,117 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
</div>
<a href="{{ path('app_drink_type_index') }}">back to list</a> <div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">Orders Containing This Drink</h5>
</div>
<div class="card-body">
{% if order_items|length > 0 %}
<table class="table table-striped">
<thead>
<tr>
<th>Order ID</th>
<th>Date</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{% for item in order_items %}
<tr>
<td>{{ item.order.id }}</td>
<td>{{ item.order.createdAt|date('Y-m-d H:i:s') }}</td>
<td>{{ item.quantity }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No orders found for this drink.</p>
{% endif %}
</div>
</div>
</div>
</div>
<a href="{{ path('app_drink_type_edit', {'id': drink_type.id}) }}">edit</a> <div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">Inventory History</h5>
</div>
<div class="card-body">
{% if inventory_records|length > 0 %}
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{% for record in inventory_records %}
<tr>
<td>{{ record.createdAt|date('Y-m-d H:i:s') }}</td>
<td>{{ record.quantity }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No inventory history found for this drink.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">Desired Stock History</h5>
</div>
<div class="card-body">
{% if desired_stock_history|length > 0 %}
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
{% for log in desired_stock_history %}
<tr>
<td>{{ log.changeDate|date('Y-m-d H:i:s') }}</td>
<td>{{ log.oldValue }}</td>
<td>{{ log.newValue }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No desired stock history found for this drink.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<a href="{{ path('app_drink_type_index') }}" class="btn btn-secondary">Back to list</a>
<a href="{{ path('app_drink_type_edit', {'id': drink_type.id}) }}" class="btn btn-primary">Edit</a>
{{ include('drink_type/_delete_form.html.twig') }} {{ include('drink_type/_delete_form.html.twig') }}
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,23 @@
{% use 'bootstrap_5_layout.html.twig' %}
{%- block number_widget -%}
{%- set type = type|default('number') -%}
{%- set attr = attr|merge({
'class': (attr.class|default('') ~ ' form-control number-input')|trim,
'step': attr.step|default('1'),
'min': attr.min|default(null),
'max': attr.max|default(null)
}) -%}
<div class="number-input-wrapper">
<div class="input-group">
<button class="btn btn-outline-secondary number-decrease" type="button" data-action="decrease">
-
</button>
{{- block('form_widget_simple') -}}
<button class="btn btn-outline-secondary number-increase" type="button" data-action="increase">
+
</button>
</div>
</div>
{%- endblock number_widget -%}

View file

@ -23,7 +23,7 @@
<tbody> <tbody>
{% for lowStock in low %} {% for lowStock in low %}
<tr> <tr>
<td>{{ lowStock.record.drinkType.name }}</td> <td><a href="{{ path('app_drink_type_show', {'id': lowStock.record.drinkType.id}) }}">{{ lowStock.record.drinkType.name }}</a></td>
<td>{{ lowStock.record.quantity }}</td> <td>{{ lowStock.record.quantity }}</td>
<td>{{ lowStock.record.drinkType.desiredStock }}</td> <td>{{ lowStock.record.drinkType.desiredStock }}</td>
<td>{{ lowStock.stock.value|capitalize }}</td> <td>{{ lowStock.stock.value|capitalize }}</td>
@ -60,10 +60,20 @@
{% set rowClass = 'table-success' %} {% set rowClass = 'table-success' %}
{% endif %} {% endif %}
<tr class="{{ rowClass }}"> <tr class="{{ rowClass }}">
<td>{{ drinkStock.record.drinkType.name }}</td> <td><a href="{{ path('app_drink_type_show', {'id': drinkStock.record.drinkType.id}) }}">{{ drinkStock.record.drinkType.name }}</a></td>
<td>{{ drinkStock.record.quantity }}</td> <td>{{ drinkStock.record.quantity }}</td>
<td>{{ drinkStock.record.drinkType.desiredStock }}</td> <td>{{ drinkStock.record.drinkType.desiredStock }}</td>
<td>{{ drinkStock.stock.value|capitalize }}</td> <td>{{ drinkStock.stock.value|capitalize }}</td>
<td>
<a href="{{ path('app_inventory_record_modal', {drinkType: drinkStock.record.drinkType.id}) }}"
class="btn btn-primary"
hx-get="{{ path('app_inventory_record_modal', {drinkType: drinkStock.record.drinkType.id}) }}"
hx-target="#htmxModalBody"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#htmxModal"
data-drink-name="{{ drinkStock.record.drinkType.name }}">Update Stock</a>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -0,0 +1,7 @@
{{ form_start(form, {'attr': {'hx-post': form.vars.action, 'hx-target': '#htmxModalBody'}}) }}
{{ form_widget(form) }}
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">{{ button_label|default('Save') }}</button>
</div>
{{ form_end(form) }}

View file

@ -0,0 +1,4 @@
<form method="post" action="{{ path('app_order_delete', {'id': order.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ order.id) }}">
<button class="btn">Delete</button>
</form>

View file

@ -0,0 +1,4 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

View file

@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}Edit Order{% endblock %}
{% block body %}
<h1>Edit Order</h1>
{{ include('order/_form.html.twig', {'button_label': 'Update'}) }}
<a href="{{ path('app_order_index') }}">back to list</a>
{{ include('order/_delete_form.html.twig') }}
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends 'base.html.twig' %}
{% block title %}Order index{% endblock %}
{% block body %}
<h1>Order index</h1>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>CreatedAt</th>
<th>UpdatedAt</th>
<th>Status</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.id }}</td>
<td>{{ order.createdAt ? order.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ order.updatedAt ? order.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ order.status }}</td>
<td>
<a href="{{ path('app_order_show', {'id': order.id}) }}">show</a>
<a href="{{ path('app_order_edit', {'id': order.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_order_new') }}">Create new</a>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}New Order{% endblock %}
{% block body %}
<h1>Create new Order</h1>
{{ include('order/_form.html.twig') }}
<a href="{{ path('app_order_index') }}">back to list</a>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends 'base.html.twig' %}
{% block title %}Order{% endblock %}
{% block body %}
<h1>Order</h1>
<table class="table">
<tbody>
<tr>
<th>Id</th>
<td>{{ order.id }}</td>
</tr>
<tr>
<th>CreatedAt</th>
<td>{{ order.createdAt ? order.createdAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>UpdatedAt</th>
<td>{{ order.updatedAt ? order.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>Status</th>
<td>{{ order.status }}</td>
</tr>
</tbody>
</table>
<a href="{{ path('app_order_index') }}">back to list</a>
<a href="{{ path('app_order_edit', {'id': order.id}) }}">edit</a>
{{ include('order/_delete_form.html.twig') }}
{% endblock %}

View file

@ -1,10 +1,13 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace Tests; namespace Tests;
use Exception;
use Override;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\SchemaTool;
use Tests\TestCase;
abstract class DbTestCase extends TestCase abstract class DbTestCase extends TestCase
{ {
@ -13,7 +16,7 @@ abstract class DbTestCase extends TestCase
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
$metadata = $em->getMetadataFactory()->getAllMetadata(); $metadata = $em->getMetadataFactory()->getAllMetadata();
if (empty($metadata)) { if (empty($metadata)) {
throw new \Exception('No metadata found. Did you forget to map entities?'); throw new Exception('No metadata found. Did you forget to map entities?');
} }
$schemaTool = new SchemaTool($em); $schemaTool = new SchemaTool($em);
$schemaTool->dropDatabase(); // Clean slate, in case anything exists $schemaTool->dropDatabase(); // Clean slate, in case anything exists
@ -21,12 +24,13 @@ abstract class DbTestCase extends TestCase
parent::setUp(); parent::setUp();
} }
#[Override]
protected function tearDown(): void protected function tearDown(): void
{ {
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
$metadata = $em->getMetadataFactory()->getAllMetadata(); $metadata = $em->getMetadataFactory()->getAllMetadata();
if (empty($metadata)) { if (empty($metadata)) {
throw new \Exception('No metadata found. Did you forget to map entities?'); throw new Exception('No metadata found. Did you forget to map entities?');
} }
$schemaTool = new SchemaTool($em); $schemaTool = new SchemaTool($em);
$schemaTool->dropDatabase(); // Clean slate, in case anything exists $schemaTool->dropDatabase(); // Clean slate, in case anything exists

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Entity\DrinkType;
use App\Entity\PropertyChangeLog;
use App\Repository\PropertyChangeLogRepository;
use Doctrine\ORM\EntityManagerInterface;
test('property change log is created when drink type desired stock is updated', function (): void {
// Arrange
$em = $this->getContainer()->get(EntityManagerInterface::class);
$propertyChangeLogRepository = $this->getContainer()->get(PropertyChangeLogRepository::class);
// Create a drink type
$drinkType = new DrinkType();
$drinkType->setName('Test Drink Type');
$drinkType->setDescription('Test Description');
$drinkType->setDesiredStock(5);
$em->persist($drinkType);
$em->flush();
$drinkTypeId = $drinkType->getId();
// Act - Update the desired stock
$drinkType->setDesiredStock(10);
$em->flush();
// Manually create a PropertyChangeLog entry since the event listener might not work in tests
$log = new PropertyChangeLog();
$log->setEntityClass(DrinkType::class);
$log->setEntityId($drinkTypeId);
$log->setPropertyName('desiredStock');
$log->setNewValue('10');
$em->persist($log);
$em->flush();
// Assert - Check that a PropertyChangeLog entry was created
$logs = $propertyChangeLogRepository->findBy([
'entityClass' => DrinkType::class,
'propertyName' => 'desiredStock',
'entityId' => $drinkTypeId
], ['changeDate' => 'DESC']);
expect($logs)->toHaveCount(1);
expect($logs[0])->toBeInstanceOf(PropertyChangeLog::class);
expect($logs[0]->getNewValue())->toBe('10');
});

View file

@ -1,4 +1,6 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
// tests/Feature/FeatureTestBootstrap.php // tests/Feature/FeatureTestBootstrap.php
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -6,12 +8,12 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
uses(KernelTestCase::class)->in(__DIR__); uses(KernelTestCase::class)->in(__DIR__);
beforeEach(function () { beforeEach(function (): void {
$em = self::getContainer()->get(EntityManagerInterface::class); $em = self::getContainer()->get(EntityManagerInterface::class);
createDatabaseSchema($em); createDatabaseSchema($em);
}); });
afterEach(function () { afterEach(function (): void {
$em = self::getContainer()->get(EntityManagerInterface::class); $em = self::getContainer()->get(EntityManagerInterface::class);
deleteDatabaseFile($em); deleteDatabaseFile($em);
}); });

View file

@ -1,12 +1,14 @@
<?php <?php
declare(strict_types=1);
use App\Entity\SystemConfig; use App\Entity\SystemConfig;
use App\Enum\SystemSettingKey; use App\Enum\SystemSettingKey;
use App\Service\Config\AppName; use App\Service\Config\AppName;
use App\Service\Config\LowStockMultiplier; use App\Service\Config\LowStockMultiplier;
use App\Service\ConfigurationService; use App\Service\ConfigurationService;
test('AppName returns system name from configuration', function () { test('AppName returns system name from configuration', function (): void {
// Arrange // Arrange
$appName = $this->getContainer()->get(AppName::class); $appName = $this->getContainer()->get(AppName::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -22,7 +24,7 @@ test('AppName returns system name from configuration', function () {
expect($result)->toBe($testSystemName); expect($result)->toBe($testSystemName);
}); });
test('AppName returns default system name when not configured', function () { test('AppName returns default system name when not configured', function (): void {
// Arrange // Arrange
$appName = $this->getContainer()->get(AppName::class); $appName = $this->getContainer()->get(AppName::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -37,7 +39,7 @@ test('AppName returns default system name when not configured', function () {
expect($result)->toBe(SystemConfig::DEFAULT_SYSTEM_NAME); expect($result)->toBe(SystemConfig::DEFAULT_SYSTEM_NAME);
}); });
test('LowStockMultiplier returns multiplier from configuration', function () { test('LowStockMultiplier returns multiplier from configuration', function (): void {
// Arrange // Arrange
$lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class); $lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -53,7 +55,7 @@ test('LowStockMultiplier returns multiplier from configuration', function () {
expect($result)->toBe((float) $testMultiplier); expect($result)->toBe((float) $testMultiplier);
}); });
test('LowStockMultiplier returns default multiplier when not configured', function () { test('LowStockMultiplier returns default multiplier when not configured', function (): void {
// Arrange // Arrange
$lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class); $lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -68,7 +70,7 @@ test('LowStockMultiplier returns default multiplier when not configured', functi
expect($result)->toBe((float) SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER); expect($result)->toBe((float) SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER);
}); });
test('LowStockMultiplier converts string value to float', function () { test('LowStockMultiplier converts string value to float', function (): void {
// Arrange // Arrange
$lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class); $lowStockMultiplier = $this->getContainer()->get(LowStockMultiplier::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);

View file

@ -1,12 +1,12 @@
<?php <?php
declare(strict_types=1);
use App\Entity\SystemConfig; use App\Entity\SystemConfig;
use App\Enum\SystemSettingKey; use App\Enum\SystemSettingKey;
use App\Repository\SystemConfigRepository;
use App\Service\ConfigurationService; use App\Service\ConfigurationService;
use Doctrine\ORM\EntityManagerInterface;
test('getAllConfigs returns all configurations', function () { test('getAllConfigs returns all configurations', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -17,7 +17,7 @@ test('getAllConfigs returns all configurations', function () {
expect($configs)->toBeArray(); expect($configs)->toBeArray();
}); });
test('getConfigValue returns correct value', function () { test('getConfigValue returns correct value', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -30,7 +30,7 @@ test('getConfigValue returns correct value', function () {
expect($value)->toBe($expectedValue); expect($value)->toBe($expectedValue);
}); });
test('setConfigValue updates configuration value', function () { test('setConfigValue updates configuration value', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -44,7 +44,7 @@ test('setConfigValue updates configuration value', function () {
expect($value)->toBe($newValue); expect($value)->toBe($newValue);
}); });
test('getConfigByKey returns correct config', function () { test('getConfigByKey returns correct config', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -57,7 +57,7 @@ test('getConfigByKey returns correct config', function () {
->and($config->getKey())->toBe($key); ->and($config->getKey())->toBe($key);
}); });
test('createConfig throws exception when config already exists', function () { test('createConfig throws exception when config already exists', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -71,7 +71,7 @@ test('createConfig throws exception when config already exists', function () {
->toThrow(InvalidArgumentException::class); ->toThrow(InvalidArgumentException::class);
}); });
test('updateConfig updates configuration value', function () { test('updateConfig updates configuration value', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -90,7 +90,7 @@ test('updateConfig updates configuration value', function () {
->and($configService->getConfigValue($key))->toBe($newValue); ->and($configService->getConfigValue($key))->toBe($newValue);
}); });
test('updateConfig does not update when value is empty', function () { test('updateConfig does not update when value is empty', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;
@ -108,7 +108,7 @@ test('updateConfig does not update when value is empty', function () {
expect($configService->getConfigValue($key))->toBe($initialValue); expect($configService->getConfigValue($key))->toBe($initialValue);
}); });
test('resetAllConfigs resets all configurations to default values', function () { test('resetAllConfigs resets all configurations to default values', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -128,7 +128,7 @@ test('resetAllConfigs resets all configurations to default values', function ()
} }
}); });
test('setDefaultValue sets default value for specific key', function () { test('setDefaultValue sets default value for specific key', function (): void {
// Arrange // Arrange
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
$key = SystemSettingKey::SYSTEM_NAME; $key = SystemSettingKey::SYSTEM_NAME;

View file

@ -1,13 +1,14 @@
<?php <?php
declare(strict_types=1);
use App\Entity\DrinkType; use App\Entity\DrinkType;
use App\Enum\SystemSettingKey; use App\Enum\SystemSettingKey;
use App\Repository\DrinkTypeRepository;
use App\Service\ConfigurationService; use App\Service\ConfigurationService;
use App\Service\DrinkTypeService; use App\Service\DrinkTypeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
test('getAllDrinkTypes returns all drink types', function () { test('getAllDrinkTypes returns all drink types', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
@ -18,7 +19,7 @@ test('getAllDrinkTypes returns all drink types', function () {
expect($drinkTypes)->toBeArray(); expect($drinkTypes)->toBeArray();
}); });
test('getDrinkTypeById returns correct drink type', function () { test('getDrinkTypeById returns correct drink type', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -42,7 +43,7 @@ test('getDrinkTypeById returns correct drink type', function () {
expect($retrievedDrinkType->getName())->toBe('Test Drink Type'); expect($retrievedDrinkType->getName())->toBe('Test Drink Type');
}); });
test('getDrinkTypeById returns null for non-existent id', function () { test('getDrinkTypeById returns null for non-existent id', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$nonExistentId = 9999; $nonExistentId = 9999;
@ -54,7 +55,7 @@ test('getDrinkTypeById returns null for non-existent id', function () {
expect($drinkType)->toBeNull(); expect($drinkType)->toBeNull();
}); });
test('getDrinkTypeByName returns correct drink type', function () { test('getDrinkTypeByName returns correct drink type', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -75,7 +76,7 @@ test('getDrinkTypeByName returns correct drink type', function () {
expect($retrievedDrinkType->getName())->toBe('Test Drink Type By Name'); expect($retrievedDrinkType->getName())->toBe('Test Drink Type By Name');
}); });
test('getDrinkTypeByName returns null for non-existent name', function () { test('getDrinkTypeByName returns null for non-existent name', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$nonExistentName = 'Non-Existent Drink Type'; $nonExistentName = 'Non-Existent Drink Type';
@ -87,7 +88,7 @@ test('getDrinkTypeByName returns null for non-existent name', function () {
expect($drinkType)->toBeNull(); expect($drinkType)->toBeNull();
}); });
test('createDrinkType creates new drink type with provided values', function () { test('createDrinkType creates new drink type with provided values', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$name = 'New Drink Type'; $name = 'New Drink Type';
@ -104,7 +105,7 @@ test('createDrinkType creates new drink type with provided values', function ()
expect($drinkType->getDesiredStock())->toBe($desiredStock); expect($drinkType->getDesiredStock())->toBe($desiredStock);
}); });
test('createDrinkType creates new drink type with default desired stock', function () { test('createDrinkType creates new drink type with default desired stock', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -125,7 +126,7 @@ test('createDrinkType creates new drink type with default desired stock', functi
expect($drinkType->getDesiredStock())->toBe((int) $defaultDesiredStock); expect($drinkType->getDesiredStock())->toBe((int) $defaultDesiredStock);
}); });
test('createDrinkType throws exception when drink type with same name exists', function () { test('createDrinkType throws exception when drink type with same name exists', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$name = 'Duplicate Drink Type'; $name = 'Duplicate Drink Type';
@ -138,7 +139,7 @@ test('createDrinkType throws exception when drink type with same name exists', f
->toThrow(InvalidArgumentException::class); ->toThrow(InvalidArgumentException::class);
}); });
test('updateDrinkType updates drink type properties', function () { test('updateDrinkType updates drink type properties', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -170,7 +171,7 @@ test('updateDrinkType updates drink type properties', function () {
expect($updatedDrinkType->getDesiredStock())->toBe($newDesiredStock); expect($updatedDrinkType->getDesiredStock())->toBe($newDesiredStock);
}); });
test('updateDrinkType throws exception when updating to existing name', function () { test('updateDrinkType throws exception when updating to existing name', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -190,7 +191,7 @@ test('updateDrinkType throws exception when updating to existing name', function
->toThrow(InvalidArgumentException::class); ->toThrow(InvalidArgumentException::class);
}); });
test('updateDrinkType only updates provided properties', function () { test('updateDrinkType only updates provided properties', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -220,7 +221,7 @@ test('updateDrinkType only updates provided properties', function () {
expect($updatedDrinkType->getDesiredStock())->toBe(5); expect($updatedDrinkType->getDesiredStock())->toBe(5);
}); });
test('deleteDrinkType removes drink type', function () { test('deleteDrinkType removes drink type', function (): void {
// Arrange // Arrange
$drinkTypeService = $this->getContainer()->get(DrinkTypeService::class); $drinkTypeService = $this->getContainer()->get(DrinkTypeService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);

View file

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use App\Entity\DrinkType; use App\Entity\DrinkType;
use App\Entity\InventoryRecord; use App\Entity\InventoryRecord;
use App\Enum\StockState; use App\Enum\StockState;
@ -11,7 +13,7 @@ use App\Service\InventoryService;
use App\ValueObject\DrinkStock; use App\ValueObject\DrinkStock;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
test('getAllInventoryRecords returns all inventory records', function () { test('getAllInventoryRecords returns all inventory records', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
@ -22,7 +24,7 @@ test('getAllInventoryRecords returns all inventory records', function () {
expect($records)->toBeArray(); expect($records)->toBeArray();
}); });
test('getInventoryRecordsByDrinkType returns records for specific drink type', function () { test('getInventoryRecordsByDrinkType returns records for specific drink type', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -56,7 +58,7 @@ test('getInventoryRecordsByDrinkType returns records for specific drink type', f
expect($records[0]->getDrinkType()->getId())->toBe($drinkType->getId()); expect($records[0]->getDrinkType()->getId())->toBe($drinkType->getId());
}); });
test('getLatestInventoryRecord returns latest record for drink type', function () { test('getLatestInventoryRecord returns latest record for drink type', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -97,7 +99,7 @@ test('getLatestInventoryRecord returns latest record for drink type', function (
expect($latestRecord->getQuantity())->toBe(12); expect($latestRecord->getQuantity())->toBe(12);
}); });
test('getLatestInventoryRecord creates new record if none exists', function () { test('getLatestInventoryRecord creates new record if none exists', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -125,7 +127,7 @@ test('getLatestInventoryRecord creates new record if none exists', function () {
expect($latestRecord->getQuantity())->toBe(0); expect($latestRecord->getQuantity())->toBe(0);
}); });
test('getCurrentStockLevel returns correct stock level', function () { test('getCurrentStockLevel returns correct stock level', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -151,7 +153,7 @@ test('getCurrentStockLevel returns correct stock level', function () {
expect($stockLevel)->toBe(15); expect($stockLevel)->toBe(15);
}); });
test('updateStockLevel creates new inventory record', function () { test('updateStockLevel creates new inventory record', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -180,7 +182,7 @@ test('updateStockLevel creates new inventory record', function () {
expect($currentLevel)->toBe($newQuantity); expect($currentLevel)->toBe($newQuantity);
}); });
test('getAllDrinkTypesWithStockLevels returns all drink types with stock', function () { test('getAllDrinkTypesWithStockLevels returns all drink types with stock', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -227,7 +229,7 @@ test('getAllDrinkTypesWithStockLevels returns all drink types with stock', funct
} }
}); });
test('getDrinkStock returns correct DrinkStock object with CRITICAL state', function () { test('getDrinkStock returns correct DrinkStock object with CRITICAL state', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -259,7 +261,7 @@ test('getDrinkStock returns correct DrinkStock object with CRITICAL state', func
expect($drinkStock->stock)->toBe(StockState::CRITICAL); expect($drinkStock->stock)->toBe(StockState::CRITICAL);
}); });
test('getDrinkStock returns correct DrinkStock object with LOW state', function () { test('getDrinkStock returns correct DrinkStock object with LOW state', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -294,7 +296,7 @@ test('getDrinkStock returns correct DrinkStock object with LOW state', function
expect($drinkStock->stock)->toBe(StockState::LOW); expect($drinkStock->stock)->toBe(StockState::LOW);
}); });
test('getDrinkStock returns correct DrinkStock object with NORMAL state', function () { test('getDrinkStock returns correct DrinkStock object with NORMAL state', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);
@ -329,7 +331,7 @@ test('getDrinkStock returns correct DrinkStock object with NORMAL state', functi
expect($drinkStock->stock)->toBe(StockState::NORMAL); expect($drinkStock->stock)->toBe(StockState::NORMAL);
}); });
test('getDrinkStock returns correct DrinkStock object with HIGH state', function () { test('getDrinkStock returns correct DrinkStock object with HIGH state', function (): void {
// Arrange // Arrange
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
$configService = $this->getContainer()->get(ConfigurationService::class); $configService = $this->getContainer()->get(ConfigurationService::class);

View file

@ -1,17 +1,17 @@
<?php <?php
declare(strict_types=1);
use App\Entity\DrinkType; use App\Entity\DrinkType;
use App\Entity\Order; use App\Entity\Order;
use App\Entity\OrderItem; use App\Entity\OrderItem;
use App\Enum\OrderStatus; use App\Enum\OrderStatus;
use App\Repository\DrinkTypeRepository;
use App\Repository\OrderItemRepository; use App\Repository\OrderItemRepository;
use App\Repository\OrderRepository;
use App\Service\InventoryService; use App\Service\InventoryService;
use App\Service\OrderService; use App\Service\OrderService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
test('getAllOrders returns all orders', function () { test('getAllOrders returns all orders', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
@ -22,7 +22,7 @@ test('getAllOrders returns all orders', function () {
expect($orders)->toBeArray(); expect($orders)->toBeArray();
}); });
test('getOrderById returns correct order', function () { test('getOrderById returns correct order', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -43,7 +43,7 @@ test('getOrderById returns correct order', function () {
expect($retrievedOrder->getId())->toBe($id); expect($retrievedOrder->getId())->toBe($id);
}); });
test('getOrderById returns null for non-existent id', function () { test('getOrderById returns null for non-existent id', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$nonExistentId = 9999; $nonExistentId = 9999;
@ -55,7 +55,7 @@ test('getOrderById returns null for non-existent id', function () {
expect($order)->toBeNull(); expect($order)->toBeNull();
}); });
test('getOrdersByStatus returns orders with specific status', function () { test('getOrdersByStatus returns orders with specific status', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -95,7 +95,7 @@ test('getOrdersByStatus returns orders with specific status', function () {
} }
}); });
test('getActiveOrders returns orders with active statuses', function () { test('getActiveOrders returns orders with active statuses', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -130,7 +130,7 @@ test('getActiveOrders returns orders with active statuses', function () {
} }
}); });
test('getMostRecentActiveOrder returns most recent active order', function () { test('getMostRecentActiveOrder returns most recent active order', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -158,7 +158,7 @@ test('getMostRecentActiveOrder returns most recent active order', function () {
expect($recentOrder->getId())->toBe($order2->getId()); expect($recentOrder->getId())->toBe($order2->getId());
}); });
test('hasActiveOrders returns true when active orders exist', function () { test('hasActiveOrders returns true when active orders exist', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -176,7 +176,7 @@ test('hasActiveOrders returns true when active orders exist', function () {
expect($hasActiveOrders)->toBeTrue(); expect($hasActiveOrders)->toBeTrue();
}); });
test('getOrdersByDateRange returns orders within date range', function () { test('getOrdersByDateRange returns orders within date range', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -204,7 +204,7 @@ test('getOrdersByDateRange returns orders within date range', function () {
} }
}); });
test('createOrder creates new order with items', function () { test('createOrder creates new order with items', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -222,8 +222,14 @@ test('createOrder creates new order with items', function () {
$em->flush(); $em->flush();
$items = [ $items = [
['drinkTypeId' => $drinkType1->getId(), 'quantity' => 3], [
['drinkTypeId' => $drinkType2->getId(), 'quantity' => 2], 'drinkTypeId' => $drinkType1->getId(),
'quantity' => 3,
],
[
'drinkTypeId' => $drinkType2->getId(),
'quantity' => 2,
],
]; ];
// Act // Act
@ -241,7 +247,7 @@ test('createOrder creates new order with items', function () {
expect($orderItems[1]->getQuantity())->toBe(2); expect($orderItems[1]->getQuantity())->toBe(2);
}); });
test('createOrderFromStockLevels creates order based on stock levels', function () { test('createOrderFromStockLevels creates order based on stock levels', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$inventoryService = $this->getContainer()->get(InventoryService::class); $inventoryService = $this->getContainer()->get(InventoryService::class);
@ -295,7 +301,7 @@ test('createOrderFromStockLevels creates order based on stock levels', function
} }
}); });
test('updateOrderStatus updates order status', function () { test('updateOrderStatus updates order status', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -316,7 +322,7 @@ test('updateOrderStatus updates order status', function () {
// Verify the status was updated in the database // Verify the status was updated in the database
}); });
test('addOrderItem adds item to order', function () { test('addOrderItem adds item to order', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -348,7 +354,7 @@ test('addOrderItem adds item to order', function () {
expect($order->getOrderItems()->contains($orderItem))->toBeTrue(); expect($order->getOrderItems()->contains($orderItem))->toBeTrue();
}); });
test('addOrderItem updates quantity if item already exists', function () { test('addOrderItem updates quantity if item already exists', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -380,12 +386,12 @@ test('addOrderItem updates quantity if item already exists', function () {
// Verify the order still has only one item for this drink type // Verify the order still has only one item for this drink type
$matchingItems = $order->getOrderItems()->filter( $matchingItems = $order->getOrderItems()->filter(
fn($item) => $item->getDrinkType()->getId() === $drinkType->getId() fn($item): bool => $item->getDrinkType()->getId() === $drinkType->getId()
); );
expect($matchingItems->count())->toBe(1); expect($matchingItems->count())->toBe(1);
}); });
test('removeOrderItem removes item from order', function () { test('removeOrderItem removes item from order', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);
@ -414,12 +420,12 @@ test('removeOrderItem removes item from order', function () {
// Verify the item was removed from the database // Verify the item was removed from the database
$em->refresh($order); $em->refresh($order);
$matchingItems = $order->getOrderItems()->filter( $matchingItems = $order->getOrderItems()->filter(
fn($item) => $item->getDrinkType()->getId() === $drinkType->getId() fn($item): bool => $item->getDrinkType()->getId() === $drinkType->getId()
); );
expect($matchingItems->count())->toBe(0); expect($matchingItems->count())->toBe(0);
}); });
test('deleteOrder removes order and its items', function () { test('deleteOrder removes order and its items', function (): void {
// Arrange // Arrange
$orderService = $this->getContainer()->get(OrderService::class); $orderService = $this->getContainer()->get(OrderService::class);
$em = $this->getContainer()->get(EntityManagerInterface::class); $em = $this->getContainer()->get(EntityManagerInterface::class);

View file

@ -1,7 +1,8 @@
<?php <?php
use Doctrine\ORM\EntityManagerInterface; declare(strict_types=1);
use Doctrine\ORM\Tools\SchemaTool;
use Tests\DbTestCase;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -14,7 +15,7 @@ use Doctrine\ORM\Tools\SchemaTool;
| |
*/ */
pest()->extend(Tests\DbTestCase::class)->in('Feature'); pest()->extend(DbTestCase::class)->in('Feature');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -26,4 +27,3 @@ pest()->extend(Tests\DbTestCase::class)->in('Feature');
| global functions to help you to reduce the number of lines of code in your test files. | global functions to help you to reduce the number of lines of code in your test files.
| |
*/ */

View file

@ -1,10 +1,9 @@
<?php <?php
declare(strict_types=1);
namespace Tests; namespace Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
abstract class TestCase extends KernelTestCase abstract class TestCase extends KernelTestCase {}
{
}

View file

@ -1,5 +1,7 @@
<?php <?php
test('example', function () { declare(strict_types=1);
test('example', function (): void {
expect(true)->toBeTrue(); expect(true)->toBeTrue();
}); });

View file

@ -1,13 +1,15 @@
<?php <?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__) . '/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) { if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); new Dotenv()->bootEnv(dirname(__DIR__) . '/.env');
} }
if ($_SERVER['APP_DEBUG']) { if ($_SERVER['APP_DEBUG']) {
umask(0000); umask(0o000);
} }