From 5effce30cb5bb87e9186a6dde4529d0eabeec425 Mon Sep 17 00:00:00 2001 From: Supan Adit Pratama Date: Thu, 27 Aug 2026 17:29:01 +0700 Subject: [PATCH] feat: initial foundation for collaborative canteen ordering system in vanilla PHP --- .env | 7 + .env.example | 15 + .gitignore | 8 + Dockerfile | 34 ++ README.md | 60 +++ app/Controllers/AuthController.php | 101 ++++ app/Controllers/CustomerController.php | 46 ++ app/Controllers/DashboardController.php | 37 ++ app/Controllers/EmployeeController.php | 27 + app/Controllers/EmployeeOrderController.php | 113 ++++ app/Controllers/OrderController.php | 147 +++++ app/Controllers/ProductController.php | 50 ++ app/Controllers/SessionController.php | 197 +++++++ app/Core/Auth.php | 74 +++ app/Core/Autoloader.php | 55 ++ app/Core/Config.php | 54 ++ app/Core/Controller.php | 47 ++ app/Core/Database/Database.php | 228 ++++++++ app/Core/Helpers.php | 19 + app/Core/Http/Request.php | 114 ++++ app/Core/Http/Response.php | 61 +++ app/Core/Routing/Router.php | 126 +++++ app/Core/View.php | 83 +++ app/Models/Customer.php | 66 +++ app/Models/DailySession.php | 114 ++++ app/Models/Employee.php | 51 ++ app/Models/EmployeeOrder.php | 130 +++++ app/Models/Order.php | 171 ++++++ app/Models/OrderItem.php | 43 ++ app/Models/Product.php | 86 +++ app/Models/SessionMenu.php | 80 +++ app/Models/User.php | 59 ++ app/Views/auth/login.php | 55 ++ app/Views/auth/register.php | 48 ++ app/Views/customers/index.php | 88 +++ app/Views/dashboard/index.php | 98 ++++ app/Views/employees/index.php | 56 ++ app/Views/layouts/main.php | 76 +++ app/Views/orders/create.php | 221 ++++++++ app/Views/orders/index.php | 92 ++++ app/Views/orders/show.php | 146 +++++ app/Views/products/index.php | 110 ++++ app/Views/sessions/create.php | 58 ++ app/Views/sessions/history.php | 65 +++ app/Views/sessions/index.php | 76 +++ app/Views/sessions/show.php | 533 ++++++++++++++++++ compose.yaml | 20 + composer.json | 13 + public/.htaccess | 14 + public/assets/css/style.css | 566 ++++++++++++++++++++ public/index.php | 76 +++ storage/.gitkeep | 1 + 52 files changed, 4915 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/CustomerController.php create mode 100644 app/Controllers/DashboardController.php create mode 100644 app/Controllers/EmployeeController.php create mode 100644 app/Controllers/EmployeeOrderController.php create mode 100644 app/Controllers/OrderController.php create mode 100644 app/Controllers/ProductController.php create mode 100644 app/Controllers/SessionController.php create mode 100644 app/Core/Auth.php create mode 100644 app/Core/Autoloader.php create mode 100644 app/Core/Config.php create mode 100644 app/Core/Controller.php create mode 100644 app/Core/Database/Database.php create mode 100644 app/Core/Helpers.php create mode 100644 app/Core/Http/Request.php create mode 100644 app/Core/Http/Response.php create mode 100644 app/Core/Routing/Router.php create mode 100644 app/Core/View.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/DailySession.php create mode 100644 app/Models/Employee.php create mode 100644 app/Models/EmployeeOrder.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderItem.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/SessionMenu.php create mode 100644 app/Models/User.php create mode 100644 app/Views/auth/login.php create mode 100644 app/Views/auth/register.php create mode 100644 app/Views/customers/index.php create mode 100644 app/Views/dashboard/index.php create mode 100644 app/Views/employees/index.php create mode 100644 app/Views/layouts/main.php create mode 100644 app/Views/orders/create.php create mode 100644 app/Views/orders/index.php create mode 100644 app/Views/orders/show.php create mode 100644 app/Views/products/index.php create mode 100644 app/Views/sessions/create.php create mode 100644 app/Views/sessions/history.php create mode 100644 app/Views/sessions/index.php create mode 100644 app/Views/sessions/show.php create mode 100644 compose.yaml create mode 100644 composer.json create mode 100644 public/.htaccess create mode 100644 public/assets/css/style.css create mode 100644 public/index.php create mode 100644 storage/.gitkeep diff --git a/.env b/.env new file mode 100644 index 0000000..e623c5a --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +APP_ENV=local +APP_DEBUG=true +PORT=8080 + +# Database Configuration (Defaults to SQLite) +DB_CONNECTION=sqlite +DB_DATABASE=storage/database.sqlite diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1fcf2e --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +APP_ENV=local +APP_DEBUG=true +PORT=8080 + +# Database Configuration (Defaults to SQLite) +DB_CONNECTION=sqlite +DB_DATABASE=storage/database.sqlite + +# Optional MySQL / MariaDB Configuration +# DB_CONNECTION=mysql +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=order_system +# DB_USERNAME=root +# DB_PASSWORD=secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4b8aa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.sqlite +*.sqlite-journal +*.log +.DS_Store +.env.local +vendor/ +storage/*.sqlite + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28e831c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM php:8.3-apache + +# Install system dependencies & SQLite / MySQL PDO drivers +RUN apt-get update && apt-get install -y \ + libsqlite3-dev \ + sqlite3 \ + unzip \ + && docker-php-ext-install pdo pdo_sqlite pdo_mysql \ + && rm -rf /var/lib/apt/lists/* + +# Enable Apache Rewrite Module +RUN a2enmod rewrite + +# Configure Apache DocumentRoot to point to /public +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public + +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ + && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + +# Set working directory +WORKDIR /var/www/html + +# Copy application files +COPY . /var/www/html/ + +# Create storage directory and ensure proper write permissions +RUN mkdir -p /var/www/html/storage && \ + chown -R www-data:www-data /var/www/html/storage && \ + chmod -R 775 /var/www/html/storage + +# Expose HTTP port +EXPOSE 80 + +CMD ["apache2-foreground"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..52e79fd --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# CanteenHub - Multi-User Collaborative Canteen & Food Ordering System + +A zero-framework PHP application built to replace informal Mattermost/chat lunch ordering threads. Anyone on the team can log in, start a food session, join any colleague's session, and place their own orders with automatic bill calculation upon food arrival. + +--- + +## Key Features + +1. **User Authentication (Zero Framework)**: + - Login (`/login`) & Register (`/register`) with secure password hashing (`password_hash()`). + - Pre-seeded test accounts (Password: `password`): + - `dea` (Dea - Coordinator) + - `alex` (Alex Turner) + - `sarah` (Sarah Jenkins) + - `budi` (Budi Santoso) + - `michael` (Michael Chen) + - 1-click Quick Demo Account switcher on login page. + +2. **Session Creation by Anyone**: + - Any team member can click **"➕ Start a Food Session"** to coordinate canteen lunch, boba/coffee runs, or snack orders. + - Hosts paste today's menu choices directly from chat (no prices needed upfront). + +3. **Self-Ordering from Any Session**: + - Logged-in colleagues browse all active sessions on the dashboard. + - **Multi-Select Interactive Menu**: Click multiple items, adjust quantities (`+` / `-`), and add specific notes (*"no spicy"*, *"extra egg"*). + - The app automatically assigns the order to the authenticated coworker. + +4. **Grouped Canteen Placement & Bill Settlement**: + - 1-Click **"Copy Canteen Text"** aggregates quantities into a formatted WhatsApp/chat message. + - When food arrives with the receipt, the host enters the final prices per item. + - The app automatically computes every coworker's personal bill! + - 1-Click **"Copy Mattermost Breakdown"** generates a ready-to-paste markdown table tagging `@coworker` with their exact individual bill and payment status (`Unpaid` / `Paid`). + +--- + +## How to Run + +### Option 1: PHP Built-in Server (`php -S`) +```bash +php -S localhost:8000 -t public +``` +Visit: `http://localhost:8000` + +--- + +### Option 2: Docker Compose +```bash +docker compose up -d --build +``` +Visit: `http://localhost:8080` + +To stop: +```bash +docker compose down +``` + +--- + +### Option 3: Apache +Point your Apache VirtualHost `DocumentRoot` to `public/` directory (rewrites handled by [`public/.htaccess`](file:///home/supanadit/Workspaces/Scalar/PHP/order-system/public/.htaccess)). diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..4404fe6 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,101 @@ +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.'); + } +} diff --git a/app/Controllers/CustomerController.php b/app/Controllers/CustomerController.php new file mode 100644 index 0000000..f81e42e --- /dev/null +++ b/app/Controllers/CustomerController.php @@ -0,0 +1,46 @@ +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()); + } + } +} diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..ffda15b --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,37 @@ + 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, + ]); + } +} diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php new file mode 100644 index 0000000..204b1b8 --- /dev/null +++ b/app/Controllers/EmployeeController.php @@ -0,0 +1,27 @@ +redirect('/login'); + } + + $users = User::all(); + return $this->render('employees/index', [ + 'title' => 'Coworkers Directory & Tabs', + 'employees' => $users, + ]); + } +} diff --git a/app/Controllers/EmployeeOrderController.php b/app/Controllers/EmployeeOrderController.php new file mode 100644 index 0000000..99b1ce5 --- /dev/null +++ b/app/Controllers/EmployeeOrderController.php @@ -0,0 +1,113 @@ +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.'); + } +} diff --git a/app/Controllers/OrderController.php b/app/Controllers/OrderController.php new file mode 100644 index 0000000..e82f189 --- /dev/null +++ b/app/Controllers/OrderController.php @@ -0,0 +1,147 @@ +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.'); + } +} diff --git a/app/Controllers/ProductController.php b/app/Controllers/ProductController.php new file mode 100644 index 0000000..dfd4b91 --- /dev/null +++ b/app/Controllers/ProductController.php @@ -0,0 +1,50 @@ +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()); + } + } +} diff --git a/app/Controllers/SessionController.php b/app/Controllers/SessionController.php new file mode 100644 index 0000000..4aa0c5b --- /dev/null +++ b/app/Controllers/SessionController.php @@ -0,0 +1,197 @@ +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.'); + } +} diff --git a/app/Core/Auth.php b/app/Core/Auth.php new file mode 100644 index 0000000..a6b9097 --- /dev/null +++ b/app/Core/Auth.php @@ -0,0 +1,74 @@ + + */ + private static array $prefixes = []; + + /** + * Register autoloader with SPL + */ + public static function register(): void + { + spl_autoload_register([self::class, 'loadClass']); + } + + /** + * Add a base directory for a namespace prefix + */ + public static function addNamespace(string $prefix, string $baseDir): void + { + $prefix = trim($prefix, '\\') . '\\'; + $baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + self::$prefixes[$prefix] = $baseDir; + } + + /** + * Loads the class file for a given class name. + */ + public static function loadClass(string $class): bool + { + foreach (self::$prefixes as $prefix => $baseDir) { + $len = strlen($prefix); + if (strncmp($prefix, $class, $len) !== 0) { + continue; + } + + $relativeClass = substr($class, $len); + $file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php'; + + if (file_exists($file)) { + require_once $file; + return true; + } + } + + return false; + } +} diff --git a/app/Core/Config.php b/app/Core/Config.php new file mode 100644 index 0000000..b521f89 --- /dev/null +++ b/app/Core/Config.php @@ -0,0 +1,54 @@ +json($data, $statusCode); + } + + protected function redirect(string $url, ?string $flashSuccess = null, ?string $flashError = null): Response + { + if ($flashSuccess) { + View::setFlash('success', $flashSuccess); + } + if ($flashError) { + View::setFlash('error', $flashError); + } + + $response = new Response(); + return $response->redirect($url); + } + + protected function validateCsrf(Request $request): bool + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + $token = $request->post('_csrf') ?: $request->getHeader('X-CSRF-Token'); + $sessionToken = $_SESSION['csrf_token'] ?? null; + + return $token && $sessionToken && hash_equals($sessionToken, $token); + } +} diff --git a/app/Core/Database/Database.php b/app/Core/Database/Database.php new file mode 100644 index 0000000..d180959 --- /dev/null +++ b/app/Core/Database/Database.php @@ -0,0 +1,228 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + self::$instance->exec('PRAGMA foreign_keys = ON;'); + } else { + $host = Config::get('DB_HOST', '127.0.0.1'); + $port = Config::get('DB_PORT', '3306'); + $database = Config::get('DB_DATABASE', 'order_system'); + $username = Config::get('DB_USERNAME', 'root'); + $password = Config::get('DB_PASSWORD', ''); + + $dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4"; + self::$instance = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + } catch (PDOException $e) { + die('Database Connection Error: ' . $e->getMessage()); + } + } + + return self::$instance; + } + + /** + * Initializes database tables and seed data for the Collaborative Canteen Ordering System + */ + public static function initSchema(): void + { + $db = self::getConnection(); + $isSqlite = Config::get('DB_CONNECTION', 'sqlite') === 'sqlite'; + + // Auto-migration check: If old schema exists without user_id / creator_id, drop old tables + if ($isSqlite) { + $checkTable = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='employee_orders'")->fetchColumn(); + if ($checkTable) { + $columns = $db->query("PRAGMA table_info(employee_orders)")->fetchAll(); + $hasUserId = false; + foreach ($columns as $col) { + if ($col['name'] === 'user_id') { + $hasUserId = true; + break; + } + } + if (!$hasUserId) { + $db->exec("DROP TABLE IF EXISTS employee_order_items;"); + $db->exec("DROP TABLE IF EXISTS employee_orders;"); + $db->exec("DROP TABLE IF EXISTS session_menus;"); + $db->exec("DROP TABLE IF EXISTS daily_sessions;"); + $db->exec("DROP TABLE IF EXISTS employees;"); + $db->exec("DROP TABLE IF EXISTS users;"); + $db->exec("DROP TABLE IF EXISTS orders;"); + $db->exec("DROP TABLE IF EXISTS order_items;"); + $db->exec("DROP TABLE IF EXISTS products;"); + $db->exec("DROP TABLE IF EXISTS customers;"); + } + } + } + + $autoIncrement = $isSqlite ? 'AUTOINCREMENT' : 'AUTO_INCREMENT'; + $dateTimeDefault = $isSqlite ? "DATETIME DEFAULT (datetime('now', 'localtime'))" : 'DATETIME DEFAULT CURRENT_TIMESTAMP'; + + // Users Table + $db->exec(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY {$autoIncrement}, + name VARCHAR(255) NOT NULL, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at {$dateTimeDefault} + ); + "); + + // Daily Sessions Table + $db->exec(" + CREATE TABLE IF NOT EXISTS daily_sessions ( + id INTEGER PRIMARY KEY {$autoIncrement}, + creator_id INTEGER NOT NULL, + session_date DATE NOT NULL, + title VARCHAR(255) NOT NULL, + canteen_name VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'open', + notes TEXT DEFAULT NULL, + created_at {$dateTimeDefault}, + updated_at {$dateTimeDefault}, + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE + ); + "); + + // Session Menus Table + $db->exec(" + CREATE TABLE IF NOT EXISTS session_menus ( + id INTEGER PRIMARY KEY {$autoIncrement}, + session_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + final_price DECIMAL(10,2) DEFAULT 0.00, + created_at {$dateTimeDefault}, + FOREIGN KEY (session_id) REFERENCES daily_sessions(id) ON DELETE CASCADE + ); + "); + + // Employee Orders Table + $db->exec(" + CREATE TABLE IF NOT EXISTS employee_orders ( + id INTEGER PRIMARY KEY {$autoIncrement}, + session_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'unpaid', + notes TEXT DEFAULT NULL, + total_bill DECIMAL(10,2) NOT NULL DEFAULT 0.00, + created_at {$dateTimeDefault}, + updated_at {$dateTimeDefault}, + FOREIGN KEY (session_id) REFERENCES daily_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + "); + + // Employee Order Items Table + $db->exec(" + CREATE TABLE IF NOT EXISTS employee_order_items ( + id INTEGER PRIMARY KEY {$autoIncrement}, + employee_order_id INTEGER NOT NULL, + session_menu_id INTEGER DEFAULT NULL, + item_name VARCHAR(255) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + total_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + notes VARCHAR(255) DEFAULT NULL, + created_at {$dateTimeDefault}, + FOREIGN KEY (employee_order_id) REFERENCES employee_orders(id) ON DELETE CASCADE + ); + "); + + // Check if seed data exists + $stmt = $db->query("SELECT COUNT(*) FROM users"); + $count = (int)$stmt->fetchColumn(); + + if ($count === 0) { + self::seedInitialData($db); + } + } + + private static function seedInitialData(PDO $db): void + { + $hash = password_hash('password', PASSWORD_DEFAULT); + + // Seed Users + $users = [ + ['Dea (Coordinator)', 'dea', 'dea@company.com', $hash], + ['Alex Turner', 'alex', 'alex@company.com', $hash], + ['Sarah Jenkins', 'sarah', 'sarah@company.com', $hash], + ['Budi Santoso', 'budi', 'budi@company.com', $hash], + ['Michael Chen', 'michael', 'michael@company.com', $hash], + ]; + + $stmtUser = $db->prepare("INSERT INTO users (name, username, email, password_hash) VALUES (?, ?, ?, ?)"); + foreach ($users as $u) { + $stmtUser->execute($u); + } + + // Seed Today's Session created by Dea (id = 1) + $today = date('Y-m-d'); + $title = "Dea's Lunch Hub - Kantin Bu Siti"; + $stmtSession = $db->prepare("INSERT INTO daily_sessions (creator_id, session_date, title, canteen_name, status, notes) VALUES (1, ?, ?, 'Kantin Bu Siti', 'open', 'Please submit your lunch orders before 11:30 AM!')"); + $stmtSession->execute([$today, $title]); + $sessionId = (int)$db->lastInsertId(); + + // Seed Menu Items + $menuItems = [ + 'Ayam Bakar Madu + Nasi', + 'Ayam Geprek Sambal Bawang + Nasi', + 'Nasi Goreng Spesial', + 'Mie Goreng Seafood', + 'Soto Ayam Lamongan + Nasi', + 'Gado-Gado Lontong', + 'Tahu & Tempe Goreng (Extra)', + 'Es Teh Manis', + 'Es Jeruk Segar', + ]; + + $stmtMenu = $db->prepare("INSERT INTO session_menus (session_id, name, final_price) VALUES (?, ?, 0.00)"); + foreach ($menuItems as $item) { + $stmtMenu->execute([$sessionId, $item]); + } + + // Seed an order for Alex (user_id = 2) + $stmtOrder = $db->prepare("INSERT INTO employee_orders (session_id, user_id, status, notes, total_bill) VALUES (?, 2, 'unpaid', 'Pedas sedang ya', 0.00)"); + $stmtOrder->execute([$sessionId]); + $alexOrderId = (int)$db->lastInsertId(); + + $stmtItem = $db->prepare("INSERT INTO employee_order_items (employee_order_id, session_menu_id, item_name, quantity, unit_price, total_price, notes) VALUES (?, ?, ?, ?, 0.00, 0.00, ?)"); + $stmtItem->execute([$alexOrderId, 1, 'Ayam Bakar Madu + Nasi', 1, 'Pedas sedang']); + $stmtItem->execute([$alexOrderId, 8, 'Es Teh Manis', 1, 'Kurang manis / less sugar']); + } +} diff --git a/app/Core/Helpers.php b/app/Core/Helpers.php new file mode 100644 index 0000000..3593160 --- /dev/null +++ b/app/Core/Helpers.php @@ -0,0 +1,19 @@ +method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + + // Handle method overriding via _method for forms (PUT/DELETE/PATCH) + if ($this->method === 'POST' && isset($_POST['_method'])) { + $this->method = strtoupper($_POST['_method']); + } + + $this->uri = $_SERVER['REQUEST_URI'] ?? '/'; + $parsedUrl = parse_url($this->uri); + $this->path = $parsedUrl['path'] ?? '/'; + + // Trim trailing slash for consistent routing (except root) + if ($this->path !== '/' && str_ends_with($this->path, '/')) { + $this->path = rtrim($this->path, '/'); + } + + $this->queryParams = $_GET; + + // Safely extract headers in all SAPIs + if (function_exists('getallheaders')) { + $this->headers = getallheaders() ?: []; + } else { + $this->headers = []; + foreach ($_SERVER as $name => $value) { + if (str_starts_with($name, 'HTTP_')) { + $headerName = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5))))); + $this->headers[$headerName] = $value; + } elseif (in_array($name, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) { + $headerName = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $name)))); + $this->headers[$headerName] = $value; + } + } + } + + // Parse JSON or form POST body + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (str_contains($contentType, 'application/json')) { + $rawBody = file_get_contents('php://input'); + $this->bodyParams = json_decode($rawBody, true) ?: []; + } else { + $this->bodyParams = $_POST; + } + } + + public function getMethod(): string + { + return $this->method; + } + + public function getPath(): string + { + return $this->path; + } + + public function getUri(): string + { + return $this->uri; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->queryParams[$key] ?? $default; + } + + public function post(string $key, mixed $default = null): mixed + { + return $this->bodyParams[$key] ?? $default; + } + + public function all(): array + { + return array_merge($this->queryParams, $this->bodyParams); + } + + public function getBody(): array + { + return $this->bodyParams; + } + + public function getHeader(string $name, ?string $default = null): ?string + { + $name = strtolower($name); + foreach ($this->headers as $key => $value) { + if (strtolower($key) === $name) { + return $value; + } + } + return $default; + } + + public function isJson(): bool + { + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + return str_contains($contentType, 'application/json') || str_contains($accept, 'application/json'); + } +} diff --git a/app/Core/Http/Response.php b/app/Core/Http/Response.php new file mode 100644 index 0000000..cad4995 --- /dev/null +++ b/app/Core/Http/Response.php @@ -0,0 +1,61 @@ +statusCode = $code; + return $this; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function setHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + return $this; + } + + public function setContent(string $content): self + { + $this->content = $content; + return $this; + } + + public function json(mixed $data, int $statusCode = 200): self + { + $this->statusCode = $statusCode; + $this->setHeader('Content-Type', 'application/json'); + $this->content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return $this; + } + + public function redirect(string $url, int $statusCode = 302): self + { + $this->statusCode = $statusCode; + $this->setHeader('Location', $url); + return $this; + } + + public function send(): void + { + http_response_code($this->statusCode); + + foreach ($this->headers as $name => $value) { + header("{$name}: {$value}"); + } + + echo $this->content; + } +} diff --git a/app/Core/Routing/Router.php b/app/Core/Routing/Router.php new file mode 100644 index 0000000..0832ea7 --- /dev/null +++ b/app/Core/Routing/Router.php @@ -0,0 +1,126 @@ +addRoute('GET', $path, $handler); + return $this; + } + + public function post(string $path, array|callable $handler): self + { + $this->addRoute('POST', $path, $handler); + return $this; + } + + public function put(string $path, array|callable $handler): self + { + $this->addRoute('PUT', $path, $handler); + return $this; + } + + public function delete(string $path, array|callable $handler): self + { + $this->addRoute('DELETE', $path, $handler); + return $this; + } + + public function any(string $path, array|callable $handler): self + { + foreach (['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as $method) { + $this->addRoute($method, $path, $handler); + } + return $this; + } + + public function setNotFound(callable $handler): void + { + $this->notFoundHandler = $handler; + } + + private function addRoute(string $method, string $path, array|callable $handler): void + { + // Normalize path + $path = '/' . trim($path, '/'); + if ($path === '//') { + $path = '/'; + } + + // Convert {param} to regex pattern (?P[^/]+) + $pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path); + $regex = '#^' . $pattern . '$#'; + + $this->routes[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'regex' => $regex, + 'handler' => $handler, + ]; + } + + public function dispatch(Request $request, Response $response): Response + { + $method = $request->getMethod(); + $path = $request->getPath(); + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + if (preg_match($route['regex'], $path, $matches)) { + // Extract named matches for params + $params = []; + foreach ($matches as $key => $value) { + if (is_string($key)) { + $params[$key] = $value; + } + } + + $handler = $route['handler']; + + if (is_callable($handler)) { + $result = call_user_func($handler, $request, $response, $params); + } elseif (is_array($handler) && count($handler) === 2) { + [$controllerClass, $action] = $handler; + $controller = new $controllerClass(); + $result = call_user_func([$controller, $action], $request, $response, $params); + } else { + throw new \RuntimeException('Invalid route handler specified.'); + } + + if ($result instanceof Response) { + return $result; + } + + if (is_string($result)) { + return $response->setContent($result); + } + + return $response; + } + } + + // 404 Not Found + if ($this->notFoundHandler) { + $result = call_user_func($this->notFoundHandler, $request, $response); + if ($result instanceof Response) { + return $result; + } + return $response->setStatusCode(404)->setContent((string)$result); + } + + return $response->setStatusCode(404)->setContent('

