{/* Height placeholder that matches Badge height */}
diff --git a/pos/src/components/TableShapeIcon.tsx b/pos/src/components/TableShapeIcon.tsx
new file mode 100644
index 0000000..f670168
--- /dev/null
+++ b/pos/src/components/TableShapeIcon.tsx
@@ -0,0 +1,20 @@
+import { Circle, RectangleHorizontal, Square } from 'lucide-react';
+import type { Table } from '../lib/table-api';
+
+interface TableShapeIconProps {
+ shape?: Table['table_shape'];
+ className?: string;
+}
+
+export const TableShapeIcon = ({ shape = 'Rectangle', className }: TableShapeIconProps) => {
+ switch (shape) {
+ case 'Circle':
+ return
;
+ case 'Square':
+ return
;
+ case 'Rectangle':
+ default:
+ return
;
+ }
+};
+
diff --git a/pos/src/data/doctypes.ts b/pos/src/data/doctypes.ts
index b9ccf74..6e61516 100644
--- a/pos/src/data/doctypes.ts
+++ b/pos/src/data/doctypes.ts
@@ -2,6 +2,7 @@ export const DOCTYPES={
"POS_PROFILE": "POS Profile",
"URY_MENU_COURSE": "URY Menu Course",
"URY_ROOM": "URY Room",
+ "URY_TABLE": "URY Table",
"CUSTOMER": "Customer",
"CUSTOMER_GROUP": "Customer Group",
"CUSTOMER_TERRITORY": "Territory",
diff --git a/pos/src/lib/table-api.ts b/pos/src/lib/table-api.ts
index 55eb5c0..6bb1bea 100644
--- a/pos/src/lib/table-api.ts
+++ b/pos/src/lib/table-api.ts
@@ -13,6 +13,7 @@ export interface Table {
is_take_away: number;
restaurant_room: string;
table_shape:'Circle' | 'Square' | 'Rectangle';
+ no_of_seats?: number;
}
export async function getRestaurantMenu(posProfile: string, room?: string | null) {
@@ -39,4 +40,19 @@ export async function getTables(room: string): Promise
{
const { call } = await import('./frappe-sdk');
const res = await call.get('ury.ury_pos.api.getTable', { room });
return res.message as Table[];
-}
\ No newline at end of file
+}
+
+export async function getTableCount(room: string, branch?: string): Promise {
+ const filters = [
+ ['restaurant_room', '=', room],
+ ...(branch ? [['branch', '=', branch]] : []),
+ ];
+ const rows = await db.getDocList(DOCTYPES.URY_TABLE, {
+ fields: ['count(name) as count'],
+ filters: filters as any,
+ limit: 1,
+ asDict: true,
+ }) as Array<{ count?: number | string }>;
+ const countValue = rows[0]?.count ?? 0;
+ return typeof countValue === 'number' ? countValue : Number(countValue) || 0;
+}
\ No newline at end of file
diff --git a/pos/src/pages/Table.tsx b/pos/src/pages/Table.tsx
new file mode 100644
index 0000000..2c470e4
--- /dev/null
+++ b/pos/src/pages/Table.tsx
@@ -0,0 +1,441 @@
+import { useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AlertTriangle, Eye, Loader2, Printer, Square, Users } from 'lucide-react';
+import { cn } from '../lib/utils';
+import { usePOSStore } from '../store/pos-store';
+import { getRooms, getTables, getTableCount, type Room, type Table } from '../lib/table-api';
+import { Spinner } from '../components/ui/spinner';
+import { Button } from '../components/ui/button';
+import { Badge } from '../components/ui/badge';
+import { DINE_IN } from '../data/order-types';
+import { TableShapeIcon } from '../components/TableShapeIcon';
+import { getTableOrder } from '../lib/order-api';
+import { printOrder } from '../lib/print';
+import { showToast } from '../components/ui/toast';
+
+const sortTables = (tables: Table[]) => [...tables].sort((a, b) => a.name.localeCompare(b.name));
+
+const TableView = () => {
+ const navigate = useNavigate();
+ const { posProfile, setSelectedTable, setSelectedOrderType } = usePOSStore();
+
+ const branch = posProfile?.branch ?? null;
+ const [rooms, setRooms] = useState([]);
+ const [selectedRoom, setSelectedRoom] = useState(null);
+ const [tables, setTables] = useState([]);
+ const [tablesCache, setTablesCache] = useState>({});
+ const [loadingRooms, setLoadingRooms] = useState(false);
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [roomCounts, setRoomCounts] = useState>({});
+ const [loadingRoomCounts, setLoadingRoomCounts] = useState(false);
+ const [error, setError] = useState(null);
+ const [printingTable, setPrintingTable] = useState(null);
+
+ const persistRoomCounts = useCallback((counts: Record) => {
+ if (!branch) return;
+ sessionStorage.setItem(`ury_room_counts_${branch}`, JSON.stringify(counts));
+ }, [branch]);
+
+ useEffect(() => {
+ async function fetchRooms() {
+ if (!branch) return;
+ setLoadingRooms(true);
+ setError(null);
+
+ try {
+ const sessionKey = `ury_rooms_${branch}`;
+ const cachedRooms = sessionStorage.getItem(sessionKey);
+
+ if (cachedRooms) {
+ const parsedRooms = JSON.parse(cachedRooms) as Room[];
+ setRooms(parsedRooms);
+ setSelectedRoom(prev => prev ?? (parsedRooms[0]?.name ?? null));
+ } else {
+ const fetchedRooms = await getRooms(branch);
+ setRooms(fetchedRooms);
+ setSelectedRoom(prev => prev ?? (fetchedRooms[0]?.name ?? null));
+ sessionStorage.setItem(sessionKey, JSON.stringify(fetchedRooms));
+ }
+ } catch (e) {
+ console.error(e);
+ setError('Failed to load rooms');
+ } finally {
+ setLoadingRooms(false);
+ }
+ }
+
+ fetchRooms();
+ }, [branch]);
+
+ useEffect(() => {
+ if (!branch || rooms.length === 0) return;
+ const cacheKey = `ury_room_counts_${branch}`;
+ const cachedCounts = sessionStorage.getItem(cacheKey);
+ let shouldFetch = true;
+
+ if (cachedCounts) {
+ try {
+ const parsedCounts = JSON.parse(cachedCounts) as Record;
+ setRoomCounts(parsedCounts);
+ const hasAllRooms = rooms.every(room => typeof parsedCounts[room.name] === 'number');
+ if (hasAllRooms) {
+ shouldFetch = false;
+ }
+ } catch {
+ sessionStorage.removeItem(cacheKey);
+ }
+ }
+
+ if (!shouldFetch) return;
+
+ async function fetchRoomCounts() {
+ setLoadingRoomCounts(true);
+ try {
+ const counts = await Promise.all(
+ rooms.map(room => getTableCount(room.name, room.branch))
+ );
+ const nextCounts = rooms.reduce((acc, room, index) => {
+ acc[room.name] = counts[index];
+ return acc;
+ }, {} as Record);
+ setRoomCounts(nextCounts);
+ persistRoomCounts(nextCounts);
+ } catch (error) {
+ console.error('Failed to load room counts', error);
+ } finally {
+ setLoadingRoomCounts(false);
+ }
+ }
+
+ fetchRoomCounts();
+ }, [branch, rooms, persistRoomCounts]);
+
+ const loadTables = useCallback(
+ async (roomName: string, options?: { useCache?: boolean }) => {
+ if (!roomName) return;
+ setError(null);
+
+ const shouldUseCache = options?.useCache !== false;
+ if (shouldUseCache && tablesCache[roomName]) {
+ setTables(sortTables(tablesCache[roomName]));
+ setLoadingTables(false);
+ return;
+ }
+
+ setLoadingTables(true);
+ try {
+ const fetchedTables = await getTables(roomName);
+ const sortedTables = sortTables(fetchedTables);
+ setTables(sortedTables);
+ setTablesCache(prev => ({ ...prev, [roomName]: sortedTables }));
+ } catch (e) {
+ console.error(e);
+ setError('Failed to load tables');
+ setTables([]);
+ } finally {
+ setLoadingTables(false);
+ }
+ },
+ [tablesCache]
+ );
+
+ const refreshRoomCount = useCallback(async (roomName: string) => {
+ const roomMeta = rooms.find(room => room.name === roomName);
+ const branchName = roomMeta?.branch ?? branch;
+ if (!roomName || !branchName) return;
+
+ try {
+ const count = await getTableCount(roomName, branchName);
+ setRoomCounts(prev => {
+ const next = { ...prev, [roomName]: count };
+ persistRoomCounts(next);
+ return next;
+ });
+ } catch (error) {
+ console.error(`Failed to refresh count for room ${roomName}`, error);
+ }
+ }, [rooms, branch, persistRoomCounts]);
+
+ useEffect(() => {
+ if (!selectedRoom) return;
+ loadTables(selectedRoom);
+ }, [selectedRoom, loadTables]);
+
+ const handleNavigateToPOS = (tableName: string) => {
+ if (!selectedRoom) return;
+ setSelectedOrderType(DINE_IN);
+ setSelectedTable(tableName, selectedRoom);
+ navigate('/');
+ };
+
+ const handlePreviewTable = (table: Table, event?: MouseEvent) => {
+ event?.stopPropagation();
+ handleNavigateToPOS(table.name);
+ };
+
+ const handlePrintTable = async (table: Table, event: MouseEvent) => {
+ event.stopPropagation();
+
+ if (!posProfile) {
+ showToast.error('POS profile not loaded yet');
+ return;
+ }
+
+ setPrintingTable(table.name);
+ try {
+ const orderResponse = await getTableOrder(table.name);
+ const invoiceId = orderResponse.message?.name;
+
+ if (!invoiceId) {
+ showToast.error('No active order found for this table');
+ return;
+ }
+
+ await printOrder({ orderId: invoiceId, posProfile });
+ showToast.success('Printed successfully');
+ await loadTables(table.restaurant_room, { useCache: false });
+ await refreshRoomCount(table.restaurant_room);
+ } catch (error) {
+ showToast.error(error instanceof Error ? error.message : 'Failed to print order');
+ } finally {
+ setPrintingTable(null);
+ }
+ };
+
+ const formatInvoiceTime = (timestamp: string | null) => {
+ if (!timestamp) return 'No bill activity yet';
+
+ const parsedDate = new Date(timestamp);
+ if (!Number.isNaN(parsedDate.getTime())) {
+ return parsedDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' });
+ }
+
+ const timeOnlyMatch = timestamp.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))?$/);
+ if (timeOnlyMatch) {
+ const [, hours, minutes, seconds] = timeOnlyMatch;
+ const date = new Date();
+ date.setHours(Number(hours), Number(minutes), Number(seconds), 0);
+ const formatted = date.toLocaleTimeString(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+ if (/^\d{1,2}:\d{2}$/.test(formatted)) {
+ return formatted;
+ }
+ return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}`;
+ }
+
+ return timestamp;
+ };
+
+ const tablesToDisplay = useMemo(() => sortTables(tables), [tables]);
+
+ const hasRooms = rooms.length > 0;
+ const showGridSkeleton = loadingTables || !selectedRoom;
+
+ const handleRoomChange = (roomName: string) => {
+ if (roomName === selectedRoom) {
+ loadTables(roomName, { useCache: false });
+ return;
+ }
+
+ setSelectedRoom(roomName);
+
+ if (tablesCache[roomName]) {
+ setTables(sortTables(tablesCache[roomName]));
+ setLoadingTables(false);
+ } else {
+ setLoadingTables(true);
+ setTables([]);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {loadingRooms && (
+
+
+
+ )}
+
+ {!loadingRooms && !hasRooms && (
+
+
+ No rooms found for this branch
+
+ )}
+
+ {rooms.map(room => (
+
+ ))}
+
+
+ {/*
+
+
*/}
+
+
+
+
+
+
+ {error && !loadingTables ? (
+
+ ) : showGridSkeleton ? (
+
+ ) : tablesToDisplay.length === 0 ? (
+
+
+
No tables found for this room
+
+ ) : (
+
+ {tablesToDisplay.map(table => {
+ const isOccupied = table.occupied === 1;
+
+ return (
+
{
+ if (!isOccupied) {
+ handleNavigateToPOS(table.name);
+ }
+ }}
+ className={cn(
+ 'relative bg-white rounded-lg border-2 p-4 transition-all flex flex-col justify-between gap-y-4',
+ isOccupied
+ ? 'border-amber-400 bg-amber-50 text-amber-900'
+ : 'border-emerald-300 bg-emerald-50 text-emerald-900 hover:border-emerald-400 hover:shadow-md cursor-pointer',
+ )}
+ >
+
+
+
+
+ {isOccupied ? 'Occupied' : 'Available'}
+
+
+
+
+
+ Room
+ {table.restaurant_room}
+
+ {isOccupied && (
+
+ Started at
+ {formatInvoiceTime(table.latest_invoice_time)}
+
+ )}
+ {typeof table.no_of_seats === 'number' && (
+
+ Seats
+
+
+ {table.no_of_seats}
+
+
+ )}
+ {table.is_take_away === 1 && (
+
+ Take away
+
+ )}
+
+
+
+ {isOccupied ? (
+
+
+
+
+ ) : (
+
Tap to start a new dine-in order
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Status Legend */}
+
+
+ );
+};
+
+export default TableView;
\ No newline at end of file
diff --git a/ury/ury_pos/api.py b/ury/ury_pos/api.py
index 64d48ec..0cbf4b7 100644
--- a/ury/ury_pos/api.py
+++ b/ury/ury_pos/api.py
@@ -9,7 +9,7 @@ def getTable(room):
branch_name = getBranch()
tables = frappe.get_all(
"URY Table",
- fields=["name", "occupied", "latest_invoice_time", "is_take_away", "restaurant_room","table_shape"],
+ fields=["name", "occupied", "latest_invoice_time", "is_take_away", "restaurant_room","table_shape","no_of_seats"],
filters={"branch": branch_name,"restaurant_room":room,}
)
return tables