feat: initial foundation for collaborative canteen ordering system in vanilla PHP
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
vendor/
|
||||||
|
storage/*.sqlite
|
||||||
|
|
||||||
+34
@@ -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"]
|
||||||
@@ -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)).
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
private static ?array $cachedUser = null;
|
||||||
|
|
||||||
|
public static function init(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function check(): bool
|
||||||
|
{
|
||||||
|
self::init();
|
||||||
|
return !empty($_SESSION['user_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function id(): ?int
|
||||||
|
{
|
||||||
|
self::init();
|
||||||
|
return $_SESSION['user_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function user(): ?array
|
||||||
|
{
|
||||||
|
self::init();
|
||||||
|
if (!self::check()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$cachedUser === null) {
|
||||||
|
self::$cachedUser = User::find((int)$_SESSION['user_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$cachedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function login(array $user): void
|
||||||
|
{
|
||||||
|
self::init();
|
||||||
|
$_SESSION['user_id'] = (int)$user['id'];
|
||||||
|
self::$cachedUser = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logout(): void
|
||||||
|
{
|
||||||
|
self::init();
|
||||||
|
unset($_SESSION['user_id']);
|
||||||
|
self::$cachedUser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function attempt(string $identifier, string $password): bool
|
||||||
|
{
|
||||||
|
$user = User::findByIdentifier($identifier);
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password_verify($password, $user['password_hash'])) {
|
||||||
|
self::login($user);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Autoloader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Map of namespace prefix to base directory
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
private static array $settings = [];
|
||||||
|
|
||||||
|
public static function load(string $envFile): void
|
||||||
|
{
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || str_starts_with($line, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($line, '=')) {
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (
|
||||||
|
(str_starts_with($value, '"') && str_ends_with($value, '"')) ||
|
||||||
|
(str_starts_with($value, "'") && str_ends_with($value, "'"))
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists($key, $_SERVER) && !array_key_exists($key, $_ENV)) {
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key) ?: $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
protected function render(string $view, array $data = [], ?string $layout = 'layouts/main'): string
|
||||||
|
{
|
||||||
|
return View::render($view, $data, $layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function json(mixed $data, int $statusCode = 200): Response
|
||||||
|
{
|
||||||
|
$response = new Response();
|
||||||
|
return $response->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core\Database;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
public static function getConnection(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
$connection = Config::get('DB_CONNECTION', 'sqlite');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($connection === 'sqlite') {
|
||||||
|
$databasePath = Config::get('DB_DATABASE', __DIR__ . '/../../../storage/database.sqlite');
|
||||||
|
$dir = dirname($databasePath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$instance = new PDO("sqlite:{$databasePath}", null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Global Helper Functions
|
||||||
|
|
||||||
|
if (!function_exists('e')) {
|
||||||
|
function e(mixed $value): string
|
||||||
|
{
|
||||||
|
return \App\Core\View::escape($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('money')) {
|
||||||
|
function money(float|int|string $amount): string
|
||||||
|
{
|
||||||
|
return '$' . number_format((float)$amount, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core\Http;
|
||||||
|
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
private string $method;
|
||||||
|
private string $uri;
|
||||||
|
private string $path;
|
||||||
|
private array $queryParams;
|
||||||
|
private array $bodyParams;
|
||||||
|
private array $headers;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core\Http;
|
||||||
|
|
||||||
|
class Response
|
||||||
|
{
|
||||||
|
private int $statusCode = 200;
|
||||||
|
private array $headers = [];
|
||||||
|
private string $content = '';
|
||||||
|
|
||||||
|
public function setStatusCode(int $code): self
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core\Routing;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
private $notFoundHandler = null;
|
||||||
|
|
||||||
|
public function get(string $path, array|callable $handler): self
|
||||||
|
{
|
||||||
|
$this->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<param>[^/]+)
|
||||||
|
$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('<h1>404 Not Found</h1><p>The requested route does not exist.</p>');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
private static string $viewsPath = __DIR__ . '/../Views/';
|
||||||
|
private static array $sections = [];
|
||||||
|
private static ?string $currentSection = null;
|
||||||
|
|
||||||
|
public static function render(string $view, array $data = [], ?string $layout = 'layouts/main'): string
|
||||||
|
{
|
||||||
|
$viewFile = self::$viewsPath . str_replace('.', '/', $view) . '.php';
|
||||||
|
|
||||||
|
if (!file_exists($viewFile)) {
|
||||||
|
throw new \RuntimeException("View file not found: {$viewFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract variables for view scope
|
||||||
|
extract($data);
|
||||||
|
|
||||||
|
// Capture view content
|
||||||
|
ob_start();
|
||||||
|
require $viewFile;
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
// If a layout is specified, wrap content with the layout
|
||||||
|
if ($layout !== null) {
|
||||||
|
$layoutFile = self::$viewsPath . str_replace('.', '/', $layout) . '.php';
|
||||||
|
if (!file_exists($layoutFile)) {
|
||||||
|
throw new \RuntimeException("Layout file not found: {$layoutFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require $layoutFile;
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setFlash(string $type, string $message): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$_SESSION['flash'][$type][] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getFlashes(): array
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$flashes = $_SESSION['flash'] ?? [];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
return $flashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function csrfToken(): string
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function csrfField(): string
|
||||||
|
{
|
||||||
|
$token = self::csrfToken();
|
||||||
|
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function escape(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class Customer
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class DailySession
|
||||||
|
{
|
||||||
|
public static function create(int $creatorId, string $title, string $canteenName, string $sessionDate, ?string $notes): int
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class Employee
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class EmployeeOrder
|
||||||
|
{
|
||||||
|
public static function getBySessionId(int $sessionId): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class Order
|
||||||
|
{
|
||||||
|
public static function all(?string $status = null, ?string $search = null): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$query = "
|
||||||
|
SELECT o.*, c.name as customer_name, c.email as customer_email,
|
||||||
|
(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
|
||||||
|
";
|
||||||
|
|
||||||
|
$conditions = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status && $status !== 'all') {
|
||||||
|
$conditions[] = "o.status = :status";
|
||||||
|
$params[':status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$conditions[] = "(o.order_number LIKE :search OR c.name LIKE :search OR c.email LIKE :search)";
|
||||||
|
$params[':search'] = "%{$search}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($conditions)) {
|
||||||
|
$query .= " WHERE " . implode(" AND ", $conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query .= " ORDER BY o.id DESC";
|
||||||
|
|
||||||
|
$stmt = $db->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class OrderItem
|
||||||
|
{
|
||||||
|
public static function getByOrderId(int $orderId): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class Product
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class SessionMenu
|
||||||
|
{
|
||||||
|
public static function getBySessionId(int $sessionId): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$stmt = $db->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<div style="max-width: 440px; margin: 3rem auto;">
|
||||||
|
|
||||||
|
<div class="card" style="box-shadow: var(--shadow-lg);">
|
||||||
|
<div class="card-header" style="text-align: center; display: block; padding: 2rem 1.5rem 1rem;">
|
||||||
|
<div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🍱</div>
|
||||||
|
<h1 style="font-size: 1.5rem; font-weight: 700; color: var(--primary);">Login to CanteenHub</h1>
|
||||||
|
<p style="font-size: 0.875rem; color: var(--text-muted); margin-top: 0.25rem;">Order lunch or host food sessions with your team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Username or Email</label>
|
||||||
|
<input type="text" name="identifier" class="form-control" placeholder="e.g. dea or alex@company.com" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem; font-size: 1rem; margin-top: 0.5rem;">
|
||||||
|
Sign In →
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 1.25rem; font-size: 0.875rem; color: var(--text-muted);">
|
||||||
|
Don't have an account? <a href="/register" style="color: var(--primary); font-weight: 600;">Register here</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Switch Demo Account helper -->
|
||||||
|
<?php if (!empty($users)): ?>
|
||||||
|
<div class="card-footer" style="background: #f8fafc;">
|
||||||
|
<div style="font-size: 0.75rem; text-transform: uppercase; font-weight: 600; color: var(--text-muted); margin-bottom: 0.5rem; text-align: center;">
|
||||||
|
⚡ 1-Click Quick Demo Login:
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap; justify-content: center;">
|
||||||
|
<?php foreach ($users as $u): ?>
|
||||||
|
<form method="POST" action="/quick-login" style="display: inline;">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<input type="hidden" name="user_id" value="<?= e($u['id']) ?>">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">
|
||||||
|
<?= e($u['name']) ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<div style="max-width: 440px; margin: 3rem auto;">
|
||||||
|
|
||||||
|
<div class="card" style="box-shadow: var(--shadow-lg);">
|
||||||
|
<div class="card-header" style="text-align: center; display: block; padding: 2rem 1.5rem 1rem;">
|
||||||
|
<div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🍱</div>
|
||||||
|
<h1 style="font-size: 1.5rem; font-weight: 700; color: var(--primary);">Join CanteenHub</h1>
|
||||||
|
<p style="font-size: 0.875rem; color: var(--text-muted); margin-top: 0.25rem;">Create your coworker account to order lunch</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/register">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Full Name <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="e.g. Andy Pratama" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mattermost Username / Handle <span style="color: var(--danger);">*</span></label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||||
|
<span style="color: var(--text-muted); font-weight: 600;">@</span>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="andy" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email Address <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="email" name="email" class="form-control" placeholder="andy@company.com" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Password <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="At least 4 characters" required minlength="4">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem; font-size: 1rem; margin-top: 0.5rem;">
|
||||||
|
Create Account →
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 1.25rem; font-size: 0.875rem; color: var(--text-muted);">
|
||||||
|
Already have an account? <a href="/login" style="color: var(--primary); font-weight: 600;">Login here</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Customer Directory</h1>
|
||||||
|
<p class="page-subtitle">Manage customer profiles and contact details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||||
|
|
||||||
|
<!-- Customers Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Registered Customers (<?= count($customers) ?>)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($customers)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 2rem;">
|
||||||
|
No customers registered yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($customers as $cust): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 600;"><?= e($cust['name']) ?></div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted);"><?= e($cust['email']) ?></div>
|
||||||
|
</td>
|
||||||
|
<td><?= e($cust['phone'] ?: '—') ?></td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-muted); max-width: 250px;">
|
||||||
|
<?= e($cust['address'] ?: '—') ?>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
<?= date('M d, Y', strtotime($cust['created_at'])) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Customer Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Register Customer</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/customers">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Full Name <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="e.g. Sarah Connor" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email Address <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="email" name="email" class="form-control" placeholder="sarah@example.com" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Phone Number</label>
|
||||||
|
<input type="tel" name="phone" class="form-control" placeholder="+1-555-0199">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Shipping / Billing Address</label>
|
||||||
|
<textarea name="address" class="form-textarea" rows="3" placeholder="Street, City, State, ZIP..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Add Customer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Dashboard Overview</h1>
|
||||||
|
<p class="page-subtitle">Real-time summary of sales, orders, and inventory status</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/orders/create" class="btn btn-primary">+ Create New Order</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics Cards -->
|
||||||
|
<div class="grid-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Total Revenue</div>
|
||||||
|
<div class="stat-value"><?= money($stats['total_revenue']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #ecfdf5; color: #10b981;">💰</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Total Orders</div>
|
||||||
|
<div class="stat-value"><?= e($stats['total_orders']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #eef2ff; color: #4f46e5;">📑</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Pending / Processing</div>
|
||||||
|
<div class="stat-value"><?= e($stats['pending_orders'] + $stats['processing_orders']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #fffbeb; color: #f59e0b;">⏳</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Total Products</div>
|
||||||
|
<div class="stat-value"><?= e($stats['total_products']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #f0f9ff; color: #0ea5e9;">🏷️</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Orders Section -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Recent Orders</h2>
|
||||||
|
<a href="/orders" class="btn btn-secondary btn-sm">View All Orders →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order #</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Items</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($recentOrders)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 2rem;">
|
||||||
|
No orders found yet. <a href="/orders/create" style="color: var(--primary); font-weight: 600;">Create the first order</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($recentOrders as $order): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/orders/<?= e($order['id']) ?>" style="font-weight: 600; color: var(--primary); text-decoration: none;">
|
||||||
|
<?= e($order['order_number']) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><?= e($order['customer_name']) ?></td>
|
||||||
|
<td><?= e($order['item_count']) ?> item(s)</td>
|
||||||
|
<td style="font-weight: 600;"><?= money($order['total']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-<?= strtolower(e($order['status'])) ?>">
|
||||||
|
<?= e($order['status']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.85rem;"><?= date('M d, Y H:i', strtotime($order['created_at'])) ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/orders/<?= e($order['id']) ?>" class="btn btn-secondary btn-sm">View Invoice</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Coworkers & Balance Tabs</h1>
|
||||||
|
<p class="page-subtitle">Directory of team members, total food orders, and unpaid balance tabs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Team Members (<?= count($employees) ?>)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Mattermost Tag</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Total Orders Placed</th>
|
||||||
|
<th>Unpaid Balance Tab</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($employees)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 2rem;">
|
||||||
|
No coworkers registered yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($employees as $emp): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 600;"><?= e($emp['name']) ?></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-neutral">@<?= e($emp['username']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.85rem;"><?= e($emp['email']) ?></td>
|
||||||
|
<td><?= e($emp['total_orders']) ?> orders</td>
|
||||||
|
<td>
|
||||||
|
<?php if ((float)$emp['unpaid_balance'] > 0): ?>
|
||||||
|
<span class="badge badge-pending" style="font-weight: 700;">
|
||||||
|
<?= money($emp['unpaid_balance']) ?> unpaid
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-completed">All Settled</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($title ?? 'Office Canteen Lunch Hub') ?> - CanteenHub</title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Header Navigation -->
|
||||||
|
<header class="navbar">
|
||||||
|
<div class="navbar-container">
|
||||||
|
<a href="/" class="navbar-brand">
|
||||||
|
<div class="navbar-brand-icon">🍱</div>
|
||||||
|
<span>CanteenHub</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<?php if (\App\Core\Auth::check()): ?>
|
||||||
|
<?php $loggedUser = \App\Core\Auth::user(); ?>
|
||||||
|
<nav>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/" class="nav-link <?= ($_SERVER['REQUEST_URI'] ?? '') === '/' ? 'active' : '' ?>">🍱 Active Sessions</a></li>
|
||||||
|
<li><a href="/sessions/create" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/sessions/create') ? 'active' : '' ?>">➕ Start Session</a></li>
|
||||||
|
<li><a href="/sessions/history" class="nav-link <?= str_contains($_SERVER['REQUEST_URI'] ?? '', '/history') ? 'active' : '' ?>">📅 Archive</a></li>
|
||||||
|
<li><a href="/employees" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/employees') ? 'active' : '' ?>">👥 Coworkers</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="nav-actions">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<div style="text-align: right; font-size: 0.85rem;">
|
||||||
|
<strong><?= e($loggedUser['name']) ?></strong>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.75rem;">@<?= e($loggedUser['username']) ?></div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/logout" style="display: inline;">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm" style="color: var(--danger);">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<a href="/login" class="btn btn-secondary btn-sm">Login</a>
|
||||||
|
<a href="/register" class="btn btn-primary btn-sm">Register</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Body -->
|
||||||
|
<main class="main-container">
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
<?php foreach (\App\Core\View::getFlashes() as $type => $messages): ?>
|
||||||
|
<?php foreach ($messages as $msg): ?>
|
||||||
|
<div class="alert alert-<?= $type === 'error' ? 'error' : 'success' ?>" x-data="{ show: true }" x-show="show">
|
||||||
|
<span><?= e($msg) ?></span>
|
||||||
|
<button type="button" @click="show = false" style="background:none; border:none; cursor:pointer; font-size:1.1rem; color:inherit;">×</button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?= $content ?>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© <?= date('Y') ?> CanteenHub • Zero Framework PHP • Native CSS & Alpine.js</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Create New Order</h1>
|
||||||
|
<p class="page-subtitle">Select customer, add products, and calculate order totals</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/orders" class="btn btn-secondary">← Back to Orders</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/orders" x-data="orderForm()">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||||
|
|
||||||
|
<!-- Left Column: Customer & Order Items -->
|
||||||
|
<div>
|
||||||
|
<!-- Customer Information Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">1. Customer Selection</h2>
|
||||||
|
<a href="/customers" target="_blank" style="font-size: 0.85rem; color: var(--primary); text-decoration: none;">+ Add Customer</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Customer <span style="color: var(--danger);">*</span></label>
|
||||||
|
<select name="customer_id" class="form-select" required>
|
||||||
|
<option value="">-- Choose Customer --</option>
|
||||||
|
<?php foreach ($customers as $customer): ?>
|
||||||
|
<option value="<?= e($customer['id']) ?>">
|
||||||
|
<?= e($customer['name']) ?> (<?= e($customer['email']) ?>)
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products / Items Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">2. Order Line Items</h2>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="addItem()">+ Add Product</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding: 0;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 45%;">Product</th>
|
||||||
|
<th style="width: 18%;">Price ($)</th>
|
||||||
|
<th style="width: 15%;">Qty</th>
|
||||||
|
<th style="width: 17%; text-align: right;">Subtotal ($)</th>
|
||||||
|
<th style="width: 5%;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="(item, index) in items" :key="index">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<select :name="`items[${index}][product_id]`"
|
||||||
|
class="form-select"
|
||||||
|
x-model="item.product_id"
|
||||||
|
@change="onProductSelect(index)"
|
||||||
|
required>
|
||||||
|
<option value="">-- Select Product --</option>
|
||||||
|
<?php foreach ($products as $prod): ?>
|
||||||
|
<option value="<?= e($prod['id']) ?>"
|
||||||
|
data-price="<?= e($prod['price']) ?>"
|
||||||
|
data-name="<?= e($prod['name']) ?>"
|
||||||
|
data-stock="<?= e($prod['stock']) ?>">
|
||||||
|
<?= e($prod['name']) ?> ($<?= number_format((float)$prod['price'], 2) ?> | Stock: <?= e($prod['stock']) ?>)
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<input type="hidden" :name="`items[${index}][product_name]`" :value="item.product_name">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number"
|
||||||
|
step="0.01"
|
||||||
|
:name="`items[${index}][unit_price]`"
|
||||||
|
class="form-control"
|
||||||
|
x-model.number="item.unit_price"
|
||||||
|
readonly
|
||||||
|
style="background-color: #f8fafc;">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number"
|
||||||
|
min="1"
|
||||||
|
:name="`items[${index}][quantity]`"
|
||||||
|
class="form-control"
|
||||||
|
x-model.number="item.quantity"
|
||||||
|
required>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; font-weight: 600;" x-text="'$' + (item.quantity * item.unit_price).toFixed(2)">
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<button type="button"
|
||||||
|
@click="removeItem(index)"
|
||||||
|
class="btn btn-sm"
|
||||||
|
style="color: var(--danger); background: none; border: none; font-size: 1.25rem;"
|
||||||
|
:disabled="items.length <= 1">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer" style="display: flex; justify-content: flex-end;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="addItem()">+ Add Another Line</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">3. Order Notes / Special Instructions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<textarea name="notes" class="form-textarea" rows="3" placeholder="Optional notes for delivery, customer requests, or reference..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Summary & Actions -->
|
||||||
|
<div>
|
||||||
|
<div class="card" style="position: sticky; top: 5.5rem;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Order Summary</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Initial Status</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="Processing">Processing</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: 1rem;">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span style="color: var(--text-muted);">Items Subtotal:</span>
|
||||||
|
<span style="font-weight: 600;" x-text="'$' + calculateSubtotal().toFixed(2)">$0.00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row" style="align-items: center; margin-top: 0.5rem;">
|
||||||
|
<span style="color: var(--text-muted);">Tax Amount ($):</span>
|
||||||
|
<input type="number" step="0.01" min="0" name="tax" class="form-control" style="width: 100px; padding: 0.35rem 0.5rem; text-align: right;" x-model.number="tax">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row" style="align-items: center; margin-top: 0.5rem;">
|
||||||
|
<span style="color: var(--text-muted);">Discount ($):</span>
|
||||||
|
<input type="number" step="0.01" min="0" name="discount" class="form-control" style="width: 100px; padding: 0.35rem 0.5rem; text-align: right; color: var(--danger);" x-model.number="discount">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row summary-total">
|
||||||
|
<span>Grand Total:</span>
|
||||||
|
<span x-text="'$' + calculateGrandTotal().toFixed(2)">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.85rem; font-size: 1rem;">
|
||||||
|
Place Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function orderForm() {
|
||||||
|
return {
|
||||||
|
tax: 0.00,
|
||||||
|
discount: 0.00,
|
||||||
|
items: [
|
||||||
|
{ product_id: '', product_name: '', unit_price: 0, quantity: 1 }
|
||||||
|
],
|
||||||
|
addItem() {
|
||||||
|
this.items.push({ product_id: '', product_name: '', unit_price: 0, quantity: 1 });
|
||||||
|
},
|
||||||
|
removeItem(index) {
|
||||||
|
if (this.items.length > 1) {
|
||||||
|
this.items.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProductSelect(index) {
|
||||||
|
const item = this.items[index];
|
||||||
|
if (!item.product_id) {
|
||||||
|
item.unit_price = 0;
|
||||||
|
item.product_name = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find option in DOM to grab data attributes
|
||||||
|
const selectEl = document.querySelectorAll('select[name^="items"]')[index];
|
||||||
|
if (selectEl) {
|
||||||
|
const selectedOption = selectEl.options[selectEl.selectedIndex];
|
||||||
|
if (selectedOption) {
|
||||||
|
item.unit_price = parseFloat(selectedOption.getAttribute('data-price') || 0);
|
||||||
|
item.product_name = selectedOption.getAttribute('data-name') || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
calculateSubtotal() {
|
||||||
|
return this.items.reduce((sum, item) => {
|
||||||
|
return sum + ((item.unit_price || 0) * (item.quantity || 0));
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
calculateGrandTotal() {
|
||||||
|
const subtotal = this.calculateSubtotal();
|
||||||
|
const total = subtotal + (Number(this.tax) || 0) - (Number(this.discount) || 0);
|
||||||
|
return Math.max(0, total);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Orders</h1>
|
||||||
|
<p class="page-subtitle">Track, filter, and manage customer orders</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/orders/create" class="btn btn-primary">+ Create New Order</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters & Search -->
|
||||||
|
<div class="card" style="margin-bottom: 1.25rem;">
|
||||||
|
<div class="card-body" style="padding: 1rem 1.25rem;">
|
||||||
|
<form method="GET" action="/orders" class="filter-bar" style="margin-bottom: 0;">
|
||||||
|
<div style="flex: 1; min-width: 240px;">
|
||||||
|
<input type="text" name="search" class="form-control" placeholder="Search by Order #, Customer name or email..." value="<?= e($search ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width: 180px;">
|
||||||
|
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||||
|
<option value="all" <?= ($currentStatus ?? 'all') === 'all' ? 'selected' : '' ?>>All Statuses</option>
|
||||||
|
<option value="Pending" <?= ($currentStatus ?? '') === 'Pending' ? 'selected' : '' ?>>Pending</option>
|
||||||
|
<option value="Processing" <?= ($currentStatus ?? '') === 'Processing' ? 'selected' : '' ?>>Processing</option>
|
||||||
|
<option value="Completed" <?= ($currentStatus ?? '') === 'Completed' ? 'selected' : '' ?>>Completed</option>
|
||||||
|
<option value="Cancelled" <?= ($currentStatus ?? '') === 'Cancelled' ? 'selected' : '' ?>>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary">Search</button>
|
||||||
|
<?php if (!empty($search) || ($currentStatus ?? 'all') !== 'all'): ?>
|
||||||
|
<a href="/orders" class="btn btn-secondary" style="color: var(--danger);">Reset</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order Number</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Subtotal</th>
|
||||||
|
<th>Tax</th>
|
||||||
|
<th>Discount</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th style="text-align: right;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($orders)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align: center; color: var(--text-muted); padding: 3rem;">
|
||||||
|
No orders found matching your filter criteria.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($orders as $order): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/orders/<?= e($order['id']) ?>" style="font-weight: 600; color: var(--primary); text-decoration: none;">
|
||||||
|
<?= e($order['order_number']) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 500;"><?= e($order['customer_name']) ?></div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted);"><?= e($order['customer_email']) ?></div>
|
||||||
|
</td>
|
||||||
|
<td><?= money($order['subtotal']) ?></td>
|
||||||
|
<td><?= money($order['tax']) ?></td>
|
||||||
|
<td style="color: var(--danger);">-<?= money($order['discount']) ?></td>
|
||||||
|
<td style="font-weight: 700; color: var(--primary); font-size: 1rem;"><?= money($order['total']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-<?= strtolower(e($order['status'])) ?>">
|
||||||
|
<?= e($order['status']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.85rem;"><?= date('Y-m-d H:i', strtotime($order['created_at'])) ?></td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<a href="/orders/<?= e($order['id']) ?>" class="btn btn-secondary btn-sm">Details</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Order <?= e($order['order_number']) ?></h1>
|
||||||
|
<p class="page-subtitle">Created on <?= date('F j, Y, g:i a', strtotime($order['created_at'])) ?></p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="window.print()">🖨️ Print Invoice</button>
|
||||||
|
<a href="/orders" class="btn btn-secondary">← Back to Orders</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||||
|
|
||||||
|
<!-- Left Column: Detailed Invoice -->
|
||||||
|
<div class="invoice-card">
|
||||||
|
<div class="invoice-header">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size: 1.5rem; font-weight: 700; color: var(--primary);">SimpleOrder</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.875rem;">Official Order Invoice</p>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-meta">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 700;"><?= e($order['order_number']) ?></div>
|
||||||
|
<div style="margin-top: 0.25rem;">
|
||||||
|
<span class="badge badge-<?= strtolower(e($order['status'])) ?>">
|
||||||
|
Status: <?= e($order['status']) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 2rem;">
|
||||||
|
<div>
|
||||||
|
<h4 style="font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.5rem;">Billed To:</h4>
|
||||||
|
<div style="font-weight: 600; font-size: 1.05rem;"><?= e($order['customer_name']) ?></div>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.9rem;"><?= e($order['customer_email']) ?></div>
|
||||||
|
<?php if (!empty($order['customer_phone'])): ?>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.9rem;"><?= e($order['customer_phone']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($order['customer_address'])): ?>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.9rem; margin-top: 0.25rem;"><?= nl2br(e($order['customer_address'])) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 style="font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.5rem;">Order Details:</h4>
|
||||||
|
<div style="font-size: 0.9rem; margin-bottom: 0.25rem;"><strong>Date:</strong> <?= date('M d, Y', strtotime($order['created_at'])) ?></div>
|
||||||
|
<div style="font-size: 0.9rem; margin-bottom: 0.25rem;"><strong>Payment Status:</strong> Paid / Pending</div>
|
||||||
|
<div style="font-size: 0.9rem;"><strong>Last Updated:</strong> <?= date('M d, Y H:i', strtotime($order['updated_at'] ?? $order['created_at'])) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Table -->
|
||||||
|
<table class="table" style="margin-bottom: 1.5rem;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item Description</th>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th style="text-align: right;">Unit Price</th>
|
||||||
|
<th style="text-align: center;">Qty</th>
|
||||||
|
<th style="text-align: right;">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($order['items'] as $item): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 600;"><?= e($item['product_name']) ?></div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted);"><?= e($item['category'] ?? 'Product') ?></div>
|
||||||
|
</td>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem;"><?= e($item['sku'] ?? 'N/A') ?></td>
|
||||||
|
<td style="text-align: right;"><?= money($item['unit_price']) ?></td>
|
||||||
|
<td style="text-align: center; font-weight: 600;"><?= e($item['quantity']) ?></td>
|
||||||
|
<td style="text-align: right; font-weight: 600;"><?= money($item['total_price']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals Breakdown -->
|
||||||
|
<div class="order-summary-box">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span style="color: var(--text-muted);">Subtotal:</span>
|
||||||
|
<span><?= money($order['subtotal']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span style="color: var(--text-muted);">Tax:</span>
|
||||||
|
<span><?= money($order['tax']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if ((float)$order['discount'] > 0): ?>
|
||||||
|
<div class="summary-row" style="color: var(--danger);">
|
||||||
|
<span>Discount:</span>
|
||||||
|
<span>-<?= money($order['discount']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="summary-row summary-total">
|
||||||
|
<span>Grand Total:</span>
|
||||||
|
<span><?= money($order['total']) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($order['notes'])): ?>
|
||||||
|
<div style="margin-top: 2rem; padding: 1rem; background-color: #f8fafc; border-radius: var(--radius-sm); border-left: 3px solid var(--primary);">
|
||||||
|
<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 0.25rem;">Order Notes:</div>
|
||||||
|
<div style="font-size: 0.9rem; color: var(--text-muted);"><?= nl2br(e($order['notes'])) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Status Controls & Actions -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Update Status</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/orders/<?= e($order['id']) ?>/status">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Current Status</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="Pending" <?= $order['status'] === 'Pending' ? 'selected' : '' ?>>Pending</option>
|
||||||
|
<option value="Processing" <?= $order['status'] === 'Processing' ? 'selected' : '' ?>>Processing</option>
|
||||||
|
<option value="Completed" <?= $order['status'] === 'Completed' ? 'selected' : '' ?>>Completed</option>
|
||||||
|
<option value="Cancelled" <?= $order['status'] === 'Cancelled' ? 'selected' : '' ?>>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Update Order Status</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title" style="color: var(--danger);">Danger Zone</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem;">Permanently remove this order and its associated records.</p>
|
||||||
|
<form method="POST" action="/orders/<?= e($order['id']) ?>/delete" onsubmit="return confirm('Are you sure you want to permanently delete this order?');">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<button type="submit" class="btn btn-danger" style="width: 100%;">Delete Order</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Products & Inventory</h1>
|
||||||
|
<p class="page-subtitle">Manage catalog items, pricing, and stock levels</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||||
|
|
||||||
|
<!-- Products Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Available Products (<?= count($products) ?>)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Product Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($products)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 2rem;">
|
||||||
|
No products found in the catalog.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($products as $prod): ?>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: monospace; font-weight: 600; font-size: 0.85rem;"><?= e($prod['sku']) ?></td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 600;"><?= e($prod['name']) ?></div>
|
||||||
|
<?php if (!empty($prod['description'])): ?>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted); max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
<?= e($prod['description']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge-neutral"><?= e($prod['category']) ?></span></td>
|
||||||
|
<td style="font-weight: 600;"><?= money($prod['price']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($prod['stock'] <= 5): ?>
|
||||||
|
<span class="badge badge-cancelled"><?= e($prod['stock']) ?> (Low)</span>
|
||||||
|
<?php elseif ($prod['stock'] <= 25): ?>
|
||||||
|
<span class="badge badge-pending"><?= e($prod['stock']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-completed"><?= e($prod['stock']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Product Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Add New Product</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/products">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">SKU / Code <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="sku" class="form-control" placeholder="e.g. PROD-007" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Product Name <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="e.g. Ergonomic Keyboard" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Price ($) <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="number" step="0.01" min="0.01" name="price" class="form-control" placeholder="49.99" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Initial Stock <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="number" min="0" name="stock" class="form-control" placeholder="50" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" name="category" class="form-control" placeholder="Electronics, Accessories, etc.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea name="description" class="form-textarea" rows="2" placeholder="Brief product overview..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Create Product</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Start a Food / Canteen Session</h1>
|
||||||
|
<p class="page-subtitle">Host a lunch order, post the available menu, and collect orders from your team</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/" class="btn btn-secondary">← Back to Active Sessions</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="max-width: 680px; margin: 0 auto;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Session Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/sessions">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Session Title <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="title" class="form-control" placeholder="e.g. Dea's Kantin Bu Siti Lunch Run" value="<?= e($currentUser['name']) ?>'s Lunch Run" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Canteen / Restaurant Name <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="text" name="canteen_name" class="form-control" placeholder="e.g. Kantin Bu Siti / Nasi Padang Sederhana" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Date <span style="color: var(--danger);">*</span></label>
|
||||||
|
<input type="date" name="session_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notice / Cut-off Instructions</label>
|
||||||
|
<input type="text" name="notes" class="form-control" placeholder="e.g. Please place your order before 11:30 AM! Delivery around 12:15 PM.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 1.5rem;">
|
||||||
|
<label class="form-label">
|
||||||
|
<span>Today's Available Menu Options (Paste line by line from chat):</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="menu_text" class="form-textarea" rows="5" placeholder="1. Ayam Bakar Madu + Nasi 2. Ayam Geprek Sambal Bawang 3. Nasi Goreng Spesial 4. Soto Ayam Lamongan 5. Es Teh Manis 6. Es Jeruk Segar"></textarea>
|
||||||
|
<small style="color: var(--text-muted); font-size: 0.8rem; margin-top: 0.25rem; display: block;">
|
||||||
|
💡 Prices are not needed yet! You can settle final prices after the food arrives with the receipt.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.85rem; font-size: 1rem; margin-top: 1rem;">
|
||||||
|
🚀 Publish Food Session & Open for Orders
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Lunch Sessions History</h1>
|
||||||
|
<p class="page-subtitle">Archive of past daily canteen lunch orders and billing</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/" class="btn btn-primary">← Back to Today's Session</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Session Title</th>
|
||||||
|
<th>Canteen</th>
|
||||||
|
<th>Orders</th>
|
||||||
|
<th>Menu Items</th>
|
||||||
|
<th>Total Billed</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style="text-align: right;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($sessions)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" style="text-align: center; color: var(--text-muted); padding: 3rem;">
|
||||||
|
No past sessions found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($sessions as $s): ?>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: 600;"><?= date('M d, Y', strtotime($s['session_date'])) ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/sessions/<?= e($s['id']) ?>" style="font-weight: 600; color: var(--primary); text-decoration: none;">
|
||||||
|
<?= e($s['title']) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><?= e($s['canteen_name']) ?></td>
|
||||||
|
<td><?= e($s['order_count']) ?> people</td>
|
||||||
|
<td><?= e($s['menu_count']) ?> items</td>
|
||||||
|
<td style="font-weight: 700; color: var(--primary);"><?= money($s['total_session_amount']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-<?= match($s['status']) {
|
||||||
|
'open' => 'completed',
|
||||||
|
'closed' => 'warning',
|
||||||
|
'arrived' => 'processing',
|
||||||
|
default => 'neutral'
|
||||||
|
} ?>">
|
||||||
|
<?= strtoupper(e($s['status'])) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<a href="/sessions/<?= e($s['id']) ?>" class="btn btn-secondary btn-sm">View Board</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Active Food & Canteen Sessions</h1>
|
||||||
|
<p class="page-subtitle">Join an active lunch session or host your own food order for the team</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/sessions/create" class="btn btn-primary">➕ Start a Food Session</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($activeSessions)): ?>
|
||||||
|
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 0.75rem;">🍱</div>
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 700; color: var(--text-main);">No Active Food Sessions Right Now</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.5rem; max-width: 400px; margin-left: auto; margin-right: auto;">
|
||||||
|
Anyone can start a session! Click below to post today's canteen menu or coordinate a team food run.
|
||||||
|
</p>
|
||||||
|
<div style="margin-top: 1.5rem;">
|
||||||
|
<a href="/sessions/create" class="btn btn-primary">➕ Start New Food Session</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-bottom: 2.5rem;">
|
||||||
|
<?php foreach ($activeSessions as $session): ?>
|
||||||
|
<div class="card" style="display: flex; flex-direction: column; height: 100%; border: 1px solid var(--border-color); transition: transform 0.15s ease, box-shadow 0.15s ease;">
|
||||||
|
<div class="card-header" style="background: white; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-<?= match($session['status']) {
|
||||||
|
'open' => 'completed',
|
||||||
|
'closed' => 'warning',
|
||||||
|
'arrived' => 'processing',
|
||||||
|
default => 'neutral'
|
||||||
|
} ?>" style="font-size: 0.75rem; margin-bottom: 0.4rem;">
|
||||||
|
<?= strtoupper(e($session['status'])) ?>
|
||||||
|
</span>
|
||||||
|
<h2 style="font-size: 1.15rem; font-weight: 700; color: var(--text-main); margin-top: 0.2rem;">
|
||||||
|
<a href="/sessions/<?= e($session['id']) ?>" style="text-decoration: none; color: inherit;">
|
||||||
|
<?= e($session['title']) ?>
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body" style="flex: 1; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||||
|
<div style="font-size: 0.9rem; color: var(--text-muted);">
|
||||||
|
🏢 Canteen: <strong><?= e($session['canteen_name']) ?></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-muted); display: flex; align-items: center; gap: 0.4rem;">
|
||||||
|
<span>👤 Hosted by:</span>
|
||||||
|
<strong><?= e($session['creator_name']) ?></strong>
|
||||||
|
<span style="font-size: 0.75rem; background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm);">@<?= e($session['creator_username']) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($session['notes'])): ?>
|
||||||
|
<div style="font-size: 0.825rem; color: #1e40af; background: #eff6ff; padding: 0.5rem 0.75rem; border-radius: var(--radius-sm); border-left: 3px solid #3b82f6;">
|
||||||
|
📢 <?= e($session['notes']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-top: auto; padding-top: 0.75rem; border-top: 1px dashed var(--border-color); font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
<div>👥 <strong><?= e($session['order_count']) ?></strong> ordered</div>
|
||||||
|
<div>🍱 <strong><?= e($session['menu_count']) ?></strong> menu items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer" style="background: #f8fafc; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-muted);"><?= date('M d, Y', strtotime($session['session_date'])) ?></span>
|
||||||
|
<a href="/sessions/<?= e($session['id']) ?>" class="btn btn-primary btn-sm">
|
||||||
|
<?= $session['status'] === 'open' ? '🍱 View & Order →' : '📋 View Live Board →' ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<h1 class="page-title"><?= e($session['title']) ?></h1>
|
||||||
|
<span class="badge badge-<?= match($session['status']) {
|
||||||
|
'open' => 'completed',
|
||||||
|
'closed' => 'warning',
|
||||||
|
'arrived' => 'processing',
|
||||||
|
default => 'neutral'
|
||||||
|
} ?>" style="font-size: 0.85rem; padding: 0.35rem 0.75rem;">
|
||||||
|
<?= strtoupper(e($session['status'])) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="page-subtitle">
|
||||||
|
🏢 Canteen: <strong><?= e($session['canteen_name']) ?></strong> •
|
||||||
|
👤 Hosted by: <strong><?= e($session['creator_name']) ?></strong> (@<?= e($session['creator_username']) ?>) •
|
||||||
|
📅 Date: <strong><?= date('F j, Y', strtotime($session['session_date'])) ?></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Controls for Session Creator -->
|
||||||
|
<?php if ($isCreator): ?>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||||
|
<form method="POST" action="/sessions/<?= e($session['id']) ?>/status" style="display: inline-flex; gap: 0.5rem;">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<select name="status" class="form-select" onchange="this.form.submit()" style="font-size: 0.85rem; padding: 0.4rem 0.75rem;">
|
||||||
|
<option value="open" <?= $session['status'] === 'open' ? 'selected' : '' ?>>🟢 Status: Open (Taking Orders)</option>
|
||||||
|
<option value="closed" <?= $session['status'] === 'closed' ? 'selected' : '' ?>>🟡 Status: Closed (Sent to Canteen)</option>
|
||||||
|
<option value="arrived" <?= $session['status'] === 'arrived' ? 'selected' : '' ?>>🔵 Status: Food Arrived (Billing)</option>
|
||||||
|
<option value="completed" <?= $session['status'] === 'completed' ? 'selected' : '' ?>>⚪ Status: Completed / Settled</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($session['notes'])): ?>
|
||||||
|
<div class="alert alert-info" style="background-color: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; margin-bottom: 1.5rem;">
|
||||||
|
<span>📢 <strong>Host Note:</strong> <?= e($session['notes']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Quick Stats Bar -->
|
||||||
|
<div class="grid-stats" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Coworkers Ordered</div>
|
||||||
|
<div class="stat-value"><?= count($session['orders']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #eef2ff; color: #4f46e5;">👥</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Total Food Items</div>
|
||||||
|
<div class="stat-value"><?= array_sum(array_column($session['recap'], 'total_quantity')) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #ecfdf5; color: #10b981;">🍱</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Total Session Bill</div>
|
||||||
|
<div class="stat-value"><?= money(array_sum(array_column($session['orders'], 'total_bill'))) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon" style="background: #fffbeb; color: #f59e0b;">💵</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main 2-Column Hub Layout -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1.2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||||
|
|
||||||
|
<!-- Left Column: Self-Ordering Widget & Live Coworker Orders Feed -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Interactive Self-Order Form (Alpine.js) -->
|
||||||
|
<?php if ($session['status'] === 'open'): ?>
|
||||||
|
<div class="card" x-data="canteenOrderForm()" style="border: 2px solid var(--primary);">
|
||||||
|
<div class="card-header" style="background: var(--primary-light); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title" style="color: var(--primary);">
|
||||||
|
<?= $myOrder ? '✏️ Update Your Lunch Order' : '➕ Place Your Lunch Order' ?>
|
||||||
|
</h2>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.15rem;">
|
||||||
|
Ordering as: <strong><?= e($currentUser['name']) ?></strong> (@<?= e($currentUser['username']) ?>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($myOrder): ?>
|
||||||
|
<span class="badge badge-completed">Already Ordered</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/employee-orders" @submit="validateSubmit($event)">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<input type="hidden" name="session_id" value="<?= e($session['id']) ?>">
|
||||||
|
|
||||||
|
<!-- Multi-Select Menu Selection -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>Click items to Multi-Select:</span>
|
||||||
|
<span style="font-size: 0.75rem; font-weight: normal; color: var(--text-muted);">
|
||||||
|
<span x-text="selectedItems.length">0</span> item(s) selected
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<?php if (empty($session['menus'])): ?>
|
||||||
|
<div style="padding: 1.5rem; text-align: center; background: #f8fafc; border-radius: var(--radius-sm); border: 1px dashed var(--border-color);">
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem;">No menu listed yet for today.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="menu-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 0.6rem;">
|
||||||
|
<?php foreach ($session['menus'] as $menu): ?>
|
||||||
|
<div class="menu-select-card"
|
||||||
|
:class="{ 'is-selected': isSelected(<?= e($menu['id']) ?>) }"
|
||||||
|
@click="toggleItem(<?= e($menu['id']) ?>, '<?= addslashes(e($menu['name'])) ?>')">
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 0.5rem;">
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="isSelected(<?= e($menu['id']) ?>)"
|
||||||
|
style="margin-top: 0.2rem; pointer-events: none;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-weight: 600; font-size: 0.875rem; line-height: 1.3;">
|
||||||
|
<?= e($menu['name']) ?>
|
||||||
|
</div>
|
||||||
|
<?php if ((float)$menu['final_price'] > 0): ?>
|
||||||
|
<div style="font-size: 0.775rem; color: var(--success); font-weight: 600; margin-top: 0.2rem;">
|
||||||
|
<?= money($menu['final_price']) ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="font-size: 0.725rem; color: var(--text-muted); margin-top: 0.2rem;">
|
||||||
|
(Price settled on arrival)
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Items Customizer (Quantity & Notes) -->
|
||||||
|
<template x-if="selectedItems.length > 0">
|
||||||
|
<div style="margin-top: 1.25rem; background: #f8fafc; border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 1rem;">
|
||||||
|
<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 0.75rem; color: var(--text-main);">
|
||||||
|
📝 Customize Quantities & Requests:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||||
|
<template x-for="(item, idx) in selectedItems" :key="idx">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; background: white; padding: 0.6rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color); flex-wrap: wrap;">
|
||||||
|
<input type="hidden" :name="`items[${idx}][session_menu_id]`" :value="item.id">
|
||||||
|
<input type="hidden" :name="`items[${idx}][item_name]`" :value="item.name">
|
||||||
|
|
||||||
|
<div style="flex: 2; min-width: 140px; font-weight: 600; font-size: 0.85rem;" x-text="item.name"></div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.35rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" style="padding: 0.15rem 0.5rem; height: 28px;" @click="item.quantity = Math.max(1, item.quantity - 1)">-</button>
|
||||||
|
<input type="number" min="1" :name="`items[${idx}][quantity]`" class="form-control" style="width: 50px; text-align: center; padding: 0.2rem;" x-model.number="item.quantity">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" style="padding: 0.15rem 0.5rem; height: 28px;" @click="item.quantity++">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex: 3; min-width: 160px;">
|
||||||
|
<input type="text" :name="`items[${idx}][notes]`" class="form-control" placeholder="Notes (e.g. no spicy, extra egg)" x-model="item.notes" style="font-size: 0.825rem; padding: 0.3rem 0.5rem;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" @click="removeItem(idx)" style="background: none; border: none; color: var(--danger); font-size: 1.25rem; cursor: pointer; padding: 0 0.25rem;">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- General Order Note -->
|
||||||
|
<div class="form-group" style="margin-top: 1rem;">
|
||||||
|
<label class="form-label">General Note (Optional)</label>
|
||||||
|
<input type="text" name="notes" class="form-control" placeholder="e.g. Please put in plastic bag" value="<?= e($myOrder['notes'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem; margin-top: 0.5rem; font-size: 1rem;">
|
||||||
|
🚀 <?= $myOrder ? 'Update My Order' : 'Submit My Lunch Order' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card" style="background: #f8fafc; border: 1px dashed var(--border-color);">
|
||||||
|
<div class="card-body" style="text-align: center; padding: 2rem;">
|
||||||
|
<div style="font-size: 2rem; margin-bottom: 0.5rem;">🔒</div>
|
||||||
|
<h3 style="font-size: 1.1rem; font-weight: 600;">Orders Closed for this Session</h3>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.25rem;">
|
||||||
|
This order session is no longer taking new submissions. Please contact the host (<strong><?= e($session['creator_name']) ?></strong>) directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Live Coworker Orders Feed -->
|
||||||
|
<div class="card" style="margin-top: 1.5rem;">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">📋 Orders in this Session (<?= count($session['orders']) ?> people)</h2>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--text-muted);">Real-time feed of all team members who joined</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($session['orders'])): ?>
|
||||||
|
<div class="card-body" style="text-align: center; padding: 3rem; color: var(--text-muted);">
|
||||||
|
No orders placed yet. Be the first to order above!
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display: flex; flex-direction: column;">
|
||||||
|
<?php foreach ($session['orders'] as $order): ?>
|
||||||
|
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; <?= (int)$order['user_id'] === (int)$currentUser['id'] ? 'background-color: #faf5ff;' : '' ?>">
|
||||||
|
<div style="flex: 1; min-width: 250px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<strong style="font-size: 1rem; color: var(--text-main);">
|
||||||
|
<?= e($order['user_name']) ?>
|
||||||
|
<?= (int)$order['user_id'] === (int)$currentUser['id'] ? ' <span style="font-size: 0.75rem; color: var(--primary); font-weight: normal;">(You)</span>' : '' ?>
|
||||||
|
</strong>
|
||||||
|
<?php if (!empty($order['mattermost_username'])): ?>
|
||||||
|
<span style="font-size: 0.775rem; color: var(--text-muted); background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: var(--radius-sm);">@<?= e($order['mattermost_username']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: auto;"><?= date('H:i', strtotime($order['created_at'])) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul style="list-style: none; margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem;">
|
||||||
|
<?php foreach ($order['items'] as $item): ?>
|
||||||
|
<li style="font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-weight: 700; color: var(--primary); background: var(--primary-light); padding: 0.1rem 0.45rem; border-radius: var(--radius-sm); font-size: 0.8rem;"><?= e($item['quantity']) ?>x</span>
|
||||||
|
<span><?= e($item['item_name']) ?></span>
|
||||||
|
<?php if (!empty($item['notes'])): ?>
|
||||||
|
<em style="color: var(--text-muted); font-size: 0.8rem;">(<?= e($item['notes']) ?>)</em>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ((float)$item['total_price'] > 0): ?>
|
||||||
|
<span style="margin-left: auto; font-weight: 600; font-size: 0.85rem;"><?= money($item['total_price']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<?php if (!empty($order['notes'])): ?>
|
||||||
|
<div style="margin-top: 0.5rem; font-size: 0.8rem; color: var(--text-muted); background: #f8fafc; padding: 0.35rem 0.6rem; border-radius: var(--radius-sm);">
|
||||||
|
💬 Note: <?= e($order['notes']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Bill & Payment Status -->
|
||||||
|
<div style="text-align: right; min-width: 140px;">
|
||||||
|
<div style="font-size: 1.15rem; font-weight: 700; color: var(--text-main);">
|
||||||
|
<?= (float)$order['total_bill'] > 0 ? money($order['total_bill']) : '<span style="font-size: 0.8rem; color: var(--text-muted); font-weight: normal;">Pending Price</span>' ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 0.35rem;">
|
||||||
|
<?php if ($isCreator): ?>
|
||||||
|
<form method="POST" action="/employee-orders/<?= e($order['id']) ?>/toggle-paid" style="display: inline;">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<input type="hidden" name="session_id" value="<?= e($session['id']) ?>">
|
||||||
|
<button type="submit" class="badge badge-<?= $order['status'] === 'paid' ? 'completed' : 'pending' ?>" style="cursor: pointer; border: none;">
|
||||||
|
<?= $order['status'] === 'paid' ? '✓ PAID' : '⌛ UNPAID' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-<?= $order['status'] === 'paid' ? 'completed' : 'pending' ?>">
|
||||||
|
<?= $order['status'] === 'paid' ? '✓ PAID' : '⌛ UNPAID' ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($isCreator || (int)$order['user_id'] === (int)$currentUser['id']): ?>
|
||||||
|
<form method="POST" action="/employee-orders/<?= e($order['id']) ?>/delete" style="display: inline; margin-left: 0.35rem;" onsubmit="return confirm('Cancel this order?');">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<input type="hidden" name="session_id" value="<?= e($session['id']) ?>">
|
||||||
|
<button type="submit" style="background: none; border: none; color: var(--danger); font-size: 0.8rem; cursor: pointer;">✕</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Host Tools (Recap, Settlement, Menu Management) -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- 1. Canteen Recap (1-Click Copy for Canteen Placement) -->
|
||||||
|
<div class="card" x-data="canteenRecapHelper()">
|
||||||
|
<div class="card-header" style="background: #f0fdf4; border-bottom: 1px solid #bbf7d0;">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title" style="color: #166534;">📦 Canteen Order Summary</h2>
|
||||||
|
<p style="font-size: 0.8rem; color: #15803d;">Grouped quantities for ordering</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" @click="copyCanteenText()">
|
||||||
|
📋 <span x-text="copyBtnText">Copy Canteen Text</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (empty($session['recap'])): ?>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 1rem;">
|
||||||
|
No items ordered yet.
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<?php foreach ($session['recap'] as $recap): ?>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; padding: 0.4rem 0; border-bottom: 1px dashed var(--border-color);">
|
||||||
|
<div>
|
||||||
|
<span style="font-weight: 700; color: var(--primary); font-size: 0.95rem;"><?= e($recap['total_quantity']) ?>x</span>
|
||||||
|
<strong style="margin-left: 0.35rem; font-size: 0.9rem;"><?= e($recap['item_name']) ?></strong>
|
||||||
|
<?php if (!empty($recap['combined_notes'])): ?>
|
||||||
|
<div style="font-size: 0.775rem; color: var(--text-muted); margin-left: 1.5rem;">
|
||||||
|
Note: <?= e($recap['combined_notes']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<textarea x-ref="recapText" style="display: none;"><?= e(generateCanteenCopyText($session)) ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. End-of-Day Food Arrival & Bill Settlement (Available for Host) -->
|
||||||
|
<?php if ($isCreator): ?>
|
||||||
|
<div class="card" style="border: 2px solid #3b82f6;" x-data="mattermostRecapHelper()">
|
||||||
|
<div class="card-header" style="background: #eff6ff; border-bottom: 1px solid #bfdbfe;">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title" style="color: #1e40af;">💰 Settle Canteen Bill (Arrival)</h2>
|
||||||
|
<p style="font-size: 0.8rem; color: #1e3a8a;">Enter final receipt prices to compute everyone's bill</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/sessions/<?= e($session['id']) ?>/settle-prices">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1rem;">
|
||||||
|
<?php foreach ($session['menus'] as $menu): ?>
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; font-size: 0.875rem;">
|
||||||
|
<span style="flex: 1; font-weight: 500;"><?= e($menu['name']) ?>:</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||||
|
<span style="color: var(--text-muted);">$</span>
|
||||||
|
<input type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
name="prices[<?= e($menu['id']) ?>]"
|
||||||
|
class="form-control"
|
||||||
|
style="width: 90px; padding: 0.25rem 0.5rem; text-align: right;"
|
||||||
|
value="<?= (float)$menu['final_price'] > 0 ? e($menu['final_price']) : '' ?>"
|
||||||
|
placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; font-size: 0.9rem;">
|
||||||
|
⚡ Save Prices & Compute All Bills
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (array_sum(array_column($session['orders'], 'total_bill')) > 0): ?>
|
||||||
|
<div style="margin-top: 1.25rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" style="width: 100%; font-size: 0.85rem;" @click="copyMattermostText()">
|
||||||
|
📢 <span x-text="mmBtnText">Copy Mattermost Billing Breakdown</span>
|
||||||
|
</button>
|
||||||
|
<textarea x-ref="mattermostText" style="display: none;"><?= e(generateMattermostBillText($session)) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Host Menu Manager -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">📝 Menu Options Manager</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/sessions/<?= e($session['id']) ?>/add-menu">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" style="font-size: 0.85rem;">Add More Items (Paste line by line):</label>
|
||||||
|
<textarea name="menu_text" class="form-textarea" rows="2" placeholder="e.g. Extra Kerupuk Es Kopi Susu" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm" style="width: 100%;">+ Add to Menu</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (!empty($session['menus'])): ?>
|
||||||
|
<div style="margin-top: 1rem; border-top: 1px solid var(--border-color); padding-top: 0.75rem;">
|
||||||
|
<ul style="list-style: none; display: flex; flex-direction: column; gap: 0.35rem;">
|
||||||
|
<?php foreach ($session['menus'] as $m): ?>
|
||||||
|
<li style="display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; padding: 0.25rem 0;">
|
||||||
|
<span>• <?= e($m['name']) ?></span>
|
||||||
|
<form method="POST" action="/sessions/<?= e($session['id']) ?>/delete-menu" style="display: inline;">
|
||||||
|
<?= \App\Core\View::csrfField() ?>
|
||||||
|
<input type="hidden" name="menu_id" value="<?= e($m['id']) ?>">
|
||||||
|
<button type="submit" style="background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.9rem;" title="Delete item">×</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alpine.js Multi-Select Form Logic -->
|
||||||
|
<script>
|
||||||
|
function canteenOrderForm() {
|
||||||
|
<?php
|
||||||
|
$initialItems = [];
|
||||||
|
if (!empty($myOrder['items'])) {
|
||||||
|
foreach ($myOrder['items'] as $item) {
|
||||||
|
$initialItems[] = [
|
||||||
|
'id' => $item['session_menu_id'] ? (int)$item['session_menu_id'] : null,
|
||||||
|
'name' => $item['item_name'],
|
||||||
|
'quantity' => (int)$item['quantity'],
|
||||||
|
'notes' => $item['notes'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
return {
|
||||||
|
selectedItems: <?= json_encode($initialItems) ?>,
|
||||||
|
|
||||||
|
isSelected(menuId) {
|
||||||
|
return this.selectedItems.some(i => i.id === menuId);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleItem(menuId, menuName) {
|
||||||
|
const idx = this.selectedItems.findIndex(i => i.id === menuId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.selectedItems.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
this.selectedItems.push({
|
||||||
|
id: menuId,
|
||||||
|
name: menuName,
|
||||||
|
quantity: 1,
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem(index) {
|
||||||
|
this.selectedItems.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
validateSubmit(e) {
|
||||||
|
if (this.selectedItems.length === 0) {
|
||||||
|
alert('Please click on at least one menu item to select it!');
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canteenRecapHelper() {
|
||||||
|
return {
|
||||||
|
copyBtnText: 'Copy Canteen Text',
|
||||||
|
copyCanteenText() {
|
||||||
|
const text = this.$refs.recapText.value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.copyBtnText = '✓ Copied to Clipboard!';
|
||||||
|
setTimeout(() => this.copyBtnText = 'Copy Canteen Text', 2500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mattermostRecapHelper() {
|
||||||
|
return {
|
||||||
|
mmBtnText: 'Copy Mattermost Breakdown',
|
||||||
|
copyMattermostText() {
|
||||||
|
const text = this.$refs.mattermostText.value;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.mmBtnText = '✓ Mattermost Summary Copied!';
|
||||||
|
setTimeout(() => this.mmBtnText = 'Copy Mattermost Breakdown', 2500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
function generateCanteenCopyText(array $session): string {
|
||||||
|
$out = "🍱 *Order Makan Siang - " . $session['title'] . "*\n";
|
||||||
|
$out .= "Canteen: " . $session['canteen_name'] . " (" . $session['session_date'] . ")\n\n";
|
||||||
|
$out .= "Rekap Pesanan:\n";
|
||||||
|
foreach ($session['recap'] as $r) {
|
||||||
|
$out .= "- " . $r['total_quantity'] . "x " . $r['item_name'];
|
||||||
|
if (!empty($r['combined_notes'])) {
|
||||||
|
$out .= " (" . $r['combined_notes'] . ")";
|
||||||
|
}
|
||||||
|
$out .= "\n";
|
||||||
|
}
|
||||||
|
$totalQty = array_sum(array_column($session['recap'], 'total_quantity'));
|
||||||
|
$out .= "\nTotal: " . $totalQty . " item(s). Terima kasih!";
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMattermostBillText(array $session): string {
|
||||||
|
$out = "📢 **Tagihan Makan Siang (" . $session['title'] . ")**\n\n";
|
||||||
|
$out .= "| Nama | Pesanan | Total Tagihan | Status |\n";
|
||||||
|
$out .= "|:---|:---|:---|:---|\n";
|
||||||
|
|
||||||
|
foreach ($session['orders'] as $o) {
|
||||||
|
$itemList = [];
|
||||||
|
foreach ($o['items'] as $i) {
|
||||||
|
$itemList[] = $i['quantity'] . "x " . $i['item_name'];
|
||||||
|
}
|
||||||
|
$tag = !empty($o['mattermost_username']) ? "@" . $o['mattermost_username'] : $o['user_name'];
|
||||||
|
$status = $o['status'] === 'paid' ? "✅ Lunas" : "⏳ Belum";
|
||||||
|
$out .= "| " . $tag . " | " . implode(', ', $itemList) . " | " . money($o['total_bill']) . " | " . $status . " |\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBill = array_sum(array_column($session['orders'], 'total_bill'));
|
||||||
|
$out .= "\n**Total Keseluruhan:** " . money($totalBill) . "\n";
|
||||||
|
$out .= "Mohon transfer/bayar ke " . $session['creator_name'] . " ya. Terima kasih! 🙏";
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -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:
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
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]
|
||||||
|
</IfModule>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Handle PHP built-in web server static files directly
|
||||||
|
if (php_sapi_name() === 'cli-server') {
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
|
||||||
|
$file = __DIR__ . $path;
|
||||||
|
if ($path !== '/' && file_exists($file) && !is_dir($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error reporting settings
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
// Register Autoloader & Global Helpers
|
||||||
|
require_once __DIR__ . '/../app/Core/Autoloader.php';
|
||||||
|
require_once __DIR__ . '/../app/Core/Helpers.php';
|
||||||
|
\App\Core\Autoloader::register();
|
||||||
|
\App\Core\Autoloader::addNamespace('App', __DIR__ . '/../app');
|
||||||
|
|
||||||
|
// Load environment configuration
|
||||||
|
\App\Core\Config::load(__DIR__ . '/../.env');
|
||||||
|
|
||||||
|
// Initialize Database Schema & Seed Data
|
||||||
|
\App\Core\Database\Database::initSchema();
|
||||||
|
|
||||||
|
// Initialize Request, Response, and Router
|
||||||
|
$request = new \App\Core\Http\Request();
|
||||||
|
$response = new \App\Core\Http\Response();
|
||||||
|
$router = new \App\Core\Routing\Router();
|
||||||
|
|
||||||
|
// Define Routes
|
||||||
|
// Authentication
|
||||||
|
$router->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(
|
||||||
|
'<div style="text-align:center; padding: 4rem; font-family: sans-serif;">
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page or food session you requested does not exist.</p>
|
||||||
|
<p><a href="/" style="color: #4f46e5; text-decoration: none; font-weight: 600;">← Back to Food Sessions</a></p>
|
||||||
|
</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch and send response
|
||||||
|
$result = $router->dispatch($request, $response);
|
||||||
|
$result->send();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Keep storage directory tracked
|
||||||
Reference in New Issue
Block a user