404 Not Found

The requested route does not exist.

'); + } +} diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..bef1d72 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,83 @@ +'; + } + + public static function escape(mixed $value): string + { + return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..c58e55d --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,66 @@ +query("SELECT * FROM customers ORDER BY name ASC"); + return $stmt->fetchAll(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT * FROM customers WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public static function count(): int + { + $db = Database::getConnection(); + return (int)$db->query("SELECT COUNT(*) FROM customers")->fetchColumn(); + } + + public static function create(array $data): int + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + INSERT INTO customers (name, email, phone, address) + VALUES (:name, :email, :phone, :address) + "); + $stmt->execute([ + ':name' => $data['name'], + ':email' => $data['email'], + ':phone' => $data['phone'] ?? '', + ':address' => $data['address'] ?? '', + ]); + return (int)$db->lastInsertId(); + } + + public static function update(int $id, array $data): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + UPDATE customers + SET name = :name, email = :email, phone = :phone, address = :address + WHERE id = :id + "); + return $stmt->execute([ + ':id' => $id, + ':name' => $data['name'], + ':email' => $data['email'], + ':phone' => $data['phone'] ?? '', + ':address' => $data['address'] ?? '', + ]); + } +} diff --git a/app/Models/DailySession.php b/app/Models/DailySession.php new file mode 100644 index 0000000..9e597aa --- /dev/null +++ b/app/Models/DailySession.php @@ -0,0 +1,114 @@ +prepare(" + INSERT INTO daily_sessions (creator_id, session_date, title, canteen_name, status, notes) + VALUES (?, ?, ?, ?, 'open', ?) + "); + $stmt->execute([ + $creatorId, + $sessionDate, + trim($title), + trim($canteenName), + trim((string)$notes) + ]); + + return (int)$db->lastInsertId(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT s.*, u.name as creator_name, u.username as creator_username + FROM daily_sessions s + JOIN users u ON s.creator_id = u.id + WHERE s.id = ? + "); + $stmt->execute([$id]); + $session = $stmt->fetch(); + + if (!$session) { + return null; + } + + $session['menus'] = SessionMenu::getBySessionId($id); + $session['orders'] = EmployeeOrder::getBySessionId($id); + $session['recap'] = self::getCanteenRecap($id); + + return $session; + } + + public static function all(): array + { + $db = Database::getConnection(); + $stmt = $db->query(" + SELECT s.*, u.name as creator_name, u.username as creator_username, + (SELECT COUNT(*) FROM employee_orders WHERE session_id = s.id) as order_count, + (SELECT COUNT(*) FROM session_menus WHERE session_id = s.id) as menu_count, + (SELECT COALESCE(SUM(total_bill), 0) FROM employee_orders WHERE session_id = s.id) as total_session_amount + FROM daily_sessions s + JOIN users u ON s.creator_id = u.id + ORDER BY s.id DESC + "); + return $stmt->fetchAll(); + } + + public static function getActiveSessions(): array + { + $db = Database::getConnection(); + $stmt = $db->query(" + SELECT s.*, u.name as creator_name, u.username as creator_username, + (SELECT COUNT(*) FROM employee_orders WHERE session_id = s.id) as order_count, + (SELECT COUNT(*) FROM session_menus WHERE session_id = s.id) as menu_count, + (SELECT COALESCE(SUM(total_bill), 0) FROM employee_orders WHERE session_id = s.id) as total_session_amount + FROM daily_sessions s + JOIN users u ON s.creator_id = u.id + WHERE s.status IN ('open', 'closed', 'arrived') + ORDER BY s.id DESC + "); + return $stmt->fetchAll(); + } + + public static function updateStatus(int $id, string $status): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("UPDATE daily_sessions SET status = ?, updated_at = datetime('now', 'localtime') WHERE id = ?"); + return $stmt->execute([$status, $id]); + } + + public static function updateCanteenInfo(int $id, string $canteenName, ?string $notes): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("UPDATE daily_sessions SET canteen_name = ?, notes = ?, updated_at = datetime('now', 'localtime') WHERE id = ?"); + return $stmt->execute([$canteenName, $notes, $id]); + } + + public static function getCanteenRecap(int $sessionId): array + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT eoi.item_name, + SUM(eoi.quantity) as total_quantity, + GROUP_CONCAT(DISTINCT eoi.notes) as combined_notes + FROM employee_order_items eoi + JOIN employee_orders eo ON eoi.employee_order_id = eo.id + WHERE eo.session_id = ? + GROUP BY eoi.item_name + ORDER BY total_quantity DESC + "); + $stmt->execute([$sessionId]); + return $stmt->fetchAll(); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php new file mode 100644 index 0000000..2b4db77 --- /dev/null +++ b/app/Models/Employee.php @@ -0,0 +1,51 @@ +query(" + SELECT e.*, + (SELECT COUNT(*) FROM employee_orders WHERE employee_id = e.id) as total_orders, + (SELECT COALESCE(SUM(total_bill), 0) FROM employee_orders WHERE employee_id = e.id AND status = 'unpaid') as unpaid_balance + FROM employees e + ORDER BY e.name ASC + "); + return $stmt->fetchAll(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT * FROM employees WHERE id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + return $res ?: null; + } + + public static function findOrCreateByName(string $name, ?string $mattermostUsername = null): int + { + $db = Database::getConnection(); + $name = trim($name); + + $stmt = $db->prepare("SELECT id FROM employees WHERE LOWER(name) = LOWER(?)"); + $stmt->execute([$name]); + $id = $stmt->fetchColumn(); + + if ($id) { + return (int)$id; + } + + $stmt = $db->prepare("INSERT INTO employees (name, mattermost_username) VALUES (?, ?)"); + $stmt->execute([$name, $mattermostUsername ?: strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name))]); + return (int)$db->lastInsertId(); + } +} diff --git a/app/Models/EmployeeOrder.php b/app/Models/EmployeeOrder.php new file mode 100644 index 0000000..3f78634 --- /dev/null +++ b/app/Models/EmployeeOrder.php @@ -0,0 +1,130 @@ +prepare(" + SELECT eo.*, u.name as user_name, u.username as mattermost_username, u.email as user_email + FROM employee_orders eo + JOIN users u ON eo.user_id = u.id + WHERE eo.session_id = ? + ORDER BY eo.id ASC + "); + $stmt->execute([$sessionId]); + $orders = $stmt->fetchAll(); + + foreach ($orders as &$order) { + $order['items'] = self::getItemsByOrderId((int)$order['id']); + } + + return $orders; + } + + public static function getItemsByOrderId(int $orderId): array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT * FROM employee_order_items WHERE employee_order_id = ? ORDER BY id ASC"); + $stmt->execute([$orderId]); + return $stmt->fetchAll(); + } + + public static function findUserOrderBySession(int $sessionId, int $userId): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT * FROM employee_orders WHERE session_id = ? AND user_id = ?"); + $stmt->execute([$sessionId, $userId]); + $order = $stmt->fetch(); + + if (!$order) { + return null; + } + + $order['items'] = self::getItemsByOrderId((int)$order['id']); + return $order; + } + + public static function createOrder(int $sessionId, int $userId, ?string $notes, array $items): int + { + $db = Database::getConnection(); + $db->beginTransaction(); + + try { + $stmtOrder = $db->prepare(" + INSERT INTO employee_orders (session_id, user_id, status, notes, total_bill) + VALUES (?, ?, 'unpaid', ?, 0.00) + "); + $stmtOrder->execute([$sessionId, $userId, $notes]); + $orderId = (int)$db->lastInsertId(); + + $stmtItem = $db->prepare(" + INSERT INTO employee_order_items (employee_order_id, session_menu_id, item_name, quantity, unit_price, total_price, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + + $totalBill = 0.0; + + foreach ($items as $item) { + $menuId = !empty($item['session_menu_id']) ? (int)$item['session_menu_id'] : null; + $name = trim((string)($item['item_name'] ?? '')); + $qty = max(1, (int)($item['quantity'] ?? 1)); + $unitPrice = (float)($item['unit_price'] ?? 0.0); + $lineTotal = $qty * $unitPrice; + $itemNotes = trim((string)($item['notes'] ?? '')); + + if (empty($name)) { + continue; + } + + $stmtItem->execute([ + $orderId, + $menuId, + $name, + $qty, + $unitPrice, + $lineTotal, + $itemNotes + ]); + + $totalBill += $lineTotal; + } + + // Update total bill + $stmtUpdateTotal = $db->prepare("UPDATE employee_orders SET total_bill = ? WHERE id = ?"); + $stmtUpdateTotal->execute([$totalBill, $orderId]); + + $db->commit(); + return $orderId; + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + } + + public static function togglePaid(int $orderId): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + UPDATE employee_orders + SET status = CASE WHEN status = 'paid' THEN 'unpaid' ELSE 'paid' END, + updated_at = datetime('now', 'localtime') + WHERE id = ? + "); + return $stmt->execute([$orderId]); + } + + public static function delete(int $orderId): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("DELETE FROM employee_orders WHERE id = ?"); + return $stmt->execute([$orderId]); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..a56651e --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,171 @@ +prepare($query); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT o.*, c.name as customer_name, c.email as customer_email, + c.phone as customer_phone, c.address as customer_address + FROM orders o + JOIN customers c ON o.customer_id = c.id + WHERE o.id = ? + "); + $stmt->execute([$id]); + $order = $stmt->fetch(); + + if (!$order) { + return null; + } + + $order['items'] = OrderItem::getByOrderId($id); + return $order; + } + + public static function count(?string $status = null): int + { + $db = Database::getConnection(); + if ($status) { + $stmt = $db->prepare("SELECT COUNT(*) FROM orders WHERE status = ?"); + $stmt->execute([$status]); + return (int)$stmt->fetchColumn(); + } + return (int)$db->query("SELECT COUNT(*) FROM orders")->fetchColumn(); + } + + public static function totalRevenue(): float + { + $db = Database::getConnection(); + return (float)$db->query("SELECT COALESCE(SUM(total), 0) FROM orders WHERE status != 'Cancelled'")->fetchColumn(); + } + + public static function recent(int $limit = 5): array + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT o.*, c.name as customer_name, + (SELECT COUNT(*) FROM order_items WHERE order_id = o.id) as item_count + FROM orders o + JOIN customers c ON o.customer_id = c.id + ORDER BY o.id DESC + LIMIT :limit + "); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); + } + + public static function createOrderWithItems(array $orderData, array $items): int + { + $db = Database::getConnection(); + $db->beginTransaction(); + + try { + $orderNumber = 'ORD-' . date('Ymd') . '-' . strtoupper(substr(uniqid(), -4)); + + $stmt = $db->prepare(" + INSERT INTO orders (order_number, customer_id, status, subtotal, tax, discount, total, notes) + VALUES (:order_number, :customer_id, :status, :subtotal, :tax, :discount, :total, :notes) + "); + + $stmt->execute([ + ':order_number' => $orderNumber, + ':customer_id' => $orderData['customer_id'], + ':status' => $orderData['status'] ?? 'Pending', + ':subtotal' => $orderData['subtotal'], + ':tax' => $orderData['tax'] ?? 0.00, + ':discount' => $orderData['discount'] ?? 0.00, + ':total' => $orderData['total'], + ':notes' => $orderData['notes'] ?? '', + ]); + + $orderId = (int)$db->lastInsertId(); + + foreach ($items as $item) { + if (empty($item['product_id']) || empty($item['quantity'])) { + continue; + } + + $productId = (int)$item['product_id']; + $quantity = (int)$item['quantity']; + $unitPrice = (float)$item['unit_price']; + $totalPrice = $quantity * $unitPrice; + $productName = $item['product_name'] ?? 'Item'; + + OrderItem::create([ + 'order_id' => $orderId, + 'product_id' => $productId, + 'product_name' => $productName, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $totalPrice, + ]); + + // Deduct stock inventory + Product::deductStock($productId, $quantity); + } + + $db->commit(); + return $orderId; + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + } + + public static function updateStatus(int $id, string $status): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("UPDATE orders SET status = ?, updated_at = datetime('now', 'localtime') WHERE id = ?"); + return $stmt->execute([$status, $id]); + } + + public static function delete(int $id): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("DELETE FROM orders WHERE id = ?"); + return $stmt->execute([$id]); + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..1ed0e52 --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,43 @@ +prepare(" + SELECT oi.*, p.sku, p.category + FROM order_items oi + LEFT JOIN products p ON oi.product_id = p.id + WHERE oi.order_id = ? + ORDER BY oi.id ASC + "); + $stmt->execute([$orderId]); + return $stmt->fetchAll(); + } + + public static function create(array $data): int + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price, total_price) + VALUES (:order_id, :product_id, :product_name, :quantity, :unit_price, :total_price) + "); + $stmt->execute([ + ':order_id' => $data['order_id'], + ':product_id' => $data['product_id'], + ':product_name' => $data['product_name'], + ':quantity' => $data['quantity'], + ':unit_price' => $data['unit_price'], + ':total_price' => $data['total_price'], + ]); + return (int)$db->lastInsertId(); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..c8106b4 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,86 @@ +query("SELECT * FROM products ORDER BY name ASC"); + return $stmt->fetchAll(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT * FROM products WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public static function count(): int + { + $db = Database::getConnection(); + return (int)$db->query("SELECT COUNT(*) FROM products")->fetchColumn(); + } + + public static function lowStockCount(int $threshold = 25): int + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT COUNT(*) FROM products WHERE stock <= ?"); + $stmt->execute([$threshold]); + return (int)$stmt->fetchColumn(); + } + + public static function create(array $data): int + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + INSERT INTO products (sku, name, description, price, stock, category) + VALUES (:sku, :name, :description, :price, :stock, :category) + "); + $stmt->execute([ + ':sku' => $data['sku'], + ':name' => $data['name'], + ':description' => $data['description'] ?? '', + ':price' => $data['price'], + ':stock' => (int)($data['stock'] ?? 0), + ':category' => $data['category'] ?? 'General', + ]); + return (int)$db->lastInsertId(); + } + + public static function update(int $id, array $data): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + UPDATE products + SET sku = :sku, name = :name, description = :description, + price = :price, stock = :stock, category = :category + WHERE id = :id + "); + return $stmt->execute([ + ':id' => $id, + ':sku' => $data['sku'], + ':name' => $data['name'], + ':description' => $data['description'] ?? '', + ':price' => $data['price'], + ':stock' => (int)($data['stock'] ?? 0), + ':category' => $data['category'] ?? 'General', + ]); + } + + public static function deductStock(int $id, int $quantity): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("UPDATE products SET stock = MAX(0, stock - ?) WHERE id = ?"); + return $stmt->execute([$quantity, $id]); + } +} diff --git a/app/Models/SessionMenu.php b/app/Models/SessionMenu.php new file mode 100644 index 0000000..03bb825 --- /dev/null +++ b/app/Models/SessionMenu.php @@ -0,0 +1,80 @@ +prepare("SELECT * FROM session_menus WHERE session_id = ? ORDER BY id ASC"); + $stmt->execute([$sessionId]); + return $stmt->fetchAll(); + } + + public static function add(int $sessionId, string $name, float $price = 0.00): int + { + $db = Database::getConnection(); + $stmt = $db->prepare("INSERT INTO session_menus (session_id, name, final_price) VALUES (?, ?, ?)"); + $stmt->execute([$sessionId, trim($name), $price]); + return (int)$db->lastInsertId(); + } + + public static function delete(int $id): bool + { + $db = Database::getConnection(); + $stmt = $db->prepare("DELETE FROM session_menus WHERE id = ?"); + return $stmt->execute([$id]); + } + + /** + * Update final prices from canteen bill and recalculate all employee order line items + */ + public static function updatePricesAndRecalculate(int $sessionId, array $menuPrices): void + { + $db = Database::getConnection(); + $db->beginTransaction(); + + try { + $stmtUpdateMenu = $db->prepare("UPDATE session_menus SET final_price = ? WHERE id = ? AND session_id = ?"); + $stmtUpdateItems = $db->prepare(" + UPDATE employee_order_items + SET unit_price = ?, total_price = quantity * ? + WHERE session_menu_id = ? + "); + + foreach ($menuPrices as $menuId => $price) { + $menuId = (int)$menuId; + $price = (float)$price; + + $stmtUpdateMenu->execute([$price, $menuId, $sessionId]); + $stmtUpdateItems->execute([$price, $price, $menuId]); + } + + // Recalculate total_bill on employee_orders + $stmtOrders = $db->prepare("SELECT id FROM employee_orders WHERE session_id = ?"); + $stmtOrders->execute([$sessionId]); + $orders = $stmtOrders->fetchAll(); + + $stmtUpdateOrderTotal = $db->prepare(" + UPDATE employee_orders + SET total_bill = (SELECT COALESCE(SUM(total_price), 0) FROM employee_order_items WHERE employee_order_id = ?) + WHERE id = ? + "); + + foreach ($orders as $order) { + $stmtUpdateOrderTotal->execute([$order['id'], $order['id']]); + } + + $db->commit(); + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..1137dd1 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,59 @@ +query(" + SELECT u.id, u.name, u.username, u.email, u.created_at, + (SELECT COUNT(*) FROM employee_orders WHERE user_id = u.id) as total_orders, + (SELECT COALESCE(SUM(total_bill), 0) FROM employee_orders WHERE user_id = u.id AND status = 'unpaid') as unpaid_balance + FROM users u + ORDER BY u.name ASC + "); + return $stmt->fetchAll(); + } + + public static function find(int $id): ?array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id, name, username, email, created_at FROM users WHERE id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + return $res ?: null; + } + + public static function findByIdentifier(string $identifier): ?array + { + $db = Database::getConnection(); + $identifier = trim($identifier); + $stmt = $db->prepare("SELECT * FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)"); + $stmt->execute([$identifier, $identifier]); + $res = $stmt->fetch(); + return $res ?: null; + } + + public static function create(string $name, string $username, string $email, string $password): int + { + $db = Database::getConnection(); + $hash = password_hash($password, PASSWORD_DEFAULT); + + $stmt = $db->prepare("INSERT INTO users (name, username, email, password_hash) VALUES (?, ?, ?, ?)"); + $stmt->execute([ + trim($name), + strtolower(trim($username)), + strtolower(trim($email)), + $hash + ]); + + return (int)$db->lastInsertId(); + } +} diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..44614ea --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,55 @@ +
+ +
+
+
🍱
+

Login to CanteenHub

+

Order lunch or host food sessions with your team

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ Don't have an account? Register here +
+
+ + + + + +
+ +
diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php new file mode 100644 index 0000000..a2ee403 --- /dev/null +++ b/app/Views/auth/register.php @@ -0,0 +1,48 @@ +
+ +
+
+
🍱
+

Join CanteenHub

+

Create your coworker account to order lunch

+
+ +
+
+ + +
+ + +
+ +
+ +
+ @ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Already have an account? Login here +
+
+
+ +
diff --git a/app/Views/customers/index.php b/app/Views/customers/index.php new file mode 100644 index 0000000..89e8616 --- /dev/null +++ b/app/Views/customers/index.php @@ -0,0 +1,88 @@ + + +
+ + +
+
+

Registered Customers ()

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
CustomerPhoneAddressRegistered
+ No customers registered yet. +
+
+
+
+ + + +
+
+
+ + +
+
+

Register Customer

+
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php new file mode 100644 index 0000000..0f2a0ab --- /dev/null +++ b/app/Views/dashboard/index.php @@ -0,0 +1,98 @@ + + + +
+
+
+
Total Revenue
+
+
+
💰
+
+ +
+
+
Total Orders
+
+
+
📑
+
+ +
+
+
Pending / Processing
+
+
+
+
+ +
+
+
Total Products
+
+
+
🏷️
+
+
+ + +
+
+

Recent Orders

+ View All Orders → +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Order #CustomerItemsTotalStatusDateAction
+ No orders found yet. Create the first order. +
+ + + + item(s) + + + + + View Invoice +
+
+
diff --git a/app/Views/employees/index.php b/app/Views/employees/index.php new file mode 100644 index 0000000..a3c4f49 --- /dev/null +++ b/app/Views/employees/index.php @@ -0,0 +1,56 @@ + + +
+
+

Team Members ()

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameMattermost TagEmailTotal Orders PlacedUnpaid Balance Tab
+ No coworkers registered yet. +
+
+
+ @ + orders + 0): ?> + + unpaid + + + All Settled + +
+
+
diff --git a/app/Views/layouts/main.php b/app/Views/layouts/main.php new file mode 100644 index 0000000..8a4e735 --- /dev/null +++ b/app/Views/layouts/main.php @@ -0,0 +1,76 @@ + + + + + + <?= e($title ?? 'Office Canteen Lunch Hub') ?> - CanteenHub + + + + + + + + + + +
+ + + $messages): ?> + +
+ + +
+ + + + + +
+ + + + + + diff --git a/app/Views/orders/create.php b/app/Views/orders/create.php new file mode 100644 index 0000000..2458078 --- /dev/null +++ b/app/Views/orders/create.php @@ -0,0 +1,221 @@ + + +
+ + +
+ + +
+ +
+
+

1. Customer Selection

+ + Add Customer +
+
+
+ + +
+
+
+ + +
+
+

2. Order Line Items

+ +
+
+
+ + + + + + + + + + + + + +
ProductPrice ($)QtySubtotal ($)
+
+
+ +
+ + +
+
+

3. Order Notes / Special Instructions

+
+
+ +
+
+
+ + +
+
+
+

Order Summary

+
+
+
+ + +
+ +
+
+ Items Subtotal: + $0.00 +
+ +
+ Tax Amount ($): + +
+ +
+ Discount ($): + +
+ +
+ Grand Total: + $0.00 +
+
+
+ +
+
+ +
+
+ + diff --git a/app/Views/orders/index.php b/app/Views/orders/index.php new file mode 100644 index 0000000..67317b9 --- /dev/null +++ b/app/Views/orders/index.php @@ -0,0 +1,92 @@ + + + +
+
+
+
+ +
+ +
+ +
+ + + + Reset + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Order NumberCustomerSubtotalTaxDiscountTotalStatusDateAction
+ No orders found matching your filter criteria. +
+ + + + +
+
+
- + + + + + Details +
+
+
diff --git a/app/Views/orders/show.php b/app/Views/orders/show.php new file mode 100644 index 0000000..56d5609 --- /dev/null +++ b/app/Views/orders/show.php @@ -0,0 +1,146 @@ + + +
+ + +
+
+
+

SimpleOrder

+

Official Order Invoice

+
+
+
+
+ + Status: + +
+
+
+ +
+
+

Billed To:

+
+
+ +
+ + +
+ +
+ +
+

Order Details:

+
Date:
+
Payment Status: Paid / Pending
+
Last Updated:
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Item DescriptionSKUUnit PriceQtyAmount
+
+
+
+ + +
+
+ Subtotal: + +
+
+ Tax: + +
+ 0): ?> +
+ Discount: + - +
+ +
+ Grand Total: + +
+
+ + +
+
Order Notes:
+
+
+ +
+ + +
+
+
+

