jerry 2 hónapja
szülő
commit
e5665698f1

+ 10 - 3
.eslintrc.json

@@ -14,11 +14,18 @@
     "node": true,
     "es2024": true
   },
-  "plugins": ["@typescript-eslint"],
-  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
+  "plugins": ["@typescript-eslint", "react-hooks"],
+  "extends": [
+    "next/core-web-vitals",
+    "eslint:recommended",
+    "plugin:@typescript-eslint/recommended",
+    "plugin:react-hooks/recommended"
+  ],
   "rules": {
     "no-unused-vars": "off",
     "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
-    "@typescript-eslint/consistent-type-imports": "warn"
+    "@typescript-eslint/consistent-type-imports": "warn",
+    "@typescript-eslint/no-explicit-any": "warn",
+    "react-hooks/exhaustive-deps": "warn"
   }
 }

+ 9 - 0
package-lock.json

@@ -26,6 +26,7 @@
                 "@typescript-eslint/parser": "^8.57.0",
                 "autoprefixer": "^10.0.1",
                 "eslint": "^8.57.1",
+                "eslint-plugin-react-hooks": "file:tools/eslint-plugin-react-hooks",
                 "postcss": "^8",
                 "tailwindcss": "^3.3.0",
                 "typescript": "5.9.3"
@@ -1509,6 +1510,10 @@
                 "url": "https://opencollective.com/eslint"
             }
         },
+        "node_modules/eslint-plugin-react-hooks": {
+            "resolved": "tools/eslint-plugin-react-hooks",
+            "link": true
+        },
         "node_modules/eslint-scope": {
             "version": "7.2.2",
             "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -3450,6 +3455,10 @@
             "funding": {
                 "url": "https://github.com/sponsors/sindresorhus"
             }
+        },
+        "tools/eslint-plugin-react-hooks": {
+            "version": "5.10.0",
+            "dev": true
         }
     }
 }

+ 6 - 5
package.json

@@ -26,9 +26,10 @@
         "@typescript-eslint/eslint-plugin": "^8.57.0",
         "@typescript-eslint/parser": "^8.57.0",
         "autoprefixer": "^10.0.1",
-        "eslint": "^8.57.1",
-        "postcss": "^8",
-        "tailwindcss": "^3.3.0",
-        "typescript": "5.9.3"
-    }
+    "eslint": "^8.57.1",
+    "postcss": "^8",
+    "tailwindcss": "^3.3.0",
+    "typescript": "5.9.3",
+    "eslint-plugin-react-hooks": "file:tools/eslint-plugin-react-hooks"
+  }
 }

+ 1 - 1
src/components/ServiceList.tsx

