bot commited on
Commit
1f334b7
·
1 Parent(s): c1da0b0

web: bulk CK edit on customer list (admin, after access code) + send-per-customer via Zalo

Browse files

- Customer list rows get an admin-only bulk checkbox; a bulk bar lets the
admin type a CK% (global or per-brand) and apply it to all selected
customers at once (POST /api/customers, V.AISTUDIO-gated).
- Customer DETAIL modal gets a 'Gửi qua Zalo' button (admin, after unlock)
that saves the current CK + all filled fields and DMs that customer a
message containing every field that has content (ma KH, CK, company,
address, representative, recipient, tax code...).
- Bulk 'Gửi qua Zalo' sends the same to each selected customer.
- Access code is no longer auto-filled into quote modal input (hidden).

Files changed (3) hide show
  1. index.html +2 -2
  2. src/cart-quote.js +3 -1
  3. src/customers.js +187 -2
index.html CHANGED
@@ -551,8 +551,8 @@
551
  <script defer src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
552
  <!-- App modules: defer keeps execution order (parse order) but doesn't block
553
  first paint — the welcome/avatar-picker modal renders immediately. -->
554
- <script defer src="./src/cart-quote.js?gc=23"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
555
- <script defer src="./src/customers.js?gc=12"></script> <!-- Customer list (Danh sách khách hàng) -->
556
  <script defer src="./src/vaix-rag.js?gc=48"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
557
  <script defer src="./src/greeting-news.js?gc=27"></script> <!-- Greeting HOT news cards + source links -->
558
  <script defer src="./src/order-sync.js?gc=15"></script> <!-- Order sync with V.AISTUDIO backend -->
 
551
  <script defer src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
552
  <!-- App modules: defer keeps execution order (parse order) but doesn't block
553
  first paint — the welcome/avatar-picker modal renders immediately. -->
554
+ <script defer src="./src/cart-quote.js?gc=24"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
555
+ <script defer src="./src/customers.js?gc=13"></script> <!-- Customer list (Danh sách khách hàng) -->
556
  <script defer src="./src/vaix-rag.js?gc=48"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
557
  <script defer src="./src/greeting-news.js?gc=27"></script> <!-- Greeting HOT news cards + source links -->
558
  <script defer src="./src/order-sync.js?gc=15"></script> <!-- Order sync with V.AISTUDIO backend -->
src/cart-quote.js CHANGED
@@ -233,7 +233,9 @@ window.openQuotation = function() {
233
  if (addr && !addr.value) addr.value = saved.addr || '';
234
  if (codeEl) codeEl.value = genOrderCode();
235
  if (dateEl) dateEl.value = new Date().toISOString().slice(0, 10);
236
- if (accessEl && _quoteUnlocked && !accessEl.value) accessEl.value = ACCESS_CODE;
 
 
237
  _pushTopbarDown();
238
  ov.style.display = 'flex';
239
  ov.classList.add('open');
 
233
  if (addr && !addr.value) addr.value = saved.addr || '';
234
  if (codeEl) codeEl.value = genOrderCode();
235
  if (dateEl) dateEl.value = new Date().toISOString().slice(0, 10);
236
+ // NOTE: the V.AISTUDIO access code is NEVER auto-filled into the modal input —
237
+ // it stays hidden (masked, type=password) and the admin types it manually.
238
+ // This prevents the access code from being displayed/revealed on any modal.
239
  _pushTopbarDown();
240
  ov.style.display = 'flex';
241
  ov.classList.add('open');
src/customers.js CHANGED
@@ -352,7 +352,12 @@
352
  var editBtn = allowEdit
353
  ? '<button class="cus-edit-btn" data-cid="' + esc(cid) + '" title="Sửa">✎</button>'
354
  : '';
 
 
 
 
355
  return '<div class="cus-row" data-cid="' + esc(cid) + '">'
 
356
  + '<div class="cus-row-main">'
357
  + '<div class="cus-row-name">' + esc(name) + '</div>'
358
  + '<div class="cus-row-meta">' + esc(cid.slice(0, 8)) + ma + ck + '</div>'
@@ -363,6 +368,16 @@
363
  + '</div>'
364
  + '</div>';
365
  }
 
 
 
 
 
 
 
 
 
 
366
 
367
  // ── Detail editor modal (V.AISTUDIO access-code gated in normal mode) ──
