From 1e156065ecd723c239bb9de4b8589e2a95d3939c Mon Sep 17 00:00:00 2001 From: zuhaibakhtarkhan Date: Wed, 5 Aug 2026 20:47:41 +0500 Subject: [PATCH 1/3] Add Module 3 receipt printing with thermal preview and print APIs. Ship payment/receipt UI, receipts endpoints, and print helpers so checkout can preview and print tickets after a sale. Co-authored-by: Cursor --- backend/app.js | 2 + backend/controllers/receiptController.js | 321 +++++++++++++++++++ backend/package.json | 4 +- backend/routes/receipts.js | 14 + backend/schema.sql | 2 + backend/tests/receipt.test.js | 94 ++++++ backend/utils/seeder.js | 6 +- frontend/components/ItemCard.jsx | 2 +- frontend/components/ReceiptPreview.jsx | 123 ++++++++ frontend/components/UserNavbarDesktop.jsx | 8 +- frontend/components/UserNavbarMobile.jsx | 7 +- frontend/layouts/DashboardLayout.jsx | 18 +- frontend/layouts/MainLayout.jsx | 3 +- frontend/pages/Dashboard.jsx | 112 ++++--- frontend/pages/LandingPage.jsx | 64 ++-- frontend/pages/Placeholders.jsx | 17 +- frontend/pages/Product.jsx | 29 +- frontend/pages/ReceiptPrinting.jsx | 358 ++++++++++++++++++++++ frontend/src/App.jsx | 2 - frontend/src/index.css | 37 ++- frontend/src/router.jsx | 4 +- frontend/utils/printReceipt.js | 59 ++++ 22 files changed, 1165 insertions(+), 121 deletions(-) create mode 100644 backend/controllers/receiptController.js create mode 100644 backend/routes/receipts.js create mode 100644 backend/tests/receipt.test.js create mode 100644 frontend/components/ReceiptPreview.jsx create mode 100644 frontend/pages/ReceiptPrinting.jsx create mode 100644 frontend/utils/printReceipt.js diff --git a/backend/app.js b/backend/app.js index 0978289..1885db3 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,6 +1,7 @@ import express from "express"; import dashboardRouter from "./routes/dashboard.js"; import itemRouter from "./routes/items.js"; +import receiptRouter from "./routes/receipts.js"; import cors from "cors"; const app = express(); @@ -15,5 +16,6 @@ app.use(express.json()); // API Routes app.use("/api/v1/dashboard", dashboardRouter); app.use("/api/v1/items", itemRouter); +app.use("/api/v1/receipts", receiptRouter); export default app; \ No newline at end of file diff --git a/backend/controllers/receiptController.js b/backend/controllers/receiptController.js new file mode 100644 index 0000000..f5c02e9 --- /dev/null +++ b/backend/controllers/receiptController.js @@ -0,0 +1,321 @@ +import pool from "../database.js"; + +const TAX_RATE = parseFloat(process.env.TAX_RATE || "0"); + +const mockOrders = [ + { + id: 1045, + created_at: new Date().toISOString(), + company_id: 1, + company_name: "Biryani Junction", + company_logo: null, + payment_method: "CASH", + printed_at: null, + items: [ + { name: "Full Chicken Biryani", type: "FULL", quantity: 2, unit_price: 520.0, line_total: 1040.0 }, + { name: "Coke 1.5L", type: "FAMILY", quantity: 1, unit_price: 150.0, line_total: 150.0 } + ] + }, + { + id: 1044, + created_at: new Date().toISOString(), + company_id: 1, + company_name: "Biryani Junction", + company_logo: null, + payment_method: "CARD", + printed_at: null, + items: [ + { name: "Half Beef Biryani", type: "HALF", quantity: 1, unit_price: 380.0, line_total: 380.0 }, + { name: "Raita", type: "HALF", quantity: 1, unit_price: 50.0, line_total: 50.0 } + ] + }, + { + id: 1043, + created_at: new Date().toISOString(), + company_id: 1, + company_name: "Biryani Junction", + company_logo: null, + payment_method: "CASH", + printed_at: null, + items: [ + { name: "Family Pack Biryani", type: "FAMILY", quantity: 1, unit_price: 1450.0, line_total: 1450.0 }, + { name: "Salad", type: "HALF", quantity: 2, unit_price: 50.0, line_total: 100.0 } + ] + } +]; + +const buildTotals = (items) => { + const subtotal = items.reduce((sum, item) => sum + Number(item.line_total), 0); + const tax = parseFloat((subtotal * TAX_RATE).toFixed(2)); + const total = parseFloat((subtotal + tax).toFixed(2)); + return { + subtotal: parseFloat(subtotal.toFixed(2)), + tax, + tax_rate: TAX_RATE, + total + }; +}; + +const withTotals = (order) => ({ + ...order, + ...buildTotals(order.items || []) +}); + +const ensureReceiptColumns = async () => { + try { + await pool.query(` + ALTER TABLE orders + ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) DEFAULT 'CASH' + `); + await pool.query(` + ALTER TABLE orders + ADD COLUMN IF NOT EXISTS printed_at TIMESTAMP + `); + } catch (err) { + // Older Postgres or missing table — controllers still work via fallbacks + console.warn("Could not ensure receipt columns:", err.message); + } +}; + +let columnsReady = false; +const ready = async () => { + if (!columnsReady) { + await ensureReceiptColumns(); + columnsReady = true; + } +}; + +const mapOrderRow = (header, itemRows) => { + const items = itemRows.map((row) => ({ + name: row.name, + type: row.type, + quantity: parseInt(row.quantity, 10), + unit_price: parseFloat(row.unit_price), + line_total: parseFloat(row.line_total) + })); + + return withTotals({ + id: header.id, + created_at: header.created_at, + company_id: header.company_id, + company_name: header.company_name, + company_logo: header.company_logo || null, + payment_method: header.payment_method || "CASH", + printed_at: header.printed_at || null, + items + }); +}; + +// GET /api/v1/receipts — recent orders for reprint / preview +export const getRecentReceipts = async (req, res) => { + const { demo, limit = 20 } = req.query; + + if (demo === "true") { + return res.status(200).json({ + success: true, + data: mockOrders.map(withTotals), + isDemoData: true + }); + } + + try { + await ready(); + + const headerQuery = ` + SELECT + o.id, + o.created_at, + o.company_id, + COALESCE(o.payment_method, 'CASH') AS payment_method, + o.printed_at, + c.name AS company_name, + NULLIF(c.logo, '') AS company_logo, + COALESCE(SUM(oi.quantity * i.price), 0) AS order_total + FROM orders o + JOIN company c ON c.id = o.company_id + JOIN order_items oi ON oi.order_id = o.id + JOIN items i ON i.id = oi.item_id + GROUP BY o.id, o.created_at, o.company_id, o.payment_method, o.printed_at, c.name, c.logo + ORDER BY o.id DESC + LIMIT $1; + `; + const headers = await pool.query(headerQuery, [Math.min(parseInt(limit, 10) || 20, 50)]); + + if (headers.rows.length === 0) { + return res.status(200).json({ + success: true, + data: mockOrders.map(withTotals), + isDemoData: true + }); + } + + const orderIds = headers.rows.map((row) => row.id); + const itemsQuery = ` + SELECT + oi.order_id, + i.name, + i.type, + oi.quantity, + i.price AS unit_price, + (oi.quantity * i.price) AS line_total + FROM order_items oi + JOIN items i ON i.id = oi.item_id + WHERE oi.order_id = ANY($1::int[]) + ORDER BY oi.order_id DESC, i.name ASC; + `; + const itemsResult = await pool.query(itemsQuery, [orderIds]); + + const itemsByOrder = {}; + for (const row of itemsResult.rows) { + if (!itemsByOrder[row.order_id]) itemsByOrder[row.order_id] = []; + itemsByOrder[row.order_id].push(row); + } + + const data = headers.rows.map((header) => + mapOrderRow(header, itemsByOrder[header.id] || []) + ); + + return res.status(200).json({ + success: true, + data, + isDemoData: false + }); + } catch (err) { + console.warn("Receipt list failed, using mock data:", err.message); + return res.status(200).json({ + success: true, + data: mockOrders.map(withTotals), + isDemoData: true + }); + } +}; + +// GET /api/v1/receipts/:id — full receipt payload for one order +export const getReceiptById = async (req, res) => { + const { id } = req.params; + const { demo } = req.query; + + if (demo === "true") { + const found = mockOrders.find((o) => String(o.id) === String(id)); + if (!found) { + return res.status(404).json({ success: false, message: "Receipt not found in demo data." }); + } + return res.status(200).json({ success: true, data: withTotals(found), isDemoData: true }); + } + + try { + await ready(); + + const headerQuery = ` + SELECT + o.id, + o.created_at, + o.company_id, + COALESCE(o.payment_method, 'CASH') AS payment_method, + o.printed_at, + c.name AS company_name, + NULLIF(c.logo, '') AS company_logo + FROM orders o + JOIN company c ON c.id = o.company_id + WHERE o.id = $1; + `; + const headerResult = await pool.query(headerQuery, [id]); + + if (headerResult.rows.length === 0) { + const mock = mockOrders.find((o) => String(o.id) === String(id)); + if (mock) { + return res.status(200).json({ success: true, data: withTotals(mock), isDemoData: true }); + } + return res.status(404).json({ success: false, message: "Order not found." }); + } + + const itemsQuery = ` + SELECT + i.name, + i.type, + oi.quantity, + i.price AS unit_price, + (oi.quantity * i.price) AS line_total + FROM order_items oi + JOIN items i ON i.id = oi.item_id + WHERE oi.order_id = $1 + ORDER BY i.name ASC; + `; + const itemsResult = await pool.query(itemsQuery, [id]); + + return res.status(200).json({ + success: true, + data: mapOrderRow(headerResult.rows[0], itemsResult.rows), + isDemoData: false + }); + } catch (err) { + console.warn("Receipt fetch failed:", err.message); + const mock = mockOrders.find((o) => String(o.id) === String(id)) || mockOrders[0]; + return res.status(200).json({ + success: true, + data: withTotals(mock), + isDemoData: true + }); + } +}; + +// PATCH /api/v1/receipts/:id/print — record payment method + printed timestamp +export const markReceiptPrinted = async (req, res) => { + const { id } = req.params; + const paymentMethod = (req.body?.payment_method || "CASH").toUpperCase(); + + if (!["CASH", "CARD"].includes(paymentMethod)) { + return res.status(400).json({ + success: false, + message: "payment_method must be CASH or CARD." + }); + } + + try { + await ready(); + + const result = await pool.query( + ` + UPDATE orders + SET payment_method = $1, + printed_at = NOW() + WHERE id = $2 + RETURNING id, payment_method, printed_at, created_at, company_id; + `, + [paymentMethod, id] + ); + + if (result.rows.length === 0) { + // Demo / missing order — acknowledge so the UI can still print + return res.status(200).json({ + success: true, + message: "Print acknowledged (demo / offline mode).", + data: { + id: Number(id), + payment_method: paymentMethod, + printed_at: new Date().toISOString() + }, + isDemoData: true + }); + } + + return res.status(200).json({ + success: true, + message: "Receipt marked as printed.", + data: result.rows[0], + isDemoData: false + }); + } catch (err) { + console.warn("Mark printed failed:", err.message); + return res.status(200).json({ + success: true, + message: "Print acknowledged (fallback mode).", + data: { + id: Number(id), + payment_method: paymentMethod, + printed_at: new Date().toISOString() + }, + isDemoData: true + }); + } +}; diff --git a/backend/package.json b/backend/package.json index edce32d..0989ef9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,8 +7,8 @@ "type": "module", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "dev": "cross-env NODE_ENV=development nodemon server.js", + "test": "node --test tests/**/*.test.js", + "dev": "NODE_ENV=development nodemon server.js", "seed": "node utils/seeder.js" }, "dependencies": { diff --git a/backend/routes/receipts.js b/backend/routes/receipts.js new file mode 100644 index 0000000..41e0d3b --- /dev/null +++ b/backend/routes/receipts.js @@ -0,0 +1,14 @@ +import express from "express"; +import { + getRecentReceipts, + getReceiptById, + markReceiptPrinted +} from "../controllers/receiptController.js"; + +const router = express.Router(); + +router.get("/", getRecentReceipts); +router.get("/:id", getReceiptById); +router.patch("/:id/print", markReceiptPrinted); + +export default router; diff --git a/backend/schema.sql b/backend/schema.sql index 2af6bf4..51fe484 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -42,6 +42,8 @@ CREATE TABLE orders ( id SERIAL PRIMARY KEY, created_at DATE DEFAULT NOW(), company_id INT NOT NULL, + payment_method VARCHAR(20) DEFAULT 'CASH', + printed_at TIMESTAMP, FOREIGN KEY(company_id) REFERENCES company(id) ON DELETE CASCADE ); diff --git a/backend/tests/receipt.test.js b/backend/tests/receipt.test.js new file mode 100644 index 0000000..fb19f4b --- /dev/null +++ b/backend/tests/receipt.test.js @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import app from "../app.js"; + +const startServer = () => + new Promise((resolve) => { + const server = app.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ server, baseUrl: `http://127.0.0.1:${port}` }); + }); + }); + +const stopServer = (server) => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + +test("GET /api/v1/receipts?demo=true returns receipt payloads", async () => { + const { server, baseUrl } = await startServer(); + + try { + const response = await fetch(`${baseUrl}/api/v1/receipts?demo=true`); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.success, true); + assert.equal(body.isDemoData, true); + assert.ok(Array.isArray(body.data)); + assert.ok(body.data.length > 0); + + const receipt = body.data[0]; + assert.ok(receipt.id); + assert.ok(Array.isArray(receipt.items)); + assert.equal(typeof receipt.subtotal, "number"); + assert.equal(typeof receipt.total, "number"); + assert.ok(receipt.total >= receipt.subtotal); + } finally { + await stopServer(server); + } +}); + +test("GET /api/v1/receipts/:id?demo=true returns a single receipt", async () => { + const { server, baseUrl } = await startServer(); + + try { + const response = await fetch(`${baseUrl}/api/v1/receipts/1045?demo=true`); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.success, true); + assert.equal(body.data.id, 1045); + assert.ok(body.data.items.length > 0); + } finally { + await stopServer(server); + } +}); + +test("PATCH /api/v1/receipts/:id/print records payment method", async () => { + const { server, baseUrl } = await startServer(); + + try { + const response = await fetch(`${baseUrl}/api/v1/receipts/1045/print`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payment_method: "CARD" }), + }); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.success, true); + assert.equal(body.data.payment_method, "CARD"); + assert.ok(body.data.printed_at); + } finally { + await stopServer(server); + } +}); + +test("PATCH /api/v1/receipts/:id/print rejects invalid payment methods", async () => { + const { server, baseUrl } = await startServer(); + + try { + const response = await fetch(`${baseUrl}/api/v1/receipts/1045/print`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payment_method: "CRYPTO" }), + }); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.equal(body.success, false); + } finally { + await stopServer(server); + } +}); diff --git a/backend/utils/seeder.js b/backend/utils/seeder.js index 982dfbe..c132bec 100644 --- a/backend/utils/seeder.js +++ b/backend/utils/seeder.js @@ -31,10 +31,10 @@ const seed = async () => { // 2. Insert Company const companyRes = await client.query(` - INSERT INTO company (name, email, master_admin) - VALUES ($1, $2, $3) + INSERT INTO company (name, logo, email, master_admin) + VALUES ($1, $2, $3, $4) RETURNING id; - `, ["Biryani Junction", "info@biryanijunction.com", userId]); + `, ["Biryani Junction", "", "info@biryanijunction.com", userId]); const companyId = companyRes.rows[0].id; // 3. Update User's company_id diff --git a/frontend/components/ItemCard.jsx b/frontend/components/ItemCard.jsx index 0d6dbdd..a414ff6 100644 --- a/frontend/components/ItemCard.jsx +++ b/frontend/components/ItemCard.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { Pencil, Trash2, Check, X } from 'lucide-react'; const ITEM_TYPES = ['HALF', 'FULL', 'FAMILY']; diff --git a/frontend/components/ReceiptPreview.jsx b/frontend/components/ReceiptPreview.jsx new file mode 100644 index 0000000..b6d6642 --- /dev/null +++ b/frontend/components/ReceiptPreview.jsx @@ -0,0 +1,123 @@ +const formatMoney = (value) => + `Rs. ${Number(value || 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const formatDateTime = (value) => { + const date = value ? new Date(value) : new Date(); + if (Number.isNaN(date.getTime())) { + return { date: "—", time: "—" }; + } + return { + date: date.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }), + time: date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }), + }; +}; + +/** + * Thermal-style receipt preview (58/80mm). + * Rendered on-screen and reused by window.print() via #receipt-print-root. + */ +export default function ReceiptPreview({ order, paymentMethod = "CASH" }) { + if (!order) { + return ( +
+ Select an order to preview the receipt +
+ ); + } + + const { date, time } = formatDateTime(order.printed_at || order.created_at); + const method = (paymentMethod || order.payment_method || "CASH").toUpperCase(); + const taxRatePct = ((order.tax_rate || 0) * 100).toFixed(1); + + return ( +
+
+ {/* Header */} +
+ {order.company_logo ? ( + {order.company_name + ) : ( +
+ BJ +
+ )} +
+ {order.company_name || "Biryani Junction"} +
+
POS Terminal 01
+
+ +
+ +
+ Order #{order.id} + {method} +
+
+ {date} + {time} +
+ +
+ + {/* Line items */} +
+ {(order.items || []).map((item, index) => ( +
+ + {item.quantity}x {item.name} + + {formatMoney(item.line_total)} +
+ ))} +
+ +
+ + {/* Totals */} +
+
+ Subtotal + {formatMoney(order.subtotal)} +
+ {(order.tax_rate || 0) > 0 && ( +
+ Tax ({taxRatePct}%) + {formatMoney(order.tax)} +
+ )} +
+ Total + {formatMoney(order.total)} +
+
+ +
+ +

+ Thank you for dining with us! +

+

+ *** Customer Copy *** +

+
+
+ ); +} diff --git a/frontend/components/UserNavbarDesktop.jsx b/frontend/components/UserNavbarDesktop.jsx index 105356f..d3ca1dd 100644 --- a/frontend/components/UserNavbarDesktop.jsx +++ b/frontend/components/UserNavbarDesktop.jsx @@ -1,14 +1,9 @@ -import React from 'react' import { LayoutDashboard, Package, ReceiptText, Printer, - Menu, - X, ChefHat, - User, - Activity } from 'lucide-react'; import { NavLink } from 'react-router-dom'; @@ -44,6 +39,7 @@ const UserNavbarDesktop = () => { ` flex items-center gap-3 px-4 py-3 rounded-xl font-medium text-sm transition-all duration-200 group ${isActive @@ -73,4 +69,4 @@ const UserNavbarDesktop = () => { ) } -export default UserNavbarDesktop \ No newline at end of file +export default UserNavbarDesktop diff --git a/frontend/components/UserNavbarMobile.jsx b/frontend/components/UserNavbarMobile.jsx index 8f3f37b..bd67c65 100644 --- a/frontend/components/UserNavbarMobile.jsx +++ b/frontend/components/UserNavbarMobile.jsx @@ -1,14 +1,10 @@ -import React from 'react' import { LayoutDashboard, Package, ReceiptText, Printer, - Menu, X, ChefHat, - User, - Activity } from 'lucide-react'; import { NavLink } from 'react-router-dom'; @@ -46,6 +42,7 @@ const UserNavbarMobile = ({sidebarOpen, setSidebarOpen}) => { setSidebarOpen(false)} className={({ isActive }) => ` flex items-center gap-3 px-4 py-3 rounded-xl font-medium text-sm transition-all duration-200 @@ -75,4 +72,4 @@ const UserNavbarMobile = ({sidebarOpen, setSidebarOpen}) => { ) } -export default UserNavbarMobile \ No newline at end of file +export default UserNavbarMobile diff --git a/frontend/layouts/DashboardLayout.jsx b/frontend/layouts/DashboardLayout.jsx index 44a8a58..94d0f2d 100644 --- a/frontend/layouts/DashboardLayout.jsx +++ b/frontend/layouts/DashboardLayout.jsx @@ -1,16 +1,6 @@ -import React, { useState } from 'react'; -import { NavLink, Link, Outlet } from 'react-router-dom'; -import { - LayoutDashboard, - Package, - ReceiptText, - Printer, - Menu, - X, - ChefHat, - User, - Activity -} from 'lucide-react'; +import { useState } from 'react'; +import { Menu, User, Activity } from 'lucide-react'; +import { Outlet } from 'react-router-dom'; import UserNavbarDesktop from '../components/UserNavbarDesktop'; import UserNavbarMobile from '../components/UserNavbarMobile'; @@ -72,4 +62,4 @@ const DashboardLayout = () => { ) } -export default DashboardLayout \ No newline at end of file +export default DashboardLayout diff --git a/frontend/layouts/MainLayout.jsx b/frontend/layouts/MainLayout.jsx index 3dba25c..feae1e8 100644 --- a/frontend/layouts/MainLayout.jsx +++ b/frontend/layouts/MainLayout.jsx @@ -1,5 +1,4 @@ -import React, { useState } from 'react'; -import { NavLink, Link, Outlet } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; export default function MainLayout() { return ( diff --git a/frontend/pages/Dashboard.jsx b/frontend/pages/Dashboard.jsx index 024ddc8..cbe1304 100644 --- a/frontend/pages/Dashboard.jsx +++ b/frontend/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { TrendingUp, ShoppingBag, @@ -13,17 +13,53 @@ import { Layers } from 'lucide-react'; +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'; + +const generateMockStats = () => { + const weeklySales = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const dayOfWeek = d.getDay(); + const baseSales = (dayOfWeek === 0 || dayOfWeek === 6) ? 14000 : 8500; + const randomFactor = Math.floor(Math.random() * 4000) - 2000; + const sales = baseSales + randomFactor; + weeklySales.push({ + date: dateStr, + sales: parseFloat(sales.toFixed(2)), + orders: Math.floor(sales / 350) + 1 + }); + } + + return { + todaySales: 9480.00, + todayOrders: 31, + halfBiryaniCount: 48, + fullBiryaniCount: 29, + familyPackCount: 9, + totalCashCollected: 9480.00, + weeklySales: weeklySales, + recentOrders: [ + { id: 1045, time: "12:45 PM", items: "2x Full Chicken Biryani, 1x Coke 1.5L", total: 1190.00, status: "Completed" }, + { id: 1044, time: "12:30 PM", items: "1x Half Beef Biryani, 1x Raita", total: 430.00, status: "Completed" }, + { id: 1043, time: "12:15 PM", items: "1x Family Pack Biryani, 1x Coke 1.5L", total: 1600.00, status: "Completed" }, + { id: 1042, time: "11:50 AM", items: "3x Half Chicken Biryani", total: 960.00, status: "Completed" }, + { id: 1041, time: "11:30 AM", items: "1x Full Chicken Biryani, 1x Salad", total: 570.00, status: "Completed" } + ], + isDemoData: true + }; +}; + export default function Dashboard() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); const [demoMode, setDemoMode] = useState(false); - const fetchStats = async (forceDemo = false) => { + const fetchStats = useCallback(async (forceDemo = false) => { setLoading(true); - setError(false); try { - const url = `${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/dashboard/stats${forceDemo ? '?demo=true' : ''}`; + const url = `${API_BASE}/dashboard/stats${forceDemo ? '?demo=true' : ''}`; const response = await fetch(url); const resData = await response.json(); if (resData.success) { @@ -34,53 +70,41 @@ export default function Dashboard() { } } catch (err) { console.error("Dashboard API error, loading mock fallback:", err); - // Fallback local mock data so the dashboard still looks premium - const mockData = generateMockStats(); - setStats(mockData); + setStats(generateMockStats()); setDemoMode(true); } finally { setLoading(false); } - }; + }, []); - const generateMockStats = () => { - const weeklySales = []; - for (let i = 6; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - const dayOfWeek = d.getDay(); - const baseSales = (dayOfWeek === 0 || dayOfWeek === 6) ? 14000 : 8500; - const randomFactor = Math.floor(Math.random() * 4000) - 2000; - const sales = baseSales + randomFactor; - weeklySales.push({ - date: dateStr, - sales: parseFloat(sales.toFixed(2)), - orders: Math.floor(sales / 350) + 1 - }); + useEffect(() => { + let cancelled = false; + + async function loadInitialStats() { + try { + const response = await fetch(`${API_BASE}/dashboard/stats`); + const resData = await response.json(); + if (cancelled) return; + if (resData.success) { + setStats(resData.data); + setDemoMode(resData.data.isDemoData); + } else { + throw new Error("Failed to load stats"); + } + } catch (err) { + if (cancelled) return; + console.error("Dashboard API error, loading mock fallback:", err); + setStats(generateMockStats()); + setDemoMode(true); + } finally { + if (!cancelled) setLoading(false); + } } - return { - todaySales: 9480.00, - todayOrders: 31, - halfBiryaniCount: 48, - fullBiryaniCount: 29, - familyPackCount: 9, - totalCashCollected: 9480.00, - weeklySales: weeklySales, - recentOrders: [ - { id: 1045, time: "12:45 PM", items: "2x Full Chicken Biryani, 1x Coke 1.5L", total: 1190.00, status: "Completed" }, - { id: 1044, time: "12:30 PM", items: "1x Half Beef Biryani, 1x Raita", total: 430.00, status: "Completed" }, - { id: 1043, time: "12:15 PM", items: "1x Family Pack Biryani, 1x Coke 1.5L", total: 1600.00, status: "Completed" }, - { id: 1042, time: "11:50 AM", items: "3x Half Chicken Biryani", total: 960.00, status: "Completed" }, - { id: 1041, time: "11:30 AM", items: "1x Full Chicken Biryani, 1x Salad", total: 570.00, status: "Completed" } - ], - isDemoData: true + loadInitialStats(); + return () => { + cancelled = true; }; - }; - - useEffect(() => { - fetchStats(); }, []); const handleToggleMode = () => { diff --git a/frontend/pages/LandingPage.jsx b/frontend/pages/LandingPage.jsx index ace91ae..9465839 100644 --- a/frontend/pages/LandingPage.jsx +++ b/frontend/pages/LandingPage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { TrendingUp, @@ -10,21 +10,30 @@ import { ReceiptText, Printer, ChevronRight, - TrendingDown, RefreshCw } from 'lucide-react'; +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'; + +const FALLBACK_STATS = { + todaySales: 8750.00, + todayOrders: 28, + halfBiryaniCount: 42, + fullBiryaniCount: 26, + familyPackCount: 8, + totalCashCollected: 8750.00, + isDemoData: true +}; + export default function LandingPage() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); const [useDemo, setUseDemo] = useState(false); - const fetchStats = async (forceDemo = false) => { + const fetchStats = useCallback(async (forceDemo = false) => { setLoading(true); - setError(false); try { - const url = `${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/dashboard/stats${forceDemo ? '?demo=true' : ''}`; + const url = `${API_BASE}/dashboard/stats${forceDemo ? '?demo=true' : ''}`; const response = await fetch(url); const resData = await response.json(); if (resData.success) { @@ -35,24 +44,41 @@ export default function LandingPage() { } } catch (err) { console.error("API error, loading fallback mock data:", err); - // Fallback local mock data so the landing page still looks amazing - setStats({ - todaySales: 8750.00, - todayOrders: 28, - halfBiryaniCount: 42, - fullBiryaniCount: 26, - familyPackCount: 8, - totalCashCollected: 8750.00, - isDemoData: true - }); + setStats(FALLBACK_STATS); setUseDemo(true); } finally { setLoading(false); } - }; + }, []); useEffect(() => { - fetchStats(); + let cancelled = false; + + async function loadInitialStats() { + try { + const response = await fetch(`${API_BASE}/dashboard/stats`); + const resData = await response.json(); + if (cancelled) return; + if (resData.success) { + setStats(resData.data); + setUseDemo(resData.data.isDemoData); + } else { + throw new Error("Failed to load stats"); + } + } catch (err) { + if (cancelled) return; + console.error("API error, loading fallback mock data:", err); + setStats(FALLBACK_STATS); + setUseDemo(true); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadInitialStats(); + return () => { + cancelled = true; + }; }, []); // Quick module access configuration @@ -85,7 +111,7 @@ export default function LandingPage() { color: "from-amber-600 to-amber-500", accent: "amber", tag: "Module 3", - assignee: "System" + assignee: "Zuhaib" } ]; diff --git a/frontend/pages/Placeholders.jsx b/frontend/pages/Placeholders.jsx index de8c172..2ee8a77 100644 --- a/frontend/pages/Placeholders.jsx +++ b/frontend/pages/Placeholders.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Package, ReceiptText, Printer } from 'lucide-react'; export const ProductManagement = () => { @@ -65,22 +64,8 @@ export const ReceiptPrinting = () => {

Module 3: Receipt Printing

- Generate professional receipt print layouts and control physical thermal printing hardware. + This placeholder has been replaced by pages/ReceiptPrinting.jsx.

-
-
-
Sleek Bill Layout
-

Clean typography, order list, date/time, and shop logo.

-
-
-
Thermal Integration
-

Driver connection for 58mm/80mm receipt printers.

-
-
-
Auto Print
-

Trigger printer automatically on billing checkout.

-
-
); }; diff --git a/frontend/pages/Product.jsx b/frontend/pages/Product.jsx index f6518c6..7a2279e 100644 --- a/frontend/pages/Product.jsx +++ b/frontend/pages/Product.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useOutletContext } from 'react-router-dom'; import { Package, Plus, RefreshCw } from 'lucide-react'; import ItemCard from '../components/ItemCard.jsx'; @@ -17,7 +17,7 @@ export default function Product() { const [submitting, setSubmitting] = useState(false); const [showForm, setShowForm] = useState(false); - const fetchItems = async () => { + const fetchItems = useCallback(async () => { setLoading(true); setError(''); try { @@ -31,10 +31,31 @@ export default function Product() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { - fetchItems(); + let cancelled = false; + + async function loadItems() { + try { + const res = await fetch(`${API_BASE}/items`); + const data = await res.json(); + if (cancelled) return; + if (!data.success) throw new Error(data.message || 'Failed to load items'); + setItems(data.data || []); + } catch (err) { + if (cancelled) return; + console.error(err); + setError(err.message || 'Failed to load items'); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadItems(); + return () => { + cancelled = true; + }; }, []); const handleChange = (e) => { diff --git a/frontend/pages/ReceiptPrinting.jsx b/frontend/pages/ReceiptPrinting.jsx new file mode 100644 index 0000000..c4de0c5 --- /dev/null +++ b/frontend/pages/ReceiptPrinting.jsx @@ -0,0 +1,358 @@ +import { useEffect, useState, useCallback } from "react"; +import { + Printer, + Wallet, + CreditCard, + RefreshCw, + CheckCircle2, + Clock, + ReceiptText, + Usb, +} from "lucide-react"; +import ReceiptPreview from "../components/ReceiptPreview"; +import { markOrderPrinted, triggerThermalPrint } from "../utils/printReceipt"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1"; + +export default function ReceiptPrinting() { + const [orders, setOrders] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [paymentMethod, setPaymentMethod] = useState("CASH"); + const [loading, setLoading] = useState(true); + const [printing, setPrinting] = useState(false); + const [demoMode, setDemoMode] = useState(false); + const [statusMsg, setStatusMsg] = useState(""); + + const selectedOrder = orders.find((o) => o.id === selectedId) || null; + + const applyReceiptPayload = useCallback((resData) => { + setOrders(resData.data || []); + setDemoMode(Boolean(resData.isDemoData)); + if (resData.data?.length) { + setSelectedId(resData.data[0].id); + setPaymentMethod(resData.data[0].payment_method || "CASH"); + } + }, []); + + const fetchReceipts = useCallback(async (forceDemo = false) => { + setLoading(true); + setStatusMsg(""); + try { + const url = `${API_BASE}/receipts${forceDemo ? "?demo=true" : ""}`; + const response = await fetch(url); + const resData = await response.json(); + if (!resData.success) throw new Error("Failed to load receipts"); + applyReceiptPayload(resData); + } catch (err) { + console.error(err); + setStatusMsg("Could not reach the API. Showing empty list."); + setOrders([]); + setDemoMode(true); + } finally { + setLoading(false); + } + }, [applyReceiptPayload]); + + useEffect(() => { + let cancelled = false; + + async function loadReceipts() { + try { + const response = await fetch(`${API_BASE}/receipts`); + const resData = await response.json(); + if (cancelled) return; + if (!resData.success) throw new Error("Failed to load receipts"); + applyReceiptPayload(resData); + } catch (err) { + if (cancelled) return; + console.error(err); + setStatusMsg("Could not reach the API. Showing empty list."); + setOrders([]); + setDemoMode(true); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadReceipts(); + return () => { + cancelled = true; + }; + }, [applyReceiptPayload]); + + const handleSelectOrder = (order) => { + setSelectedId(order.id); + setPaymentMethod(order.payment_method || "CASH"); + setStatusMsg(""); + }; + + const handlePrint = async () => { + if (!selectedOrder) return; + setPrinting(true); + setStatusMsg(""); + + try { + // Card path: in production this is where you'd await the card-reader SDK. + // For this web POS we record CARD and continue to print. + if (paymentMethod === "CARD") { + setStatusMsg("Waiting for card terminal… (simulated approval)"); + await new Promise((r) => setTimeout(r, 600)); + } + + const result = await markOrderPrinted(selectedOrder.id, paymentMethod); + + setOrders((prev) => + prev.map((o) => + o.id === selectedOrder.id + ? { + ...o, + payment_method: paymentMethod, + printed_at: result?.data?.printed_at || new Date().toISOString(), + } + : o + ) + ); + + setStatusMsg( + paymentMethod === "CARD" + ? "Card payment recorded. Sending receipt to printer…" + : "Cash payment recorded. Sending receipt to printer…" + ); + + // Let React re-render the preview with updated payment/time + setTimeout(() => { + triggerThermalPrint(); + setPrinting(false); + setStatusMsg("Print dialog opened. Choose your thermal printer (80mm / 58mm)."); + }, 80); + } catch (err) { + console.error(err); + setPrinting(false); + setStatusMsg("Print failed. Check printer connection and try again."); + } + }; + + return ( +
+ {/* Page header */} +
+
+
+ + Module 3 · Receipt Printing +
+

+ Payment & Receipt Preview +

+

+ Choose payment method, preview the thermal layout, then print. After Module 2 + saves a sale, call the same print helper to auto-print the ticket. +

+
+
+ {demoMode && ( + + Demo data + + )} + +
+
+ +
+ {/* Left: orders + payment */} +
+ {/* Payment method */} +
+

Payment Method

+

+ Cash opens the drawer flow. Card is where the card machine is triggered. +

+
+ + + +
+
+ + {/* Recent orders */} +
+
+
+

+ + Recent Orders +

+

+ Select a sale to reprint or test the thermal layout +

+
+
+ + {loading ? ( +
Loading orders…
+ ) : orders.length === 0 ? ( +
+ No orders yet. Complete a sale on the Billing screen first. +
+ ) : ( +
    + {orders.map((order) => { + const active = order.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {/* Hardware notes */} +
+

+ + Hardware connection +

+
+
+
Thermal printer
+ Plug in via USB or LAN, install ESC/POS drivers, then pick the printer in the + browser print dialog (paper 58mm or 80mm, margins none). +
+
+
Card machine
+ USB/serial terminals act as a keyboard or use a vendor SDK. On approval, + Module 2 stores the sale and this module prints the receipt. +
+
+
+
+ + {/* Right: live receipt + print */} +
+
+
+
+

Receipt Preview

+

80mm thermal layout

+
+ +
+ +
+ +
+ + {statusMsg && ( +

+ {statusMsg} +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1c2c0cd..8f6c033 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,3 @@ -import { useState } from 'react' -import './App.css' import { RouterProvider } from 'react-router-dom' import router from './router' diff --git a/frontend/src/index.css b/frontend/src/index.css index a461c50..2159ad1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,36 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +/* Hide non-receipt chrome when printing to a thermal printer */ +@media print { + @page { + size: 80mm auto; + margin: 0; + } + + html, + body { + background: white !important; + margin: 0 !important; + padding: 0 !important; + } + + body * { + visibility: hidden !important; + } + + #receipt-print-root, + #receipt-print-root * { + visibility: visible !important; + } + + #receipt-print-root { + position: absolute !important; + left: 0 !important; + top: 0 !important; + width: 80mm !important; + max-width: 80mm !important; + margin: 0 !important; + box-shadow: none !important; + border: none !important; + } +} diff --git a/frontend/src/router.jsx b/frontend/src/router.jsx index 6ac8266..0de0d8c 100644 --- a/frontend/src/router.jsx +++ b/frontend/src/router.jsx @@ -1,11 +1,11 @@ -import React from 'react'; import { createBrowserRouter, createRoutesFromElements, Route } from 'react-router-dom'; import MainLayout from '../layouts/MainLayout.jsx'; import DashboardLayout from '../layouts/DashboardLayout.jsx'; import LandingPage from '../pages/LandingPage.jsx'; import Dashboard from '../pages/Dashboard.jsx'; -import { ProductManagement, BillingScreen, ReceiptPrinting } from '../pages/Placeholders.jsx'; +import { BillingScreen } from '../pages/Placeholders.jsx'; import Product from '../pages/Product.jsx'; +import ReceiptPrinting from '../pages/ReceiptPrinting.jsx'; const router = createBrowserRouter( createRoutesFromElements( diff --git a/frontend/utils/printReceipt.js b/frontend/utils/printReceipt.js new file mode 100644 index 0000000..0cf0d34 --- /dev/null +++ b/frontend/utils/printReceipt.js @@ -0,0 +1,59 @@ +/** + * Browser-side thermal receipt printing. + * + * Hardware path (shop setup): + * 1. Install the thermal printer drivers on the POS PC (58mm or 80mm ESC/POS). + * 2. Connect via USB (most common) or LAN; set it as the default printer + * OR choose it in the browser print dialog. + * 3. In Chrome print settings: paper size = custom 80mm (or 58mm), margins = none, + * scale = 100%, background graphics on. + * + * Card reader path (for Module 2 / payment): + * - USB HID / serial readers usually type the card token like a keyboard, OR + * - Use the vendor SDK / payment gateway (e.g. Stripe Terminal, PayFast, JazzCash) + * which exposes a JS/Node bridge. After approval, call printOrderReceipt(). + * + * Module 2 integration after checkout: + * import { printOrderReceipt } from '../utils/printReceipt'; + * await printOrderReceipt(orderPayload, { paymentMethod: 'CARD' }); + */ + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1"; + +export async function markOrderPrinted(orderId, paymentMethod = "CASH") { + try { + const response = await fetch(`${API_BASE}/receipts/${orderId}/print`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payment_method: paymentMethod }), + }); + return await response.json(); + } catch (err) { + console.warn("Could not mark receipt printed:", err); + return { success: false, message: err.message }; + } +} + +/** + * Opens the system print dialog for the element with id `receipt-print-root`. + * Call after the receipt preview is mounted with the current order. + */ +export function triggerThermalPrint() { + window.print(); +} + +/** + * Marks the order as printed in the DB, then opens the print dialog. + * Safe for Module 2 to call right after a successful sale. + */ +export async function printOrderReceipt(order, options = {}) { + const paymentMethod = (options.paymentMethod || order?.payment_method || "CASH").toUpperCase(); + + if (order?.id) { + await markOrderPrinted(order.id, paymentMethod); + } + + // Allow React to paint any payment-method change on the preview first + await new Promise((resolve) => setTimeout(resolve, 50)); + triggerThermalPrint(); +} From 160ca1a81d7352a3496027147ddfad2d2d324ae6 Mon Sep 17 00:00:00 2001 From: zuhaibakhtarkhan Date: Fri, 7 Aug 2026 14:32:50 +0500 Subject: [PATCH 2/3] Add project setup and startup instructions to README. Document prerequisites, database setup, env configuration, and dev server commands so contributors can run the app locally. Co-authored-by: Cursor --- README.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bd56b5..36a2e9a 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# POS SYSTEM +# POS System + +A point-of-sale web app for managing menu items, billing, receipts, and sales dashboards. The stack is **React + Vite** on the frontend and **Node.js + Express + PostgreSQL** on the backend. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later recommended) +- [PostgreSQL](https://www.postgresql.org/) running locally on port `5432` + +## First-time setup + +### 1. Clone and install dependencies + +```bash +git clone +cd Pos_System + +cd backend && npm install +cd ../frontend && npm install +``` + +### 2. Set up the database + +Create a PostgreSQL database, then apply the schema: + +```bash +psql -U -d -f backend/schema.sql +``` + +Seed the database with sample data (company, menu items, and orders): + +```bash +cd backend +npm run seed +``` + +### 3. Configure environment variables + +**Backend** — create `backend/.env`: + +```env +DATABASE_USERNAME=your_postgres_user +DATABASE_HOST=localhost +DATABASE_NAME=your_database_name +DATABASE_PASSWORD=your_postgres_password +DATABASE_PORT=5432 +SERVER_PORT=5000 +TAX_RATE=0 +``` + +**Frontend** — create `frontend/.env`: + +```env +VITE_API_URL=http://localhost:5000/api/v1 +``` + +## How to start the project + +Open **two terminal windows** and run one command in each: + +**Terminal 1 — Backend** (runs on port 5000): + +```bash +cd backend +npm run dev +``` + +If `npm run dev` fails with a permission error on nodemon, start the server directly instead: + +```bash +cd backend +NODE_ENV=development node server.js +``` + +**Terminal 2 — Frontend** (runs on port 5173): + +```bash +cd frontend +npm run dev +``` + +Then open the app in your browser: + +| Service | URL | +|----------|--------------------------| +| Frontend | http://localhost:5173/ | +| Backend | http://localhost:5000/ | + +## Available pages + +| Route | Description | +|--------------|--------------------------------------| +| `/` | Landing page with sales overview | +| `/dashboard` | Sales dashboard and charts | +| `/products` | Menu item management | +| `/billing` | Billing screen (placeholder) | +| `/receipts` | Receipt printing and order history | + +## Useful commands + +```bash +# Backend +cd backend +npm run dev # Start dev server with auto-reload +npm run seed # Re-seed the database with sample data +npm test # Run backend tests + +# Frontend +cd frontend +npm run dev # Start Vite dev server +npm run build # Production build +npm run preview # Preview production build +``` From 613a7581532b8e169e9b13b242fa6134ddc969e3 Mon Sep 17 00:00:00 2001 From: zuhaibakhtarkhan Date: Fri, 7 Aug 2026 14:50:59 +0500 Subject: [PATCH 3/3] Fix receipt printing to output a single page via window.print(). Use a print-only mount with inline receipt HTML and @media print rules that hide the app so the browser prints only the receipt content. Co-authored-by: Cursor --- frontend/components/ReceiptPreview.jsx | 34 +------ frontend/pages/ReceiptPrinting.jsx | 11 ++- frontend/src/index.css | 25 +++--- frontend/utils/printReceipt.js | 57 +++++++----- frontend/utils/receiptTemplate.js | 117 +++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 65 deletions(-) create mode 100644 frontend/utils/receiptTemplate.js diff --git a/frontend/components/ReceiptPreview.jsx b/frontend/components/ReceiptPreview.jsx index b6d6642..37b305c 100644 --- a/frontend/components/ReceiptPreview.jsx +++ b/frontend/components/ReceiptPreview.jsx @@ -1,30 +1,8 @@ -const formatMoney = (value) => - `Rs. ${Number(value || 0).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - -const formatDateTime = (value) => { - const date = value ? new Date(value) : new Date(); - if (Number.isNaN(date.getTime())) { - return { date: "—", time: "—" }; - } - return { - date: date.toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }), - time: date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - }), - }; -}; +import { formatMoney, formatDateTime } from "../utils/receiptTemplate.js"; /** * Thermal-style receipt preview (58/80mm). - * Rendered on-screen and reused by window.print() via #receipt-print-root. + * On-screen preview; printing uses receiptTemplate.js for identical inline HTML. */ export default function ReceiptPreview({ order, paymentMethod = "CASH" }) { if (!order) { @@ -40,12 +18,8 @@ export default function ReceiptPreview({ order, paymentMethod = "CASH" }) { const taxRatePct = ((order.tax_rate || 0) * 100).toFixed(1); return ( -
+
- {/* Header */}
{order.company_logo ? ( - {/* Line items */}
{(order.items || []).map((item, index) => (
@@ -91,7 +64,6 @@ export default function ReceiptPreview({ order, paymentMethod = "CASH" }) {
- {/* Totals */}
Subtotal diff --git a/frontend/pages/ReceiptPrinting.jsx b/frontend/pages/ReceiptPrinting.jsx index c4de0c5..5538bd6 100644 --- a/frontend/pages/ReceiptPrinting.jsx +++ b/frontend/pages/ReceiptPrinting.jsx @@ -121,9 +121,16 @@ export default function ReceiptPrinting() { // Let React re-render the preview with updated payment/time setTimeout(() => { - triggerThermalPrint(); + triggerThermalPrint( + { + ...selectedOrder, + payment_method: paymentMethod, + printed_at: result?.data?.printed_at || new Date().toISOString(), + }, + paymentMethod + ); setPrinting(false); - setStatusMsg("Print dialog opened. Choose your thermal printer (80mm / 58mm)."); + setStatusMsg("Print dialog opened. Choose your printer (80mm / 58mm) and print."); }, 80); } catch (err) { console.error(err); diff --git a/frontend/src/index.css b/frontend/src/index.css index 2159ad1..1908c15 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,12 @@ @import "tailwindcss"; -/* Hide non-receipt chrome when printing to a thermal printer */ +/* Receipt print container: hidden on screen, shown only when printing. */ +#receipt-print-mount { + display: none; +} + @media print { + /* One receipt page, sized to the 80mm roll and content height. */ @page { size: 80mm auto; margin: 0; @@ -9,24 +14,22 @@ html, body { - background: white !important; margin: 0 !important; padding: 0 !important; + background: #ffffff !important; } - body * { - visibility: hidden !important; + /* Hide the entire app (React mounts into #root). */ + body > #root { + display: none !important; } - #receipt-print-root, - #receipt-print-root * { - visibility: visible !important; + /* Show only the injected receipt. */ + #receipt-print-mount { + display: block !important; } - #receipt-print-root { - position: absolute !important; - left: 0 !important; - top: 0 !important; + #receipt-print-mount #receipt-print-root { width: 80mm !important; max-width: 80mm !important; margin: 0 !important; diff --git a/frontend/utils/printReceipt.js b/frontend/utils/printReceipt.js index 0cf0d34..22a3238 100644 --- a/frontend/utils/printReceipt.js +++ b/frontend/utils/printReceipt.js @@ -1,24 +1,20 @@ /** * Browser-side thermal receipt printing. * - * Hardware path (shop setup): - * 1. Install the thermal printer drivers on the POS PC (58mm or 80mm ESC/POS). - * 2. Connect via USB (most common) or LAN; set it as the default printer - * OR choose it in the browser print dialog. - * 3. In Chrome print settings: paper size = custom 80mm (or 58mm), margins = none, - * scale = 100%, background graphics on. + * Approach: inject the receipt HTML into a print-only container on the page, + * hide the rest of the app via @media print (see src/index.css), then call + * window.print(). No pop-ups, no iframes — the browser's own print dialog opens. * - * Card reader path (for Module 2 / payment): - * - USB HID / serial readers usually type the card token like a keyboard, OR - * - Use the vendor SDK / payment gateway (e.g. Stripe Terminal, PayFast, JazzCash) - * which exposes a JS/Node bridge. After approval, call printOrderReceipt(). - * - * Module 2 integration after checkout: - * import { printOrderReceipt } from '../utils/printReceipt'; - * await printOrderReceipt(orderPayload, { paymentMethod: 'CARD' }); + * In the print dialog: + * - Margins: Default (or None for real thermal roll) + * - Scale: 100% + * - It should be a single page. */ +import { buildReceiptPrintHtml } from "./receiptTemplate.js"; + const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1"; +const PRINT_MOUNT_ID = "receipt-print-mount"; export async function markOrderPrinted(orderId, paymentMethod = "CASH") { try { @@ -35,16 +31,36 @@ export async function markOrderPrinted(orderId, paymentMethod = "CASH") { } /** - * Opens the system print dialog for the element with id `receipt-print-root`. - * Call after the receipt preview is mounted with the current order. + * Prints only the receipt by mounting it into a print-only container and + * calling window.print(). The main app is hidden during printing by CSS. */ -export function triggerThermalPrint() { - window.print(); +export function triggerThermalPrint(order, paymentMethod = "CASH") { + if (!order) return; + + document.getElementById(PRINT_MOUNT_ID)?.remove(); + + const mount = document.createElement("div"); + mount.id = PRINT_MOUNT_ID; + mount.innerHTML = buildReceiptPrintHtml(order, paymentMethod); + document.body.appendChild(mount); + + const cleanup = () => { + mount.remove(); + window.removeEventListener("afterprint", cleanup); + }; + + window.addEventListener("afterprint", cleanup, { once: true }); + + // Let the browser paint the receipt before opening the dialog. + setTimeout(() => { + window.print(); + // Safety cleanup in case afterprint never fires (some browsers). + setTimeout(cleanup, 1000); + }, 60); } /** * Marks the order as printed in the DB, then opens the print dialog. - * Safe for Module 2 to call right after a successful sale. */ export async function printOrderReceipt(order, options = {}) { const paymentMethod = (options.paymentMethod || order?.payment_method || "CASH").toUpperCase(); @@ -53,7 +69,6 @@ export async function printOrderReceipt(order, options = {}) { await markOrderPrinted(order.id, paymentMethod); } - // Allow React to paint any payment-method change on the preview first await new Promise((resolve) => setTimeout(resolve, 50)); - triggerThermalPrint(); + triggerThermalPrint(order, paymentMethod); } diff --git a/frontend/utils/receiptTemplate.js b/frontend/utils/receiptTemplate.js new file mode 100644 index 0000000..f048b0a --- /dev/null +++ b/frontend/utils/receiptTemplate.js @@ -0,0 +1,117 @@ +export const formatMoney = (value) => + `Rs. ${Number(value || 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +export const formatDateTime = (value) => { + const date = value ? new Date(value) : new Date(); + if (Number.isNaN(date.getTime())) { + return { date: "—", time: "—" }; + } + return { + date: date.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }), + time: date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }), + }; +}; + +const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +const row = (left, right, opts = {}) => { + const leftStyle = opts.leftStyle || ""; + const rightStyle = opts.rightStyle || ""; + const rowStyle = opts.rowStyle || ""; + return ` + ${left} + ${right} + `; +}; + +const divider = () => + `
`; + +/** + * Self-contained receipt HTML with table layout + inline styles. + * Tables print more reliably than flexbox across browsers. + */ +export function buildReceiptPrintHtml(order, paymentMethod = "CASH") { + if (!order) return ""; + + const { date, time } = formatDateTime(order.printed_at || order.created_at); + const method = (paymentMethod || order.payment_method || "CASH").toUpperCase(); + const taxRatePct = ((order.tax_rate || 0) * 100).toFixed(1); + const companyName = escapeHtml(order.company_name || "Biryani Junction"); + + const logoHtml = order.company_logo + ? `${companyName}` + : `
BJ
`; + + const itemsHtml = (order.items || []) + .map((item) => + row( + escapeHtml(`${item.quantity}x ${item.name}`), + escapeHtml(formatMoney(item.line_total)) + ) + ) + .join(""); + + const taxHtml = + (order.tax_rate || 0) > 0 + ? row(`Tax (${taxRatePct}%)`, escapeHtml(formatMoney(order.tax)), { + leftStyle: "color:#475569;", + rightStyle: "color:#475569;", + }) + : ""; + + return ` +
+
+
+ ${logoHtml} +
${companyName}
+
POS Terminal 01
+
+ + + ${divider()} + ${row(`Order #${escapeHtml(order.id)}`, escapeHtml(method), { + leftStyle: "font-size:10px;color:#475569;", + rightStyle: "font-size:10px;color:#475569;", + })} + ${row(escapeHtml(date), escapeHtml(time), { + leftStyle: "font-size:10px;color:#475569;", + rightStyle: "font-size:10px;color:#475569;", + })} + ${divider()} + ${itemsHtml} + ${divider()} + ${row("Subtotal", escapeHtml(formatMoney(order.subtotal)), { + leftStyle: "color:#475569;", + rightStyle: "color:#475569;", + })} + ${taxHtml} + ${row("Total", escapeHtml(formatMoney(order.total)), { + leftStyle: "font-size:13px;font-weight:700;", + rightStyle: "font-size:13px;font-weight:700;", + rowStyle: "font-weight:700;", + })} + ${divider()} +
+ +

Thank you for dining with us!

+

*** Customer Copy ***

+
+
`; +}