Update Status

+
+
+
+ +
+ + +
+ +
+
+
+ +
+
+

Danger Zone

+
+
+

Permanently remove this order and its associated records.

+
+ + +
+
+
+
+ +
diff --git a/app/Views/products/index.php b/app/Views/products/index.php new file mode 100644 index 0000000..99f6759 --- /dev/null +++ b/app/Views/products/index.php @@ -0,0 +1,110 @@ + + +
+ + +
+
+

Available Products ()

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
SKUProduct NameCategoryPriceStock
+ No products found in the catalog. +
+
+ +
+ +
+ +
+ + (Low) + + + + + +
+
+
+ + +
+
+

Add New Product

+
+
+
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
diff --git a/app/Views/sessions/create.php b/app/Views/sessions/create.php new file mode 100644 index 0000000..b4024f2 --- /dev/null +++ b/app/Views/sessions/create.php @@ -0,0 +1,58 @@ + + +
+
+
+

Session Details

+
+
+
+ + +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + + + 💡 Prices are not needed yet! You can settle final prices after the food arrives with the receipt. + +
+ + +
+
+
+
diff --git a/app/Views/sessions/history.php b/app/Views/sessions/history.php new file mode 100644 index 0000000..511b5d8 --- /dev/null +++ b/app/Views/sessions/history.php @@ -0,0 +1,65 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateSession TitleCanteenOrdersMenu ItemsTotal BilledStatusAction
+ No past sessions found. +
+ + + + people items + + + + + View Board +
+
+
diff --git a/app/Views/sessions/index.php b/app/Views/sessions/index.php new file mode 100644 index 0000000..c09c4f7 --- /dev/null +++ b/app/Views/sessions/index.php @@ -0,0 +1,76 @@ + + + +
+
🍱
+