368
  function openCustomerDetail(cid, c) {
@@ -433,7 +448,7 @@
433
  + '<div style="border-top:1px solid #e2e8f0;padding-top:12px"></div>'
434
  + fieldsHtml
435
  + (allowEdit
436
- ? '<div style="text-align:right;margin-top:16px"><button id="cus-save" style="padding:10px 22px;background:#0077b6;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:700">Lưu</button></div>'
437
  : '')
438
  + '</div>';
439
  document.body.appendChild(ov);
@@ -528,7 +543,155 @@
528
  }
529
  };
530
  } // end if (saveBtn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  function renderCustomerList() {
534
  var box = document.getElementById('cus-list');
@@ -569,10 +732,17 @@
569
  + '<button id="cus-close" style="background:none;border:none;color:#fff;font-size:22px;cursor:pointer">✕</button></div>'
570
  + '<div id="cus-gate"></div>'
571
  + '<div style="padding:10px 18px;border-bottom:1px solid #e2e8f0"><input id="cus-q" placeholder="Tìm khách hàng..." style="width:100%;padding:9px;border:2px solid #e2e8f0;border-radius:8px;font-size:13px;box-sizing:border-box;outline:none"></div>'
 
 
 
 
 
 
572
  + '<div id="cus-list" style="flex:1;overflow-y:auto;padding:10px 18px"></div>'
573
  + '</div>';
574
  document.body.appendChild(ov);
575
- document.getElementById('cus-close').onclick = function () { ov.remove(); };
 
576
 
577
  // Access gate for NORMAL mode (no ?kh link): the full customer directory is
578
  // revealed only after the V.AISTUDIO access code is entered. In
@@ -618,6 +788,21 @@
618
  return;
619
  }
620
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
 
622
  // Search filter (non-admin sees only their own row, so the filter is no-op)
623
  document.getElementById('cus-q').oninput = function () {
 
352
  var editBtn = allowEdit
353
  ? '<button class="cus-edit-btn" data-cid="' + esc(cid) + '" title="Sửa">✎</button>'
354
  : '';
355
+ // Admin-only checkbox for bulk CK editing (homepage, after access code).
356
+ var chk = allowEdit
357
+ ? '<label class="cus-chk" title="Chọn chỉnh CK hàng loạt" style="display:flex;align-items:center;margin-right:10px;cursor:pointer"><input type="checkbox" class="cus-bulk-chk" data-cid="' + esc(cid) + '" style="width:16px;height:16px;cursor:pointer"></label>'
358
+ : '';
359
  return '<div class="cus-row" data-cid="' + esc(cid) + '">'
360
+ + (chk)
361
  + '<div class="cus-row-main">'
362
  + '<div class="cus-row-name">' + esc(name) + '</div>'
363
  + '<div class="cus-row-meta">' + esc(cid.slice(0, 8)) + ma + ck + '</div>'
 
368
  + '</div>'
369
  + '</div>';
370
  }
371
+ // Collect the cids currently ticked in the (admin) bulk-CK checkboxes.
372
+ function selectedBulkCids() {
373
+ var out = [];
374
+ (window.__cusModal && window.__cusModal.querySelectorAll ? window.__cusModal.querySelectorAll('.cus-bulk-chk:checked') : []).forEach(function (chk) {
375
+ var cid = chk.getAttribute('data-cid');
376
+ if (cid) out.push(cid);
377
+ });
378
+ return out;
379
+ }
380
+ window.selectedBulkCids = selectedBulkCids;
381
 
382
  // ── Detail editor modal (V.AISTUDIO access-code gated in normal mode) ──
383
  function openCustomerDetail(cid, c) {
 
448
  + '<div style="border-top:1px solid #e2e8f0;padding-top:12px"></div>'
449
  + fieldsHtml
450
  + (allowEdit
451
+ ? '<div style="text-align:right;margin-top:16px"><button id="cus-save" style="padding:10px 22px;background:#0077b6;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:700">Lưu</button> <button id="cus-send-zalo" style="padding:10px 16px;background:#25D366;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:700"><i class="fab fa-whatsapp"></i> Gửi qua Zalo</button></div>'
452
  : '')
453
  + '</div>';
454
  document.body.appendChild(ov);
 
543
  }
544
  };
545
  } // end if (saveBtn)
