diff --git a/pos/src/components/LayoutView.tsx b/pos/src/components/LayoutView.tsx new file mode 100644 index 0000000..1a4ca94 --- /dev/null +++ b/pos/src/components/LayoutView.tsx @@ -0,0 +1,572 @@ +import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'; +import { CreditCard as Edit3, Save, Users, Move, X, Grid3x3 as Grid3X3, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; +import { cn, formatInvoiceTime } from '../lib/utils'; +import { Table, updateTableLayout } from '../lib/table-api'; +import { getTableOrder, POSInvoice } from '../lib/order-api'; +import { Button } from './ui'; + + + +interface Props { + selectedRoom: string; + tables: Table[]; + onBackToGrid: () => void; + onRefresh?: () => void; // Add refresh callback +} + +const LayoutView: React.FC = ({ selectedRoom, tables, onBackToGrid, onRefresh }) => { + const [isEditMode, setIsEditMode] = useState(false); + + // Local state for optimistic updates + const [localLayouts, setLocalLayouts] = useState>>({}); + const [draggedTable, setDraggedTable] = useState(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [selectedTable, setSelectedTable] = useState(null); + const [selectedTableOrder, setSelectedTableOrder] = useState(null); + const canvasRef = useRef(null); + const [zoom, setZoom] = useState(1); + const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [capacityInput, setCapacityInput] = useState(''); + + // Save to local storage effect removed + + + // Merge props.tables with saved positions + const tablesWithPosition = useMemo(() => { + return tables.map((table, index) => { + const local = localLayouts[table.name] || {}; + + // Use local overrides, then backend fields, then grid defaults + const x = local.layout_x ?? table.layout_x ?? (100 + (index % 5) * 150); + const y = local.layout_y ?? table.layout_y ?? (100 + Math.floor(index / 5) * 150); + + return { + ...table, + x, + y, + table_shape: local.table_shape ?? table.table_shape, + no_of_seats: local.no_of_seats ?? table.no_of_seats, + }; + }); + }, [tables, localLayouts]); + + // Sync capacity input when selected table changes + useEffect(() => { + if (selectedTable) { + const table = tablesWithPosition.find(t => t.name === selectedTable); + setCapacityInput(table?.no_of_seats?.toString() ?? ''); + } + }, [selectedTable, tablesWithPosition]); + + // Calculate table dimensions based on capacity and shape + const getTableDimensions = (shape: string, capacity: number = 4) => { + // Dynamic sizing: minimum 60px, scales up by 10px per person, max 250px + const size = Math.max(60, Math.min(250, 60 + (capacity * 10))); + + const normalizedShape = shape?.toLowerCase() || 'rectangle'; + + switch (normalizedShape) { + case 'circle': + return { width: size, height: size }; + case 'square': + return { width: size, height: size }; + case 'rectangle': + default: + return { width: size * 1.5, height: size }; + } + }; + + // Zoom functionality (simple scale) + const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.1, 3)); + const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.3)); + const handleResetZoom = () => { + setZoom(1); + setPanOffset({ x: 0, y: 0 }); + }; + + // Mouse wheel zoom (simple scale) + const handleWheel = useCallback((e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setZoom(prev => Math.max(0.3, Math.min(3, prev + delta))); + }, []); + + // Use ref to attach non-passive listener for proper preventDefault + useEffect(() => { + const canvas = canvasRef.current; + if (canvas) { + canvas.addEventListener('wheel', handleWheel, { passive: false }); + } + return () => { + if (canvas) { + canvas.removeEventListener('wheel', handleWheel); + } + }; + }, [handleWheel]); + + // Pan functionality + const handleCanvasMouseDown = (e: React.MouseEvent) => { + // Start panning if we clicked on the background (wrapper or outer container) + // Tables stop propagation, so if we get here, it's safe to pan + setIsPanning(true); + setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }); + }; + + const handleCanvasMouseMove = (e: React.MouseEvent) => { + if (isPanning) { + setPanOffset({ + x: e.clientX - panStart.x, + y: e.clientY - panStart.y + }); + return; + } + + // Drag functionality + if (!draggedTable || !isEditMode || !canvasRef.current) return; + + const canvasRect = canvasRef.current.getBoundingClientRect(); + + // Classic drag math + const newX = (e.clientX - canvasRect.left) / zoom - dragOffset.x - panOffset.x / zoom; + const newY = (e.clientY - canvasRect.top) / zoom - dragOffset.y - panOffset.y / zoom; + + // Update local state for immediate feedback + setLocalLayouts(prev => ({ + ...prev, + [draggedTable]: { + ...(prev[draggedTable] || {}), + layout_x: newX, + layout_y: newY, + } + })); + }; + + const persistTableUpdate = (tableName: string, changes: Partial) => { + const table = tablesWithPosition.find(t => t.name === tableName); + if (!table) return Promise.reject("Table not found"); + + // AND ensure we fallback to backend values for undefined fields. + const payload = { + layout_x: changes.layout_x ?? table.x, + layout_y: changes.layout_y ?? table.y, + table_shape: changes.table_shape ?? table.table_shape, + no_of_seats: changes.no_of_seats ?? table.no_of_seats, + minimum_seating: table.minimum_seating // preserve existing if not changing + }; + return updateTableLayout(tableName, payload); + }; + + const handleCanvasMouseUp = () => { + if (draggedTable && isEditMode) { + const table = tablesWithPosition.find(t => t.name === draggedTable); + if (table) { + // table.x and table.y are already updated via local state during drag + persistTableUpdate(table.name, { + layout_x: table.x, + layout_y: table.y + }).catch(err => console.error("Failed to save layout", err)); + } + } + + setIsPanning(false); + setDraggedTable(null); + setDragOffset({ x: 0, y: 0 }); + }; + + const getTableStatusColor = (occupied: number) => { + return occupied + ? 'bg-amber-100 border-amber-300 text-amber-900 shadow-sm' + : 'bg-emerald-50 border-emerald-200 text-emerald-800 hover:shadow-md'; + }; + + const handleMouseDown = (e: React.MouseEvent, table: typeof tablesWithPosition[0]) => { + e.stopPropagation(); + + setSelectedTable(table.name); + + if (table.occupied) { + getTableOrder(table.name).then(res => { + setSelectedTableOrder(res.message); + }).catch(console.error); + } else { + setSelectedTableOrder(null); + } + + if (!isEditMode) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + // Calculate offset for drag start + // We need to match the drag math: + // newX = (mouseX - rect.left)/zoom - dragOffset - pan/zoom + // So dragOffset = (mouseX - rect)/zoom - startX - pan/zoom + + // Actually, just use standard offset from top-left of element? + // Wait, the newX formula sets the new TopLeft. + // So dragOffset should be the difference between Mouse and TableTopLeft (in scaled/world units?) + + // User formula: newX = (mouseX - canvasRect.left) / zoom - dragOffset.x - panOffset.x / zoom + + // So when we start drag: + // table.x = (mouseX - rect.left)/zoom - dragOffset.x - panOffset.x/zoom + // dragOffset.x = (mouseX - rect.left)/zoom - table.x - panOffset.x/zoom + + const mouseX = e.clientX; + const mouseY = e.clientY; + + setDraggedTable(table.name); + setDragOffset({ + x: (mouseX - canvasRect.left) / zoom - table.x - panOffset.x / zoom, + y: (mouseY - canvasRect.top) / zoom - table.y - panOffset.y / zoom + }); + }; + + const TableShape = ({ table }: { table: typeof tablesWithPosition[0] }) => { + const dimensions = getTableDimensions(table.table_shape, table.no_of_seats); + + const baseClasses = cn( + 'absolute border-2 flex items-center justify-center text-sm font-semibold cursor-pointer transition-all select-none', + getTableStatusColor(table.occupied), + isEditMode && 'hover:ring-2 hover:ring-blue-400 cursor-move', + draggedTable === table.name && 'shadow-xl scale-105 z-20', + selectedTable === table.name && 'ring-2 ring-blue-600 z-10' + ); + + const style = { + left: table.x, + top: table.y, + width: dimensions.width, + height: dimensions.height, + transform: `scale(${zoom})`, + transformOrigin: 'top left', + }; + + const shapeLower = table.table_shape?.toLowerCase(); + const shapeClasses = { + circle: 'rounded-full', + square: 'rounded-lg', + rectangle: 'rounded-md' + }; + + const roundedClass = shapeClasses[shapeLower as keyof typeof shapeClasses] || shapeClasses.rectangle; + + return ( +
handleMouseDown(e, table)} + > +
+
{table.name}
+
+ + {table.no_of_seats || '-'} +
+
+ {isEditMode && ( + <> +
+ +
+ + )} +
+ ); + }; + + // Helper to format invoice time (consistent with Table.tsx) removed - imported from utils + const handleCapacityChange = (capacityStr: string) => { + if (!selectedTable) return; + + // Always update the input field value to allow free typing + setCapacityInput(capacityStr); + + const capacity = parseInt(capacityStr); + if (isNaN(capacity) || capacity < 1 || capacity > 20) return; + + const currentTable = tablesWithPosition.find(t => t.name === selectedTable); + if (!currentTable) return; + + setLocalLayouts(prev => ({ + ...prev, + [selectedTable]: { + ...(prev[selectedTable] || {}), + no_of_seats: capacity + } + })); + + updateTableLayout(selectedTable, { no_of_seats: capacity }) + .catch(console.error); + } + + const handleDropdownShapeChange = (shape: string) => { + if (!selectedTable) return; + const currentTable = tablesWithPosition.find(t => t.name === selectedTable); + if (!currentTable) return; + + setLocalLayouts(prev => ({ + ...prev, + [selectedTable]: { + ...(prev[selectedTable] || {}), + table_shape: shape as any + } + })); + + updateTableLayout(selectedTable, { table_shape: shape as any }) + .catch(console.error); + } + + const selectedTableData = tablesWithPosition.find(t => t.name === selectedTable); + + return ( +
+ {/* Header Controls */} +
+
+
+ +

