Переглянути джерело

fix(qa): 12 QA fixes — analytics crash, search, widget UX, workflow persistence

CRITICAL:
- Analytics.tsx: fix null dereference crash on summary?.resolvedByBot ?? 0

HIGH:
- routers.ts: replace plain Error throws with TRPCError (NOT_FOUND)
- routers.ts: agent reply re-activates resolved conversations → "active"
- db.ts: replace like() with ilike() for case-insensitive conversation search
- ChatbotWidgetLive.tsx: disable input until sessionId ready (blocks Enter bypass)
- ChatbotWidgetLive.tsx: replace Date.now() IDs with crypto.randomUUID()
- WorkflowDesigner.tsx: load saved workflow on mount via useEffect

MEDIUM/LOW:
- DataSources.tsx: fix stale batchIndex in catch block error message
- DataSources.tsx: fix progress counter (update before mutateAsync, not after)
- DataSources.tsx: remove debug console.info from production code
- DataSources.tsx: fix misleading import note text (Excel supported natively)
- ChatbotWidgetLive.tsx: remove non-functional MoreVertical button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 1 день тому
батько
коміт
eed983343a

+ 1 - 0
.gitignore

@@ -122,3 +122,4 @@ deploy/package*.json
 
 # Allow example env templates
 !erp-bridge/.env.example
+.gstack/

+ 5 - 8
client/src/components/ChatbotWidgetLive.tsx

@@ -5,7 +5,7 @@
 import { useState, useRef, useEffect, useMemo } from "react";
 import { useTranslation } from "react-i18next";
 import { trpc } from "@/lib/trpc";