546
+
547
+ // ── "Gửi qua Zalo" on the customer DETAIL modal: saves the current CK + all
548
+ // filled fields to the dataset, then DMs this customer a message containing
549
+ // every field that has content (mã KH, CK, Zalo name, company, address,
550
+ // representative, recipient, tax code...). Only shown for admin after unlock.
551
+ var sendZaloBtn = document.getElementById('cus-send-zalo');
552
+ if (sendZaloBtn) {
553
+ sendZaloBtn.onclick = async function () {
554
+ var record = Object.assign({}, c);
555
+ record.cid = cid;
556
+ record.ma_kh = document.getElementById('cus-ma').value.trim();
557
+ record.ck = document.getElementById('cus-ck').value.trim();
558
+ MULTI_FIELDS.forEach(function (f) {
559
+ var vals = [];
560
+ ov.querySelectorAll('.cus-mv-row[data-field="' + f.key + '"] .cus-mv-input').forEach(function (i) {
561
+ var v = i.value.trim(); if (v) vals.push(v);
562
+ });
563
+ if (vals.length) record[f.key] = vals; else delete record[f.key];
564
+ });
565
+ // Resolve the real Zalo cid to send to.
566
+ var zcid = customerCidForSend(record);
567
+ if (!zcid) { toast('❌ Khách hàng này chưa có Zalo ID để gửi.'); return; }
568
+ try {
569
+ // Persist first (so the Zalo message reflects the just-saved CK).
570
+ var resp = await saveCustomers([record], ACCESS_CODE);
571
+ if (resp && resp.customers) window.__customers = resp.customers;
572
+ else { try { window.__customers = await fetchCustomers(); } catch (_e) {} }
573
+ var msg = buildCustomerSendMessage(record);
574
+ toast('📨 Đang gửi qua Zalo cho ' + (record.name || record.ma_kh || '') + '...');
575
+ var r = await fetch('https://bep40-vaistudio-zalo-bot.hf.space/send', {
576
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
577
+ body: JSON.stringify({ user_id: zcid, message: msg })
578
+ });
579
+ var d = await r.json().catch(function () { return {}; });
580
+ if (r.ok && d && d.ok) toast('✅ Đã lưu & gửi qua Zalo.');
581
+ else toast('✅ Đã lưu, nhưng gửi qua Zalo thất bại.');
582
+ renderCustomerList();
583
+ } catch (e) {
584
+ toast('❌ Lỗi: ' + e.message);
585
+ }
586
+ };
587
+ }
588
+ }
589
+
590
+ // ── Bulk CK editing on the customer list (homepage, after access code) ──
591
+ // The admin selects multiple customers, types a CK% (global "35" or per-brand
592
+ // "Malloca 35, Grob 20") and hits "Áp cho đã chọn". Saves every selected
593
+ // customer via POST /api/customers (V.AISTUDIO-gated, dataset-backed).
594
+ window.bulkApplyCk = async function () {
595
+ var cids = selectedBulkCids();
596
+ if (!cids.length) { toast('⚠️ Chưa chọn khách h��ng nào.'); return; }
597
+ if (!(window.canEditCk ? window.canEditCk() : false)) { toast('🔒 Cần nhập mã truy cập để chỉnh CK hàng loạt.'); return; }
598
+ var ckEl = document.getElementById('cus-bulk-ck');
599
+ var ckVal = (ckEl && ckEl.value || '').trim();
600
+ if (!ckVal) { toast('⚠️ Nhập giá trị chiết khấu (VD: 35 hoặc Malloca 35, Grob 20).'); ckEl && ckEl.focus(); return; }
601
+ // Basic validation so junk like "abc" doesn't reach the server.
602
+ var parsed = parseCk(ckVal);
603
+ if (parsed.all == null && !parsed.hasBrand) { toast('❌ CK không hợp lệ. VD: 35 hoặc Malloca 35, Grob 20'); return; }
604
+ var records = cids.map(function (cid) {
605
+ var c = (window.__customers || {})[cid] || {};
606
+ var rec = {};
607
+ Object.keys(c).forEach(function (k) { rec[k] = c[k]; });
608
+ rec.cid = cid;
609
+ rec.ck = ckVal;
610
+ return rec;
611
+ });
612
+ try {
613
+ toast('💾 Đang lưu CK cho ' + records.length + ' khách...');
614
+ var resp = await saveCustomers(records, ACCESS_CODE);
615
+ if (resp && resp.customers) window.__customers = resp.customers;
616
+ else { try { window.__customers = await fetchCustomers(); } catch (_e) {} }
617
+ toast('✅ Đã cập nhật chiết khấu cho ' + records.length + ' khách hàng.');
618
+ if (window.__cusModal) window.__cusModal.querySelectorAll('.cus-bulk-chk:checked').forEach(function (chk) { chk.checked = false; });
619
+ var n = document.getElementById('cus-bulk-count'); if (n) n.textContent = 0;
620
+ renderCustomerList();
621
+ } catch (e) {
622
+ toast('❌ Lưu CK thất bại: ' + e.message);
623
+ }
624
+ };
625
+ // ── Send the edited CK / customer info to each selected customer via the Zalo
626
+ // bot (/send). Content = the fields that have content on the customer's detail
627
+ // record (mã KH, Zalo name, company, address, representative, recipient, tax
628
+ // code, shipping address, CK...) — so each customer receives their own updated
629
+ // policy. The bot prepends the "📋 Mã KH + 🔗 Chiết khấu" header automatically.
630
+ function buildCustomerSendMessage(c) {
631
+ var lines = [];
632
+ var name = c.name || (Array.isArray(c.zalo_name) ? c.zalo_name[0] : c.zalo_name) || '';
633
+ var ma = c.ma_kh || '';
634
+ if (name) lines.push('👋 Chào ' + name + '!');
635
+ if (ma) lines.push('📋 Mã khách hàng: ' + ma);
636
+ if (c.ck) lines.push('💳 Chiết khấu cập nhật: ' + c.ck + '%');
637
+ lines.push('');
638
+ // Fields that HAVE content — same set the customer detail modal shows.
639
+ [
640
+ ['Công ty', c.company], ['Người đại diện', c.representative], ['Người nhận', c.recipient],
641
+ ['Địa chỉ giao hàng', c.shipping_address], ['Địa chỉ công ty', c.company_address],
642
+ ['Mã số thuế', c.tax_code]
643
+ ].forEach(function (pair) {
644
+ var label = pair[0], v = pair[1];
645
+ if (v == null) return;
646
+ var arr = Array.isArray(v) ? v.filter(Boolean) : [String(v).trim()];
647
+ arr.forEach(function (item) { if (item) lines.push('• ' + label + ': ' + item); });
648
+ });
649
+ lines.push('');
650
+ lines.push('Thông tin chiết khấu mới nhất của anh/chị đã được cập nhật. Truy cập web để xem báo giá kèm chiết khấu nhé!');
651
+ return lines.join('\n');
652
+ }
653
+ // Resolve a customer to a sendable Zalo cid (the customer record's own cid).
654
+ // Zalo chat ids are long NUMERIC strings (e.g. "479b1..." is hex, but real Zalo
655
+ // OA user cids are numeric ~13-24 digits; the web/zalo customers use those).
656
+ // Ketoan-directory rows are keyed by ma_kh with an EMPTY cid — those can't be
657
+ // DMed until the real Zalo cid is present. Skip (return '').
658
+ function customerCidForSend(cust) {
659
+ if (!cust) return '';
660
+ var cidv = cust.cid || '';
661
+ var s = String(cidv).trim();
662
+ if (s && s.length >= 10) {
663
+ // Accept both numeric Zalo ids AND the known admin/hex format; but reject
664
+ // ma_kh-lookalike keys (letters like VASCAS...) with no numeric core that
665
+ // are really directory keys, not Zalo chat ids.
666
+ if (/^\d+$/.test(s)) return s;
667
+ // Known OA/hex cids used in this system (admin 479b1cfad6a83ff666b9 etc.).
668
+ if (/^[0-9a-f]{8,}$/i.test(s) && /^[a-f0-9]+$/i.test(s)) return s;
669
+ return '';
670
+ }
671
+ return '';
672
  }
