Просмотр исходного кода

feat: complete 5 chatbot improvements

1. Admin UI: add ERP Contact ID column to UserManagement with inline
   edit — pencil icon opens input, saves via users.updateErpContactCid
2. Dealer 'my orders' no CID: return helpful LLM note instead of silent
   empty context when erpContactCid is not linked
3. ChatbotWidget.tsx scroll: apply smart scroll (isNearBottomRef) same
   as ChatbotWidgetLive — user can scroll up without snapping back
4. ERP error handling: catch block now sets erpContext to a helpful note
   so LLM tells user lookup is temporarily unavailable
5. Env validation: warn at startup if ERP_API_KEY is unset in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 6 дней назад
Родитель
Сommit
1ba288c808
4 измененных файлов с 91 добавлено и 6 удалено
  1. 16 2
      client/src/components/ChatbotWidget.tsx
  2. 68 3
      client/src/pages/UserManagement.tsx
  3. 4 0
      server/_core/env.ts
  4. 3 1
      server/routers.ts

+ 16 - 2
client/src/components/ChatbotWidget.tsx

@@ -3,7 +3,7 @@
  * Design: Warm Showroom — forest green header, cream body, terracotta accents
  * Design: Warm Showroom — forest green header, cream body, terracotta accents
  * Floats in bottom-right corner, exactly as it would appear on homelegance.com
  * Floats in bottom-right corner, exactly as it would appear on homelegance.com
  */
  */
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useCallback } from "react";
 import { MessageCircle, X, Send, Bot, User } from "lucide-react";
 import { MessageCircle, X, Send, Bot, User } from "lucide-react";
 import { motion, AnimatePresence } from "framer-motion";
 import { motion, AnimatePresence } from "framer-motion";
 
 