-import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical, Star } from "lucide-react";
+import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, Star } from "lucide-react";
 import { Streamdown } from "streamdown";
 
 const QUICK_REPLIES = [
@@ -20,7 +20,7 @@ export default function ChatbotWidgetLive() {
   const [isOpen, setIsOpen] = useState(false);
   const [inputText, setInputText] = useState("");
   const [sessionId, setSessionId] = useState<string | null>(null);
-  const [localMessages, setLocalMessages] = useState<Array<{ id: number; sender: string; content: string; createdAt: string }>>([]);
+  const [localMessages, setLocalMessages] = useState<Array<{ id: number | string; sender: string; content: string; createdAt: string }>>([]);
   const [isTyping, setIsTyping] = useState(false);
   const [showQuickReplies, setShowQuickReplies] = useState(true);
   const [pendingMessage, setPendingMessage] = useState<string | null>(null);
@@ -121,7 +121,7 @@ export default function ChatbotWidgetLive() {
 
     // Optimistically add user message
     setLocalMessages(prev => [...prev, {
-      id: Date.now(),
+      id: crypto.randomUUID(),
       sender: "visitor",
       content,
       createdAt: new Date().toISOString(),
@@ -136,7 +136,7 @@ export default function ChatbotWidgetLive() {
     setIsTyping(true);
     isNearBottomRef.current = true;
     setLocalMessages(prev => [...prev, {
-      id: Date.now(), sender: "visitor", content: text, createdAt: new Date().toISOString(),
+      id: crypto.randomUUID(), sender: "visitor", content: text, createdAt: new Date().toISOString(),
     }]);
     if (sessionId) {
       sendMessage.mutate({ sessionId, content: text });
@@ -197,9 +197,6 @@ export default function ChatbotWidgetLive() {
               </div>
             </div>
             <div className="flex items-center gap-1">
-              <button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
-                <MoreVertical className="w-4 h-4 text-white/70" />
-              </button>
               <button onClick={() => setIsOpen(false)} className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
                 <ChevronDown className="w-4 h-4 text-white/70" />
               </button>
@@ -385,7 +382,7 @@ export default function ChatbotWidgetLive() {
                     placeholder={t("widget.placeholder")}
                     className="flex-1 px-3 py-2 rounded-xl text-sm border"
                     style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
-                    disabled={isTyping || startSession.isPending}
+                    disabled={!sessionId || isTyping || startSession.isPending}
                   />
                   <button
                     onClick={() => handleSend()}

+ 4 - 4
client/src/pages/Analytics.tsx

@@ -150,10 +150,10 @@ export default function Analytics() {
 
   // Compute rates for donut
   const totalOutcomes = summary ? (summary.resolvedByBot + summary.resolvedByAgent + summary.escalated + summary.abandoned) : 0;
-  const botRate = totalOutcomes > 0 ? Math.round((summary!.resolvedByBot / totalOutcomes) * 100) : 0;
-  const agentRate = totalOutcomes > 0 ? Math.round((summary!.resolvedByAgent / totalOutcomes) * 100) : 0;
-  const escalatedRate = totalOutcomes > 0 ? Math.round((summary!.escalated / totalOutcomes) * 100) : 0;
-  const abandonedRate = totalOutcomes > 0 ? Math.round((summary!.abandoned / totalOutcomes) * 100) : 0;
+  const botRate = totalOutcomes > 0 ? Math.round(((summary?.resolvedByBot ?? 0) / totalOutcomes) * 100) : 0;
+  const agentRate = totalOutcomes > 0 ? Math.round(((summary?.resolvedByAgent ?? 0) / totalOutcomes) * 100) : 0;
+  const escalatedRate = totalOutcomes > 0 ? Math.round(((summary?.escalated ?? 0) / totalOutcomes) * 100) : 0;
+  const abandonedRate = totalOutcomes > 0 ? Math.round(((summary?.abandoned ?? 0) / totalOutcomes) * 100) : 0;
 
   return (
     <div className="flex flex-col" style={{ height: "calc(100vh - 4rem)" }}>

+ 8 - 11
client/src/pages/DataSources.tsx

@@ -212,14 +212,6 @@ export default function DataSources() {
       dimensions:   Array.from({ length: 10 }, (_, i) => idx(`additional dimension ${i + 1}`)),
     };
 
-    // Debug: show detected columns
-    const detected = Object.entries(col)
-      .filter(([, v]) => !Array.isArray(v))
-      .filter(([, v]) => (v as number) >= 0)
-      .map(([k]) => k).join(", ");
-    console.info("[ImportProducts] Detected columns:", detected);
-    console.info("[ImportProducts] Header row:", first.slice(0, 15).join(", "));
-
     const get = (r: string[], i: number) => (i >= 0 ? r[i]?.trim() || "" : "");
     const joinCols = (r: string[], indices: number[]) =>
       indices.map(i => get(r, i)).filter(Boolean).join(" | ") || undefined;
@@ -248,14 +240,19 @@ export default function DataSources() {
     const batches = Math.ceil(items.length / BATCH);
     setImportProgress({ current: 0, total: batches, status: "running" });
 
+    let batchIndex = 0;
     try {
       for (let i = 0; i < batches; i++) {
+        batchIndex = i;
+        setImportProgress({ current: i + 1, total: batches, status: "running" });
         const chunk = items.slice(i * BATCH, (i + 1) * BATCH);
         await importProducts.mutateAsync({
           products: chunk,
           replaceAll: i === 0 ? replaceAllProducts : false, // only delete on first batch
         });
-        setImportProgress({ current: i + 1, total: batches, status: i + 1 < batches ? "running" : "done" });
+        if (i + 1 === batches) {
+          setImportProgress(prev => ({ ...prev, status: "done" }));
+        }
       }
       toast.success(`Imported ${items.length} products in ${batches} batch${batches > 1 ? "es" : ""}`);
       utils.knowledge.listProducts.invalidate();
@@ -267,7 +264,7 @@ export default function DataSources() {
       }, 1500);
     } catch (err: any) {
       setImportProgress(prev => ({ ...prev, status: "error", error: err?.message || "Unknown error" }));
-      toast.error(`Import failed at batch ${importProgress.current + 1}: ${err?.message || "Unknown error"}`);
+      toast.error(`Import failed at batch ${batchIndex + 1}: ${err?.message || "Unknown error"}`);
     }
   };
 
@@ -512,7 +509,7 @@ export default function DataSources() {
             {/* Import note */}
             <div className="p-3 rounded-xl border text-xs" style={{ background: "#fff", borderColor: "#e7e0d5", color: "#78716C" }}>
               <FileText className="w-3.5 h-3.5 inline mr-1" style={{ color: "#0369a1" }} />
-              Supports Homelegance Excel format (.xlsx) — convert to CSV for import.
+              Supports Homelegance Excel (.xlsx) and CSV formats directly — no conversion needed.
               Columns: <code>model,description,categories,collection,price,availability,features,dimensions,imageUrl</code>
             </div>
 

+ 20 - 0
client/src/pages/WorkflowDesigner.tsx

@@ -477,6 +477,26 @@ export default function WorkflowDesigner() {
   // Load workflow from server
   const { data: savedWorkflow } = trpc.workflow.load.useQuery({ workflowId: "default" });
 
+  // Apply saved workflow to canvas when it loads
+  useEffect(() => {
+    if (savedWorkflow?.nodes?.length) {
+      setNodes(savedWorkflow.nodes.map((n: any) => ({
+        id: n.nodeId,
+        type: n.type as NodeType,
+        label: n.label,
+        config: n.config ?? {},
+        x: n.positionX,
+        y: n.positionY,
+      })));
+      setEdges((savedWorkflow.edges ?? []).map((e: any) => ({
+        id: `${e.sourceNodeId}_${e.targetNodeId}`,
+        sourceId: e.sourceNodeId,
+        targetId: e.targetNodeId,
+        label: e.label ?? undefined,
+      })));
+    }
+  }, [savedWorkflow]);
+
   // Drag handlers
   const handleDragStart = useCallback((nodeId: string, e: React.MouseEvent) => {
     const node = nodes.find(n => n.id === nodeId);

+ 4 - 4
server/db.ts

@@ -1,4 +1,4 @@
-import { eq, desc, asc, and, sql, like, or, lt, gte, lte, isNotNull, inArray } from "drizzle-orm";
+import { eq, desc, asc, and, sql, like, ilike, or, lt, gte, lte, isNotNull, inArray } from "drizzle-orm";
 import { drizzle } from "drizzle-orm/postgres-js";
 import {
   InsertUser, users,
@@ -317,11 +317,11 @@ export async function getConversationsAdvanced(params: {
     const searchTerm = `%${params.search}%`;
     conditions.push(
       or(
-        like(conversations.visitorName, searchTerm),
-        like(conversations.visitorEmail, searchTerm),
+        ilike(conversations.visitorName, searchTerm),
+        ilike(conversations.visitorEmail, searchTerm),
         like(conversations.sessionId, searchTerm),
         like(conversations.customerId, searchTerm),
-        like(conversations.salesRep, searchTerm),
+        ilike(conversations.salesRep, searchTerm),
       )!
     );
   }

+ 4 - 4
server/routers.ts

@@ -299,7 +299,7 @@ export const appRouter = router({
       }))
       .mutation(async ({ input, ctx }) => {
         const conversation = await getConversationBySessionId(input.sessionId);
-        if (!conversation) throw new Error("Conversation not found");
+        if (!conversation) throw new TRPCError({ code: "NOT_FOUND", message: "Conversation not found" });
 
         await addMessage({
           conversationId: conversation.id,
@@ -617,7 +617,7 @@ export const appRouter = router({
       }))
       .mutation(async ({ input, ctx }) => {
         const conversation = await getConversationById(input.conversationId);
-        if (!conversation) throw new Error("Conversation not found");
+        if (!conversation) throw new TRPCError({ code: "NOT_FOUND", message: "Conversation not found" });
 
         const msg = await addMessage({
           conversationId: input.conversationId,
@@ -626,8 +626,8 @@ export const appRouter = router({
           metadata: { agentName: ctx.user.name || "Agent", agentId: ctx.user.id },
         });
 
-        if (conversation.status === "escalated") {
-          await updateConversationStatus(input.conversationId, "escalated", ctx.user.id);
+        if (conversation.status === "escalated" || conversation.status === "resolved") {
+          await updateConversationStatus(input.conversationId, "active", ctx.user.id);
         }
 
         return msg;