feat: initial foundation for collaborative canteen ordering system in vanilla PHP

This commit is contained in:
2026-08-27 17:29:13 +07:00
commit 5effce30cb
52 changed files with 4915 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Auth;
use App\Models\User;
class AuthController extends Controller
{
public function loginForm(Request $request, Response $response): string|Response
{
if (Auth::check()) {
return $this->redirect('/');
}
$users = User::all();
return $this->render('auth/login', [
'title' => 'Login - CanteenHub',
'users' => $users,
], 'layouts/main');
}
public function login(Request $request, Response $response): Response
{
$identifier = trim((string)$request->post('identifier', ''));
$password = (string)$request->post('password', '');
if (empty($identifier) || empty($password)) {
return $this->redirect('/login', null, 'Please provide your username/email and password.');
}
if (Auth::attempt($identifier, $password)) {
$user = Auth::user();
return $this->redirect('/', "Welcome back, {$user['name']}!");
}
return $this->redirect('/login', null, 'Invalid username or password.');
}
public function quickLogin(Request $request, Response $response): Response
{
$userId = (int)$request->post('user_id');
$user = User::find($userId);
if ($user) {
Auth::login($user);
return $this->redirect('/', "Logged in as {$user['name']}!");
}
return $this->redirect('/login', null, 'User not found.');
}
public function registerForm(Request $request, Response $response): string|Response
{
if (Auth::check()) {
return $this->redirect('/');
}
return $this->render('auth/register', [
'title' => 'Register Account - CanteenHub',
], 'layouts/main');
}
public function register(Request $request, Response $response): Response
{
$name = trim((string)$request->post('name', ''));
$username = trim((string)$request->post('username', ''));
$email = trim((string)$request->post('email', ''));
$password = (string)$request->post('password', '');
if (empty($name) || empty($username) || empty($email) || strlen($password) < 4) {
return $this->redirect('/register', null, 'Please fill in all fields (password must be at least 4 characters).');
}
if (User::findByIdentifier($username) || User::findByIdentifier($email)) {
return $this->redirect('/register', null, 'Username or Email is already registered.');
}
try {
$userId = User::create($name, $username, $email, $password);
$user = User::find($userId);
Auth::login($user);
return $this->redirect('/', "Account registered! Welcome to CanteenHub, {$name}!");
} catch (\Throwable $e) {
return $this->redirect('/register', null, 'Registration error: ' . $e->getMessage());
}
}
public function logout(Request $request, Response $response): Response
{
Auth::logout();
return $this->redirect('/login', 'You have been logged out.');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Models\Customer;
class CustomerController extends Controller
{
public function index(Request $request, Response $response): string
{
$customers = Customer::all();
return $this->render('customers/index', [
'title' => 'Customer Directory',
'customers' => $customers,
]);
}
public function store(Request $request, Response $response): Response
{
$name = trim((string)$request->post('name'));
$email = trim((string)$request->post('email'));
$phone = trim((string)$request->post('phone', ''));
$address = trim((string)$request->post('address', ''));
if (empty($name) || empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->redirect('/customers', null, 'Please provide a valid name and email address.');
}
try {
Customer::create([
'name' => $name,
'email' => $email,
'phone' => $phone,
'address' => $address,
]);
return $this->redirect('/customers', 'Customer registered successfully!');
} catch (\Throwable $e) {
return $this->redirect('/customers', null, 'Error adding customer: ' . $e->getMessage());
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Models\Order;
use App\Models\Product;
use App\Models\Customer;
class DashboardController extends Controller
{
public function index(Request $request, Response $response): string
{
$stats = [
'total_revenue' => Order::totalRevenue(),
'total_orders' => Order::count(),
'pending_orders' => Order::count('Pending'),
'processing_orders' => Order::count('Processing'),
'completed_orders' => Order::count('Completed'),
'total_products' => Product::count(),
'low_stock_products' => Product::lowStockCount(25),
'total_customers' => Customer::count(),
];
$recentOrders = Order::recent(6);
return $this->render('dashboard/index', [
'title' => 'Dashboard Overview',
'stats' => $stats,
'recentOrders' => $recentOrders,
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Auth;
use App\Models\User;
class EmployeeController extends Controller
{
public function index(Request $request, Response $response): string|Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$users = User::all();
return $this->render('employees/index', [
'title' => 'Coworkers Directory & Tabs',
'employees' => $users,
]);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Auth;
use App\Models\EmployeeOrder;
use App\Models\DailySession;
class EmployeeOrderController extends Controller
{
public function store(Request $request, Response $response): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$sessionId = (int)$request->post('session_id');
$userId = (int)Auth::id();
$generalNotes = trim((string)$request->post('notes', ''));
$rawItems = $request->post('items', []);
$session = DailySession::find($sessionId);
if (!$session) {
return $this->redirect('/', null, 'Session not found.');
}
if ($session['status'] === 'closed' || $session['status'] === 'completed') {
return $this->redirect("/sessions/{$sessionId}", null, 'Order placement is currently closed for this session.');
}
if (empty($rawItems) || !is_array($rawItems)) {
return $this->redirect("/sessions/{$sessionId}", null, 'Please select at least one menu item.');
}
// If user already had an order in this session, remove old order first to update
$existingOrder = EmployeeOrder::findUserOrderBySession($sessionId, $userId);
if ($existingOrder) {
EmployeeOrder::delete((int)$existingOrder['id']);
}
$itemsToSave = [];
foreach ($rawItems as $item) {
$name = trim((string)($item['item_name'] ?? ''));
$qty = max(1, (int)($item['quantity'] ?? 1));
$notes = trim((string)($item['notes'] ?? ''));
$menuId = !empty($item['session_menu_id']) ? (int)$item['session_menu_id'] : null;
if (empty($name)) {
continue;
}
$unitPrice = 0.0;
if ($menuId && !empty($session['menus'])) {
foreach ($session['menus'] as $m) {
if ((int)$m['id'] === $menuId) {
$unitPrice = (float)$m['final_price'];
break;
}
}
}
$itemsToSave[] = [
'session_menu_id' => $menuId,
'item_name' => $name,
'quantity' => $qty,
'unit_price' => $unitPrice,
'notes' => $notes,
];
}
if (empty($itemsToSave)) {
return $this->redirect("/sessions/{$sessionId}", null, 'No valid menu items selected.');
}
try {
EmployeeOrder::createOrder($sessionId, $userId, $generalNotes, $itemsToSave);
return $this->redirect("/sessions/{$sessionId}", 'Your lunch order has been submitted successfully!');
} catch (\Throwable $e) {
return $this->redirect("/sessions/{$sessionId}", null, 'Failed to submit order: ' . $e->getMessage());
}
}
public function togglePaid(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$orderId = (int)($params['id'] ?? 0);
$sessionId = (int)$request->post('session_id');
EmployeeOrder::togglePaid($orderId);
return $this->redirect("/sessions/{$sessionId}", 'Payment status updated.');
}
public function delete(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$orderId = (int)($params['id'] ?? 0);
$sessionId = (int)$request->post('session_id');
EmployeeOrder::delete($orderId);
return $this->redirect("/sessions/{$sessionId}", 'Order cancelled.');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Models\Order;
use App\Models\Product;
use App\Models\Customer;
class OrderController extends Controller
{
public function index(Request $request, Response $response): string
{
$status = $request->get('status', 'all');
$search = $request->get('search');
$orders = Order::all($status, $search);
return $this->render('orders/index', [
'title' => 'Order Management',
'orders' => $orders,
'currentStatus' => $status,
'search' => $search,
]);
}
public function create(Request $request, Response $response): string
{
$customers = Customer::all();
$products = Product::all();
return $this->render('orders/create', [
'title' => 'Create New Order',
'customers' => $customers,
'products' => $products,
]);
}
public function store(Request $request, Response $response): Response
{
$customerId = (int)$request->post('customer_id');
$status = $request->post('status', 'Pending');
$tax = (float)$request->post('tax', 0.00);
$discount = (float)$request->post('discount', 0.00);
$notes = trim((string)$request->post('notes', ''));
$rawItems = $request->post('items', []);
if ($customerId <= 0) {
return $this->redirect('/orders/create', null, 'Please select a customer.');
}
if (empty($rawItems) || !is_array($rawItems)) {
return $this->redirect('/orders/create', null, 'Please add at least one product item.');
}
$subtotal = 0.0;
$itemsToSave = [];
foreach ($rawItems as $item) {
$productId = (int)($item['product_id'] ?? 0);
$qty = (int)($item['quantity'] ?? 0);
if ($productId <= 0 || $qty <= 0) {
continue;
}
$product = Product::find($productId);
if (!$product) {
continue;
}
$unitPrice = (float)$product['price'];
$lineTotal = $unitPrice * $qty;
$subtotal += $lineTotal;
$itemsToSave[] = [
'product_id' => $productId,
'product_name' => $product['name'],
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $lineTotal,
];
}
if (empty($itemsToSave)) {
return $this->redirect('/orders/create', null, 'Please select valid products with quantities greater than 0.');
}
$total = max(0, $subtotal + $tax - $discount);
try {
$orderId = Order::createOrderWithItems([
'customer_id' => $customerId,
'status' => $status,
'subtotal' => $subtotal,
'tax' => $tax,
'discount' => $discount,
'total' => $total,
'notes' => $notes,
], $itemsToSave);
return $this->redirect("/orders/{$orderId}", 'Order created successfully!');
} catch (\Throwable $e) {
return $this->redirect('/orders/create', null, 'Failed to create order: ' . $e->getMessage());
}
}
public function show(Request $request, Response $response, array $params): string|Response
{
$id = (int)($params['id'] ?? 0);
$order = Order::find($id);
if (!$order) {
return $this->redirect('/orders', null, 'Order not found.');
}
return $this->render('orders/show', [
'title' => "Order #{$order['order_number']}",
'order' => $order,
]);
}
public function updateStatus(Request $request, Response $response, array $params): Response
{
$id = (int)($params['id'] ?? 0);
$status = $request->post('status');
$validStatuses = ['Pending', 'Processing', 'Completed', 'Cancelled'];
if (in_array($status, $validStatuses, true)) {
Order::updateStatus($id, $status);
return $this->redirect("/orders/{$id}", "Order status updated to '{$status}'.");
}
return $this->redirect("/orders/{$id}", null, 'Invalid order status.');
}
public function delete(Request $request, Response $response, array $params): Response
{
$id = (int)($params['id'] ?? 0);
Order::delete($id);
return $this->redirect('/orders', 'Order deleted successfully.');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Models\Product;
class ProductController extends Controller
{
public function index(Request $request, Response $response): string
{
$products = Product::all();
return $this->render('products/index', [
'title' => 'Product Inventory',
'products' => $products,
]);
}
public function store(Request $request, Response $response): Response
{
$sku = trim((string)$request->post('sku'));
$name = trim((string)$request->post('name'));
$price = (float)$request->post('price', 0);
$stock = (int)$request->post('stock', 0);
$category = trim((string)$request->post('category', 'General'));
$description = trim((string)$request->post('description', ''));
if (empty($sku) || empty($name) || $price <= 0) {
return $this->redirect('/products', null, 'Please provide valid SKU, name, and positive price.');
}
try {
Product::create([
'sku' => $sku,
'name' => $name,
'price' => $price,
'stock' => $stock,
'category' => $category,
'description' => $description,
]);
return $this->redirect('/products', 'Product added successfully!');
} catch (\Throwable $e) {
return $this->redirect('/products', null, 'Error adding product: ' . $e->getMessage());
}
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Auth;
use App\Models\DailySession;
use App\Models\SessionMenu;
use App\Models\EmployeeOrder;
use App\Models\User;
class SessionController extends Controller
{
public function index(Request $request, Response $response): string|Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$activeSessions = DailySession::getActiveSessions();
$pastSessions = DailySession::all();
return $this->render('sessions/index', [
'title' => 'Food & Canteen Sessions Hub',
'activeSessions' => $activeSessions,
'pastSessions' => $pastSessions,
'currentUser' => Auth::user(),
]);
}
public function createForm(Request $request, Response $response): string|Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
return $this->render('sessions/create', [
'title' => 'Start New Food Session',
'currentUser' => Auth::user(),
]);
}
public function store(Request $request, Response $response): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$creatorId = (int)Auth::id();
$title = trim((string)$request->post('title', ''));
$canteenName = trim((string)$request->post('canteen_name', ''));
$sessionDate = trim((string)$request->post('session_date', date('Y-m-d')));
$notes = trim((string)$request->post('notes', ''));
$menuText = trim((string)$request->post('menu_text', ''));
if (empty($title) || empty($canteenName)) {
return $this->redirect('/sessions/create', null, 'Please provide session title and canteen name.');
}
try {
$sessionId = DailySession::create($creatorId, $title, $canteenName, $sessionDate, $notes);
// Add menu items if provided
if (!empty($menuText)) {
$lines = explode("\n", $menuText);
foreach ($lines as $line) {
$clean = preg_replace('/^(\d+[\.\)]\s*|[\-\*•]\s*)/u', '', trim($line));
$clean = trim((string)$clean);
if (!empty($clean)) {
SessionMenu::add($sessionId, $clean);
}
}
}
return $this->redirect("/sessions/{$sessionId}", 'Food session created! Share it with your colleagues.');
} catch (\Throwable $e) {
return $this->redirect('/sessions/create', null, 'Failed to create session: ' . $e->getMessage());
}
}
public function show(Request $request, Response $response, array $params): string|Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$id = (int)($params['id'] ?? 0);
$session = DailySession::find($id);
if (!$session) {
return $this->redirect('/', null, 'Food session not found.');
}
$currentUser = Auth::user();
$myOrder = EmployeeOrder::findUserOrderBySession($id, (int)$currentUser['id']);
return $this->render('sessions/show', [
'title' => e($session['title']),
'session' => $session,
'currentUser' => $currentUser,
'myOrder' => $myOrder,
'isCreator' => (int)$session['creator_id'] === (int)$currentUser['id'],
]);
}
public function history(Request $request, Response $response): string|Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$sessions = DailySession::all();
return $this->render('sessions/history', [
'title' => 'All Past Sessions History',
'sessions' => $sessions,
]);
}
public function updateStatus(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$id = (int)($params['id'] ?? 0);
$status = $request->post('status');
$valid = ['open', 'closed', 'arrived', 'completed'];
if (in_array($status, $valid, true)) {
DailySession::updateStatus($id, $status);
return $this->redirect("/sessions/{$id}", "Session status updated to: " . strtoupper($status));
}
return $this->redirect("/sessions/{$id}", null, 'Invalid status.');
}
public function addMenu(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$id = (int)($params['id'] ?? 0);
$rawText = trim((string)$request->post('menu_text', ''));
if (!empty($rawText)) {
$lines = explode("\n", $rawText);
$added = 0;
foreach ($lines as $line) {
$clean = preg_replace('/^(\d+[\.\)]\s*|[\-\*•]\s*)/u', '', trim($line));
$clean = trim((string)$clean);
if (!empty($clean)) {
SessionMenu::add($id, $clean);
$added++;
}
}
return $this->redirect("/sessions/{$id}", "Added {$added} menu item(s).");
}
return $this->redirect("/sessions/{$id}", null, 'Please provide menu items.');
}
public function deleteMenu(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$sessionId = (int)($params['id'] ?? 0);
$menuId = (int)$request->post('menu_id');
SessionMenu::delete($menuId);
return $this->redirect("/sessions/{$sessionId}", 'Menu item removed.');
}
public function settlePrices(Request $request, Response $response, array $params): Response
{
if (!Auth::check()) {
return $this->redirect('/login');
}
$sessionId = (int)($params['id'] ?? 0);
$prices = $request->post('prices', []);
if (is_array($prices)) {
SessionMenu::updatePricesAndRecalculate($sessionId, $prices);
DailySession::updateStatus($sessionId, 'arrived');
return $this->redirect("/sessions/{$sessionId}", 'Prices saved and personal bills computed!');
}
return $this->redirect("/sessions/{$sessionId}", null, 'Failed to update prices.');
}
}