{selectedRoom} | Layout

+
+
+ {/* Edit Mode Toggle */} +
+ +
+
+
+
+ + {/* Canvas Area */} +
+ {/* Zoom Controls */} +
+ + + +
+ {Math.round(zoom * 100)}% +
+
+ + {/* Instructions */} +
+ {isEditMode ? ( +
+
Editing Layout
+
• Drag tables to reposition
+
• Changes autosave
+
+ ) : ( +
+ Use Scroll to zoom • Drag background to pan +
+ )} +
+ +
+ {/* Tables Container with Transform */} +
+ {tablesWithPosition.map(table => ( + + ))} +
+
+
+ + {/* Table Properties Panel */} + {selectedTable && selectedTableData && ( +
+
+

+ {isEditMode ? 'Edit Table Settings' : 'Table Info'} +

+ +
+ +
+
+ + +
+ +
+ + handleCapacityChange(e.target.value)} + disabled={!isEditMode} + className={cn( + "w-full px-3 py-2 border rounded-md text-sm", + isEditMode + ? "border-gray-300 bg-white" + : "border-gray-200 bg-gray-50 cursor-not-allowed" + )} + placeholder="Enter 1-20" + /> +

Valid range: 1-20 pax

+
+ +
+ + +
+ +
+ +
+ {selectedTableData.occupied ? 'Occupied' : 'Available'} +
+
+ + {/* Position Information */} +
+ +
+
+ X: + {Math.round(selectedTableData.x)}px +
+
+ Y: + {Math.round(selectedTableData.y)}px +
+
+
+ + {/* Size Information */} +
+ +
+
+ W: + {getTableDimensions(selectedTableData.table_shape || 'Rectangle').width}px +
+
+ H: + {getTableDimensions(selectedTableData.table_shape || 'Rectangle').height}px +
+
+
+ + {/* Show current bill info if table is occupied */} + {selectedTableData.latest_invoice_time && ( +
+ +
+
+ Started at: + {formatInvoiceTime(selectedTableData.latest_invoice_time)} +
+ {selectedTableOrder && ( +
+ Total Amount: + + {selectedTableOrder.grand_total.toFixed(2)} + +
+ )} +
+
+ )} + +
+
+ )} +
+ ); +}; + +export default LayoutView; \ No newline at end of file diff --git a/pos/src/lib/table-api.ts b/pos/src/lib/table-api.ts index 6bb1bea..b25d16a 100644 --- a/pos/src/lib/table-api.ts +++ b/pos/src/lib/table-api.ts @@ -12,10 +12,14 @@ export interface Table { latest_invoice_time: string | null; is_take_away: number; restaurant_room: string; - table_shape:'Circle' | 'Square' | 'Rectangle'; + table_shape: 'Circle' | 'Square' | 'Rectangle'; no_of_seats?: number; + layout_x?: number; + layout_y?: number; + minimum_seating?: number; } + export async function getRestaurantMenu(posProfile: string, room?: string | null) { const { call } = await import('./frappe-sdk'); const params: Record = { pos_profile: posProfile }; @@ -36,12 +40,6 @@ export async function getRooms(branch: string): Promise { return rooms as Room[]; } -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[]; -} - export async function getTableCount(room: string, branch?: string): Promise { const filters = [ ['restaurant_room', '=', room], @@ -55,4 +53,30 @@ export async function getTableCount(room: string, branch?: string): Promise; const countValue = rows[0]?.count ?? 0; return typeof countValue === 'number' ? countValue : Number(countValue) || 0; -} \ No newline at end of file +} +export async function getTables(room: string): Promise { + const tables = await db.getDocList(DOCTYPES.URY_TABLE, { + fields: [ + 'name', + 'occupied', + 'latest_invoice_time', + 'is_take_away', + 'restaurant_room', + 'table_shape', + 'no_of_seats', + 'layout_x', + 'layout_y', + 'minimum_seating' + ], + filters: [['restaurant_room', '=', room]], + asDict: true, + }); + + return tables as Table[]; +} + + +export async function updateTableLayout(name: string, data: Partial
) { + return db.updateDoc(DOCTYPES.URY_TABLE, name, data); +} + diff --git a/pos/src/lib/utils.ts b/pos/src/lib/utils.ts index 96db635..9d99401 100644 --- a/pos/src/lib/utils.ts +++ b/pos/src/lib/utils.ts @@ -9,4 +9,31 @@ export function cn(...inputs: ClassValue[]) { export function formatCurrency(amount: number): string { const symbol = storage.getItem('currencySymbol'); return `${symbol} ${amount}`; -} \ No newline at end of file +} + +export 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; + }; \ No newline at end of file diff --git a/pos/src/pages/Table.tsx b/pos/src/pages/Table.tsx index 2c470e4..33eca0f 100644 --- a/pos/src/pages/Table.tsx +++ b/pos/src/pages/Table.tsx @@ -1,9 +1,9 @@ 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 { AlertTriangle, Eye, Layout, Loader2, Printer, Square, Users } from 'lucide-react'; +import { cn, formatInvoiceTime } from '../lib/utils'; import { usePOSStore } from '../store/pos-store'; -import { getRooms, getTables, getTableCount, type Room, type Table } from '../lib/table-api'; +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'; @@ -13,6 +13,8 @@ import { getTableOrder } from '../lib/order-api'; import { printOrder } from '../lib/print'; import { showToast } from '../components/ui/toast'; +import LayoutView from '../components/LayoutView'; + const sortTables = (tables: Table[]) => [...tables].sort((a, b) => a.name.localeCompare(b.name)); const TableView = () => { @@ -88,7 +90,7 @@ const TableView = () => { if (!shouldFetch) return; - async function fetchRoomCounts() { + async function fetchRoomCounts() { setLoadingRoomCounts(true); try { const counts = await Promise.all( @@ -139,23 +141,6 @@ const TableView = () => { [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); @@ -194,7 +179,6 @@ const TableView = () => { 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 { @@ -202,33 +186,6 @@ const TableView = () => { } }; - 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; @@ -251,57 +208,75 @@ const TableView = () => { } }; + const [isLayoutView, setIsLayoutView] = useState(false); + + const handleLayoutView = () => { + if (selectedRoom) { + loadTables(selectedRoom, { useCache: false }); + } + setIsLayoutView(true); + }; + + if (isLayoutView && selectedRoom) { + return ( + setIsLayoutView(false)} + onRefresh={() => loadTables(selectedRoom, { useCache: false })} + /> + ); + } + return (
-
- {loadingRooms && ( -
- -
- )} - - {!loadingRooms && !hasRooms && ( -
- - No rooms found for this branch -
- )} - - {rooms.map(room => ( +
+
+ {loadingRooms && ( +
+ +
+ )} + + {!loadingRooms && !hasRooms && ( +
+ + No rooms found for this branch +
+ )} + + {rooms.map(room => ( + + ))} +
+ +
- ))} +
- - {/*
- -
*/}
@@ -343,42 +318,42 @@ const TableView = () => { )} >
-
+
- - {table.name} + + {table.name}
- {isOccupied ? 'Occupied' : 'Available'} + {isOccupied ? 'Occupied' : 'Available'} -
+
-
+
- Room - {table.restaurant_room} + Room + {table.restaurant_room}
{isOccupied && ( -
+
Started at {formatInvoiceTime(table.latest_invoice_time)} -
+
)} {typeof table.no_of_seats === 'number' && ( -
+
Seats - - {table.no_of_seats} + + {table.no_of_seats} -
+
)} {table.is_take_away === 1 && ( - + Take away - + )} -
+
{isOccupied ? ( diff --git a/ury/ury/doctype/ury_table/ury_table.json b/ury/ury/doctype/ury_table/ury_table.json index 0494e01..74f1d60 100644 --- a/ury/ury/doctype/ury_table/ury_table.json +++ b/ury/ury/doctype/ury_table/ury_table.json @@ -16,6 +16,12 @@ "restaurant", "restaurant_room", "branch", + "layout_position_section", + "layout_x", + "layout_width", + "column_break_olsi", + "layout_y", + "layout_height", "section_break_mcm3o", "is_take_away", "active_info_tab", @@ -104,11 +110,40 @@ "fieldtype": "Select", "label": "Table Shape", "options": "\nRectangle\nSquare\nCircle" + }, + { + "fieldname": "layout_position_section", + "fieldtype": "Section Break", + "label": "Layout Position" + }, + { + "fieldname": "layout_x", + "fieldtype": "Float", + "label": "Layout X" + }, + { + "fieldname": "layout_width", + "fieldtype": "Float", + "label": "Layout Width" + }, + { + "fieldname": "column_break_olsi", + "fieldtype": "Column Break" + }, + { + "fieldname": "layout_y", + "fieldtype": "Float", + "label": "Layout Y" + }, + { + "fieldname": "layout_height", + "fieldtype": "Float", + "label": "Layout Height" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-07-17 11:29:34.334457", + "modified": "2025-12-31 15:45:13.235634", "modified_by": "Administrator", "module": "URY", "name": "URY Table", @@ -157,6 +192,7 @@ } ], "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/ury/ury_pos/api.py b/ury/ury_pos/api.py index 0cbf4b7..dd2cb52 100644 --- a/ury/ury_pos/api.py +++ b/ury/ury_pos/api.py @@ -3,17 +3,16 @@ from datetime import date, datetime, timedelta - -@frappe.whitelist() -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","no_of_seats"], - filters={"branch": branch_name,"restaurant_room":room,} - ) - return tables - +#GetTable decripted temporarily +# @frappe.whitelist() +# 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","no_of_seats","layout_x","layout_y"], +# filters={"branch": branch_name,"restaurant_room":room,} +# ) +# return tables @frappe.whitelist() def getRestaurantMenu(pos_profile, room=None, order_type=None):