673
+ window.bulkSendViaZalo = async function () {
674
+ var cids = selectedBulkCids();
675
+ if (!cids.length) { toast('⚠️ Chưa chọn khách hàng nào.'); return; }
676
+ if (!(window.canEditCk ? window.canEditCk() : false)) { toast('🔒 Cần nhập mã truy cập.'); return; }
677
+ var sent = 0, skipped = 0;
678
+ for (var i = 0; i < cids.length; i++) {
679
+ var cid = cids[i];
680
+ var cust = (window.__customers || {})[cid] || {};
681
+ var zcid = customerCidForSend(cust);
682
+ if (!zcid) { skipped++; continue; }
683
+ var msg = buildCustomerSendMessage(cust);
684
+ try {
685
+ var r = await fetch('https://bep40-vaistudio-zalo-bot.hf.space/send', {
686
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
687
+ body: JSON.stringify({ user_id: zcid, message: msg })
688
+ });
689
+ var d = await r.json().catch(function () { return {}; });
690
+ if (r.ok && d && d.ok) sent++; else skipped++;
691
+ } catch (e) { skipped++; }
692
+ }
693
+ toast('✅ Đã gửi qua Zalo cho ' + sent + ' khách' + (skipped ? ', bỏ qua ' + skipped + ' (thiếu Zalo ID)' : ''));
694
+ };
695
 