No Active Food Sessions Right Now

+

+ Anyone can start a session! Click below to post today's canteen menu or coordinate a team food run. +

+
+ ➕ Start New Food Session +
+
+ +
+ +
+
+
+ + + +

+ + + +

+
+
+ +
+
+ 🏢 Canteen: +
+ +
+ 👤 Hosted by: + + @ +
+ + +
+ 📢 +
+ + +
+
👥 ordered
+
🍱 menu items
+
+
+ + +
+ +
+ diff --git a/app/Views/sessions/show.php b/app/Views/sessions/show.php new file mode 100644 index 0000000..118b72d --- /dev/null +++ b/app/Views/sessions/show.php @@ -0,0 +1,533 @@ + + + +
+ 📢 Host Note: +
+ + + +
+
+
+
Coworkers Ordered
+
+
+
👥
+
+ +
+
+
Total Food Items
+
+
+
🍱
+
+ +
+
+
Total Session Bill
+
+
+
💵
+
+
+ + +
+ + +
+ + + +
+
+
+

+ +

+

+ Ordering as: (@) +

+
+ + + Already Ordered + +
+ +
+
+ + + + +
+ + + +
+

No menu listed yet for today.

+
+ + + +
+ + + + + +
+ + +
+ + +
+
+
+ +
+
+
🔒
+

