Initial commit

This commit is contained in:
Nayan Hossain 2025-05-08 21:05:06 +06:00
commit e473c9e5c8
20 changed files with 2620 additions and 0 deletions

5
backend/config.php Normal file
View File

@ -0,0 +1,5 @@
<?php
define('DB_HOST', 'localhost');
define('DB_NAME', 'rms');
define('DB_USER', 'rms');
define('DB_PASS', 'kelokelo');

88
backend/dashboard.php Normal file
View File

@ -0,0 +1,88 @@
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
require_once __DIR__ . '/config.php';
try {
$pdo = new PDO(
"mysql:host=".DB_HOST.";dbname=".DB_NAME.";charset=utf8mb4",
DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'DB connection failed']);
exit();
}
$sql = <<<SQL
SELECT
-- Sales sums
SUM(CASE WHEN i.created_at >= CURDATE() THEN il.quantity * mi.price ELSE 0 END) AS daily_sales,
SUM(CASE WHEN i.created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)
AND i.created_at < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY)
THEN il.quantity * mi.price ELSE 0 END) AS weekly_sales,
SUM(CASE WHEN i.created_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH) THEN il.quantity * mi.price ELSE 0 END) AS monthly_sales,
-- Most popular dish in each period
(SELECT mi2.name
FROM invoice_items AS il2
JOIN invoices AS i2 ON i2.id = il2.invoice_id
JOIN menu_items AS mi2 ON mi2.id = il2.menu_item_id
WHERE i2.created_at >= CURDATE()
GROUP BY il2.menu_item_id
ORDER BY SUM(il2.quantity) DESC
LIMIT 1
) AS daily_top,
(SELECT mi3.name
FROM invoice_items AS il3
JOIN invoices AS i3 ON i3.id = il3.invoice_id
JOIN menu_items AS mi3 ON mi3.id = il3.menu_item_id
WHERE i3.created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)
AND i3.created_at < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY)
GROUP BY il3.menu_item_id
ORDER BY SUM(il3.quantity) DESC
LIMIT 1
) AS weekly_top,
(SELECT mi4.name
FROM invoice_items AS il4
JOIN invoices AS i4 ON i4.id = il4.invoice_id
JOIN menu_items AS mi4 ON mi4.id = il4.menu_item_id
WHERE i4.created_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
GROUP BY il4.menu_item_id
ORDER BY SUM(il4.quantity) DESC
LIMIT 1
) AS monthly_top
FROM invoices AS i
JOIN invoice_items AS il ON il.invoice_id = i.id
JOIN menu_items AS mi ON mi.id = il.menu_item_id;
SQL;
try {
$stmt = $pdo->query($sql);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode([
'daily_sales' => (float)$result['daily_sales'],
'weekly_sales' => (float)$result['weekly_sales'],
'monthly_sales' => (float)$result['monthly_sales'],
'daily_top' => $result['daily_top'] ?? null,
'weekly_top' => $result['weekly_top'] ?? null,
'monthly_top' => $result['monthly_top'] ?? null,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Query failed: ' . $e->getMessage()]);
}

147
backend/invoice.php Normal file
View File

@ -0,0 +1,147 @@
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
require_once __DIR__ . '/config.php';
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'DB Connection failed: ' . $e->getMessage()]);
exit();
}
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
$parts = explode('/', trim($_SERVER['PATH_INFO'] ?? '', '/'));
$resource = array_shift($parts);
$id = array_shift($parts);
// Ensure resource is 'invoices'
if ($resource !== 'invoices') {
http_response_code(404);
echo json_encode(['error' => 'Resource not found']);
exit();
}
// Decode JSON input
$input = json_decode(file_get_contents('php://input'), true);
// Helper: fetch a single invoice with its lines
function fetchInvoice(PDO $pdo, $invoiceId) {
// Fetch invoice meta
$stmt = $pdo->prepare("SELECT * FROM invoices WHERE id = ?");
$stmt->execute([$invoiceId]);
$invoice = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$invoice) return null;
// Fetch lines
$stmt = $pdo->prepare("
SELECT il.id, il.menu_item_id, il.quantity,
mi.name, mi.price
FROM invoice_items il
JOIN menu_items mi ON mi.id = il.menu_item_id
WHERE il.invoice_id = ?
");
$stmt->execute([$invoiceId]);
$lines = $stmt->fetchAll(PDO::FETCH_ASSOC);
$invoice['lines'] = $lines;
return $invoice;
}
switch ($method) {
case 'GET':
if ($id) {
// GET /invoices/{id}
$invoice = fetchInvoice($pdo, $id);
if (!$invoice) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
} else {
echo json_encode($invoice);
}
} else {
// GET /invoices
// List all invoices without lines
$stmt = $pdo->query("SELECT id, created_at FROM invoices ORDER BY created_at DESC");
$invs = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($invs);
}
break;
case 'POST':
// POST /invoices
// Expect payload { lines: [ { menu_item_id, quantity }, ... ] }
if (empty($input['lines']) || !is_array($input['lines'])) {
http_response_code(400);
echo json_encode(['error' => 'Invoice lines are required']);
exit();
}
try {
$pdo->beginTransaction();
// Create invoice record
$stmt = $pdo->prepare("INSERT INTO invoices () VALUES ()");
$stmt->execute();
$invoiceId = $pdo->lastInsertId();
// Insert each line
$stmtLine = $pdo->prepare("
INSERT INTO invoice_items (invoice_id, menu_item_id, quantity)
VALUES (?, ?, ?)
");
foreach ($input['lines'] as $line) {
if (empty($line['menu_item_id']) || empty($line['quantity'])) {
throw new Exception("menu_item_id and quantity required for each line");
}
$stmtLine->execute([
$invoiceId,
$line['menu_item_id'],
$line['quantity']
]);
}
$pdo->commit();
http_response_code(201);
echo json_encode(['message' => 'Invoice created', 'id' => $invoiceId]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Failed to create invoice: ' . $e->getMessage()]);
}
break;
case 'DELETE':
// DELETE /invoices/{id}
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Invoice ID is required']);
exit();
}
$stmt = $pdo->prepare("DELETE FROM invoices WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['message' => 'Invoice deleted']);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
break;
}

118
backend/menu.php Normal file
View File

@ -0,0 +1,118 @@
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
require_once __DIR__ . '/config.php';
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
// Handle connection errors
http_response_code(500);
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
exit();
}
// Get the HTTP method and request URI
$method = $_SERVER['REQUEST_METHOD'];
$request = explode('/', trim($_SERVER['PATH_INFO'] ?? '', '/'));
$resource = array_shift($request);
$id = array_shift($request);
// Ensure the resource is 'menu'
if ($resource !== 'menu') {
http_response_code(404);
echo json_encode(['error' => 'Resource not found']);
exit();
}
// Read the input JSON
$input = json_decode(file_get_contents('php://input'), true);
// Define the SQL queries
switch ($method) {
case 'GET':
if ($id) {
// Retrieve a single menu item
$stmt = $pdo->prepare("SELECT * FROM menu_items WHERE id = ?");
$stmt->execute([$id]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if ($item) {
echo json_encode($item);
} else {
http_response_code(404);
echo json_encode(['error' => 'Menu item not found']);
}
} else {
// Retrieve all menu items
$stmt = $pdo->query("SELECT * FROM menu_items");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($items);
}
break;
case 'POST':
// Create a new menu item
if (!isset($input['name'], $input['price'])) {
http_response_code(400);
echo json_encode(['error' => 'Name and price are required']);
exit();
}
$stmt = $pdo->prepare("INSERT INTO menu_items (name, description, price) VALUES (?, ?, ?)");
$stmt->execute([
$input['name'],
$input['description'] ?? null,
$input['price']
]);
$id = $pdo->lastInsertId();
http_response_code(201);
echo json_encode(['message' => 'Menu item created', 'id' => $id]);
break;
case 'PUT':
// Update an existing menu item
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'ID is required']);
exit();
}
$stmt = $pdo->prepare("UPDATE menu_items SET name = ?, description = ?, price = ? WHERE id = ?");
$stmt->execute([
$input['name'] ?? null,
$input['description'] ?? null,
$input['price'] ?? null,
$id
]);
echo json_encode(['message' => 'Menu item updated']);
break;
case 'DELETE':
// Delete a menu item
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'ID is required']);
exit();
}
$stmt = $pdo->prepare("DELETE FROM menu_items WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['message' => 'Menu item deleted']);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
break;
}

