diff --git a/App.jsx b/App.jsx
deleted file mode 100644
index 8e190c9..0000000
--- a/App.jsx
+++ /dev/null
@@ -1,3277 +0,0 @@
-import React, { useState, useEffect, useMemo } from 'react';
-import {
- Search, Plus, X, Mail, Edit2, Trash2, Check, Clock,
- Settings as SettingsIcon, Copy, ChevronRight,
- Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
- Package, Layers, Zap, Tag, Inbox, Paperclip,
- Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon,
- ShieldCheck, Settings2, Boxes
-} from 'lucide-react';
-import {
- storageGet, storageSet, uploadFile, deleteFile,
- authCheck, authLogin, authLogout, authChangePassword,
- getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
-} from './storage';
-
-/* ----------------------------- constants ----------------------------- */
-
-const REGIONS = [
- { id: 'africa', label: 'Africa / 非洲' },
- { id: 'middle_east', label: 'Middle East / 中东' },
- { id: 'australia', label: 'Australia / 大洋洲' },
- { id: 'europe', label: 'Europe / 欧洲' },
- { id: 'sea', label: 'SE Asia / 东南亚' },
- { id: 'other', label: 'Other / 其他' },
-];
-
-const PRODUCT_LINES = ['UPS不间断电源', '光伏逆变器', '储能电池', 'Other'];
-
-// LEGACY_PRODUCT_TAXONOMY:旧版写死的三级分类树,仅用于首次迁移生成默认的
-// 动态类目数据(类目/品牌/类型现在都存在 crm:productCategories 里,用户可自行增删)。
-const LEGACY_PRODUCT_TAXONOMY = [
- {
- id: 'ups', label: 'UPS不间断电源 / UPS', short: 'UPS不间断电源',
- subs: [
- { id: 'mini', label: '迷你UPS / Mini UPS', short: '迷你UPS' },
- { id: 'offline', label: '离线式UPS / Offline UPS', short: '离线式UPS' },
- { id: 'line_interactive', label: '在线互动式UPS / Line-Interactive UPS', short: '在线互动式UPS' },
- {
- id: 'online', label: '在线式UPS / Online UPS', short: '在线式UPS',
- subs: [
- { id: 'rack', label: '机架式UPS / Rack-mount UPS', short: '机架式UPS' },
- { id: 'high_freq_tower', label: '高频塔式UPS / High-Frequency Tower UPS', short: '高频塔式UPS' },
- { id: 'low_freq', label: '工频UPS / Low-Frequency UPS', short: '工频UPS' },
- { id: 'modular', label: '模块化UPS / Modular UPS', short: '模块化UPS' },
- ],
- },
- ],
- },
- {
- id: 'inverter', label: '光伏逆变器 / Solar Inverter', short: '光伏逆变器',
- subs: [
- { id: 'hybrid', label: '太阳能混网逆变器 / Hybrid Solar Inverter', short: '太阳能混网逆变器' },
- { id: 'grid_off_grid', label: '并网/离网逆变器 / Grid-tied / Off-grid Inverter', short: '并网/离网逆变器' },
- ],
- },
- {
- id: 'battery', label: '储能电池 / Energy Storage Battery', short: '储能电池',
- subs: [
- { id: 'lead_acid', label: '铅酸电池 / Lead-acid Battery', short: '铅酸电池' },
- { id: 'lifepo4', label: '磷酸铁锂电池 / LiFePO4 Battery', short: '磷酸铁锂电池' },
- ],
- },
-];
-
-function flattenLegacyTypes(top) {
- const out = [];
- (top.subs || []).forEach(s => {
- out.push(s.short);
- (s.subs || []).forEach(ss => out.push(ss.short));
- });
- return out;
-}
-function buildDefaultProductCategories() {
- return LEGACY_PRODUCT_TAXONOMY.map(top => ({
- id: top.id, name: top.short, brands: [], types: flattenLegacyTypes(top),
- }));
-}
-
-const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 };
-
-const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
-const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
-
-// 国家 -> 地区 / 电话国家代码 / 时区(以首都为准)映射表
-// 用于:选择国家后自动匹配所属大洲、电话区号前缀、当地时间
-const COUNTRIES = [
- // Africa / 非洲
- { name: '尼日利亚', en: 'Nigeria', region: 'africa', dial: '+234', tz: 'Africa/Lagos' },
- { name: '埃及', en: 'Egypt', region: 'africa', dial: '+20', tz: 'Africa/Cairo' },
- { name: '南非', en: 'South Africa', region: 'africa', dial: '+27', tz: 'Africa/Johannesburg' },
- { name: '肯尼亚', en: 'Kenya', region: 'africa', dial: '+254', tz: 'Africa/Nairobi' },
- { name: '加纳', en: 'Ghana', region: 'africa', dial: '+233', tz: 'Africa/Accra' },
- { name: '阿尔及利亚', en: 'Algeria', region: 'africa', dial: '+213', tz: 'Africa/Algiers' },
- { name: '摩洛哥', en: 'Morocco', region: 'africa', dial: '+212', tz: 'Africa/Casablanca' },
- { name: '突尼斯', en: 'Tunisia', region: 'africa', dial: '+216', tz: 'Africa/Tunis' },
- { name: '埃塞俄比亚', en: 'Ethiopia', region: 'africa', dial: '+251', tz: 'Africa/Addis_Ababa' },
- { name: '坦桑尼亚', en: 'Tanzania', region: 'africa', dial: '+255', tz: 'Africa/Dar_es_Salaam' },
- { name: '乌干达', en: 'Uganda', region: 'africa', dial: '+256', tz: 'Africa/Kampala' },
- { name: '安哥拉', en: 'Angola', region: 'africa', dial: '+244', tz: 'Africa/Luanda' },
- { name: '莫桑比克', en: 'Mozambique', region: 'africa', dial: '+258', tz: 'Africa/Maputo' },
- { name: '赞比亚', en: 'Zambia', region: 'africa', dial: '+260', tz: 'Africa/Lusaka' },
- { name: '津巴布韦', en: 'Zimbabwe', region: 'africa', dial: '+263', tz: 'Africa/Harare' },
- { name: '塞内加尔', en: 'Senegal', region: 'africa', dial: '+221', tz: 'Africa/Dakar' },
- { name: '科特迪瓦', en: "Cote d'Ivoire", region: 'africa', dial: '+225', tz: 'Africa/Abidjan' },
- { name: '喀麦隆', en: 'Cameroon', region: 'africa', dial: '+237', tz: 'Africa/Douala' },
- { name: '刚果(金)', en: 'DR Congo', region: 'africa', dial: '+243', tz: 'Africa/Kinshasa' },
- { name: '刚果(布)', en: 'Congo', region: 'africa', dial: '+242', tz: 'Africa/Brazzaville' },
- { name: '利比亚', en: 'Libya', region: 'africa', dial: '+218', tz: 'Africa/Tripoli' },
- { name: '苏丹', en: 'Sudan', region: 'africa', dial: '+249', tz: 'Africa/Khartoum' },
- { name: '卢旺达', en: 'Rwanda', region: 'africa', dial: '+250', tz: 'Africa/Kigali' },
- { name: '博茨瓦纳', en: 'Botswana', region: 'africa', dial: '+267', tz: 'Africa/Gaborone' },
- { name: '纳米比亚', en: 'Namibia', region: 'africa', dial: '+264', tz: 'Africa/Windhoek' },
- { name: '马里', en: 'Mali', region: 'africa', dial: '+223', tz: 'Africa/Bamako' },
- { name: '尼日尔', en: 'Niger', region: 'africa', dial: '+227', tz: 'Africa/Niamey' },
- { name: '贝宁', en: 'Benin', region: 'africa', dial: '+229', tz: 'Africa/Porto-Novo' },
- { name: '布基纳法索', en: 'Burkina Faso', region: 'africa', dial: '+226', tz: 'Africa/Ouagadougou' },
- { name: '多哥', en: 'Togo', region: 'africa', dial: '+228', tz: 'Africa/Lome' },
- { name: '几内亚', en: 'Guinea', region: 'africa', dial: '+224', tz: 'Africa/Conakry' },
- { name: '塞拉利昂', en: 'Sierra Leone', region: 'africa', dial: '+232', tz: 'Africa/Freetown' },
- { name: '利比里亚', en: 'Liberia', region: 'africa', dial: '+231', tz: 'Africa/Monrovia' },
- { name: '乍得', en: 'Chad', region: 'africa', dial: '+235', tz: 'Africa/Ndjamena' },
- { name: '索马里', en: 'Somalia', region: 'africa', dial: '+252', tz: 'Africa/Mogadishu' },
- { name: '吉布提', en: 'Djibouti', region: 'africa', dial: '+253', tz: 'Africa/Djibouti' },
- { name: '南苏丹', en: 'South Sudan', region: 'africa', dial: '+211', tz: 'Africa/Juba' },
- { name: '马拉维', en: 'Malawi', region: 'africa', dial: '+265', tz: 'Africa/Blantyre' },
- { name: '马达加斯加', en: 'Madagascar', region: 'africa', dial: '+261', tz: 'Indian/Antananarivo' },
- { name: '毛里求斯', en: 'Mauritius', region: 'africa', dial: '+230', tz: 'Indian/Mauritius' },
- { name: '加蓬', en: 'Gabon', region: 'africa', dial: '+241', tz: 'Africa/Libreville' },
- { name: '布隆迪', en: 'Burundi', region: 'africa', dial: '+257', tz: 'Africa/Bujumbura' },
- { name: '斯威士兰', en: 'Eswatini', region: 'africa', dial: '+268', tz: 'Africa/Mbabane' },
- { name: '厄立特里亚', en: 'Eritrea', region: 'africa', dial: '+291', tz: 'Africa/Asmara' },
-
- // Middle East / 中东
- { name: '沙特阿拉伯', en: 'Saudi Arabia', region: 'middle_east', dial: '+966', tz: 'Asia/Riyadh' },
- { name: '阿联酋', en: 'UAE', region: 'middle_east', dial: '+971', tz: 'Asia/Dubai' },
- { name: '卡塔尔', en: 'Qatar', region: 'middle_east', dial: '+974', tz: 'Asia/Qatar' },
- { name: '科威特', en: 'Kuwait', region: 'middle_east', dial: '+965', tz: 'Asia/Kuwait' },
- { name: '巴林', en: 'Bahrain', region: 'middle_east', dial: '+973', tz: 'Asia/Bahrain' },
- { name: '阿曼', en: 'Oman', region: 'middle_east', dial: '+968', tz: 'Asia/Muscat' },
- { name: '伊拉克', en: 'Iraq', region: 'middle_east', dial: '+964', tz: 'Asia/Baghdad' },
- { name: '伊朗', en: 'Iran', region: 'middle_east', dial: '+98', tz: 'Asia/Tehran' },
- { name: '约旦', en: 'Jordan', region: 'middle_east', dial: '+962', tz: 'Asia/Amman' },
- { name: '黎巴嫩', en: 'Lebanon', region: 'middle_east', dial: '+961', tz: 'Asia/Beirut' },
- { name: '以色列', en: 'Israel', region: 'middle_east', dial: '+972', tz: 'Asia/Jerusalem' },
- { name: '也门', en: 'Yemen', region: 'middle_east', dial: '+967', tz: 'Asia/Aden' },
- { name: '叙利亚', en: 'Syria', region: 'middle_east', dial: '+963', tz: 'Asia/Damascus' },
- { name: '土耳其', en: 'Turkey', region: 'middle_east', dial: '+90', tz: 'Europe/Istanbul' },
- { name: '巴勒斯坦', en: 'Palestine', region: 'middle_east', dial: '+970', tz: 'Asia/Gaza' },
-
- // Australia / 大洋洲
- { name: '澳大利亚', en: 'Australia', region: 'australia', dial: '+61', tz: 'Australia/Sydney' },
- { name: '新西兰', en: 'New Zealand', region: 'australia', dial: '+64', tz: 'Pacific/Auckland' },
- { name: '巴布亚新几内亚', en: 'Papua New Guinea', region: 'australia', dial: '+675', tz: 'Pacific/Port_Moresby' },
- { name: '斐济', en: 'Fiji', region: 'australia', dial: '+679', tz: 'Pacific/Fiji' },
-
- // Europe / 欧洲
- { name: '俄罗斯', en: 'Russia', region: 'europe', dial: '+7', tz: 'Europe/Moscow' },
- { name: '德国', en: 'Germany', region: 'europe', dial: '+49', tz: 'Europe/Berlin' },
- { name: '法国', en: 'France', region: 'europe', dial: '+33', tz: 'Europe/Paris' },
- { name: '英国', en: 'United Kingdom', region: 'europe', dial: '+44', tz: 'Europe/London' },
- { name: '意大利', en: 'Italy', region: 'europe', dial: '+39', tz: 'Europe/Rome' },
- { name: '西班牙', en: 'Spain', region: 'europe', dial: '+34', tz: 'Europe/Madrid' },
- { name: '波兰', en: 'Poland', region: 'europe', dial: '+48', tz: 'Europe/Warsaw' },
- { name: '乌克兰', en: 'Ukraine', region: 'europe', dial: '+380', tz: 'Europe/Kyiv' },
- { name: '荷兰', en: 'Netherlands', region: 'europe', dial: '+31', tz: 'Europe/Amsterdam' },
- { name: '比利时', en: 'Belgium', region: 'europe', dial: '+32', tz: 'Europe/Brussels' },
- { name: '瑞典', en: 'Sweden', region: 'europe', dial: '+46', tz: 'Europe/Stockholm' },
- { name: '挪威', en: 'Norway', region: 'europe', dial: '+47', tz: 'Europe/Oslo' },
- { name: '芬兰', en: 'Finland', region: 'europe', dial: '+358', tz: 'Europe/Helsinki' },
- { name: '丹麦', en: 'Denmark', region: 'europe', dial: '+45', tz: 'Europe/Copenhagen' },
- { name: '瑞士', en: 'Switzerland', region: 'europe', dial: '+41', tz: 'Europe/Zurich' },
- { name: '奥地利', en: 'Austria', region: 'europe', dial: '+43', tz: 'Europe/Vienna' },
- { name: '葡萄牙', en: 'Portugal', region: 'europe', dial: '+351', tz: 'Europe/Lisbon' },
- { name: '希腊', en: 'Greece', region: 'europe', dial: '+30', tz: 'Europe/Athens' },
- { name: '罗马尼亚', en: 'Romania', region: 'europe', dial: '+40', tz: 'Europe/Bucharest' },
- { name: '匈牙利', en: 'Hungary', region: 'europe', dial: '+36', tz: 'Europe/Budapest' },
- { name: '捷克', en: 'Czech Republic', region: 'europe', dial: '+420', tz: 'Europe/Prague' },
- { name: '塞尔维亚', en: 'Serbia', region: 'europe', dial: '+381', tz: 'Europe/Belgrade' },
- { name: '保加利亚', en: 'Bulgaria', region: 'europe', dial: '+359', tz: 'Europe/Sofia' },
- { name: '克罗地亚', en: 'Croatia', region: 'europe', dial: '+385', tz: 'Europe/Zagreb' },
- { name: '爱尔兰', en: 'Ireland', region: 'europe', dial: '+353', tz: 'Europe/Dublin' },
- { name: '白俄罗斯', en: 'Belarus', region: 'europe', dial: '+375', tz: 'Europe/Minsk' },
- { name: '摩尔多瓦', en: 'Moldova', region: 'europe', dial: '+373', tz: 'Europe/Chisinau' },
- { name: '立陶宛', en: 'Lithuania', region: 'europe', dial: '+370', tz: 'Europe/Vilnius' },
- { name: '拉脱维亚', en: 'Latvia', region: 'europe', dial: '+371', tz: 'Europe/Riga' },
- { name: '爱沙尼亚', en: 'Estonia', region: 'europe', dial: '+372', tz: 'Europe/Tallinn' },
- { name: '斯洛伐克', en: 'Slovakia', region: 'europe', dial: '+421', tz: 'Europe/Bratislava' },
- { name: '斯洛文尼亚', en: 'Slovenia', region: 'europe', dial: '+386', tz: 'Europe/Ljubljana' },
- { name: '波黑', en: 'Bosnia and Herzegovina', region: 'europe', dial: '+387', tz: 'Europe/Sarajevo' },
- { name: '北马其顿', en: 'North Macedonia', region: 'europe', dial: '+389', tz: 'Europe/Skopje' },
- { name: '阿尔巴尼亚', en: 'Albania', region: 'europe', dial: '+355', tz: 'Europe/Tirane' },
- { name: '黑山', en: 'Montenegro', region: 'europe', dial: '+382', tz: 'Europe/Podgorica' },
- { name: '冰岛', en: 'Iceland', region: 'europe', dial: '+354', tz: 'Atlantic/Reykjavik' },
- { name: '马耳他', en: 'Malta', region: 'europe', dial: '+356', tz: 'Europe/Malta' },
- { name: '塞浦路斯', en: 'Cyprus', region: 'europe', dial: '+357', tz: 'Asia/Nicosia' },
- { name: '卢森堡', en: 'Luxembourg', region: 'europe', dial: '+352', tz: 'Europe/Luxembourg' },
-
- // SE Asia / 东南亚
- { name: '印度尼西亚', en: 'Indonesia', region: 'sea', dial: '+62', tz: 'Asia/Jakarta' },
- { name: '马来西亚', en: 'Malaysia', region: 'sea', dial: '+60', tz: 'Asia/Kuala_Lumpur' },
- { name: '泰国', en: 'Thailand', region: 'sea', dial: '+66', tz: 'Asia/Bangkok' },
- { name: '越南', en: 'Vietnam', region: 'sea', dial: '+84', tz: 'Asia/Ho_Chi_Minh' },
- { name: '菲律宾', en: 'Philippines', region: 'sea', dial: '+63', tz: 'Asia/Manila' },
- { name: '新加坡', en: 'Singapore', region: 'sea', dial: '+65', tz: 'Asia/Singapore' },
- { name: '缅甸', en: 'Myanmar', region: 'sea', dial: '+95', tz: 'Asia/Yangon' },
- { name: '柬埔寨', en: 'Cambodia', region: 'sea', dial: '+855', tz: 'Asia/Phnom_Penh' },
- { name: '老挝', en: 'Laos', region: 'sea', dial: '+856', tz: 'Asia/Vientiane' },
- { name: '文莱', en: 'Brunei', region: 'sea', dial: '+673', tz: 'Asia/Brunei' },
-
- // Other / 其他
- { name: '美国', en: 'United States', region: 'other', dial: '+1', tz: 'America/New_York' },
- { name: '加拿大', en: 'Canada', region: 'other', dial: '+1', tz: 'America/Toronto' },
- { name: '巴西', en: 'Brazil', region: 'other', dial: '+55', tz: 'America/Sao_Paulo' },
- { name: '墨西哥', en: 'Mexico', region: 'other', dial: '+52', tz: 'America/Mexico_City' },
- { name: '阿根廷', en: 'Argentina', region: 'other', dial: '+54', tz: 'America/Argentina/Buenos_Aires' },
- { name: '印度', en: 'India', region: 'other', dial: '+91', tz: 'Asia/Kolkata' },
- { name: '巴基斯坦', en: 'Pakistan', region: 'other', dial: '+92', tz: 'Asia/Karachi' },
- { name: '孟加拉国', en: 'Bangladesh', region: 'other', dial: '+880', tz: 'Asia/Dhaka' },
- { name: '中国', en: 'China', region: 'other', dial: '+86', tz: 'Asia/Shanghai' },
- { name: '韩国', en: 'South Korea', region: 'other', dial: '+82', tz: 'Asia/Seoul' },
- { name: '日本', en: 'Japan', region: 'other', dial: '+81', tz: 'Asia/Tokyo' },
- { name: '乌兹别克斯坦', en: 'Uzbekistan', region: 'other', dial: '+998', tz: 'Asia/Tashkent' },
- { name: '哈萨克斯坦', en: 'Kazakhstan', region: 'other', dial: '+7', tz: 'Asia/Almaty' },
- { name: '吉尔吉斯斯坦', en: 'Kyrgyzstan', region: 'other', dial: '+996', tz: 'Asia/Bishkek' },
- { name: '塔吉克斯坦', en: 'Tajikistan', region: 'other', dial: '+992', tz: 'Asia/Dushanbe' },
- { name: '土库曼斯坦', en: 'Turkmenistan', region: 'other', dial: '+993', tz: 'Asia/Ashgabat' },
- { name: '格鲁吉亚', en: 'Georgia', region: 'other', dial: '+995', tz: 'Asia/Tbilisi' },
- { name: '亚美尼亚', en: 'Armenia', region: 'other', dial: '+374', tz: 'Asia/Yerevan' },
- { name: '阿塞拜疆', en: 'Azerbaijan', region: 'other', dial: '+994', tz: 'Asia/Baku' },
- { name: '斯里兰卡', en: 'Sri Lanka', region: 'other', dial: '+94', tz: 'Asia/Colombo' },
- { name: '尼泊尔', en: 'Nepal', region: 'other', dial: '+977', tz: 'Asia/Kathmandu' },
- { name: '阿富汗', en: 'Afghanistan', region: 'other', dial: '+93', tz: 'Asia/Kabul' },
- { name: '蒙古', en: 'Mongolia', region: 'other', dial: '+976', tz: 'Asia/Ulaanbaatar' },
- { name: '智利', en: 'Chile', region: 'other', dial: '+56', tz: 'America/Santiago' },
- { name: '秘鲁', en: 'Peru', region: 'other', dial: '+51', tz: 'America/Lima' },
- { name: '哥伦比亚', en: 'Colombia', region: 'other', dial: '+57', tz: 'America/Bogota' },
- { name: '厄瓜多尔', en: 'Ecuador', region: 'other', dial: '+593', tz: 'America/Guayaquil' },
- { name: '委内瑞拉', en: 'Venezuela', region: 'other', dial: '+58', tz: 'America/Caracas' },
- { name: '玻利维亚', en: 'Bolivia', region: 'other', dial: '+591', tz: 'America/La_Paz' },
- { name: '巴拉圭', en: 'Paraguay', region: 'other', dial: '+595', tz: 'America/Asuncion' },
- { name: '乌拉圭', en: 'Uruguay', region: 'other', dial: '+598', tz: 'America/Montevideo' },
-];
-
-const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
-
-const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists' };
-
-const blankCustomer = () => ({
- id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
- company: '', contact: '', country: '', region: 'middle_east', email: '', phone: '',
- isVip: false,
- hasOrdered: false,
- preferredContacts: [],
- website: '',
- productLines: [], intendedModel: '', intendedQty: '',
- source: '',
- tags: '', notes: '', lastContact: '', followUpDays: '',
- warehouseAddress: '', freightContact: '', shippingMarkNote: '',
- attachments: [],
-});
-
-const blankProduct = () => ({
- id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
- name: '', brand: '', category: '', type: '',
- powerOrCapacity: '', voltage: '', certifications: '', targetRegions: [], notes: '',
-});
-
-/* ------------------------------- quotes/invoices ------------------------------ */
-
-const CURRENCIES = [
- { code: 'USD', symbol: 'US$' },
- { code: 'CNY', symbol: '¥' },
- { code: 'RUB', symbol: '₽' },
-];
-
-const DEFAULT_SELLER_PROFILES = [
- {
- id: 'seller_1', name: 'BEIJING JINYIDA BORUI TECHNOLOGY DEVELOPMENT CO., LTD.',
- address: '313, Floor 3, Building 1, Yard 4, Fuze Road, Fangshan, Beijing, China',
- logoFileName: '', bankInfo: '',
- },
- {
- id: 'seller_2', name: 'XINHUA CHUIXUE COMPUTER CO., LTD',
- address: 'No.8 Wenyuan Street Shijiazhuang, Hebei, China',
- logoFileName: '', bankInfo: '',
- },
- {
- id: 'seller_3', name: 'Shenzhen Pengcheng Electric Co., Ltd.',
- address: "No. 6093, Bao'an Avenue, Xinhe Community, Fuhai Street, Bao'an District, Shenzhen, China",
- logoFileName: '', bankInfo: '',
- },
-];
-
-const PAYMENT_TERMS_OPTIONS = [
- '100% IN ADVANCE T/T',
- '50% IN ADVANCE T/T 50% BEFORE DELIVERY',
- '30% IN ADVANCE T/T 70% BEFORE DELIVERY',
-];
-
-const QUOTE_NOTE_DEFAULTS = ['BRAND NEW ORIGINAL PACKED BY CARTON', 'PRICE BASED ON EXW TERM'];
-const QUOTE_NOTE_OPTIONS = ['IN STOCK', 'LEAD TIME 1 WEEK', 'LEAD TIME 2 WEEKS', 'WARRANTY 1 YEAR', 'WARRANTY 2 YEARS', 'WARRANTY 3 YEARS', 'WARRANTY 5 YEARS'];
-
-const blankLineItem = () => ({ id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), description: '', quantity: '', rate: '' });
-
-const blankPackingLineItem = () => ({
- id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
- description: '', qty: '', cartons: '', grossWeight: '', netWeight: '', volume: '',
-});
-
-const blankPackingList = () => ({
- id: 'pl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
- type: 'packing',
- number: '',
- date: todayStr(),
- sellerProfileId: 'seller_1',
- customerId: '',
- billToName: '',
- showSeller: true,
- showBillTo: true,
- lineItems: [blankPackingLineItem()],
- notes: '',
- createdAt: todayStr(),
-});
-
-function packingTotals(pl) {
- return (pl.lineItems || []).reduce((sum, li) => ({
- qty: sum.qty + (parseFloat(li.qty) || 0),
- cartons: sum.cartons + (parseFloat(li.cartons) || 0),
- grossWeight: sum.grossWeight + (parseFloat(li.grossWeight) || 0),
- netWeight: sum.netWeight + (parseFloat(li.netWeight) || 0),
- volume: sum.volume + (parseFloat(li.volume) || 0),
- }), { qty: 0, cartons: 0, grossWeight: 0, netWeight: 0, volume: 0 });
-}
-
-const blankQuote = (type) => ({
- id: 'q_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
- type, // 'quote' | 'invoice'
- number: '',
- date: todayStr(),
- currency: 'USD',
- sellerProfileId: 'seller_1',
- customerId: '',
- billToName: '',
- shipToName: '',
- paymentTerms: '',
- bankInfo: '', signerName: '',
- lineItems: [blankLineItem()],
- notes: type === 'quote' ? QUOTE_NOTE_DEFAULTS.join('\n') : '',
- createdAt: todayStr(),
-});
-
-function suggestDocNumber(date, existingQuotes, type) {
- const d = new Date((date || todayStr()) + 'T00:00:00');
- const yyyy = d.getFullYear();
- const mm = String(d.getMonth() + 1).padStart(2, '0');
- const dd = String(d.getDate()).padStart(2, '0');
- const base = `${yyyy}${mm}${dd}`;
- const sameDay = (existingQuotes || []).filter(q => q.type === type && (q.number || '').startsWith(base));
- const letter = String.fromCharCode(65 + sameDay.length);
- return `${base}${letter}`;
-}
-
-function addOneMonth(dateStr) {
- if (!dateStr) return '';
- const d = new Date(dateStr + 'T00:00:00');
- d.setMonth(d.getMonth() + 1);
- return d.toISOString().slice(0, 10);
-}
-
-function formatMoney(amount, currencyCode) {
- const found = CURRENCIES.find(c => c.code === currencyCode);
- const symbol = found ? found.symbol : '';
- const safeAmount = Number.isFinite(amount) ? amount : 0;
- return `${symbol}${safeAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
-}
-
-function quoteTotals(quote) {
- const total = (quote.lineItems || []).reduce((sum, li) => {
- const qty = parseFloat(li.quantity) || 0;
- const rate = parseFloat(li.rate) || 0;
- return sum + qty * rate;
- }, 0);
- return { total };
-}
-
-// 从所有历史报价单/Invoice里提取"型号(首行) -> 完整描述+单价"的记录,按日期保留最新的一条,
-// 方便录入新产品明细时按关键词自动带出之前报过的价格
-function buildPriceHistory(quotes) {
- const map = new Map();
- const sorted = [...quotes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
- for (const q of sorted) {
- for (const li of q.lineItems || []) {
- const firstLine = (li.description || '').split('\n')[0].trim();
- if (!firstLine || !li.rate) continue;
- map.set(firstLine.toUpperCase(), { description: li.description, rate: li.rate, date: q.date });
- }
- }
- return Array.from(map.values());
-}
-
-// 客户档案在下拉框里展示的标签:意向型号 - 公司名字 - 客户姓名,方便辨认是哪一个客户
-function quoteLinkLabel(c) {
- const modelQty = [c.intendedModel, c.intendedQty ? `×${c.intendedQty}` : ''].filter(Boolean).join(' ');
- return [modelQty, c.company, c.contact].filter(Boolean).join(' - ') || '(未命名)';
-}
-// 真正要打印在报价单/Invoice上的客户名称:用公司名/联系人,不能用型号(型号只是用来辨认客户,不是收件人名字)
-function billPartyName(c) {
- return c.company || c.contact || '';
-}
-
-/* ------------------------------- helpers ------------------------------ */
-
-function todayStr() {
- const d = new Date();
- return d.toISOString().slice(0, 10);
-}
-function daysSince(dateStr) {
- if (!dateStr) return Infinity;
- const last = new Date(dateStr + 'T00:00:00');
- const today = new Date(); today.setHours(0, 0, 0, 0);
- return Math.floor((today - last) / 86400000);
-}
-function statusOf(customer, settings) {
- if (!customer.lastContact) return 'overdue';
- const d = daysSince(customer.lastContact);
- const interval = Number(customer.followUpDays) || settings.defaultFollowUpDays || 30;
- if (d >= interval) return 'overdue';
- if (d >= interval - (settings.dueSoonWindow || 7)) return 'due';
- return 'ok';
-}
-function statusLabel(s) {
- return s === 'overdue' ? '已逾期' : s === 'due' ? '即将到期' : '状态良好';
-}
-function regionLabel(id) {
- return (REGIONS.find(r => r.id === id) || {}).label || id;
-}
-function localTimeInfo(countryName, now) {
- const c = COUNTRY_BY_NAME[countryName];
- if (!c) return null;
- try {
- const time = new Intl.DateTimeFormat('zh-CN', { timeZone: c.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(now);
- return { time, en: c.en, tz: c.tz };
- } catch (e) {
- return null;
- }
-}
-// 没有手动标记常用联系方式时,邮箱和电话都展示;标了哪个就只展示哪个
-function contactDisplayFlags(c) {
- const preferred = c.preferredContacts || [];
- const none = preferred.length === 0;
- return {
- showEmail: (none || preferred.includes('email')) && !!c.email,
- showPhone: (none || preferred.includes('phone')) && !!c.phone,
- };
-}
-// 标题统一规则:意向型号(+数量)优先,没有就退回公司名,再没有就显示未命名
-function customerTitle(c) {
- const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
- return modelQty || c.company || '(未命名)';
-}
-function customerTitleIsModel(c) {
- return !!(c.intendedModel || c.intendedQty);
-}
-function reorderArray(arr, fromIndex, toIndex) {
- const copy = [...arr];
- const [moved] = copy.splice(fromIndex, 1);
- copy.splice(toIndex, 0, moved);
- return copy;
-}
-function reorderWithinCategory(products, category, fromIndex, toIndex) {
- const catItems = products.filter(p => p.category === category);
- const reordered = reorderArray(catItems, fromIndex, toIndex);
- let i = 0;
- return products.map(p => (p.category === category ? reordered[i++] : p));
-}
-function fmtDate(d) {
- if (!d) return '—';
- return d;
-}
-function categoryBreadcrumb(p, categories) {
- const cat = (categories || []).find(c => c.id === p.category);
- if (!cat) return '未分类';
- return [cat.name, p.type].filter(Boolean).join(' › ');
-}
-// 一次性迁移:把旧版 subCategory/subSubCategory(写死id)转换成新版的 type(自由文本,
-// 存在类目自己的 types 列表里);把产品里已有的自由文本品牌,补进所属类目的 brands 列表,
-// 这样老数据在新的下拉选择模型里仍然是合法可选项,不会丢失也不会报错。幂等:跑第二次不会重复添加。
-function normalizeProductTaxonomy(products, categories) {
- let cats = (categories && categories.length ? categories : buildDefaultProductCategories())
- .map(c => ({ ...c, brands: [...(c.brands || [])], types: [...(c.types || [])] }));
-
- const migratedProducts = (products || []).map(p => {
- let type = p.type || '';
- if (!type && (p.subCategory || p.subSubCategory)) {
- const top = LEGACY_PRODUCT_TAXONOMY.find(t => t.id === p.category);
- const sub = top && (top.subs || []).find(s => s.id === p.subCategory);
- const subsub = sub && (sub.subs || []).find(s => s.id === p.subSubCategory);
- type = (subsub && subsub.short) || (sub && sub.short) || '';
- }
- return { ...p, type };
- });
-
- migratedProducts.forEach(p => {
- if (!p.category) return;
- let cat = cats.find(c => c.id === p.category);
- if (!cat) {
- cat = { id: p.category, name: p.category, brands: [], types: [] };
- cats.push(cat);
- }
- if (p.brand && !cat.brands.includes(p.brand)) cat.brands.push(p.brand);
- if (p.type && !cat.types.includes(p.type)) cat.types.push(p.type);
- });
-
- return { products: migratedProducts, categories: cats };
-}
-function generateFollowUpEmail(customer) {
- const name = customer.contact || customer.company || 'there';
- const product = customer.productLines && customer.productLines.length ? customer.productLines.join(', ') : 'our products';
- const subject = `Following up – ${customer.company || name}`;
- const body = `Dear ${name},
-
-I hope this message finds you well. It has been a little while since we last connected, and I wanted to check in regarding ${product}.
-
-If you have any upcoming projects or requirements, I would be happy to share our latest pricing, lead times, and technical updates. Please let me know if there is anything I can help with.
-
-Looking forward to hearing from you.
-
-Best regards,
-[Your name]`;
- return { subject, body };
-}
-
-/* -------------------------------- storage ------------------------------ */
-
-async function loadAll() {
- let customers = [];
- let products = [];
- let settings = DEFAULT_SETTINGS;
- let quotes = [];
- let sellerProfiles = DEFAULT_SELLER_PROFILES;
- let certifications = [];
- let productCategories = [];
- let packingLists = [];
- try {
- const c = await storageGet(STORAGE_KEYS.customers);
- if (c && c.value) customers = JSON.parse(c.value);
- } catch (e) {}
- try {
- const p = await storageGet(STORAGE_KEYS.products);
- if (p && p.value) products = JSON.parse(p.value);
- } catch (e) {}
- try {
- const s = await storageGet(STORAGE_KEYS.settings);
- if (s && s.value) settings = { ...DEFAULT_SETTINGS, ...JSON.parse(s.value) };
- } catch (e) {}
- try {
- const q = await storageGet(STORAGE_KEYS.quotes);
- if (q && q.value) quotes = JSON.parse(q.value);
- } catch (e) {}
- try {
- const sp = await storageGet(STORAGE_KEYS.sellerProfiles);
- if (sp && sp.value) sellerProfiles = JSON.parse(sp.value);
- } catch (e) {}
- try {
- const cf = await storageGet(STORAGE_KEYS.certifications);
- if (cf && cf.value) certifications = JSON.parse(cf.value);
- } catch (e) {}
- try {
- const pc = await storageGet(STORAGE_KEYS.productCategories);
- if (pc && pc.value) productCategories = JSON.parse(pc.value);
- } catch (e) {}
- try {
- const pl = await storageGet(STORAGE_KEYS.packingLists);
- if (pl && pl.value) packingLists = JSON.parse(pl.value);
- } catch (e) {}
- return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists };
-}
-
-/* -------------------------------- styles -------------------------------- */
-
-const Styles = () => (
-
-);
-
-/* -------------------------------- pieces -------------------------------- */
-
-// 品牌 / 类型标签行:展示某类目下的 tax-pill 列表,可点击 x 删除单个,
-// 末尾的虚线圆圈 + 号点击后原地变成一个小输入框,回车/失焦保存,Esc取消。
-function TaxPillRow({ label, values, catId, kind, addingPill, addingPillValue, setAddingPillValue, onOpenAdd, onConfirmAdd, onCancelAdd, onRemove }) {
- const isAdding = addingPill && addingPill.catId === catId && addingPill.kind === kind;
- return (
-
- {label &&
{label}}
- {values.map(v => (
-
- {v}
- onRemove(catId, kind, v)}>
-
- ))}
- {isAdding ? (
-
setAddingPillValue(e.target.value)}
- onBlur={onConfirmAdd}
- onKeyDown={e => {
- if (e.key === 'Enter') e.currentTarget.blur();
- if (e.key === 'Escape') onCancelAdd();
- }}
- placeholder={kind === 'brand' ? '品牌名称' : '类型名称'}
- />
- ) : (
-
onOpenAdd(catId, kind)}>
- )}
-
- );
-}
-
-function StatCard({ num, label, tone }) {
- return (
-
- );
-}
-
-function ProductPills({ value, onChange }) {
- const toggle = (p) => {
- const has = value.includes(p);
- onChange(has ? value.filter(x => x !== p) : [...value, p]);
- };
- return (
-
- {PRODUCT_LINES.map(p => (
-
toggle(p)}>
- {value.includes(p) && } {p}
-
- ))}
-
- );
-}
-
-function RegionPills({ value, onChange }) {
- const toggle = (id) => {
- const has = value.includes(id);
- onChange(has ? value.filter(x => x !== id) : [...value, id]);
- };
- return (
-
- {REGIONS.map(r => (
-
toggle(r.id)}>
- {value.includes(r.id) && } {r.label}
-
- ))}
-
- );
-}
-
-/* --------------------------------- app ---------------------------------- */
-
-/* --------------------------------- nav --------------------------------- */
-
-const NAV_GROUPS = [
- { label: '客户', items: [
- { id: 'monitor', label: '客户监控', icon: LayoutDashboard },
- { id: 'manage', label: '客户管理', icon: Users },
- ] },
- { label: '产品', items: [
- { id: 'groups', label: '产品分组', icon: FolderTree },
- { id: 'products', label: '产品管理', icon: Package },
- { id: 'catalog', label: '产品目录', icon: BookOpen },
- ] },
-];
-const STANDALONE_NAV = [
- { id: 'certs', label: '授权管理', icon: ShieldCheck },
- { id: 'quotes', label: '报价单制作', icon: FileText },
- { id: 'packing', label: 'Packing List制作', icon: Boxes },
- { id: 'settings', label: '设置', icon: SettingsIcon },
-];
-function viewTitle(view) {
- const all = [...NAV_GROUPS.flatMap(g => g.items), ...STANDALONE_NAV];
- const found = all.find(i => i.id === view);
- return found ? found.label : '客户档案系统';
-}
-
-/* ------------------------------- login screen ----------------------------- */
-
-function LoginScreen({ onSuccess }) {
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
- const [error, setError] = useState('');
- const [loading, setLoading] = useState(false);
-
- async function handleSubmit(e) {
- e.preventDefault();
- if (loading) return;
- setError('');
- setLoading(true);
- try {
- const res = await authLogin(username, password);
- onSuccess(res.username || username);
- } catch (e) {
- setError('用户名或密码不正确');
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
- );
-}
-
-/* --------------------------------- root --------------------------------- */
-
-export default function CustomerCRM() {
- const [authState, setAuthState] = useState('checking'); // checking | out | in
- const [username, setUsername] = useState(null);
-
- useEffect(() => {
- authCheck()
- .then(res => {
- if (res.authed) { setUsername(res.username); setAuthState('in'); }
- else setAuthState('out');
- })
- .catch(() => setAuthState('out'));
- }, []);
-
- function handleLogout() {
- authLogout().finally(() => {
- setUsername(null);
- setAuthState('out');
- });
- }
-
- if (authState === 'checking') {
- return (
-
- );
- }
- if (authState === 'out') {
- return { setUsername(name); setAuthState('in'); }} />;
- }
- return ;
-}
-
-export { LoginScreen, MainApp };
-
-function MainApp({ username, onLogout }) {
- const [loaded, setLoaded] = useState(false);
- const [customers, setCustomers] = useState([]);
- const [products, setProducts] = useState([]);
- const [settings, setSettings] = useState(DEFAULT_SETTINGS);
- const [view, setView] = useState('monitor');
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
-
- const [selectedId, setSelectedId] = useState(null);
- const [draft, setDraft] = useState(null);
- const [search, setSearch] = useState('');
- const [regionFilter, setRegionFilter] = useState('all');
- const [attachmentNote, setAttachmentNote] = useState('');
- const [attachmentCategory, setAttachmentCategory] = useState(FILE_CATEGORIES[0]);
- const [attachmentFile, setAttachmentFile] = useState(null);
- const [uploading, setUploading] = useState(false);
- const [fileInputKey, setFileInputKey] = useState(0);
- const [fileDragActive, setFileDragActive] = useState(false);
-
- const [selectedProductId, setSelectedProductId] = useState(null);
- const [productDraft, setProductDraft] = useState(null);
- const [productSearch, setProductSearch] = useState('');
- const [productCategoryFilter, setProductCategoryFilter] = useState('all');
-
- const [emailModal, setEmailModal] = useState(null);
- const [exportOpen, setExportOpen] = useState(false);
- const [toast, setToast] = useState('');
- const [now, setNow] = useState(() => new Date());
-
- const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
- const [dragCatalogIdx, setDragCatalogIdx] = useState(null);
- const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
-
- const [pwCurrent, setPwCurrent] = useState('');
- const [pwNew, setPwNew] = useState('');
- const [pwBusy, setPwBusy] = useState(false);
-
- const [aiConfigHasKey, setAiConfigHasKey] = useState(false);
- const [aiKeyInput, setAiKeyInput] = useState('');
- const [aiSaving, setAiSaving] = useState(false);
- const [aiTesting, setAiTesting] = useState(false);
- const [aiGenerating, setAiGenerating] = useState(false);
- const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
-
- const [quotes, setQuotes] = useState([]);
- const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
- const [productCategories, setProductCategories] = useState([]);
- const [addingPill, setAddingPill] = useState(null); // { catId, kind: 'brand' | 'type' | 'category' }
- const [addingPillValue, setAddingPillValue] = useState('');
- const [taxManageMode, setTaxManageMode] = useState(null); // 'category' | 'brand' | 'type' | null
- const [certifications, setCertifications] = useState([]);
- const [certUploading, setCertUploading] = useState(false);
- const [editingCertId, setEditingCertId] = useState(null);
- const [certBrandDraft, setCertBrandDraft] = useState('');
- const [quoteSubView, setQuoteSubView] = useState('list'); // 'list' | 'editor'
- const [quoteDraft, setQuoteDraft] = useState(null);
- const [quoteTypeFilter, setQuoteTypeFilter] = useState('all');
- const [sellerManagerOpen, setSellerManagerOpen] = useState(false);
- const [sellerLogoUploading, setSellerLogoUploading] = useState(false);
- const [pdfExporting, setPdfExporting] = useState(false);
- const [packingLists, setPackingLists] = useState([]);
- const [packingSubView, setPackingSubView] = useState('list'); // 'list' | 'editor'
- const [packingDraft, setPackingDraft] = useState(null);
- const [packingPdfExporting, setPackingPdfExporting] = useState(false);
-
- useEffect(() => {
- getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {});
- }, []);
-
- useEffect(() => {
- const id = setInterval(() => setNow(new Date()), 30000);
- return () => clearInterval(id);
- }, []);
-
- useEffect(() => {
- (async () => {
- const data = await loadAll();
- const normalized = normalizeProductTaxonomy(data.products, data.productCategories);
- setCustomers(data.customers);
- setProducts(normalized.products);
- setProductCategories(normalized.categories);
- setSettings(data.settings);
- setQuotes(data.quotes);
- setSellerProfiles(data.sellerProfiles);
- setCertifications(data.certifications);
- setPackingLists(data.packingLists);
- setLoaded(true);
- })();
- }, []);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.quotes, JSON.stringify(quotes)).catch(() => {}); }, [quotes, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.productCategories, JSON.stringify(productCategories)).catch(() => {}); }, [productCategories, loaded]);
- useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.packingLists, JSON.stringify(packingLists)).catch(() => {}); }, [packingLists, loaded]);
-
- function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
-
- function copyText(text) {
- navigator.clipboard.writeText(text).then(
- () => notify('已复制到剪贴板'),
- () => notify('复制失败,请手动选择文本复制')
- );
- }
-
- const selected = customers.find(c => c.id === selectedId) || null;
- const selectedProduct = products.find(p => p.id === selectedProductId) || null;
-
- const filteredCustomers = useMemo(() => {
- let list = customers;
- if (regionFilter !== 'all') list = list.filter(c => c.region === regionFilter);
- if (search.trim()) {
- const q = search.trim().toLowerCase();
- list = list.filter(c =>
- (c.company || '').toLowerCase().includes(q) ||
- (c.contact || '').toLowerCase().includes(q) ||
- (c.email || '').toLowerCase().includes(q) ||
- (c.country || '').toLowerCase().includes(q)
- );
- }
- const order = { overdue: 0, due: 1, ok: 2 };
- return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || ''));
- }, [customers, search, regionFilter, settings]);
-
- const filteredProducts = useMemo(() => {
- let list = products;
- if (productCategoryFilter !== 'all') list = list.filter(p => p.category === productCategoryFilter);
- if (productSearch.trim()) {
- const q = productSearch.trim().toLowerCase();
- list = list.filter(p =>
- (p.name || '').toLowerCase().includes(q) ||
- (p.brand || '').toLowerCase().includes(q) ||
- categoryBreadcrumb(p, productCategories).toLowerCase().includes(q)
- );
- }
- return [...list].sort((a, b) => categoryBreadcrumb(a, productCategories).localeCompare(categoryBreadcrumb(b, productCategories)) || (a.name || '').localeCompare(b.name || ''));
- }, [products, productSearch, productCategoryFilter, productCategories]);
-
- const counts = useMemo(() => ({
- overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length,
- due: customers.filter(c => statusOf(c, settings) === 'due').length,
- total: customers.length,
- products: products.length,
- }), [customers, settings, products]);
-
- const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
-
- function handleCustomerDrop(dropIdx) {
- if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
- setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
- setDragCustomerIdx(null);
- }
- function handleCatalogDrop(dropIdx) {
- if (dragCatalogIdx === null || dragCatalogIdx === dropIdx) { setDragCatalogIdx(null); return; }
- setProducts(prev => reorderArray(prev, dragCatalogIdx, dropIdx));
- setDragCatalogIdx(null);
- }
- function handleGroupDrop(category, dropIdx) {
- if (!dragGroupState || dragGroupState.category !== category || dragGroupState.index === dropIdx) { setDragGroupState(null); return; }
- setProducts(prev => reorderWithinCategory(prev, category, dragGroupState.index, dropIdx));
- setDragGroupState(null);
- }
-
- function openAddPill(catId, kind) { setAddingPill({ catId, kind }); setAddingPillValue(''); }
- function cancelAddPill() { setAddingPill(null); setAddingPillValue(''); }
- function confirmAddPill() {
- if (!addingPill) return;
- const val = addingPillValue.trim();
- if (!val) { cancelAddPill(); return; }
- if (addingPill.kind === 'category') {
- const exists = productCategories.some(c => c.name === val);
- if (!exists) {
- const id = 'cat_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
- setProductCategories(prev => [...prev, { id, name: val, brands: [], types: [] }]);
- }
- } else {
- const listKey = addingPill.kind === 'brand' ? 'brands' : 'types';
- setProductCategories(prev => prev.map(c => {
- if (c.id !== addingPill.catId) return c;
- if (c[listKey].includes(val)) return c;
- return { ...c, [listKey]: [...c[listKey], val] };
- }));
- }
- cancelAddPill();
- }
- function removeTaxonomyValue(catId, kind, value) {
- const listKey = kind === 'brand' ? 'brands' : 'types';
- setProductCategories(prev => prev.map(c => (c.id === catId ? { ...c, [listKey]: c[listKey].filter(v => v !== value) } : c)));
- }
- function deleteProductCategory(cat) {
- const count = products.filter(p => p.category === cat.id).length;
- const msg = count > 0
- ? `确定删除类目"${cat.name}"?该类目下还有 ${count} 个产品,删除后这些产品会显示为"未分类",产品本身不会被删除。`
- : `确定删除类目"${cat.name}"?`;
- if (!window.confirm(msg)) return;
- setProductCategories(prev => prev.filter(c => c.id !== cat.id));
- }
-
- async function handleChangePassword() {
- if (!pwCurrent || !pwNew) { notify('请填写当前密码和新密码'); return; }
- if (pwNew.length < 4) { notify('新密码至少4位'); return; }
- setPwBusy(true);
- try {
- await authChangePassword(pwCurrent, pwNew);
- setPwCurrent('');
- setPwNew('');
- notify('密码已修改');
- } catch (e) {
- notify('修改失败:当前密码不正确');
- } finally {
- setPwBusy(false);
- }
- }
-
- async function handleSaveAiKey() {
- if (!aiKeyInput.trim()) { notify('请输入API Key'); return; }
- setAiSaving(true);
- try {
- await saveAiConfig(aiKeyInput.trim());
- setAiConfigHasKey(true);
- setAiKeyInput('');
- notify('已保存');
- } catch (e) {
- notify('保存失败');
- } finally {
- setAiSaving(false);
- }
- }
- async function handleTestAiKey() {
- setAiTesting(true);
- try {
- await testAiConfig();
- notify('连接成功');
- } catch (e) {
- notify('连接失败,请检查API Key');
- } finally {
- setAiTesting(false);
- }
- }
- async function handleClearAiKey() {
- if (!window.confirm('确定清除已保存的DeepSeek API Key?')) return;
- await deleteAiConfig();
- setAiConfigHasKey(false);
- notify('已清除');
- }
- async function handleGenerateAi(customerId, channel) {
- if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; }
- setAiGenerating(true);
- try {
- const result = await generateAiDraft(customerId, channel);
- setAiDraftModal({ channel, customerId, subject: result.subject || '', body: result.body || '' });
- } catch (e) {
- notify('生成失败:' + (e.message || '请检查API Key和网络'));
- } finally {
- setAiGenerating(false);
- }
- }
-
- function startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
- function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
- function cancelDraft() { setDraft(null); }
- function saveDraft() {
- setCustomers(prev => {
- const exists = prev.some(c => c.id === draft.id);
- return exists ? prev.map(c => (c.id === draft.id ? draft : c)) : [...prev, draft];
- });
- setSelectedId(draft.id);
- setDraft(null);
- notify('已保存');
- }
- function deleteCustomer(id) {
- if (!window.confirm('确定删除该客户档案?此操作不可恢复。')) return;
- setCustomers(prev => prev.filter(c => c.id !== id));
- if (selectedId === id) setSelectedId(null);
- notify('已删除');
- }
- function markContacted(id) {
- setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
- notify('已标记为今日已联系');
- }
- function togglePreferredContact(id, channel) {
- setCustomers(prev => prev.map(c => {
- if (c.id !== id) return c;
- const has = (c.preferredContacts || []).includes(channel);
- return { ...c, preferredContacts: has ? c.preferredContacts.filter(x => x !== channel) : [...(c.preferredContacts || []), channel] };
- }));
- }
- function toggleVip(id) {
- setCustomers(prev => prev.map(c => (c.id === id ? { ...c, isVip: !c.isVip } : c)));
- }
- function toggleOrdered(id) {
- setCustomers(prev => prev.map(c => (c.id === id ? { ...c, hasOrdered: !c.hasOrdered } : c)));
- }
- function handleFileDragOver(e) { e.preventDefault(); setFileDragActive(true); }
- function handleFileDragLeave() { setFileDragActive(false); }
- function handleFileDrop(e) {
- e.preventDefault();
- setFileDragActive(false);
- const f = e.dataTransfer.files && e.dataTransfer.files[0];
- if (f) setAttachmentFile(f);
- }
- async function handleUploadAttachment(customerId) {
- if (!attachmentFile) { notify('请先选择文件'); return; }
- setUploading(true);
- try {
- const res = await uploadFile(customerId, attachmentFile);
- setCustomers(prev => prev.map(c => {
- if (c.id !== customerId) return c;
- const entry = {
- fileName: res.fileName,
- originalName: res.originalName,
- category: attachmentCategory,
- note: attachmentNote.trim(),
- date: todayStr(),
- };
- return { ...c, attachments: [entry, ...(c.attachments || [])] };
- }));
- setAttachmentFile(null);
- setAttachmentNote('');
- setAttachmentCategory(FILE_CATEGORIES[0]);
- setFileInputKey(k => k + 1);
- notify('已上传');
- } catch (e) {
- notify('上传失败,请重试');
- } finally {
- setUploading(false);
- }
- }
- async function handleDeleteAttachment(customerId, fileName) {
- if (!window.confirm('确定删除这个附件?此操作不可恢复。')) return;
- try {
- await deleteFile(customerId, fileName);
- } catch (e) {
- // 即使服务端删除失败(比如文件已经不存在),也继续把记录从客户档案里移除
- }
- setCustomers(prev => prev.map(c => (
- c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
- )));
- }
- function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); }
-
- function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
- function startEditProduct(p) { setProductDraft({ ...p, targetRegions: [...(p.targetRegions || [])] }); setSelectedProductId(p.id); }
- function cancelProductDraft() { setProductDraft(null); }
- function saveProductDraft() {
- if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; }
- if (!productDraft.category) { notify('请选择产品大类'); return; }
- setProducts(prev => {
- const exists = prev.some(p => p.id === productDraft.id);
- return exists ? prev.map(p => (p.id === productDraft.id ? productDraft : p)) : [...prev, productDraft];
- });
- setSelectedProductId(productDraft.id);
- setProductDraft(null);
- notify('已保存');
- }
- function deleteProduct(id) {
- if (!window.confirm('确定删除该产品档案?此操作不可恢复。')) return;
- setProducts(prev => prev.filter(p => p.id !== id));
- if (selectedProductId === id) setSelectedProductId(null);
- notify('已删除');
- }
-
- /* --------------------------- quotes / invoices --------------------------- */
-
- function startNewQuote(type) {
- const draft = blankQuote(type);
- draft.number = suggestDocNumber(draft.date, quotes, type);
- if (type === 'invoice') {
- const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId);
- if (seller && seller.bankInfo) draft.bankInfo = seller.bankInfo;
- }
- setQuoteDraft(draft);
- setQuoteSubView('editor');
- }
- function startEditQuote(q) {
- setQuoteDraft({ ...q, lineItems: q.lineItems.map(li => ({ ...li })) });
- setQuoteSubView('editor');
- }
- function convertQuoteToInvoice(quote) {
- const draft = blankQuote('invoice');
- draft.sellerProfileId = quote.sellerProfileId;
- draft.customerId = quote.customerId;
- draft.billToName = quote.billToName;
- draft.currency = quote.currency;
- draft.lineItems = quote.lineItems.map(li => ({ ...li, id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) }));
- draft.notes = quote.notes;
- draft.number = suggestDocNumber(draft.date, quotes, 'invoice');
- const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId);
- if (seller && seller.bankInfo) draft.bankInfo = seller.bankInfo;
- setQuoteDraft(draft);
- setQuoteSubView('editor');
- notify('已基于该Quote创建Invoice草稿,补充Ship To / Payment Terms后保存');
- }
- function backToQuoteList() {
- setQuoteDraft(null);
- setQuoteSubView('list');
- }
- function updateQuoteDraft(patch) {
- setQuoteDraft(d => ({ ...d, ...patch }));
- }
- function handleLinkCustomer(customerId) {
- if (!customerId) { updateQuoteDraft({ customerId: '', billToName: '' }); return; }
- const cust = customers.find(c => c.id === customerId);
- if (!cust) return;
- updateQuoteDraft({ customerId, billToName: billPartyName(cust) });
- }
- function addLineItem() {
- setQuoteDraft(d => ({ ...d, lineItems: [...d.lineItems, blankLineItem()] }));
- }
- function removeLineItem(liId) {
- setQuoteDraft(d => ({ ...d, lineItems: d.lineItems.filter(li => li.id !== liId) }));
- }
- function updateLineItem(liId, patch) {
- setQuoteDraft(d => ({ ...d, lineItems: d.lineItems.map(li => (li.id === liId ? { ...li, ...patch } : li)) }));
- }
- function handleSellerChange(sellerId) {
- const seller = sellerProfiles.find(sp => sp.id === sellerId);
- updateQuoteDraft({
- sellerProfileId: sellerId,
- bankInfo: (seller && seller.bankInfo) || '',
- });
- }
- function handleCurrencyChange(newCurrency) {
- if (!quoteDraft || newCurrency === quoteDraft.currency) return;
- const oldCurrency = quoteDraft.currency;
- const rateStr = window.prompt(`1 ${oldCurrency} = ? ${newCurrency}\n请输入汇率,所有产品单价会按这个汇率自动换算(不会改动数量)`, '');
- if (rateStr === null) return;
- const rate = parseFloat(rateStr);
- if (!rate || rate <= 0) { notify('汇率输入无效,币种未切换'); return; }
- const newLineItems = quoteDraft.lineItems.map(li => ({
- ...li,
- rate: li.rate !== '' && li.rate != null ? String(Math.round((parseFloat(li.rate) || 0) * rate * 100) / 100) : li.rate,
- }));
- updateQuoteDraft({ currency: newCurrency, lineItems: newLineItems });
- notify(`已按 1 ${oldCurrency} = ${rate} ${newCurrency} 换算单价`);
- }
- function saveQuoteDraft() {
- if (!quoteDraft.number.trim()) { notify('请填写单号'); return; }
- setQuotes(prev => {
- const exists = prev.some(q => q.id === quoteDraft.id);
- return exists ? prev.map(q => (q.id === quoteDraft.id ? quoteDraft : q)) : [...prev, quoteDraft];
- });
- notify('已保存');
- setQuoteDraft(null);
- setQuoteSubView('list');
- }
- function deleteQuote(id) {
- if (!window.confirm('确定删除这份文档?此操作不可恢复。')) return;
- setQuotes(prev => prev.filter(q => q.id !== id));
- notify('已删除');
- }
- function handlePrint() {
- window.print();
- }
- function toggleQuoteNoteLine(line) {
- const lines = quoteDraft.notes.split('\n').map(l => l.trim()).filter(Boolean);
- if (lines.includes(line)) {
- updateQuoteDraft({ notes: lines.filter(l => l !== line).join('\n') });
- } else {
- updateQuoteDraft({ notes: [...lines, line].join('\n') });
- }
- }
- async function handleExportPdf() {
- setPdfExporting(true);
- const el = document.querySelector('.doc-preview');
- let originalBorder, originalBoxShadow;
- try {
- const mod = await import('html2pdf.js');
- const html2pdf = mod.default || mod;
- if (!el) { notify('找不到预览内容'); return; }
- // html2canvas snapshots the on-screen styling as-is (it doesn't apply the
- // @media print override), so the card border/shadow that's nice on screen
- // would otherwise show up baked into the exported PDF — strip it first.
- originalBorder = el.style.border;
- originalBoxShadow = el.style.boxShadow;
- el.style.border = 'none';
- el.style.boxShadow = 'none';
- const filename = `${quoteDraft.type === 'invoice' ? 'Invoice' : 'Quote'}_${quoteDraft.number || 'draft'}.pdf`;
- await html2pdf().set({
- margin: 10,
- filename,
- image: { type: 'jpeg', quality: 0.98 },
- html2canvas: { scale: 2 },
- jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
- }).from(el).save();
- } catch (e) {
- notify('导出PDF失败,请重试');
- } finally {
- if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; }
- setPdfExporting(false);
- }
- }
-
- /* -------------------------------- packing lists -------------------------------- */
-
- function startNewPackingList() {
- const draft = blankPackingList();
- draft.number = suggestDocNumber(draft.date, packingLists, 'packing');
- setPackingDraft(draft);
- setPackingSubView('editor');
- }
- function startEditPackingList(pl) {
- setPackingDraft({ ...pl, lineItems: pl.lineItems.map(li => ({ ...li })) });
- setPackingSubView('editor');
- }
- function convertQuoteToPackingList(quote) {
- const draft = blankPackingList();
- draft.sellerProfileId = quote.sellerProfileId;
- draft.customerId = quote.customerId;
- draft.billToName = quote.billToName;
- draft.lineItems = quote.lineItems.map(li => ({
- id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
- description: li.description, qty: li.quantity, cartons: '', grossWeight: '', netWeight: '', volume: '',
- }));
- draft.number = suggestDocNumber(draft.date, packingLists, 'packing');
- setPackingDraft(draft);
- setPackingSubView('editor');
- setView('packing');
- notify('已基于该文档创建Packing List草稿,补充箱数/重量/体积后保存');
- }
- function backToPackingList() {
- setPackingDraft(null);
- setPackingSubView('list');
- }
- function updatePackingDraft(patch) {
- setPackingDraft(d => ({ ...d, ...patch }));
- }
- function handleLinkPackingCustomer(customerId) {
- if (!customerId) { updatePackingDraft({ customerId: '', billToName: '' }); return; }
- const cust = customers.find(c => c.id === customerId);
- if (!cust) return;
- updatePackingDraft({ customerId, billToName: billPartyName(cust) });
- }
- function addPackingLineItem() {
- setPackingDraft(d => ({ ...d, lineItems: [...d.lineItems, blankPackingLineItem()] }));
- }
- function removePackingLineItem(liId) {
- setPackingDraft(d => ({ ...d, lineItems: d.lineItems.filter(li => li.id !== liId) }));
- }
- function updatePackingLineItem(liId, patch) {
- setPackingDraft(d => ({ ...d, lineItems: d.lineItems.map(li => (li.id === liId ? { ...li, ...patch } : li)) }));
- }
- function savePackingDraft() {
- if (!packingDraft.number.trim()) { notify('请填写单号'); return; }
- setPackingLists(prev => {
- const exists = prev.some(p => p.id === packingDraft.id);
- return exists ? prev.map(p => (p.id === packingDraft.id ? packingDraft : p)) : [...prev, packingDraft];
- });
- notify('已保存');
- setPackingDraft(null);
- setPackingSubView('list');
- }
- function deletePackingList(id) {
- if (!window.confirm('确定删除这份Packing List?此操作不可恢复。')) return;
- setPackingLists(prev => prev.filter(p => p.id !== id));
- notify('已删除');
- }
- async function handleExportPackingPdf() {
- setPackingPdfExporting(true);
- const el = document.querySelector('.doc-preview');
- let originalBorder, originalBoxShadow;
- try {
- const mod = await import('html2pdf.js');
- const html2pdf = mod.default || mod;
- if (!el) { notify('找不到预览内容'); return; }
- originalBorder = el.style.border;
- originalBoxShadow = el.style.boxShadow;
- el.style.border = 'none';
- el.style.boxShadow = 'none';
- const filename = `PackingList_${packingDraft.number || 'draft'}.pdf`;
- await html2pdf().set({
- margin: 10,
- filename,
- image: { type: 'jpeg', quality: 0.98 },
- html2canvas: { scale: 2 },
- jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
- }).from(el).save();
- } catch (e) {
- notify('导出PDF失败,请重试');
- } finally {
- if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; }
- setPackingPdfExporting(false);
- }
- }
-
- /* ------------------------------ seller profiles ----------------------------- */
-
- function updateSellerProfile(id, patch) {
- setSellerProfiles(prev => prev.map(sp => (sp.id === id ? { ...sp, ...patch } : sp)));
- }
- function addSellerProfile() {
- const id = 'seller_' + Date.now().toString(36);
- setSellerProfiles(prev => [...prev, { id, name: '新公司名称', address: '', logoFileName: '', bankInfo: '' }]);
- }
- function deleteSellerProfile(id) {
- if (sellerProfiles.length <= 1) { notify('至少要保留一个卖方信息'); return; }
- if (!window.confirm('确定删除这个卖方信息?')) return;
- setSellerProfiles(prev => prev.filter(sp => sp.id !== id));
- }
- async function handleSellerLogoPick(id, file) {
- if (!file) return;
- setSellerLogoUploading(true);
- try {
- const res = await uploadFile(id, file);
- updateSellerProfile(id, { logoFileName: res.fileName });
- notify('Logo已上传');
- } catch (e) {
- notify('上传失败');
- } finally {
- setSellerLogoUploading(false);
- }
- }
-
- async function handleAddCertificationFile(file) {
- if (!file) return;
- setCertUploading(true);
- try {
- const id = 'cert_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
- const res = await uploadFile(id, file);
- const entry = { id, brand: '', fileName: res.fileName };
- setCertifications(prev => [entry, ...prev]);
- setEditingCertId(id);
- setCertBrandDraft('');
- } catch (e) {
- notify('上传失败,请重试');
- } finally {
- setCertUploading(false);
- }
- }
- function handleCertFilesSelected(files) {
- Array.from(files || []).forEach(f => handleAddCertificationFile(f));
- }
- function startEditCertBrand(cert) {
- setEditingCertId(cert.id);
- setCertBrandDraft(cert.brand || '');
- }
- function saveCertBrand() {
- setCertifications(prev => prev.map(c => (c.id === editingCertId ? { ...c, brand: certBrandDraft.trim() } : c)));
- setEditingCertId(null);
- setCertBrandDraft('');
- }
- async function deleteCertification(cert) {
- if (!window.confirm('确定删除这张授权证书?此操作不可恢复。')) return;
- try { await deleteFile(cert.id, cert.fileName); } catch (e) {}
- setCertifications(prev => prev.filter(c => c.id !== cert.id));
- if (editingCertId === cert.id) setEditingCertId(null);
- }
-
- if (!loaded) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- {viewTitle(view)}
-
- {mobileMenuOpen &&
setMobileMenuOpen(false)} />}
-
-
-
-
客户档案系统
-
Customer Console
-
-
- {NAV_GROUPS.map(group => (
-
-
{group.label}
- {group.items.map(item => {
- const Icon = item.icon;
- return (
-
{ setView(item.id); setMobileMenuOpen(false); }}
- >
- {item.label}
-
- );
- })}
-
- ))}
-
- {STANDALONE_NAV.map(item => {
- const Icon = item.icon;
- return (
-
{ setView(item.id); setMobileMenuOpen(false); }}
- >
- {item.label}
-
- );
- })}
-
-
-
-
-
-
-
- {view === 'monitor' && (
- <>
-
-
-
-
-
-
-
-
- {customers.length === 0 ? (
-
- ) : (
-
- {customers.map((c, idx) => {
- const s = statusOf(c, settings);
- const lt = localTimeInfo(c.country, now);
- const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
- const cardTitle = customerTitle(c);
- const showCompanyLine = !!(modelQty && c.company);
- const { showEmail, showPhone } = contactDisplayFlags(c);
- return (
-
setDragCustomerIdx(idx)}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleCustomerDrop(idx)}
- onDragEnd={() => setDragCustomerIdx(null)}
- >
-
-
-
-
{cardTitle}
- {c.isVip &&
}
- {c.hasOrdered &&
}
-
-
{regionLabel(c.region)} · {c.country || '—'}
- {showCompanyLine &&
{c.company}
}
- {c.contact &&
{c.contact}
}
- {showEmail &&
{c.email}
}
- {showPhone &&
}
- {lt &&
当地时间 {lt.time}
}
-
上次联系 {fmtDate(c.lastContact)}
-
-
-
-
-
- );
- })}
-
- )}
- >
- )}
-
- {view === 'manage' && (
-
-
-
setSearch(e.target.value)} />
-
-
-
-
-
-
- {filteredCustomers.length === 0 ? (
-
暂无客户,点击上方按钮添加第一个
- ) : filteredCustomers.map(c => {
- const s = statusOf(c, settings);
- const showCompanyInSub = customerTitleIsModel(c) && c.company;
- return (
-
{ setDraft(null); setSelectedId(c.id); }}>
-
-
-
{c.isVip && }{c.hasOrdered && }{customerTitle(c)}
-
{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}
-
-
-
- );
- })}
-
-
-
-
-
- {draft ? (
-
- ) : selected ? (
-
startEditCustomer(selected)}
- onDelete={() => deleteCustomer(selected.id)}
- onMarkContacted={() => markContacted(selected.id)}
- onEmail={() => openFollowUpEmail(selected)}
- onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
- onToggleVip={() => toggleVip(selected.id)}
- onToggleOrdered={() => toggleOrdered(selected.id)}
- attachmentNote={attachmentNote}
- setAttachmentNote={setAttachmentNote}
- attachmentCategory={attachmentCategory}
- setAttachmentCategory={setAttachmentCategory}
- attachmentFileName={attachmentFile ? attachmentFile.name : ''}
- onPickAttachment={(f) => setAttachmentFile(f)}
- uploading={uploading}
- fileInputKey={fileInputKey}
- onUploadAttachment={() => handleUploadAttachment(selected.id)}
- onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
- fileDragActive={fileDragActive}
- onFileDragOver={handleFileDragOver}
- onFileDragLeave={handleFileDragLeave}
- onFileDrop={handleFileDrop}
- aiConfigHasKey={aiConfigHasKey}
- aiGenerating={aiGenerating}
- onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)}
- />
- ) : (
-
- )}
-
-
- )}
-
- {view === 'groups' && (
- <>
-
按产品大类分组浏览;每个类目右上角管理品牌,类目内第二行管理类型;新建产品时先选类目,再从对应的品牌 / 类型里选。
- {productCategories.map(cat => {
- const items = products.filter(p => p.category === cat.id);
- return (
-
-
-
{cat.name}({items.length})
-
-
-
-
-
-
-
-
-
- {items.length === 0 ? (
-
暂无产品
- ) : items.map((p, idx) => (
-
setDragGroupState({ category: cat.id, index: idx })}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleGroupDrop(cat.id, idx)}
- onDragEnd={() => setDragGroupState(null)}
- >
-
-
-
{p.name || '(未命名)'}
-
{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}
-
-
-
- ))}
-
-
- );
- })}
-
- {addingPill && addingPill.kind === 'category' ? (
-
- setAddingPillValue(e.target.value)}
- onBlur={confirmAddPill}
- onKeyDown={e => {
- if (e.key === 'Enter') e.currentTarget.blur();
- if (e.key === 'Escape') cancelAddPill();
- }}
- placeholder="新类目名称,如:精密空调"
- />
-
- ) : (
-
openAddPill(null, 'category')}>
-
新增类目
-
- )}
- >
- )}
-
- {view === 'products' && (
-
-
-
setProductSearch(e.target.value)} />
-
-
-
-
-
-
- {filteredProducts.length === 0 ? (
-
- ) : filteredProducts.map(p => (
-
{ setProductDraft(null); setSelectedProductId(p.id); }}>
-
-
-
{p.name || '(未命名)'}
-
{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}
-
-
-
- ))}
-
-
-
-
-
- {productDraft ? (
-
setTaxManageMode(mode)} />
- ) : selectedProduct ? (
- startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} categories={productCategories} />
- ) : (
-
- )}
-
-
- )}
-
- {view === 'catalog' && (
- <>
-
拖拽卡片调整展示顺序,方便整理成给客户看的产品介绍顺序。
- {products.length === 0 ? (
-
- ) : (
-
- {products.map((p, idx) => {
- const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
- return (
-
setDragCatalogIdx(idx)}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleCatalogDrop(idx)}
- onDragEnd={() => setDragCatalogIdx(null)}
- >
-
-
-
{p.name || '(未命名)'}
-
-
{categoryBreadcrumb(p, productCategories)}
- {p.brand &&
品牌:{p.brand}
}
- {p.powerOrCapacity &&
功率 / 容量:{p.powerOrCapacity}
}
- {p.voltage &&
电压:{p.voltage}
}
- {certs.length > 0 &&
{certs.map(cc => {cc})}
}
-
- );
- })}
-
- )}
- >
- )}
-
- {view === 'certs' && (
- <>
-
- {certifications.length === 0 ? (
-
- ) : (
-
- {certifications.map(cert => (
-
-
-
-
-
-
-
-
- {editingCertId === cert.id ? (
-
setCertBrandDraft(e.target.value)}
- onBlur={saveCertBrand}
- onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
- placeholder="品牌名称"
- />
- ) : (
-
startEditCertBrand(cert)}
- >
- {cert.brand || '点击填写品牌名'}
-
- )}
-
-
- ))}
-
- )}
- >
- )}
-
- {view === 'quotes' && quoteSubView === 'list' && (
- <>
-
-
-
-
-
-
-
-
- {quotes.filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).length === 0 ? (
-
暂无文档,点击上方按钮新建一份
- ) : [...quotes].filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).reverse().map(q => {
- const { total } = quoteTotals(q);
- return (
-
startEditQuote(q)}>
-
{q.type === 'quote' ? 'Quote' : 'Invoice'}
-
-
{q.number || '(无单号)'} · {q.billToName || '(未填写客户)'}
-
{q.date} · {formatMoney(total, q.currency)}
-
-
-
- {q.type === 'quote' && (
-
- )}
-
- );
- })}
-
-
- >
- )}
-
- {view === 'quotes' && quoteSubView === 'editor' && quoteDraft && (
-
setSellerManagerOpen(true)}
- onToggleNoteLine={toggleQuoteNoteLine}
- priceHistory={priceHistory}
- />
- )}
-
- {view === 'packing' && packingSubView === 'list' && (
- <>
-
-
-
-
-
-
- {packingLists.length === 0 ? (
-
暂无Packing List,点击上方按钮新建一份,也可以到"报价单制作"里把Quote/Invoice转成Packing List
- ) : [...packingLists].reverse().map(pl => {
- const t = packingTotals(pl);
- return (
-
startEditPackingList(pl)}>
-
P/L
-
-
{pl.number || '(无单号)'} · {pl.billToName || '(未填写客户)'}
-
{pl.date} · {t.cartons || 0} 箱 · {t.grossWeight || 0} kg 毛重
-
-
-
- );
- })}
-
-
- >
- )}
-
- {view === 'packing' && packingSubView === 'editor' && packingDraft && (
- updatePackingDraft({ sellerProfileId: sellerId })}
- onAddLineItem={addPackingLineItem}
- onRemoveLineItem={removePackingLineItem}
- onUpdateLineItem={updatePackingLineItem}
- onManageSellers={() => setSellerManagerOpen(true)}
- />
- )}
-
- {view === 'settings' && (
- <>
-
-
-
-
-
-
-
-
-
- 说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
-
-
-
-
-
-
-
-
当前登录账号:{username}
-
-
-
- 这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
-
-
-
-
-
-
-
-
- 状态:{aiConfigHasKey ? 已配置 : 未配置}
-
-
- DeepSeek API Key
- setAiKeyInput(e.target.value)}
- placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'}
- />
-
-
-
-
- {aiConfigHasKey && }
-
-
- 用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
-
-
-
- >
- )}
-
-
-
-
-
- {aiDraftModal && (
-
setAiDraftModal(null)}>
-
e.stopPropagation()}>
-
-
{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}
-
-
-
- {aiDraftModal.channel === 'email' && (
-
主题 Subject setAiDraftModal(m => ({ ...m, subject: e.target.value }))} />
- )}
-
- {aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}
-
-
-
- {aiDraftModal.channel === 'email' && }
-
-
-
-
- )}
-
- {emailModal && (
-
setEmailModal(null)}>
-
e.stopPropagation()}>
-
邮件文案
-
-
-
-
-
-
-
- )}
-
- {exportOpen && (
-
setExportOpen(false)}>
-
e.stopPropagation()}>
-
数据备份(JSON)
-
-
-
-
-
-
-
-
- )}
-
- {sellerManagerOpen && (
-
setSellerManagerOpen(false)}>
-
e.stopPropagation()}>
-
管理卖方信息
-
- {sellerProfiles.map(sp => (
-
-
-
-
公司名称 updateSellerProfile(sp.id, { name: e.target.value })} />
-
-
Logo
-
- {sp.logoFileName &&

}
-
-
-
-
-
地址
-
银行信息(仅Invoice使用,可留空)
-
-
-
- ))}
-
-
-
-
-
-
-
- )}
-
- {taxManageMode && (() => {
- const targetCat = productDraft ? productCategories.find(c => c.id === productDraft.category) : null;
- const closeModal = () => { setTaxManageMode(null); cancelAddPill(); };
- const titleMap = { category: '产品大类管理', brand: '品牌管理', type: '品类管理' };
- return (
-
-
e.stopPropagation()}>
-
-
{titleMap[taxManageMode]}{targetCat && taxManageMode !== 'category' ? ` · ${targetCat.name}` : ''}
-
-
-
- {taxManageMode === 'category' ? (
- <>
- {productCategories.length === 0 &&
暂无类目
}
- {productCategories.map(c => (
-
-
-
{c.name}
-
{c.brands.length} 个品牌 · {c.types.length} 个品类
-
-
-
- ))}
-
- {addingPill && addingPill.kind === 'category' ? (
-
setAddingPillValue(e.target.value)}
- onBlur={confirmAddPill}
- onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') cancelAddPill(); }}
- placeholder="新类目名称,如:精密空调"
- />
- ) : (
-
- )}
-
- >
- ) : targetCat ? (
-
- ) : (
-
请先在表单里选择产品大类,再来管理{taxManageMode === 'brand' ? '品牌' : '品类'}
- )}
-
-
-
-
-
-
- );
- })()}
-
- {toast &&
{toast}
}
-
- );
-}
-
-/* ----------------------------- customer form / detail ----------------------------- */
-
-function CustomerForm({ draft, setDraft, onSave, onCancel }) {
- const set = (patch) => setDraft(d => ({ ...d, ...patch }));
-
- function handleCountryChange(value) {
- const match = COUNTRY_BY_NAME[value];
- if (match) {
- setDraft(d => ({
- ...d,
- country: value,
- region: match.region,
- // 国家代码只在电话号码当前为空时自动填入,避免覆盖已经填好的号码
- phone: d.phone && d.phone.trim() ? d.phone : match.dial + ' ',
- }));
- } else {
- set({ country: value });
- }
- }
-
- return (
-
-
{draft.company ? '编辑客户' : '新建客户'}
-
-
-
-
关注产品线 set({ productLines: v })} />
- {draft.productLines.length > 0 && (
-
- )}
-
-
-
备注
-
-
-
- 箱唛 / 备注
-
-
-
客户背景信息调查
-
-
-
- );
-}
-
-function ContactInfo({ icon, label, value, active, onToggle }) {
- return (
-
-
- {icon}{label}{active ? '(常用)' : ''}
-
-
{value}
-
- );
-}
-
-function CollapsibleSection({ title, count, defaultOpen, children }) {
- const [open, setOpen] = useState(!!defaultOpen);
- return (
-
-
setOpen(o => !o)}
- >
- {title}{typeof count === 'number' ? `(${count})` : ''}
-
-
- {open &&
{children}
}
-
- );
-}
-
-function CustomerDetail({
- customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
- onTogglePreferred, onToggleVip, onToggleOrdered,
- attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
- attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
- fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
- aiConfigHasKey, aiGenerating, onGenerateAi,
-}) {
- const s = statusOf(c, settings);
- const lt = localTimeInfo(c.country, now);
- const preferred = c.preferredContacts || [];
- const [fileSearch, setFileSearch] = useState('');
- const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
- const filteredAttachments = (c.attachments || []).filter(a => {
- if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
- if (fileSearch.trim()) {
- const q = fileSearch.trim().toLowerCase();
- return (a.originalName || '').toLowerCase().includes(q) || (a.note || '').toLowerCase().includes(q);
- }
- return true;
- });
- return (
-
-
-
-
- {customerTitle(c)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
} label="公司名称" value={c.company || '—'} />
-
} label="联系人" value={c.contact || '—'} />
-
} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
-
} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
-
} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
-
} label="当地时间" value={lt ? `${lt.time}(${lt.en})` : '—'} />
-
} label="网站" value={c.website ?
{c.website} : '—'} />
-
- {c.source &&
客户来源{c.source}
}
- {c.productLines && c.productLines.length > 0 && (
-
-
关注产品线
-
{c.productLines.map(p => {p})}
- {(c.intendedModel || c.intendedQty) && (
-
- 意向型号:{c.intendedModel || '—'}{c.intendedQty ? ` × ${c.intendedQty}` : ''}
-
- )}
-
- )}
- {c.tags &&
}
-
-
- {!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
- 暂无货代信息
- ) : (
- <>
-
- {c.warehouseAddress && }
- {c.freightContact && }
-
- {c.shippingMarkNote && 箱唛 / 备注:{c.shippingMarkNote}
}
- >
- )}
-
-
- {c.notes &&
}
-
-
- 上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}
-
-
-
-
-
-
-
- {!aiConfigHasKey && 未配置DeepSeek,去"设置"里填API Key}
-
-
-
-
客户文件管理
-
-
-
-
-
setAttachmentNote(e.target.value)}
- />
-
-
-
-
-
0}>
- {(!c.attachments || c.attachments.length === 0) ? (
- 暂无文件
- ) : (
- <>
-
- setFileSearch(e.target.value)}
- />
-
-
- {filteredAttachments.length === 0 ? (
- 没有匹配的文件
- ) : filteredAttachments.map((a, i) => (
-
-
-
-
{a.date}
- {' '}
{a.category || 'Other'}
-
-
-
-
- ))}
- >
- )}
-
-
-
- );
-}
-
-/* ----------------------------- product form / detail ----------------------------- */
-
-function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage }) {
- const set = (patch) => setDraft(d => ({ ...d, ...patch }));
- const cat = (categories || []).find(c => c.id === draft.category);
- const brandOptions = cat ? cat.brands : [];
- const typeOptions = cat ? cat.types : [];
- return (
-
-
{draft.name ? '编辑产品' : '新建产品'}
-
-
-
型号 / 产品名称 * set({ name: e.target.value })} />
-
-
- 产品大类 *
- onManage('category')}>大类管理
-
-
-
-
-
-
-
- 品牌
- onManage('brand')}>品牌管理
-
-
-
-
-
- 细分品类
- onManage('type')}>品类管理
-
-
-
-
-
-
认证(逗号分隔) set({ certifications: e.target.value })} placeholder="如:CE, FCC, UL, SASO" />
-
适用市场地区 set({ targetRegions: v })} />
-
备注 / 卖点说明
-
-
-
- );
-}
-
-function ProductDetail({ product: p, onEdit, onDelete, categories }) {
- const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
- return (
-
-
-
-
- } label="分类" value={categoryBreadcrumb(p, categories)} />
- } label="品牌" value={p.brand || '—'} />
- } label="功率 / 容量" value={p.powerOrCapacity || '—'} />
- } label="电压" value={p.voltage || '—'} />
-
- {certs.length > 0 && (
-
- )}
- {p.targetRegions && p.targetRegions.length > 0 && (
-
适用市场地区{p.targetRegions.map(r => {regionLabel(r)})}
- )}
- {p.notes &&
}
-
-
- );
-}
-
-/* ----------------------------- quote / invoice editor ----------------------------- */
-
-function QuoteEditor({
- draft, onUpdate, onBack, onSave, onPrint, onExportPdf, pdfExporting,
- customers, sellerProfiles, onLinkCustomer, onSellerChange, onCurrencyChange,
- onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers, onToggleNoteLine,
- priceHistory,
-}) {
- const isInvoice = draft.type === 'invoice';
- const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId) || sellerProfiles[0];
- const { total } = quoteTotals(draft);
- const dueDate = addOneMonth(draft.date);
- const logoUrl = seller && seller.logoFileName ? `/uploads/${seller.id}/${seller.logoFileName}` : null;
- const sortedCustomers = [...customers].sort((a, b) => quoteLinkLabel(a).localeCompare(quoteLinkLabel(b)));
- const activeNoteLines = (draft.notes || '').split('\n').map(l => l.trim()).filter(Boolean);
- const [suggestLineId, setSuggestLineId] = useState(null);
-
- function suggestionsFor(li) {
- const firstLine = (li.description || '').split('\n')[0].trim();
- if (firstLine.length < 2) return [];
- const q = firstLine.toUpperCase();
- return (priceHistory || []).filter(h => h.description.split('\n')[0].toUpperCase().includes(q)).slice(0, 5);
- }
- function applySuggestion(liId, hist) {
- onUpdateLineItem(liId, { description: hist.description, rate: hist.rate });
- setSuggestLineId(null);
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
{isInvoice ? 'Invoice 信息' : 'Quote 信息'}
-
-
-
- 卖方
-
-
-
- 关联客户档案(可选,仅用于自动填名称)
-
-
-
单号 onUpdate({ number: e.target.value })} />
-
日期 onUpdate({ date: e.target.value })} />
-
- 币种
-
-
- {isInvoice && (
- <>
-
- Payment Terms
-
-
-
- Due Date(自动计算:日期 + 1 个月)
-
-
- >
- )}
-
-
-
-
-
- {isInvoice ? 'Bill To 名称' : 'Quote To 名称'}(可换行,第一行作为主标题)
-
- {isInvoice &&
Ship To 名称(留空则单据上不显示这一项) onUpdate({ shipToName: e.target.value })} />
}
-
-
-
产品明细
- {draft.lineItems.map(li => {
- const suggestions = suggestLineId === li.id ? suggestionsFor(li) : [];
- return (
-
-
-
onUpdateLineItem(li.id, { quantity: e.target.value })} />
-
onUpdateLineItem(li.id, { rate: e.target.value })} />
-
- {formatMoney((parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0), draft.currency)}
-
-
-
- );
- })}
-
-
- {isInvoice && (
-
-
银行信息 Bank Information
-
签署人姓名 onUpdate({ signerName: e.target.value })} />
-
- )}
-
-
{isInvoice ? 'Terms 条款' : 'Notes 备注'}
-
-
-
-
-
-
-
- {logoUrl ? (
-

- ) : (
-
{(seller && seller.name) || ''}
- )}
-
-
-
{isInvoice ? 'INVOICE' : 'QUOTE'}
-
# {draft.number}
-
-
-
-
-
-
{seller && seller.name}
-
{seller && seller.address}
-
-
-
Date:{draft.date}
- {isInvoice && (
- <>
- {draft.paymentTerms &&
Payment Terms:{draft.paymentTerms}
}
-
Due Date:{dueDate}
-
Balance Due:{formatMoney(total, draft.currency)}
- >
- )}
-
-
-
- {!isInvoice ? (
-
-
Quote To:
- {(() => {
- const billLines = (draft.billToName || '').split('\n');
- return (
- <>
-
{billLines[0] || '—'}
- {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
- >
- );
- })()}
-
- ) : (
-
-
-
Bill To:
- {(() => {
- const billLines = (draft.billToName || '').split('\n');
- return (
- <>
-
{billLines[0] || '—'}
- {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
- >
- );
- })()}
-
- {draft.shipToName && draft.shipToName.trim() && (
-
-
Ship To:
-
{draft.shipToName}
-
- )}
-
- )}
-
-
-
-
- | Item |
- Quantity |
- Rate |
- Amount |
-
-
-
- {draft.lineItems.map(li => {
- const lines = (li.description || '').split('\n');
- const amount = (parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0);
- return (
-
- |
- {lines[0] || '—'}
- {lines.slice(1).map((l, i) => l.trim() && {l} )}
- |
- {li.quantity || '—'} |
- {li.rate ? formatMoney(parseFloat(li.rate), draft.currency) : '—'} |
- {formatMoney(amount, draft.currency)} |
-
- );
- })}
-
-
-
-
-
Total:{formatMoney(total, draft.currency)}
-
-
- {isInvoice && (draft.bankInfo || (seller && seller.bankInfo)) && (
-
-
Bank Information:
-
{draft.bankInfo || (seller && seller.bankInfo)}
-
- )}
-
- {draft.notes && (
-
-
{isInvoice ? 'Terms:' : 'Notes:'}
-
{draft.notes}
-
- )}
-
- {isInvoice && (
-
-
For and on behalf of
-
{seller && seller.name}
- {draft.signerName &&
{draft.signerName}
}
-
-
Authorized Signature(s)
-
- )}
-
-
- );
-}
-
-function PackingListEditor({
- draft, onUpdate, onBack, onSave, onPrint, onExportPdf, pdfExporting,
- customers, sellerProfiles, onLinkCustomer, onSellerChange,
- onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers,
-}) {
- const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId) || sellerProfiles[0];
- const logoUrl = seller && seller.logoFileName ? `/uploads/${seller.id}/${seller.logoFileName}` : null;
- const sortedCustomers = [...customers].sort((a, b) => quoteLinkLabel(a).localeCompare(quoteLinkLabel(b)));
- const totals = packingTotals(draft);
- const billLines = (draft.billToName || '').split('\n');
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 卖方
-
-
-
-
-
- 关联客户档案(可选,仅用于自动填名称)
-
-
-
单号 onUpdate({ number: e.target.value })} />
-
日期 onUpdate({ date: e.target.value })} />
-
-
-
-
-
- 客户名称 / 地址(可换行,第一行作为主标题)
-
-
-
-
-
装箱明细(按产品行汇总)
- {draft.lineItems.map(li => (
-
- ))}
-
-
-
备注
-
-
-
-
-
-
- {draft.showSeller ? (
- logoUrl ? (
-

- ) : (
-
{(seller && seller.name) || ''}
- )
- ) :
}
-
-
-
PACKING LIST
-
# {draft.number}
-
-
-
-
-
- {draft.showSeller && (
- <>
-
{seller && seller.name}
-
{seller && seller.address}
- >
- )}
-
-
-
-
- {draft.showBillTo && (
-
-
Consignee / Customer:
-
{billLines[0] || '—'}
- {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
-
- )}
-
-
-
-
- | Description of Goods |
- Qty |
- Cartons |
- G.W. (kg) |
- N.W. (kg) |
- Volume (CBM) |
-
-
-
- {draft.lineItems.map(li => {
- const lines = (li.description || '').split('\n');
- return (
-
- |
- {lines[0] || '—'}
- {lines.slice(1).map((l, i) => l.trim() && {l} )}
- |
- {li.qty || '—'} |
- {li.cartons || '—'} |
- {li.grossWeight || '—'} |
- {li.netWeight || '—'} |
- {li.volume || '—'} |
-
- );
- })}
-
-
-
-
-
Total:{totals.qty} pcs · {totals.cartons} ctns · {totals.grossWeight} kg G.W. · {totals.netWeight} kg N.W. · {totals.volume} CBM
-
-
- {draft.notes && (
-
-
Notes:
-
{draft.notes}
-
- )}
-
-
- );
-}
-
-function Info({ icon, label, value }) {
- return (
-
-
{icon}{label}
-
{value}
-
- );
-}