Orders Closed for this Session

+

+ This order session is no longer taking new submissions. Please contact the host () directly. +

+
+
+ + + +
+
+
+

📋 Orders in this Session ( people)

+

Real-time feed of all team members who joined

+
+
+ + +
+ No orders placed yet. Be the first to order above! +
+ +
+ +
+
+
+ + + (You)' : '' ?> + + + @ + + +
+ +
    + +
  • + x + + + () + + 0): ?> + + +
  • + +
+ + +
+ 💬 Note: +
+ +
+ + +
+
+ 0 ? money($order['total_bill']) : 'Pending Price' ?> +
+ +
+ +
+ + + +
+ + + + + + + +
+ + + +
+ +
+
+
+ +
+ +
+ +
+ + +
+ + +
+
+
+

📦 Canteen Order Summary

+

Grouped quantities for ordering

+
+ +
+ +
+ +

+ No items ordered yet. +

+ +
+ +
+
+ x + + +
+ Note: +
+ +
+
+ +
+ + + +
+
+ + + +
+
+
+

💰 Settle Canteen Bill (Arrival)

+

Enter final receipt prices to compute everyone's bill

+
+
+ +
+
+ + +
+ +
+ : +
+ $ + +
+
+ +
+ + +
+ + 0): ?> +
+ + +
+ +
+
+ + +
+
+