@@ -250,7 +250,7 @@ export default function ServiceList() {
       )}
       {!error && (
         <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
-          {(loading ? Array.from({ length: pageSize }) : products).map((item, index) => (
+          {(loading ? Array.from({ length: pageSize }, () => null) : products).map((item: Product | null, index) => (
             <div
               key={item ? item.id : `placeholder-${index}`}
               className={`

+ 76 - 0
src/components/admin/orders/OrderDetailModal.tsx

@@ -56,6 +56,16 @@ interface UserDetail {
   created_at?: string;
 }
 
+interface OrderEvent {
+  id: number;
+  order_no?: string;
+  event_title?: string;
+  event_message?: string;
+  email_uid?: number | null;
+  event_time?: string;
+  created_at?: string;
+}
+
 interface OrderDetailModalProps {
   isOpen: boolean;
   onClose: () => void;
@@ -80,10 +90,13 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
   const [loadingUser, setLoadingUser] = useState(false);
   const [qrDetails, setQrDetails] = useState<Record<number, PaymentQrDetail>>({});
   const [loadingQrDetails, setLoadingQrDetails] = useState(false);
+  const [events, setEvents] = useState<OrderEvent[]>([]);
+  const [loadingEvents, setLoadingEvents] = useState(false);
 
   useEffect(() => {
     if (isOpen && order?.id) {
       fetchPayments(order.id);
+      fetchEvents(order.id);
       if (order.user_id) {
         fetchUserDetails(order.user_id);
       } else {
@@ -91,6 +104,7 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
       }
     } else {
       setPaymentList([]);
+      setEvents([]);
       setUserData(null);
     }
   }, [isOpen, order]);
@@ -121,6 +135,20 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
     }
   };
 
+  const fetchEvents = async (orderId: string) => {
+    setLoadingEvents(true);
+    try {
+      const res = await api.get('/api/vas/order-event/list', { params: { order_id: orderId } });
+      const list = Array.isArray(res.data.data) ? res.data.data : [];
+      setEvents(list);
+    } catch (error) {
+      console.warn('Fetch order events failed', error);
+      setEvents([]);
+    } finally {
+      setLoadingEvents(false);
+    }
+  };
+
   // 2. 使用 useMemo 对 user_inputs 进行排序
   const sortedUserInputs = useMemo(() => {
     if (!order?.user_inputs) return [];
@@ -461,6 +489,54 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
             </div>
           </div>
 
+          {/* 4. 订单事件 */}
+          <div className="border rounded-lg overflow-hidden">
+            <div className="bg-slate-100 px-4 py-2 border-b flex items-center gap-2 justify-between">
+              <div className="flex items-center gap-2">
+                <FileText size={16} className="text-slate-600" />
+                <h4 className="font-bold text-sm text-slate-700">订单事件</h4>
+              </div>
+              {loadingEvents && (
+                <span className="text-xs text-blue-600 flex items-center">
+                  <Loader2 size={12} className="animate-spin mr-1" /> 加载中...
+                </span>
+              )}
+            </div>
+            <div className="bg-white p-4">
+              {events.length > 0 ? (
+                <div className="space-y-4">
+                  {events.map((evt) => (
+                    <div key={evt.id} className="border border-slate-100 rounded-lg p-3 bg-slate-50/30">
+                      <div className="flex items-center justify-between gap-2">
+                        <div className="text-sm font-bold text-slate-800">
+                          {evt.event_title || '事件'}
+                        </div>
+                        <div className="text-xs text-slate-500">
+                          <LocalTime date={evt.event_time || evt.created_at} />
+                        </div>
+                      </div>
+                      {evt.event_message && (
+                        <p className="mt-2 text-sm text-slate-600 whitespace-pre-wrap">
+                          {evt.event_message}
+                        </p>
+                      )}
+                      <div className="mt-2 text-[11px] text-slate-400 flex flex-wrap gap-3">
+                        {evt.order_no && <span>订单号: {evt.order_no}</span>}
+                        {evt.email_uid !== undefined && evt.email_uid !== null && (
+                          <span>Email UID: {evt.email_uid}</span>
+                        )}
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              ) : (
+                <div className="text-center text-gray-400 text-sm py-4">
+                  {loadingEvents ? '正在查询事件记录...' : '该订单暂无事件记录'}
+                </div>
+              )}
+            </div>
+          </div>
+
         </div>
         
         {/* Footer */}

+ 101 - 2
src/components/admin/tasks/TaskTable.tsx

@@ -3,7 +3,7 @@
 import { useState } from 'react';
 import { 
   RotateCcw, CheckCircle, ChevronDown, ChevronUp, Terminal, FileJson, 
-  User, History, Edit
+  User, History, Edit, MessageCircle, MessageSquare, X
 } from 'lucide-react';
 import LocalTime from '@/components/common/LocalTime';
 
@@ -36,6 +36,10 @@ interface TaskTableProps {
 
 export default function TaskTable({ tasks, loading, onRetry, onManualConfirm, onEdit }: TaskTableProps) {
   const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
+  const [isMessageOpen, setIsMessageOpen] = useState(false);
+  const [messageChannel, setMessageChannel] = useState<'sms' | 'whatsapp'>('sms');
+  const [messageTask, setMessageTask] = useState<VasTask | null>(null);
+  const [messageText, setMessageText] = useState('');
 
   const toggleRow = (id: number) => {
     const newSet = new Set(expandedRows);
@@ -44,6 +48,42 @@ export default function TaskTable({ tasks, loading, onRetry, onManualConfirm, on
     setExpandedRows(newSet);
   };
 
+  const openMessageModal = (task: VasTask, channel: 'sms' | 'whatsapp') => {
+    const template = channel === 'whatsapp'
+      ? 'Visafly: Your appointment status changed. Please review updates and confirm next steps.'
+      : 'Your appointment status changed. Please review updates and confirm next steps.';
+    setMessageTask(task);
+    setMessageChannel(channel);
+    setMessageText(template);
+    setIsMessageOpen(true);
+  };
+
+  const closeMessageModal = () => {
+    setIsMessageOpen(false);
+    setMessageTask(null);
+  };
+
+  const getRecipientName = (task: VasTask) => {
+    if (!task.user_inputs) return '-';
+    const { first_name, last_name, social_media_account } = task.user_inputs;
+    if (social_media_account) return social_media_account;
+    const full = `${first_name || ''} ${last_name || ''}`.trim();
+    return full || 'Customer';
+  };
+
+  const getRecipientPhone = (task: VasTask) => {
+    if (!task.user_inputs) return '-';
+    const code = task.user_inputs.phone_country_code || '';
+    const rawPhone = String(task.user_inputs.phone || '');
+    const phone = rawPhone.replace(/^0+/, '');
+    const combined = `${code} ${phone}`.trim();
+    return combined || '-';
+  };
+
+  const getSmsSender = (task: VasTask) => {
+    return 'Visafly';
+  };
+
   const getUserSummary = (inputs: any) => {
     if (!inputs) return '-';
     const name = inputs.social_media_account ? (inputs.social_media_account): `${inputs.first_name} ${inputs.last_name}`;
@@ -159,6 +199,8 @@ export default function TaskTable({ tasks, loading, onRetry, onManualConfirm, on
                         <button onClick={() => onEdit(task)} className="p-1.5 rounded text-indigo-600 hover:bg-indigo-50 border border-transparent hover:border-indigo-100" title="编辑"><Edit size={16} /></button>
                         <button onClick={() => onRetry(task.id)} className="p-1.5 rounded text-blue-600 hover:bg-blue-50 border border-transparent hover:border-blue-100" title="重置"><RotateCcw size={16} /></button>
                         <button onClick={() => onManualConfirm(task.id)} className="p-1.5 rounded text-green-600 hover:bg-green-50 border border-transparent hover:border-green-100" title="完成"><CheckCircle size={16} /></button>
+                        <button onClick={() => openMessageModal(task, 'sms')} className="p-1.5 rounded text-amber-600 hover:bg-amber-50 border border-transparent hover:border-amber-100" title="短信提醒"><MessageSquare size={16} /></button>
+                        <button onClick={() => openMessageModal(task, 'whatsapp')} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 border border-transparent hover:border-emerald-100" title="WhatsApp 提醒"><MessageCircle size={16} /></button>
                       </div>
                     </td>
                   </tr>
@@ -196,6 +238,8 @@ export default function TaskTable({ tasks, loading, onRetry, onManualConfirm, on
                 <div className="flex gap-2 flex-shrink-0">
                    <button onClick={() => onRetry(task.id)} className="p-2 bg-blue-50 text-blue-600 rounded-lg active:scale-95"><RotateCcw size={18}/></button>
                    <button onClick={() => onManualConfirm(task.id)} className="p-2 bg-green-50 text-green-600 rounded-lg active:scale-95"><CheckCircle size={18}/></button>
+                   <button onClick={() => openMessageModal(task, 'sms')} className="p-2 bg-amber-50 text-amber-600 rounded-lg active:scale-95"><MessageSquare size={18} /></button>
+                   <button onClick={() => openMessageModal(task, 'whatsapp')} className="p-2 bg-emerald-50 text-emerald-600 rounded-lg active:scale-95"><MessageCircle size={18} /></button>
                 </div>
               </div>
 
@@ -234,6 +278,61 @@ export default function TaskTable({ tasks, loading, onRetry, onManualConfirm, on
           );
         })}
       </div>
+
+      {isMessageOpen && messageTask && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
+          <div className="bg-white rounded-xl shadow-2xl w-full max-w-lg flex flex-col animate-in zoom-in duration-200">
+            <div className="px-6 py-4 border-b flex justify-between items-center bg-slate-50">
+              <div>
+                <h3 className="font-bold text-slate-900 text-lg">
+                  {messageChannel === 'sms' ? 'Send SMS' : 'Send WhatsApp'}
+                </h3>
+                <p className="text-xs text-slate-500 mt-1">Order #{messageTask.order_id}</p>
+              </div>
+              <button onClick={closeMessageModal} className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 transition">
+                <X size={20} />
+              </button>
+            </div>
+            <div className="p-6 space-y-3">
+              <div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600 space-y-1">
+                <div className="flex justify-between">
+                  <span className="font-semibold text-slate-500">Recipient</span>
+                  <span className="text-slate-700">{getRecipientName(messageTask)}</span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="font-semibold text-slate-500">{messageChannel === 'sms' ? 'SMS To' : 'WhatsApp To'}</span>
+                  <span className="text-slate-700">{getRecipientPhone(messageTask)}</span>
+                </div>
+                {messageChannel === 'sms' && (
+                  <div className="flex justify-between">
+                    <span className="font-semibold text-slate-500">Sender</span>
+                    <span className="text-slate-700">{getSmsSender(messageTask)}</span>
+                  </div>
+                )}
+              </div>
+              <label className="text-xs font-bold uppercase text-slate-500">Message (English)</label>
+              <textarea
+                rows={4}
+                value={messageText}
+                onChange={(e) => setMessageText(e.target.value)}
+                className="w-full border border-slate-200 rounded-lg p-3 text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none"
+              />
+              <p className="text-xs text-slate-400">Keep it concise (about 20 words).</p>
+            </div>
+            <div className="px-6 py-4 border-t bg-slate-50 flex justify-end gap-3">
+              <button onClick={closeMessageModal} className="px-4 py-2 border border-slate-200 rounded-lg text-slate-600 text-sm hover:bg-slate-100">
+                Cancel
+              </button>
+              <button
+                onClick={closeMessageModal}
+                className="px-5 py-2 bg-slate-900 text-white rounded-lg text-sm font-semibold hover:bg-slate-800"
+              >
+                Send
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }
@@ -326,4 +425,4 @@ function TaskDetailContent({ task }: { task: VasTask }) {
 
     </div>
   );
-}
+}

+ 5 - 0
tools/eslint-plugin-react-hooks/package.json

@@ -0,0 +1,5 @@
+{
+  "name": "eslint-plugin-react-hooks",
+  "version": "5.10.0",
+  "main": "index.js"
+}