696
  function renderCustomerList() {
697
  var box = document.getElementById('cus-list');
 
732
  + '<button id="cus-close" style="background:none;border:none;color:#fff;font-size:22px;cursor:pointer">✕</button></div>'
733
  + '<div id="cus-gate"></div>'
734
  + '<div style="padding:10px 18px;border-bottom:1px solid #e2e8f0"><input id="cus-q" placeholder="Tìm khách hàng..." style="width:100%;padding:9px;border:2px solid #e2e8f0;border-radius:8px;font-size:13px;box-sizing:border-box;outline:none"></div>'
735
+ + '<div id="cus-bulkbar" style="display:none;padding:8px 18px;background:#e0f2fe;border-bottom:1px solid #bae6fd;align-items:center;gap:8px;flex-wrap:wrap">'
736
+ + '<b style="font-size:12px;color:#0369a1">Chiết khấu hàng loạt:</b>'
737
+ + '<input id="cus-bulk-ck" placeholder="CK%: 35 hoặc Malloca 35, Grob 20" style="flex:1;min-width:180px;padding:7px 10px;border:1.5px solid #7dd3fc;border-radius:8px;font-size:13px;outline:none">'
738
+ + '<button id="cus-bulk-apply" style="padding:8px 14px;background:#0284c7;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer">Áp cho đã chọn (<span id="cus-bulk-count">0</span>)</button>'
739
+ + '<button id="cus-bulk-send" style="padding:8px 14px;background:#25D366;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer">Gửi qua Zalo</button>'
740
+ + '</div>'
741
  + '<div id="cus-list" style="flex:1;overflow-y:auto;padding:10px 18px"></div>'
742
  + '</div>';
743
  document.body.appendChild(ov);
744
+ window.__cusModal = ov;
745
+ document.getElementById('cus-close').onclick = function () { ov.remove(); window.__cusModal = null; };
746
 
747
  // Access gate for NORMAL mode (no ?kh link): the full customer directory is
748
  // revealed only after the V.AISTUDIO access code is entered. In
 
788
  return;
789
  }
790
  });
791
+ // ── Live bulk-CK counter: as the admin ticks/unticks checkboxes, show how
792
+ // many are selected (requires admin identity — checked in openCustomerModal).
793
+ ov.addEventListener('change', function (e) {
794
+ if (e.target && e.target.classList && e.target.classList.contains('cus-bulk-chk')) {
795
+ var n = document.getElementById('cus-bulk-count');
796
+ if (n) n.textContent = selectedBulkCids().length;
797
+ }
798
+ });
799
+ // Bulk bar visible only for the admin (after V.AISTUDIO unlock in normal mode).
800
+ var bulkBar = document.getElementById('cus-bulkbar');
801
+ if (bulkBar) bulkBar.style.display = (window.isAdmin ? window.isAdmin() : false) ? 'flex' : 'none';
802
+ var bapply = document.getElementById('cus-bulk-apply');
803
+ if (bapply) bapply.onclick = function () { if (window.bulkApplyCk) window.bulkApplyCk(); };
804
+ var bsend = document.getElementById('cus-bulk-send');
805
+ if (bsend) bsend.onclick = function () { if (window.bulkSendViaZalo) window.bulkSendViaZalo(); };
806
 
807
  // Search filter (non-admin sees only their own row, so the filter is no-op)
808
  document.getElementById('cus-q').oninput = function () {