📝 Menu Options Manager

+
+
+
+ +
+ + +
+ +
+ + +
+
    + +
  • + +
    + + + +
    +
  • + +
+
+ +
+
+ + +
+ +
+ + + + + diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..cb2ec68 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: order_system_app + restart: unless-stopped + ports: + - "${PORT:-8080}:80" + environment: + - APP_ENV=local + - APP_DEBUG=true + - DB_CONNECTION=sqlite + - DB_DATABASE=/var/www/html/storage/database.sqlite + volumes: + - .:/var/www/html + - storage_data:/var/www/html/storage + +volumes: + storage_data: diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..968996d --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "name": "app/order-system", + "description": "Zero-framework Order System Foundation in PHP with Alpine.js and native CSS", + "type": "project", + "require": { + "php": ">=8.1" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..8ae878a --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,14 @@ + + RewriteEngine On + RewriteBase / + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [QSA,L] + diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..8410e59 --- /dev/null +++ b/public/assets/css/style.css @@ -0,0 +1,566 @@ +/* ========================================================================== + Order System - Pure Native CSS Design System + ========================================================================== */ + +:root { + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-light: #eef2ff; + --secondary: #64748b; + --secondary-hover: #475569; + --success: #10b981; + --success-light: #ecfdf5; + --warning: #f59e0b; + --warning-light: #fffbeb; + --danger: #ef4444; + --danger-light: #fef2f2; + --info: #0ea5e9; + --info-light: #f0f9ff; + + --bg-main: #f8fafc; + --bg-card: #ffffff; + --bg-card-header: #f8fafc; + --text-main: #0f172a; + --text-muted: #64748b; + --text-light: #94a3b8; + + --border-color: #e2e8f0; + --border-focus: #818cf8; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-full: 9999px; + + --font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-main); + color: var(--text-main); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ---------------- Navbar ---------------- */ +.navbar { + background-color: #ffffff; + border-bottom: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 50; +} + +.navbar-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + height: 4.25rem; +} + +.navbar-brand { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 700; + color: var(--primary); + text-decoration: none; +} + +.navbar-brand-icon { + width: 2rem; + height: 2rem; + background: linear-gradient(135deg, var(--primary), #818cf8); + color: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-size: 1rem; +} + +.nav-links { + display: flex; + list-style: none; + gap: 0.5rem; +} + +.nav-link { + display: inline-flex; + align-items: center; + padding: 0.5rem 0.85rem; + border-radius: var(--radius-sm); + font-size: 0.925rem; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + transition: all 0.15s ease; +} + +.nav-link:hover { + color: var(--primary); + background-color: var(--primary-light); +} + +.nav-link.active { + color: var(--primary); + background-color: var(--primary-light); + font-weight: 600; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* ---------------- Container & Layout ---------------- */ +.main-container { + max-width: 1200px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; + flex: 1; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.page-title { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-main); +} + +.page-subtitle { + font-size: 0.95rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +/* ---------------- Cards & Grid ---------------- */ +.grid-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.25rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + justify-content: space-between; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-main); + margin-top: 0.25rem; +} + +.stat-icon { + width: 3rem; + height: 3rem; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + overflow: hidden; + margin-bottom: 1.5rem; +} + +.card-header { + padding: 1.25rem 1.5rem; + background-color: var(--bg-card-header); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-main); +} + +.card-body { + padding: 1.5rem; +} + +.card-footer { + padding: 1rem 1.5rem; + background-color: var(--bg-card-header); + border-top: 1px solid var(--border-color); +} + +/* ---------------- Tables ---------------- */ +.table-responsive { + width: 100%; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.925rem; +} + +.table th { + background-color: #f8fafc; + color: var(--text-muted); + font-weight: 600; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--border-color); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.table td { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-main); + vertical-align: middle; +} + +.table tr:last-child td { + border-bottom: none; +} + +.table tbody tr:hover { + background-color: #f8fafc; +} + +/* ---------------- Badges ---------------- */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.65rem; + border-radius: var(--radius-full); + font-size: 0.775rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.badge-pending { + background-color: var(--warning-light); + color: #b45309; +} + +.badge-processing { + background-color: var(--info-light); + color: #0369a1; +} + +.badge-completed { + background-color: var(--success-light); + color: #047857; +} + +.badge-cancelled { + background-color: var(--danger-light); + color: #b91c1c; +} + +.badge-neutral { + background-color: #f1f5f9; + color: #475569; +} + +/* ---------------- Buttons ---------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.6rem 1.15rem; + font-size: 0.925rem; + font-weight: 500; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; + line-height: 1.25; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: white; + border-color: var(--border-color); + color: var(--text-main); +} + +.btn-secondary:hover { + background-color: #f8fafc; + border-color: #cbd5e1; +} + +.btn-danger { + background-color: var(--danger); + color: white; +} + +.btn-danger:hover { + background-color: #dc2626; +} + +.btn-success { + background-color: var(--success); + color: white; +} + +.btn-success:hover { + background-color: #059669; +} + +.btn-sm { + padding: 0.35rem 0.65rem; + font-size: 0.825rem; +} + +/* ---------------- Forms ---------------- */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-main); + margin-bottom: 0.4rem; +} + +.form-control, .form-select, .form-textarea { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 0.925rem; + color: var(--text-main); + background-color: #ffffff; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-control:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.filter-bar { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +/* ---------------- Alerts / Toasts ---------------- */ +.alert { + padding: 1rem 1.25rem; + border-radius: var(--radius-sm); + margin-bottom: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.925rem; +} + +.alert-success { + background-color: var(--success-light); + color: #065f46; + border: 1px solid #a7f3d0; +} + +.alert-error { + background-color: var(--danger-light); + color: #991b1b; + border: 1px solid #fecaca; +} + +/* ---------------- Invoice / Detail View ---------------- */ +.invoice-card { + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 2.5rem; + box-shadow: var(--shadow-sm); +} + +.invoice-header { + display: flex; + justify-content: space-between; + border-bottom: 2px solid var(--border-color); + padding-bottom: 1.5rem; + margin-bottom: 2rem; +} + +.invoice-meta { + text-align: right; +} + +.order-summary-box { + background: #f8fafc; + border-radius: var(--radius-sm); + padding: 1.25rem; + margin-left: auto; + width: 320px; + max-width: 100%; +} + +.summary-row { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + font-size: 0.925rem; +} + +.summary-total { + font-size: 1.2rem; + font-weight: 700; + border-top: 1px solid var(--border-color); + padding-top: 0.75rem; + margin-top: 0.5rem; + color: var(--primary); +} + +/* ---------------- Footer ---------------- */ +.footer { + background-color: #ffffff; + border-top: 1px solid var(--border-color); + padding: 1.5rem; + text-align: center; + font-size: 0.85rem; + color: var(--text-muted); + margin-top: auto; +} + +/* ---------------- Responsive ---------------- */ +@media (max-width: 768px) { + .navbar-container { + flex-direction: column; + height: auto; + padding: 1rem; + gap: 1rem; + } + + .nav-links { + width: 100%; + justify-content: center; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + } + + .invoice-card { + padding: 1.25rem; + } + + .invoice-header { + flex-direction: column; + gap: 1rem; + } + + .invoice-meta { + text-align: left; + } + + .order-summary-box { + width: 100%; + } +} + +/* ---------------- Multi-Select Interactive Menu Cards ---------------- */ +.menu-select-card { + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.65rem 0.85rem; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.menu-select-card:hover { + border-color: var(--primary); + background-color: #f8faff; +} + +.menu-select-card.is-selected { + border-color: var(--primary); + background-color: var(--primary-light); + box-shadow: 0 0 0 1px var(--primary); +} + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..74f0f80 --- /dev/null +++ b/public/index.php @@ -0,0 +1,76 @@ +get('/login', [\App\Controllers\AuthController::class, 'loginForm']); +$router->post('/login', [\App\Controllers\AuthController::class, 'login']); +$router->post('/quick-login', [\App\Controllers\AuthController::class, 'quickLogin']); +$router->get('/register', [\App\Controllers\AuthController::class, 'registerForm']); +$router->post('/register', [\App\Controllers\AuthController::class, 'register']); +$router->post('/logout', [\App\Controllers\AuthController::class, 'logout']); + +// Daily Food & Canteen Sessions +$router->get('/', [\App\Controllers\SessionController::class, 'index']); +$router->get('/sessions/create', [\App\Controllers\SessionController::class, 'createForm']); +$router->post('/sessions', [\App\Controllers\SessionController::class, 'store']); +$router->get('/sessions/history', [\App\Controllers\SessionController::class, 'history']); +$router->get('/sessions/{id}', [\App\Controllers\SessionController::class, 'show']); +$router->post('/sessions/{id}/status', [\App\Controllers\SessionController::class, 'updateStatus']); +$router->post('/sessions/{id}/add-menu', [\App\Controllers\SessionController::class, 'addMenu']); +$router->post('/sessions/{id}/delete-menu', [\App\Controllers\SessionController::class, 'deleteMenu']); +$router->post('/sessions/{id}/settle-prices', [\App\Controllers\SessionController::class, 'settlePrices']); + +// Employee Self-Orders +$router->post('/employee-orders', [\App\Controllers\EmployeeOrderController::class, 'store']); +$router->post('/employee-orders/{id}/toggle-paid', [\App\Controllers\EmployeeOrderController::class, 'togglePaid']); +$router->post('/employee-orders/{id}/delete', [\App\Controllers\EmployeeOrderController::class, 'delete']); + +// Coworkers & Balances +$router->get('/employees', [\App\Controllers\EmployeeController::class, 'index']); + +// 404 Fallback +$router->setNotFound(function ($req, $res) { + return $res->setStatusCode(404)->setContent( + '
+

404 - Page Not Found

+

The page or food session you requested does not exist.

+

← Back to Food Sessions

+
' + ); +}); + +// Dispatch and send response +$result = $router->dispatch($request, $response); +$result->send(); diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..d1fd362 --- /dev/null +++ b/storage/.gitkeep @@ -0,0 +1 @@ +# Keep storage directory tracked