sashank1989 commited on
Commit
2126cb7
·
1 Parent(s): 5e69f93

feat: add per-item comments in POS cart with notes in cumulative KDS view

Browse files
backend/internal/handlers/external_handler.go CHANGED
@@ -8,6 +8,7 @@ import (
8
  "encoding/json"
9
  "fmt"
10
  "io"
 
11
  "net/http"
12
  "time"
13
 
@@ -152,10 +153,17 @@ func (h *WhatsAppHandler) sendTextMessage(to, message string) {
152
  h.DB.First(&config)
153
 
154
  if config.WhatsAppToken == "" || config.WhatsAppPhoneID == "" {
 
155
  return
156
  }
157
 
158
- url := fmt.Sprintf("https://graph.facebook.com/v18.0/%s/messages", config.WhatsAppPhoneID)
 
 
 
 
 
 
159
 
160
  payload := map[string]interface{}{
161
  "messaging_product": "whatsapp",
@@ -172,10 +180,12 @@ func (h *WhatsAppHandler) sendTextMessage(to, message string) {
172
  client := &http.Client{}
173
  resp, err := client.Do(req)
174
  if err != nil {
 
175
  return
176
  }
177
  defer resp.Body.Close()
178
- io.ReadAll(resp.Body)
 
179
 
180
  h.DB.Create(&models.NotificationLog{
181
  Type: "whatsapp",
 
8
  "encoding/json"
9
  "fmt"
10
  "io"
11
+ "log"
12
  "net/http"
13
  "time"
14
 
 
153
  h.DB.First(&config)
154
 
155
  if config.WhatsAppToken == "" || config.WhatsAppPhoneID == "" {
156
+ log.Println("[WA] Skipping: token or phone_id empty")
157
  return
158
  }
159
 
160
+ baseURL := "https://graph.facebook.com/v18.0"
161
+ if config.WhatsAppBaseURL != "" {
162
+ baseURL = config.WhatsAppBaseURL
163
+ }
164
+
165
+ url := fmt.Sprintf("%s/%s/messages", baseURL, config.WhatsAppPhoneID)
166
+ log.Printf("[WA] Sending to %s via %s", to, url)
167
 
168
  payload := map[string]interface{}{
169
  "messaging_product": "whatsapp",
 
180
  client := &http.Client{}
181
  resp, err := client.Do(req)
182
  if err != nil {
183
+ log.Printf("[WA] HTTP error: %v", err)
184
  return
185
  }
186
  defer resp.Body.Close()
187
+ body, _ := io.ReadAll(resp.Body)
188
+ log.Printf("[WA] Response: %d %s", resp.StatusCode, string(body))
189
 
190
  h.DB.Create(&models.NotificationLog{
191
  Type: "whatsapp",
backend/internal/models/models.go CHANGED
@@ -94,8 +94,9 @@ type RestaurantConfig struct {
94
  PrinterIP string `json:"printer_ip" gorm:"type:text"`
95
  PrinterPort int `json:"printer_port" gorm:"type:integer;default:9100"`
96
  KOTPrinterIP string `json:"kot_printer_ip" gorm:"type:text"`
97
- WhatsAppToken string `json:"-" gorm:"type:text"`
98
  WhatsAppPhoneID string `json:"-" gorm:"type:text"`
 
99
  SwiggyAPIKey string `json:"-" gorm:"type:text"`
100
  ZomatoAPIKey string `json:"-" gorm:"type:text"`
101
 
 
94
  PrinterIP string `json:"printer_ip" gorm:"type:text"`
95
  PrinterPort int `json:"printer_port" gorm:"type:integer;default:9100"`
96
  KOTPrinterIP string `json:"kot_printer_ip" gorm:"type:text"`
97
+ WhatsAppToken string `json:"-" gorm:"type:text"`
98
  WhatsAppPhoneID string `json:"-" gorm:"type:text"`
99
+ WhatsAppBaseURL string `json:"whatsapp_base_url" gorm:"type:text"` // Override for testing (default: https://graph.facebook.com/v18.0)
100
  SwiggyAPIKey string `json:"-" gorm:"type:text"`
101
  ZomatoAPIKey string `json:"-" gorm:"type:text"`
102
 
backend/server.log ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026/06/19 01:04:27 === HSS POS System ===
2
+ 2026/06/19 01:04:27 Starting server...
3
+ 2026/06/19 01:04:27 [DB] Running auto-migrations...
4
+ 2026/06/19 01:04:27 [DB] Indexes created successfully
5
+ 2026/06/19 01:04:27 [DB] SQLite database initialized successfully at ./data/restaurant.db
6
+ 2026/06/19 01:04:27 [DB] Seed data loaded successfully
7
+ 2026/06/19 01:04:27 [WS] WebSocket hub started
8
+ 2026/06/19 01:04:27 Server starting on http://localhost:8080
9
+ 2026/06/19 01:04:27 API docs: http://localhost:8080/api/v1
10
+ 2026/06/19 01:04:27 WebSocket: ws://localhost:8080/ws
11
+ 2026/06/19 01:04:27 Environment: development
12
+ 2026/06/19 01:04:39 [WA] Sending to 919876543210 via http://127.0.0.1:9876/1234567890/messages
13
+ 2026/06/19 01:04:39 [WA] HTTP error: Post "http://127.0.0.1:9876/1234567890/messages": EOF
14
+ 2026/06/19 01:06:15 [WA] Sending to 919876543210 via http://127.0.0.1:9876/1234567890/messages
15
+ 2026/06/19 01:06:15 [WA] Response: 200 {"messaging_product": "whatsapp", "contacts": [{"input": "919876543210", "wa_id": "919876543210"}], "messages": [{"id": "wamid.mock_1781811375"}]}
backend/server_out.log ADDED
File without changes
frontend/src/routes/(app)/kds/+page.svelte CHANGED
@@ -86,8 +86,10 @@
86
  if (item.is_cancelled) continue;
87
  if (item.status === 'ready' || kot.status === 'ready') continue;
88
  const name = item.product_name || item.name || 'Item';
89
- if (!itemMap.has(name)) itemMap.set(name, { total: 0, dine: 0, parcel: 0 });
90
- const entry = itemMap.get(name);
 
 
91
  entry.total += item.quantity;
92
  if (cat === 'dining') entry.dine += item.quantity;
93
  else entry.parcel += item.quantity;
@@ -288,6 +290,9 @@
288
  <span class="text-sm font-medium text-gray-800 truncate mr-2">{item.name}</span>
289
  <span class="flex-shrink-0 bg-amber-100 text-amber-800 text-xs font-bold rounded-full px-2.5 py-0.5">x{item.total}</span>
290
  </div>
 
 
 
291
  <div class="flex gap-2 mt-1">
292
  {#if item.dine > 0}
293
  <span class="text-[11px] text-blue-600 font-medium">Dine {item.dine}</span>
@@ -326,6 +331,9 @@
326
  <span class="text-sm font-medium text-gray-800 truncate mr-2">{item.name}</span>
327
  <span class="flex-shrink-0 bg-amber-100 text-amber-800 text-xs font-bold rounded-full px-2.5 py-0.5">x{item.total}</span>
328
  </div>
 
 
 
329
  <div class="flex gap-2 mt-0.5">
330
  {#if item.dine > 0}
331
  <span class="text-[11px] text-blue-600 font-medium">Dine {item.dine}</span>
 
86
  if (item.is_cancelled) continue;
87
  if (item.status === 'ready' || kot.status === 'ready') continue;
88
  const name = item.product_name || item.name || 'Item';
89
+ const notes = item.notes || '';
90
+ const key = notes ? `${name}||${notes}` : name;
91
+ if (!itemMap.has(key)) itemMap.set(key, { name, notes, total: 0, dine: 0, parcel: 0 });
92
+ const entry = itemMap.get(key);
93
  entry.total += item.quantity;
94
  if (cat === 'dining') entry.dine += item.quantity;
95
  else entry.parcel += item.quantity;
 
290
  <span class="text-sm font-medium text-gray-800 truncate mr-2">{item.name}</span>
291
  <span class="flex-shrink-0 bg-amber-100 text-amber-800 text-xs font-bold rounded-full px-2.5 py-0.5">x{item.total}</span>
292
  </div>
293
+ {#if item.notes}
294
+ <p class="text-[11px] text-amber-600 italic truncate mt-0.5">"{item.notes}"</p>
295
+ {/if}
296
  <div class="flex gap-2 mt-1">
297
  {#if item.dine > 0}
298
  <span class="text-[11px] text-blue-600 font-medium">Dine {item.dine}</span>
 
331
  <span class="text-sm font-medium text-gray-800 truncate mr-2">{item.name}</span>
332
  <span class="flex-shrink-0 bg-amber-100 text-amber-800 text-xs font-bold rounded-full px-2.5 py-0.5">x{item.total}</span>
333
  </div>
334
+ {#if item.notes}
335
+ <p class="text-[11px] text-amber-600 italic truncate mt-0.5">"{item.notes}"</p>
336
+ {/if}
337
  <div class="flex gap-2 mt-0.5">
338
  {#if item.dine > 0}
339
  <span class="text-[11px] text-blue-600 font-medium">Dine {item.dine}</span>
frontend/src/routes/(app)/pos/+page.svelte CHANGED
@@ -61,6 +61,7 @@
61
 
62
  // Mobile cart visibility
63
  let mobileCartOpen = $state(false);
 
64
 
65
  // ── Load menu ──
66
  onMount(async () => {
@@ -417,15 +418,41 @@
417
  onclick={() => cartStore.updateQuantity(item.cartId, item.quantity + 1)}
418
  >+</button>
419
  </div>
420
- <button
421
- class="p-2 text-gray-400 hover:text-pos-danger transition-colors"
422
- onclick={() => cartStore.removeItem(item.cartId)}
423
- >
424
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
425
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
426
- </svg>
427
- </button>
 
 
 
 
 
 
 
 
 
 
 
428
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  </div>
430
  {/each}
431
  {/if}
@@ -535,15 +562,41 @@
535
  onclick={() => cartStore.updateQuantity(item.cartId, item.quantity + 1)}
536
  >+</button>
537
  </div>
538
- <button
539
- class="p-2 text-gray-400 hover:text-pos-danger"
540
- onclick={() => cartStore.removeItem(item.cartId)}
541
- >
542
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
543
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
544
- </svg>
545
- </button>
 
 
 
 
 
 
 
 
 
 
 
546
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  </div>
548
  {/each}
549
  </div>
 
61
 
62
  // Mobile cart visibility
63
  let mobileCartOpen = $state(false);
64
+ let editingNoteId = $state(null);
65
 
66
  // ── Load menu ──
67
  onMount(async () => {
 
418
  onclick={() => cartStore.updateQuantity(item.cartId, item.quantity + 1)}
419
  >+</button>
420
  </div>
421
+ <div class="flex items-center gap-1">
422
+ <button
423
+ class="p-2 text-gray-400 hover:text-amber-600 transition-colors {item.notes ? 'text-amber-500' : ''}"
424
+ onclick={() => { editingNoteId = editingNoteId === item.cartId ? null : item.cartId; }}
425
+ title="Add note"
426
+ >
427
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
428
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
429
+ </svg>
430
+ </button>
431
+ <button
432
+ class="p-2 text-gray-400 hover:text-pos-danger transition-colors"
433
+ onclick={() => cartStore.removeItem(item.cartId)}
434
+ >
435
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
436
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
437
+ </svg>
438
+ </button>
439
+ </div>
440
  </div>
441
+ {#if item.notes && editingNoteId !== item.cartId}
442
+ <p class="mt-1 text-xs text-amber-600 italic truncate">"{item.notes}"</p>
443
+ {/if}
444
+ {#if editingNoteId === item.cartId}
445
+ <div class="mt-2">
446
+ <input
447
+ type="text"
448
+ class="w-full px-2 py-1.5 text-xs border border-amber-300 rounded-md focus:outline-none focus:ring-1 focus:ring-amber-400 bg-amber-50"
449
+ placeholder="e.g. less spicy, no onion, extra cheese..."
450
+ value={item.notes}
451
+ oninput={(e) => cartStore.setItemNotes(item.cartId, e.target.value)}
452
+ onkeydown={(e) => { if (e.key === 'Enter') editingNoteId = null; }}
453
+ />
454
+ </div>
455
+ {/if}
456
  </div>
457
  {/each}
458
  {/if}
 
562
  onclick={() => cartStore.updateQuantity(item.cartId, item.quantity + 1)}
563
  >+</button>
564
  </div>
565
+ <div class="flex items-center gap-1">
566
+ <button
567
+ class="p-2 text-gray-400 hover:text-amber-600 {item.notes ? 'text-amber-500' : ''}"
568
+ onclick={() => { editingNoteId = editingNoteId === item.cartId ? null : item.cartId; }}
569
+ title="Add note"
570
+ >
571
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
572
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
573
+ </svg>
574
+ </button>
575
+ <button
576
+ class="p-2 text-gray-400 hover:text-pos-danger"
577
+ onclick={() => cartStore.removeItem(item.cartId)}
578
+ >
579
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
580
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
581
+ </svg>
582
+ </button>
583
+ </div>
584
  </div>
585
+ {#if item.notes && editingNoteId !== item.cartId}
586
+ <p class="mt-1 text-xs text-amber-600 italic truncate">"{item.notes}"</p>
587
+ {/if}
588
+ {#if editingNoteId === item.cartId}
589
+ <div class="mt-2">
590
+ <input
591
+ type="text"
592
+ class="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-amber-400 bg-amber-50"
593
+ placeholder="e.g. less spicy, no onion, extra cheese..."
594
+ value={item.notes}
595
+ oninput={(e) => cartStore.setItemNotes(item.cartId, e.target.value)}
596
+ onkeydown={(e) => { if (e.key === 'Enter') editingNoteId = null; }}
597
+ />
598
+ </div>
599
+ {/if}
600
  </div>
601
  {/each}
602
  </div>
tools/mock_whatsapp.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mock Meta WhatsApp Graph API Server
3
+ Simulates https://graph.facebook.com/v18.0/{phone_id}/messages
4
+ Uses Flask for robust HTTP handling with Go clients.
5
+ """
6
+ import sys
7
+ import json
8
+ from datetime import datetime
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+
11
+ class MockWhatsAppAPI(BaseHTTPRequestHandler):
12
+ protocol_version = "HTTP/1.1"
13
+
14
+ def do_POST(self):
15
+ content_length = int(self.headers.get('Content-Length', 0))
16
+ body = self.rfile.read(content_length) if content_length > 0 else b''
17
+
18
+ try:
19
+ data = json.loads(body) if body else {}
20
+ except (json.JSONDecodeError, ValueError):
21
+ data = {"raw": body.decode('utf-8', errors='replace')}
22
+
23
+ # Print the outbound message
24
+ to = data.get('to', '?') if isinstance(data, dict) else '?'
25
+ msg_type = data.get('type', '?') if isinstance(data, dict) else '?'
26
+
27
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] -> To: {to} | Type: {msg_type} | Path: {self.path}", flush=True)
28
+
29
+ if isinstance(data, dict) and data.get('type') == 'text':
30
+ text_body = data.get('text', {}).get('body', '')
31
+ lines = text_body.split('\n')
32
+ for line in lines[:5]:
33
+ print(f" {line}", flush=True)
34
+ if len(lines) > 5:
35
+ print(f" ... ({len(lines)} lines total)", flush=True)
36
+
37
+ # Respond like Meta
38
+ response = json.dumps({
39
+ "messaging_product": "whatsapp",
40
+ "contacts": [{"input": to, "wa_id": to}],
41
+ "messages": [{"id": f"wamid.mock_{int(datetime.now().timestamp())}"}]
42
+ }).encode('utf-8')
43
+
44
+ self.send_response(200)
45
+ self.send_header('Content-Type', 'application/json')
46
+ self.send_header('Content-Length', str(len(response)))
47
+ self.send_header('Connection', 'close')
48
+ self.end_headers()
49
+ self.wfile.write(response)
50
+
51
+ def log_message(self, format, *args):
52
+ pass # Suppress default access logs
53
+
54
+ if __name__ == '__main__':
55
+ port = 9876
56
+ server = HTTPServer(('127.0.0.1', port), MockWhatsAppAPI)
57
+ print(f"Mock WhatsApp API on http://127.0.0.1:{port}", flush=True)
58
+ print("Waiting for messages...", flush=True)
59
+ try:
60
+ server.serve_forever()
61
+ except KeyboardInterrupt:
62
+ print("Stopped.")
tools/setup_whatsapp_test.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ conn = sqlite3.connect(r'C:\Users\SSASHANK\restaurant-pos\backend\data\restaurant.db')
3
+
4
+ # Check if notification_logs table exists
5
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_logs'")
6
+ table = cursor.fetchone()
7
+ print(f"Table exists: {table is not None}")
8
+
9
+ # Check config values server would read
10
+ cursor = conn.execute("SELECT whats_app_token, whats_app_phone_id, whats_app_base_url FROM restaurant_configs LIMIT 1")
11
+ row = cursor.fetchone()
12
+ print(f"Token: '{row[0]}'")
13
+ print(f"PhoneID: '{row[1]}'")
14
+ print(f"BaseURL: '{row[2]}'")
15
+
16
+ # Check if any products exist (for menu)
17
+ cursor = conn.execute("SELECT COUNT(*) FROM products WHERE is_active=1 AND is_available=1")
18
+ count = cursor.fetchone()[0]
19
+ print(f"Active products: {count}")
20
+
21
+ conn.close()