@@ -91,13 +91,23 @@ export default function ChatbotWidget() {
   const [inputValue, setInputValue] = useState("");
   const [inputValue, setInputValue] = useState("");
   const [isTyping, setIsTyping] = useState(false);
   const [isTyping, setIsTyping] = useState(false);
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const messagesEndRef = useRef<HTMLDivElement>(null);
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
+  const isNearBottomRef = useRef(true);
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
   let nextId = useRef(2);
   let nextId = useRef(2);
 
 
   useEffect(() => {
   useEffect(() => {
-    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+    if (isNearBottomRef.current) {
+      messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+    }
   }, [messages, isTyping]);
   }, [messages, isTyping]);
 
 
+  const handleScroll = useCallback(() => {
+    const el = scrollContainerRef.current;
+    if (!el) return;
+    isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
+  }, []);
+
   useEffect(() => {
   useEffect(() => {
     if (isOpen) {
     if (isOpen) {
       setTimeout(() => inputRef.current?.focus(), 300);
       setTimeout(() => inputRef.current?.focus(), 300);
@@ -121,6 +131,8 @@ export default function ChatbotWidget() {
     const messageText = text || inputValue.trim();
     const messageText = text || inputValue.trim();
     if (!messageText) return;
     if (!messageText) return;
 
 
+    isNearBottomRef.current = true;
+
     const userMsg: Message = {
     const userMsg: Message = {
       id: nextId.current++,
       id: nextId.current++,
       text: messageText,
       text: messageText,
@@ -208,6 +220,8 @@ export default function ChatbotWidget() {
 
 
             {/* Messages area */}
             {/* Messages area */}
             <div
             <div
+              ref={scrollContainerRef}
+              onScroll={handleScroll}
               className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
               className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
               style={{ background: "#FFFBEB" }}
               style={{ background: "#FFFBEB" }}
             >
             >

+ 68 - 3
client/src/pages/UserManagement.tsx

@@ -30,7 +30,7 @@ import {
   CheckCircle2, AlertTriangle, Crown, Mail, Send,
   CheckCircle2, AlertTriangle, Crown, Mail, Send,
   Clock, XCircle, RefreshCw, Download, History,
   Clock, XCircle, RefreshCw, Download, History,
   MoreHorizontal, UserPlus, CheckSquare, Square,
   MoreHorizontal, UserPlus, CheckSquare, Square,
-  ArrowUpDown, Eye, Copy, ExternalLink,
+  ArrowUpDown, Eye, Copy, ExternalLink, Pencil, Link2Off,
 } from "lucide-react";
 } from "lucide-react";
 
 
 /* ─── Role config ─── */
 /* ─── Role config ─── */
@@ -286,6 +286,9 @@ function UsersTab() {
     open: boolean; type: "role" | "delete" | "bulkRole" | "bulkDelete";
     open: boolean; type: "role" | "delete" | "bulkRole" | "bulkDelete";
     userId?: number; userName?: string; currentRole?: string; newRole?: string;
     userId?: number; userName?: string; currentRole?: string; newRole?: string;
   }>({ open: false, type: "role" });
   }>({ open: false, type: "role" });
+  const [erpEditState, setErpEditState] = useState<{
+    userId: number | null; value: string;
+  }>({ userId: null, value: "" });
 
 
   const utils = trpc.useUtils();
   const utils = trpc.useUtils();
   const { data: usersList, isLoading } = trpc.users.list.useQuery();
   const { data: usersList, isLoading } = trpc.users.list.useQuery();
@@ -334,6 +337,15 @@ function UsersTab() {
     onError: (error) => toast.error("Bulk delete failed", { description: error.message }),
     onError: (error) => toast.error("Bulk delete failed", { description: error.message }),
   });
   });
 
 
+  const updateErpCidMutation = trpc.users.updateErpContactCid.useMutation({
+    onSuccess: () => {
+      toast.success("ERP Contact ID updated");
+      utils.users.list.invalidate();
+      setErpEditState({ userId: null, value: "" });
+    },
+    onError: (error) => toast.error("Failed to update ERP Contact ID", { description: error.message }),
+  });
+
   const exportCsvQuery = trpc.users.exportCsv.useQuery(undefined, { enabled: false });
   const exportCsvQuery = trpc.users.exportCsv.useQuery(undefined, { enabled: false });
 
 
   const filteredUsers = useMemo(() => {
   const filteredUsers = useMemo(() => {
@@ -472,6 +484,7 @@ function UsersTab() {
                 </th>
                 </th>
                 <th className="text-left text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>User</th>
                 <th className="text-left text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>User</th>
                 <th className="text-left text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Role</th>
                 <th className="text-left text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Role</th>
+                <th className="text-left text-xs font-semibold px-4 py-3 hidden lg:table-cell" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>ERP Contact ID</th>
                 <th className="text-left text-xs font-semibold px-4 py-3 hidden md:table-cell" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Joined</th>
                 <th className="text-left text-xs font-semibold px-4 py-3 hidden md:table-cell" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Joined</th>
                 <th className="text-left text-xs font-semibold px-4 py-3 hidden md:table-cell" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Last Active</th>
                 <th className="text-left text-xs font-semibold px-4 py-3 hidden md:table-cell" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Last Active</th>
                 <th className="text-right text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Actions</th>
                 <th className="text-right text-xs font-semibold px-4 py-3" style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}>Actions</th>
@@ -480,14 +493,14 @@ function UsersTab() {
             <tbody>
             <tbody>
               {isLoading ? (
               {isLoading ? (
                 <tr>
                 <tr>
-                  <td colSpan={6} className="text-center py-12">
+                  <td colSpan={7} className="text-center py-12">
                     <div className="animate-spin w-6 h-6 border-2 rounded-full mx-auto" style={{ borderColor: "#14532D", borderTopColor: "transparent" }} />
                     <div className="animate-spin w-6 h-6 border-2 rounded-full mx-auto" style={{ borderColor: "#14532D", borderTopColor: "transparent" }} />
                     <p className="text-sm mt-2" style={{ color: "#a8a29e" }}>Loading users...</p>
                     <p className="text-sm mt-2" style={{ color: "#a8a29e" }}>Loading users...</p>
                   </td>
                   </td>
                 </tr>
                 </tr>
               ) : filteredUsers.length === 0 ? (
               ) : filteredUsers.length === 0 ? (
                 <tr>
                 <tr>
-                  <td colSpan={6} className="text-center py-12">
+                  <td colSpan={7} className="text-center py-12">
                     <Users className="w-8 h-8 mx-auto mb-2" style={{ color: "#d6d3d1" }} />
                     <Users className="w-8 h-8 mx-auto mb-2" style={{ color: "#d6d3d1" }} />
                     <p className="text-sm" style={{ color: "#a8a29e" }}>No users found</p>
                     <p className="text-sm" style={{ color: "#a8a29e" }}>No users found</p>
                   </td>
                   </td>
@@ -533,6 +546,58 @@ function UsersTab() {
                         </div>
                         </div>
                       </td>
                       </td>
                       <td className="px-4 py-3"><RoleBadge role={u.role} /></td>
                       <td className="px-4 py-3"><RoleBadge role={u.role} /></td>
+                      <td className="px-4 py-3 hidden lg:table-cell">
+                        {erpEditState.userId === u.id ? (
+                          <div className="flex items-center gap-1">
+                            <input
+                              type="text"
+                              value={erpEditState.value}
+                              onChange={(e) => setErpEditState(s => ({ ...s, value: e.target.value }))}
+                              placeholder="e.g. C001234"
+                              className="px-2 py-1 text-xs rounded border w-28"
+                              style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif" }}
+                              onKeyDown={(e) => {
+                                if (e.key === "Enter") {
+                                  updateErpCidMutation.mutate({ userId: u.id, erpContactCid: erpEditState.value.trim() || null });
+                                } else if (e.key === "Escape") {
+                                  setErpEditState({ userId: null, value: "" });
+                                }
+                              }}
+                              autoFocus
+                            />
+                            <button
+                              onClick={() => updateErpCidMutation.mutate({ userId: u.id, erpContactCid: erpEditState.value.trim() || null })}
+                              disabled={updateErpCidMutation.isPending}
+                              className="text-xs px-2 py-1 rounded text-white"
+                              style={{ background: "#14532D" }}
+                            >
+                              Save
+                            </button>
+                            <button
+                              onClick={() => setErpEditState({ userId: null, value: "" })}
+                              className="text-xs px-2 py-1 rounded border"
+                              style={{ borderColor: "#e7e0d5" }}
+                            >
+                              Cancel
+                            </button>
+                          </div>
+                        ) : (
+                          <div className="flex items-center gap-1.5 group">
+                            <span className="text-xs font-mono" style={{ color: u.erpContactCid ? "#292524" : "#a8a29e" }}>
+                              {u.erpContactCid || "—"}
+                            </span>
+                            {!isSelf && (
+                              <button
+                                onClick={() => setErpEditState({ userId: u.id, value: u.erpContactCid ?? "" })}
+                                className="opacity-0 group-hover:opacity-100 transition-opacity"
+                                title="Edit ERP Contact ID"
+                              >
+                                <Pencil className="w-3 h-3" style={{ color: "#78716C" }} />
+                              </button>
+                            )}
+                          </div>
+                        )}
+                      </td>
                       <td className="px-4 py-3 hidden md:table-cell">
                       <td className="px-4 py-3 hidden md:table-cell">
                         <span className="text-xs" style={{ color: "#78716C" }}>
                         <span className="text-xs" style={{ color: "#78716C" }}>
                           {u.createdAt ? new Date(u.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "—"}
                           {u.createdAt ? new Date(u.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "—"}

+ 4 - 0
server/_core/env.ts

@@ -10,3 +10,7 @@ export const ENV = {
   erpApiKey: process.env.ERP_API_KEY ?? "",
   erpApiKey: process.env.ERP_API_KEY ?? "",
   dealerPortalSsoSecret: process.env.DEALER_PORTAL_SSO_SECRET ?? "",
   dealerPortalSsoSecret: process.env.DEALER_PORTAL_SSO_SECRET ?? "",
 };
 };
+
+if (process.env.NODE_ENV === "production" && !process.env.ERP_API_KEY) {
+  console.warn("[ENV] WARNING: ERP_API_KEY is not set — ERP features will be disabled");
+}

+ 3 - 1
server/routers.ts

@@ -365,6 +365,8 @@ export const appRouter = router({
               const cid = ctx.user?.erpContactCid ?? (conversation as any).customerId as string | undefined;
               const cid = ctx.user?.erpContactCid ?? (conversation as any).customerId as string | undefined;
               if (cid) {
               if (cid) {
                 erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
                 erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
+              } else {
+                erpContext = "NOTE: This user has not been linked to an ERP dealer account. Their erpContactCid is not set. Let them know they should contact support to link their dealer account before order history can be retrieved.";
               }
               }
 
 
             // 3. PO number lookup  —  "PO-12345", "purchase order 5678"
             // 3. PO number lookup  —  "PO-12345", "purchase order 5678"
@@ -407,8 +409,8 @@ export const appRouter = router({
               }
               }
             }
             }
           } catch (erpErr) {
           } catch (erpErr) {
-            // ERP errors must never break the chat — just log and continue without context
             console.error("[ERP] intent lookup error:", erpErr);
             console.error("[ERP] intent lookup error:", erpErr);
+            erpContext = "NOTE: ERP data lookup failed (service temporarily unavailable). Do not mention specific order details. Let the user know order lookup is temporarily unavailable and suggest they try again shortly or contact their sales rep.";
           }
           }
         }
         }