22
backend/schema.sql Normal file
View File

@ -0,0 +1,22 @@
CREATE TABLE menu_items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Invoices table
CREATE TABLE invoices (
id INT AUTO_INCREMENT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Invoice items table
CREATE TABLE invoice_items (
id INT AUTO_INCREMENT PRIMARY KEY,
invoice_id INT NOT NULL,
menu_item_id INT NOT NULL,
quantity INT NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

86
frontend/dashboard.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard | Restaurant Management</title>
<script type="module" src="src/main.js" defer></script>
</head>
<body class="h-full flex items-center justify-center p-4">
<div
x-data="dashboardApp()"
class="w-full max-w-2xl bg-white shadow-lg rounded-lg overflow-hidden font-mono"
>
<!-- Header & Close -->
<div class="p-6 flex justify-between items-center">
<h2 class="text-2xl font-semibold">Sales Dashboard</h2>
<div class="flex justify-end items-center">
<button
type="button"
@click="init()"
class="bg-blue-500 text-white px-4 py-2 mr-4 rounded hover:bg-blue-600"
>
Refresh
</button>
<button
type="button"
@click="openMain()"
class="bg-red-400 text-white px-4 py-2 rounded hover:bg-red-500"
>
Close Manager
</button>
</div>
</div>
<!-- KPI Cards -->
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Daily Sales -->
<div class="bg-gray-50 border rounded p-4 flex flex-col">
<div class="text-gray-600">Daily Sales</div>
<div
class="text-2xl font-bold mt-1"
x-text="formatCurrency(dailySales)"
></div>
</div>
<!-- Weekly Sales -->
<div class="bg-gray-50 border rounded p-4 flex flex-col">
<div class="text-gray-600">Weekly Sales</div>
<div
class="text-2xl font-bold mt-1"
x-text="formatCurrency(weeklySales)"
></div>
</div>
<!-- Monthly Sales -->
<div class="bg-gray-50 border rounded p-4 flex flex-col">
<div class="text-gray-600">Monthly Sales</div>
<div
class="text-2xl font-bold mt-1"
x-text="formatCurrency(monthlySales)"
></div>
</div>
<!-- Top Dish (Daily) -->
<div class="bg-gray-50 border rounded p-4 flex flex-col">
<div class="text-gray-600">Top Dish (Daily)</div>
<div
class="text-xl font-medium mt-1"
x-text="dailyTop || '—'"
></div>
</div>
</div>
<!-- Top Dish Weekly & Monthly -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="bg-gray-50 border rounded p-4">
<div class="text-gray-600 mb-2">Top Dish (Weekly)</div>
<div class="text-xl font-medium" x-text="weeklyTop || '—'"></div>
</div>
<div class="bg-gray-50 border rounded p-4">
<div class="text-gray-600 mb-2">Top Dish (Monthly)</div>
<div class="text-xl font-medium" x-text="monthlyTop || '—'"></div>
</div>
</div>
</div>
</div>
</body>
</html>

57
frontend/index.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Restaurant Management</title>
<script type="module" src="src/main.js" defer></script>
</head>
<body class="h-full flex items-center justify-center">
<div x-data="mainApp()" class="w-72 bg-white shadow-lg rounded-lg overflow-hidden font-mono">
<div class="bg-gray-200 text-gray-800 p-4 h-96 flex flex-col justify-between">
<!-- Menu Options -->
<ul id="feature-menu" class="space-y-2">
<template x-for="(opt, idx) in options" :key="opt.view">
<li
:class="idx === selected
? 'bg-gray-800 text-gray-100'
: 'text-gray-700'"
class="px-3 py-2 rounded cursor-pointer"
@click="select(idx)"
>
<span x-text="`${idx + 1}. ${opt.label}`"></span>
</li>
</template>
</ul>
<!-- Soft-Key “Open” -->
<div class="flex justify-center">
<button
@click="openSelected()"
class="bg-green-600 text-white px-4 py-1 rounded hover:bg-green-700 focus:outline-none"
>
Open
</button>
</div>
</div>
<!-- Views Container -->
<div class="p-4">
<template x-if="view === 'dashboard'">
<h2 class="text-xl font-semibold">Dashboard</h2>
</template>
<template x-if="view === 'menu'">
<h2 class="text-xl font-semibold">Menu Manager</h2>
</template>
<template x-if="view === 'invoices'">
<h2 class="text-xl font-semibold">Invoices</h2>
</template>
</div>
</div>
</body>
</html>

106
frontend/invoices.html Normal file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Invoices | Restaurant Management</title>
<script type="module" src="src/main.js" defer></script>
</head>
<body class="h-full flex items-center justify-center p-4">
<div x-data="invoiceApp()" class="w-full max-w-2xl bg-white shadow-lg rounded-lg overflow-hidden font-mono">
<!-- Create Invoice -->
<div class="p-6 space-y-4">
<div class="flex justify-between">
<h2 class="text-2xl font-semibold">Create Invoice</h2>
<button
type="button"
@click="openMain()"
class="bg-red-400 text-white px-4 py-2 rounded hover:bg-red-500"
>Close Manager</button>
</div>
<div class="flex gap-2">
<select x-model="selectedId" class="flex-1 border rounded p-2">
<option value="" disabled>Select item…</option>
<template x-for="item in menu" :key="item.id">
<option :value="item.id"
x-text="item.name + ' ($' + Number(item.price).toFixed(2) + ')'"></option>
</template>
</select>
<input type="number" x-model.number="quantity" min="1" placeholder="Qty"
class="w-20 border rounded p-2" />
<button @click="addLine()"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Add
</button>
</div>
<!-- Invoice Lines & Total -->
<template x-if="lines.length">
<div class="space-y-2 border-t pt-4">
<template x-for="(line, idx) in lines" :key="idx">
<div class="flex justify-between">
<span x-text="line.name + ' × ' + line.qty"></span>
<span x-text="'$' + (line.price * line.qty).toFixed(2)"></span>
</div>
</template>
<div class="flex justify-between font-semibold">
<span>Total:</span>
<span x-text="'$' + total.toFixed(2)"></span>
</div>
<button @click="saveInvoice()"
class="w-full bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
Save Invoice
</button>
</div>
</template>
<template x-if="!lines.length">
<p class="text-gray-500">No items added.</p>
</template>
</div>
<!-- Invoice List -->
<div class="bg-gray-50 p-6 border-t space-y-4 max-h-[400px] overflow-auto">
<h2 class="text-2xl font-semibold">Invoices</h2>
<template x-if="invoices.length">
<template x-for="inv in invoices" :key="inv.id">
<div class="border rounded p-4 bg-white space-y-2">
<div class="flex justify-between items-center">
<span>Invoice #<span x-text="inv.id"></span></span>
<div class="space-x-2">
<button @click="printInvoice(inv)"
class="text-green-600 hover:underline text-sm">
Print
</button>
<button @click="deleteInvoice(inv.id)"
class="text-red-500 hover:underline text-sm">
Delete
</button>
</div>
</div>
<div class="space-y-1 mb-2">
<template x-for="line in inv.lines" :key="line.id">
<div class="flex justify-between text-sm">
<span x-text="line.name + ' × ' + line.quantity"></span>
<span x-text="'$' + (line.price * line.quantity).toFixed(2)"></span>
</div>
</template>
</div>
<div class="flex justify-between font-medium">
<span>Total:</span>
<span x-text="'$' + inv.total.toFixed(2)"></span>
</div>
</div>
</template>
</template>
<template x-if="!invoices.length">
<p class="text-gray-500">No invoices yet.</p>
</template>
</div>
</div>
</body>
</html>

82
frontend/menu.html Normal file
View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Menu Manager | Restaurant Management</title>
<script type="module" src="src/main.js" defer></script>
</head>
<body class="h-full flex items-center justify-center p-4">
<div x-data="menuApp()" class="w-full max-w-2xl bg-white shadow-lg rounded-lg overflow-hidden font-mono">
<!-- Header & Close -->
<div class="p-6 flex justify-between items-center">
<h2 class="text-2xl font-semibold">Menu Management</h2>
<button
type="button"
@click="openMain()"
class="bg-red-400 text-white px-4 py-2 rounded hover:bg-red-500"
>Close Manager</button>
</div>
<!-- New/Edit Form -->
<div class="p-6 space-y-4">
<div class="flex gap-2">
<input
type="text"
x-model="form.name"
placeholder="Item Name"
class="flex-1 border rounded p-2"
/>
<input
type="number"
x-model.number="form.price"
placeholder="Price"
class="w-24 border rounded p-2"
step="0.01"
/>
<button
@click="saveItem()"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
x-text="form.id ? 'Update' : 'Add'"
></button>
</div>
<textarea
x-model="form.description"
placeholder="Description"
class="w-full border rounded p-2"
rows="2"
></textarea>
</div>
<!-- Items Grid -->
<div class="bg-gray-50 p-6 border-t space-y-4 max-h-[400px] overflow-auto">
<template x-if="items.length">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="item in items" :key="item.id">
<div class="border rounded p-4 bg-white flex flex-col justify-between">
<div>
<div class="font-semibold" x-text="item.name"></div>
<div class="text-sm text-gray-600" x-text="item.description"></div>
</div>
<div class="flex justify-between items-center mt-4">
<div class="text-gray-800 font-medium" x-text="`$${Number(item.price).toFixed(2)}`"></div>
<div class="space-x-2">
<button @click="editItem(item)" class="text-blue-500 hover:underline text-sm">Edit</button>
<button @click="deleteItem(item.id)" class="text-red-500 hover:underline text-sm">Delete</button>
</div>
</div>
</div>
</template>
</div>
</template>
<template x-if="!items.length">
<p class="text-gray-500 text-center">No menu items found.</p>
</template>
</div>
</div>
</body>
</html>

1514
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "restaurant-management-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^6.3.1"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.4",
"alpinejs": "^3.14.9",
"tailwindcss": "^4.1.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,40 @@
const baseUrl = "http://localhost:8080/dashboard.php";
export function dashboardApp() {
return {
dailySales: 0,
weeklySales: 0,
monthlySales: 0,
dailyTop: '',
weeklyTop: '',
monthlyTop: '',
async init() {
try {
const res = await fetch(`${baseUrl}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.dailySales = data['daily_sales']
this.weeklySales = data['weekly_sales']
this.monthlySales = data['monthly_sales']
this.dailyTop = data['daily_top']
this.weeklyTop = data['weekly_top']
this.monthlyTop = data['monthly_top']
} catch (e) {
console.error('Analytics fetch failed:', e);
}
},
// Format number as currency
formatCurrency(value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
},
openMain() {
window.location.href = '/'
}
};
}

177
frontend/src/invoiceApp.js Normal file
View File

@ -0,0 +1,177 @@
const baseUrl = "http://localhost:8080/invoice.php";
const menuBaseUrl = "http://localhost:8080/menu.php";
export function invoiceApp() {
return {
// Data
menu: [],
selectedId: "",
quantity: 1,
lines: [],
invoices: [],
// Computed total for current invoice lines
get total() {
return this.lines.reduce((sum, l) => sum + l.price * l.qty, 0);
},
async init() {
await this.loadMenu();
await this.loadInvoices();
},
openMain() {
window.location.href = '/';
},
async loadMenu() {
try {
const res = await fetch(`${menuBaseUrl}/menu`);
this.menu = await res.json();
} catch (e) {
console.error('Failed to load menu:', e);
}
},
async loadInvoices() {
try {
const listRes = await fetch(`${baseUrl}/invoices`);
const list = await listRes.json();
this.invoices = await Promise.all(
list.map(async inv => {
const linesRes = await fetch(`${baseUrl}/invoices/${inv.id}`);
const full = await linesRes.json();
// Ensure numeric values
full.lines = full.lines.map(line => ({
...line,
price: Number(line.price),
quantity: Number(line.quantity)
}));
full.total = full.lines.reduce((s, l) => s + l.price * l.quantity, 0);
full.date = full.created_at || inv.created_at || new Date().toLocaleString();
return full;
})
);
} catch (e) {
console.error('Failed to load invoices:', e);
}
},
addLine() {
if (!this.selectedId || this.quantity < 1) return;
const item = this.menu.find(i => i.id == this.selectedId);
if (!item) return;
this.lines.push({
id: Date.now(),
name: item.name,
price: Number(item.price),
qty: Number(this.quantity)
});
this.selectedId = "";
this.quantity = 1;
},
async saveInvoice() {
if (!this.lines.length) return;
const payload = {
lines: this.lines.map(l => ({
menu_item_id: Number(this.menu.find(i => i.name === l.name).id),
quantity: l.qty
}))
};
try {
const res = await fetch(`${baseUrl}/invoices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
this.lines = [];
await this.loadInvoices();
} else {
console.error('Save invoice failed:', res.status);
}
} catch (e) {
console.error('Error saving invoice:', e);
}
},
async deleteInvoice(id) {
if (!confirm(`Delete invoice #${id}?`)) return;
try {
const res = await fetch(`${baseUrl}/invoices/${id}`, { method: 'DELETE' });
if (res.ok) {
this.invoices = this.invoices.filter(i => i.id !== id);
} else {
console.error('Delete invoice failed:', res.status);
}
} catch (e) {
console.error('Error deleting invoice:', e);
}
},
// Print a formatted invoice
printInvoice(inv) {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice #${inv.id}</title>
<style>
body { font-family: Arial, sans-serif; margin:20px; }
h1 { text-align: center; margin-bottom: 20px; }
.meta { margin-bottom: 20px; }
.meta div { margin-bottom: 4px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f3f3f3; }
.text-right { text-align: right; }
tfoot td { font-weight: bold; background: #f9f9f9; }
</style>
</head>
<body>
<h1>Invoice #${inv.id}</h1>
<div class="meta">
<div>Date: ${inv.date}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="text-right">Qty</th>
<th class="text-right">Unit Price</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
${inv.lines.map(l => `
<tr>
<td>${l.name}</td>
<td class="text-right">${l.quantity}</td>
<td class="text-right">$${l.price.toFixed(2)}</td>
<td class="text-right">$${(l.price * l.quantity).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-right">Total:</td>
<td class="text-right">$${inv.total.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</body>
</html>`;
const win = window.open('', '_blank');
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => win.print(), 300);
win.addEventListener('afterprint', () => win.close());
setTimeout(() => { if (!win.closed) win.close(); }, 2000);
}
};
}

13
frontend/src/main.js Normal file
View File

@ -0,0 +1,13 @@
import './style.css'
import Alpine from 'alpinejs'
import { mainApp } from './mainApp.js'
import { menuApp } from './menuApp.js'
import { invoiceApp } from './invoiceApp.js'
import { dashboardApp } from './dashboardApp.js'
window.mainApp = mainApp
window.menuApp = menuApp
window.invoiceApp = invoiceApp
window.dashboardApp = dashboardApp
Alpine.start()

43
frontend/src/mainApp.js Normal file
View File

@ -0,0 +1,43 @@
export function mainApp() {
return {
options: [
{ label: 'Dashboard', view: 'dashboard' },
{ label: 'Menu Manager', view: 'menu' },
{ label: 'Invoices Manager', view: 'invoices' },
],
selected: 0,
view: 'menu',
init() {
window.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 's') {
this.selected = (this.selected + 1) % this.options.length
}
if (e.key === 'ArrowUp' || e.key === 'w') {
this.selected = (this.selected - 1 + this.options.length) % this.options.length
}
if (e.key === 'Enter') this.openSelected()
})
},
select(idx) {
this.selected = idx
this.view = this.options[idx].view
},
openSelected() {
if (this.options[this.selected].view === 'dashboard') {
window.location.href = '/dashboard'
}
if (this.options[this.selected].view === 'menu') {
window.location.href = '/menu'
}
if (this.options[this.selected].view === 'invoices') {
window.location.href = '/invoices'
} else {
this.view = this.options[this.selected].view
}
},
}
}

70
frontend/src/menuApp.js Normal file
View File

@ -0,0 +1,70 @@
const baseUrl = "http://localhost:8080/menu.php";
export function menuApp() {
return {
items: [],
form: { id: null, name: "", description: "", price: null },
async init() {
try {
const res = await fetch(`${baseUrl}/menu`);
if (!res.ok) throw new Error("Fetch error " + res.status);
this.items = await res.json();
} catch (e) {
console.error("Error loading items:", e);
}
},
resetForm() {
this.form = { id: null, name: "", description: "", price: null };
},
async saveItem() {
const isEdit = Boolean(this.form.id);
const url = isEdit
? `${baseUrl}/menu/${this.form.id}`
: `${baseUrl}/menu`;
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
if (!res.ok) {
console.error("Save failed:", res.status);
return;
}
const payload = await res.json();
if (isEdit) {
const idx = this.items.findIndex(i => i.id === this.form.id);
if (idx !== -1) this.items.splice(idx, 1, { ...this.form });
} else {
this.items.push({ id: payload.id ?? Date.now(), ...this.form });
}
await this.init();
this.resetForm();
},
editItem(item) {
this.form = { ...item };
},
async deleteItem(id) {
const res = await fetch(`${baseUrl}/menu/${id}`, { method: "DELETE" });
if (res.ok) {
this.items = this.items.filter(i => i.id !== id);
} else {
console.error("Delete failed:", res.status);
}
},
openMain() {
window.location.href = '/'
}
};
}

1
frontend/src/style.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

7
frontend/vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
],
})