3328 lines
178 KiB
JavaScript
3328 lines
178 KiB
JavaScript
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: '', extraNames: [], brand: '', category: '', type: '',
|
||
powerOrCapacity: '', voltage: '', costPrice: '', 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);
|
||
});
|
||
|
||
// UPS不间断电源类目固定补充"标机 / 长机"两个品类选项(区分标准后备时长与加长后备时长的机型)
|
||
const upsCat = cats.find(c => c.id === 'ups' || c.name === 'UPS不间断电源');
|
||
if (upsCat) {
|
||
['标机', '长机'].forEach(t => { if (!upsCat.types.includes(t)) upsCat.types.push(t); });
|
||
}
|
||
|
||
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 = () => (
|
||
<style>{`
|
||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap');
|
||
|
||
.crm-root {
|
||
--bg: #EDF1F4;
|
||
--panel: #FFFFFF;
|
||
--ink: #18233A;
|
||
--ink-soft: #5B6B82;
|
||
--line: #D9E1E8;
|
||
--copper: #B5651D;
|
||
--copper-soft: #F1E0CC;
|
||
--blue: #3D6E99;
|
||
--led-green: #2F8F5B;
|
||
--led-amber: #C9842E;
|
||
--led-red: #C2403A;
|
||
--led-green-bg: #E4F3EA;
|
||
--led-amber-bg: #FAEFDD;
|
||
--led-red-bg: #FAE5E3;
|
||
--led-blue-bg: #E4ECF2;
|
||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||
color: var(--ink);
|
||
background:
|
||
linear-gradient(var(--bg), var(--bg)),
|
||
repeating-linear-gradient(0deg, rgba(61,110,153,0.05) 0 1px, transparent 1px 28px),
|
||
repeating-linear-gradient(90deg, rgba(61,110,153,0.05) 0 1px, transparent 1px 28px);
|
||
min-height: 100vh;
|
||
width: 100%;
|
||
}
|
||
.crm-display { font-family: 'Space Grotesk', 'Inter', sans-serif; }
|
||
.crm-mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
|
||
|
||
.crm-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 18px 24px; border-bottom: 1px solid var(--line);
|
||
background: var(--panel);
|
||
}
|
||
.crm-title { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
|
||
.crm-subtitle { font-size: 12.5px; color: var(--ink-soft); margin-top: 2px; }
|
||
|
||
.crm-tabs { display: flex; gap: 4px; padding: 10px 24px 0; background: var(--panel); border-bottom: 1px solid var(--line); }
|
||
.crm-tab {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 9px 14px; font-size: 13.5px; font-weight: 600; color: var(--ink-soft);
|
||
border: none; background: transparent; cursor: pointer;
|
||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||
}
|
||
.crm-tab.active { color: var(--copper); border-bottom-color: var(--copper); }
|
||
.crm-tab:hover:not(.active) { color: var(--ink); }
|
||
|
||
.crm-body { padding: 22px 24px 60px; max-width: 1180px; margin: 0 auto; }
|
||
|
||
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; }
|
||
.stat-card {
|
||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
|
||
}
|
||
.stat-num { font-family: 'IBM Plex Mono', monospace; font-size: 26px; font-weight: 500; line-height: 1; }
|
||
.stat-label { font-size: 11.5px; color: var(--ink-soft); margin-top: 3px; }
|
||
|
||
.led-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
|
||
.led-dot.green { background: var(--led-green); box-shadow: 0 0 0 3px var(--led-green-bg); }
|
||
.led-dot.amber { background: var(--led-amber); box-shadow: 0 0 0 3px var(--led-amber-bg); }
|
||
.led-dot.red { background: var(--led-red); box-shadow: 0 0 0 3px var(--led-red-bg); }
|
||
.led-dot.blue { background: var(--blue); box-shadow: 0 0 0 3px var(--led-blue-bg); }
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.led-dot.red.pulse { animation: led-pulse 1.8s ease-in-out infinite; }
|
||
}
|
||
@keyframes led-pulse { 0%,100% { box-shadow: 0 0 0 3px var(--led-red-bg); } 50% { box-shadow: 0 0 0 5px var(--led-red-bg); } }
|
||
|
||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
|
||
.panel-header { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
||
.panel-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||
.panel-body { padding: 6px 0; }
|
||
.section-gap { margin-bottom: 18px; }
|
||
|
||
.row { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
|
||
.row:last-child { border-bottom: none; }
|
||
.row:hover { background: #FAFBFC; }
|
||
.row-main { flex: 1; min-width: 0; }
|
||
.row-title { font-size: 13.5px; font-weight: 600; }
|
||
.row-sub { font-size: 12px; color: var(--ink-soft); margin-top: 2px; font-family: 'IBM Plex Mono', monospace; }
|
||
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 12.5px; font-weight: 600; padding: 7px 12px; border-radius: 7px;
|
||
border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.btn:hover { background: #F4F6F8; }
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.btn-primary { background: var(--copper); border-color: var(--copper); color: white; }
|
||
.btn-primary:hover { background: #9c5418; }
|
||
.btn-ghost { border-color: transparent; background: transparent; }
|
||
.btn-danger { color: var(--led-red); }
|
||
.btn-sm { padding: 5px 9px; font-size: 11.5px; }
|
||
|
||
.empty-state { text-align: center; padding: 50px 20px; color: var(--ink-soft); }
|
||
.empty-state svg { margin: 0 auto 10px; opacity: 0.5; }
|
||
|
||
.field-label { font-size: 11.5px; font-weight: 600; color: var(--ink-soft); margin-bottom: 5px; display: block; text-transform: uppercase; letter-spacing: 0.03em; }
|
||
.field { margin-bottom: 14px; }
|
||
.input, .select, .textarea {
|
||
width: 100%; font-size: 13.5px; padding: 8px 10px; border: 1px solid var(--line);
|
||
border-radius: 7px; background: white; color: var(--ink); font-family: inherit;
|
||
}
|
||
.input:focus, .select:focus, .textarea:focus { outline: 2px solid var(--blue); outline-offset: 0; border-color: var(--blue); }
|
||
.textarea { resize: vertical; min-height: 70px; font-family: 'IBM Plex Mono', monospace; font-size: 12.5px; line-height: 1.6; }
|
||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||
.checkbox-pill {
|
||
display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 5px 10px;
|
||
border: 1px solid var(--line); border-radius: 16px; cursor: pointer; user-select: none;
|
||
}
|
||
.checkbox-pill.checked { background: var(--copper-soft); border-color: var(--copper); }
|
||
|
||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.crm-layout { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
|
||
@media (max-width: 760px) {
|
||
.crm-layout { grid-template-columns: 1fr; }
|
||
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
||
.grid-2 { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.search-box { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: white; margin-bottom: 10px; }
|
||
.search-box input { border: none; outline: none; font-size: 13px; flex: 1; }
|
||
|
||
.modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(24,35,58,0.45);
|
||
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px;
|
||
}
|
||
.modal-box { background: white; border-radius: 12px; max-width: 520px; width: 100%; max-height: 86vh; overflow-y: auto; }
|
||
.modal-head { padding: 16px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
||
.modal-body { padding: 18px 20px; }
|
||
.modal-foot { padding: 14px 20px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
|
||
background: var(--ink); color: white; padding: 9px 16px; border-radius: 8px;
|
||
font-size: 12.5px; z-index: 60; box-shadow: 0 6px 18px rgba(0,0,0,0.2);
|
||
}
|
||
.badge { font-size: 10.5px; padding: 2px 7px; border-radius: 10px; font-weight: 600; }
|
||
.badge-region { background: #EAF1F6; color: var(--blue); }
|
||
.disclaimer { font-size: 12px; color: var(--ink-soft); background: var(--copper-soft); border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; }
|
||
|
||
/* ---------- shell / sidebar ---------- */
|
||
.crm-shell { display: flex; min-height: 100vh; }
|
||
.crm-sidebar {
|
||
width: 226px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--line);
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.crm-sidebar-brand { padding: 18px 16px; border-bottom: 1px solid var(--line); }
|
||
.crm-sidebar-scroll { flex: 1; overflow-y: auto; padding-bottom: 10px; }
|
||
.crm-sidebar-group-label {
|
||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||
color: var(--ink-soft); padding: 16px 16px 6px;
|
||
}
|
||
.crm-sidebar-item {
|
||
display: flex; align-items: center; gap: 9px; padding: 9px 16px; font-size: 13.5px; font-weight: 600;
|
||
color: var(--ink-soft); cursor: pointer; border-left: 3px solid transparent; user-select: none;
|
||
}
|
||
.crm-sidebar-item.active { color: var(--copper); background: var(--copper-soft); border-left-color: var(--copper); }
|
||
.crm-sidebar-item:hover:not(.active) { background: #F4F6F8; }
|
||
.crm-sidebar-footer { border-top: 1px solid var(--line); padding: 12px 16px; }
|
||
.crm-sidebar-user { font-size: 12px; color: var(--ink-soft); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||
.crm-main { flex: 1; min-width: 0; }
|
||
.crm-main-body { padding: 22px 24px 60px; max-width: 1180px; margin: 0 auto; }
|
||
.crm-topbar-mobile { display: none; align-items: center; gap: 10px; padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||
.crm-sidebar-overlay { display: none; }
|
||
|
||
@media (max-width: 880px) {
|
||
.crm-topbar-mobile { display: flex; }
|
||
.crm-sidebar {
|
||
position: fixed; top: 0; left: -260px; height: 100vh; width: 240px; z-index: 40;
|
||
transition: left 0.2s ease; box-shadow: 4px 0 24px rgba(0,0,0,0.18);
|
||
}
|
||
.crm-sidebar.open { left: 0; }
|
||
.crm-sidebar-overlay.show { display: block; position: fixed; inset: 0; background: rgba(18,25,40,0.4); z-index: 35; }
|
||
}
|
||
|
||
/* ---------- drag and drop ---------- */
|
||
.drag-handle { cursor: grab; color: var(--ink-soft); flex-shrink: 0; touch-action: none; }
|
||
.drag-handle:active { cursor: grabbing; }
|
||
.draggable-row.drag-over { outline: 2px dashed var(--blue); outline-offset: 2px; background: #F0F6FA; }
|
||
|
||
/* ---------- card grid (monitor / catalog) ---------- */
|
||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
|
||
.monitor-card, .catalog-card {
|
||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px;
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
}
|
||
.monitor-card-head, .catalog-card-head { display: flex; align-items: center; gap: 8px; }
|
||
.monitor-card-title { font-size: 13.5px; font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
/* ---------- dropzone ---------- */
|
||
.dropzone {
|
||
border: 1.5px dashed var(--line); border-radius: 8px; padding: 14px; text-align: center;
|
||
font-size: 12px; color: var(--ink-soft); flex: 1 1 160px; cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
.dropzone.drag-active { border-color: var(--blue); background: #F0F6FA; color: var(--blue); }
|
||
|
||
/* ---------- certification gallery ---------- */
|
||
.cert-card {
|
||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||
overflow: hidden; display: flex; flex-direction: column;
|
||
}
|
||
.cert-card-img-wrap {
|
||
aspect-ratio: 3 / 4; background: #F4F6F8; position: relative; overflow: hidden;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cert-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.cert-card-delete {
|
||
position: absolute; top: 6px; right: 6px; padding: 5px 6px;
|
||
background: rgba(255,255,255,0.92); border-color: var(--line);
|
||
}
|
||
.cert-card-brand { padding: 10px 12px; text-align: center; }
|
||
.cert-card-brand-text { font-size: 13px; font-weight: 700; cursor: pointer; word-break: break-word; }
|
||
.cert-card-brand-text.empty { font-weight: 400; color: var(--ink-soft); }
|
||
.cert-card-brand input { text-align: center; font-weight: 700; }
|
||
.field-label-row { display: flex; align-items: center; justify-content: space-between; }
|
||
.field-manage-link {
|
||
font-size: 11.5px; font-weight: 600; color: var(--blue); cursor: pointer;
|
||
display: inline-flex; align-items: center; gap: 3px; text-transform: none; letter-spacing: 0;
|
||
}
|
||
.field-manage-link:hover { text-decoration: underline; }
|
||
.tax-manage-list-row {
|
||
display: flex; align-items: center; justify-content: space-between; padding: 9px 12px;
|
||
border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px;
|
||
}
|
||
/* ---------- product taxonomy (groups view: brand / type / category management) ---------- */
|
||
.tax-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.tax-section-label { font-size: 11px; font-weight: 600; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.03em; margin-right: 2px; flex-shrink: 0; }
|
||
.tax-pill {
|
||
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600;
|
||
padding: 4px 6px 4px 10px; border-radius: 14px; background: var(--copper-soft); color: var(--copper);
|
||
white-space: nowrap;
|
||
}
|
||
.tax-pill-x { display: inline-flex; cursor: pointer; opacity: 0.55; border-radius: 50%; padding: 2px; }
|
||
.tax-pill-x:hover { opacity: 1; background: rgba(0,0,0,0.06); }
|
||
.tax-add-btn {
|
||
display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px;
|
||
border-radius: 50%; border: 1.5px dashed var(--line); background: transparent; color: var(--ink-soft);
|
||
cursor: pointer; flex-shrink: 0;
|
||
}
|
||
.tax-add-btn:hover { border-color: var(--blue); color: var(--blue); }
|
||
.tax-add-input {
|
||
font-size: 11.5px; padding: 4px 10px; border: 1px solid var(--blue); border-radius: 14px;
|
||
width: 108px; font-family: inherit;
|
||
}
|
||
.tax-types-row { padding: 10px 18px; border-bottom: 1px solid var(--line); }
|
||
.tax-add-category {
|
||
border: 1.5px dashed var(--line); border-radius: 10px; padding: 16px; text-align: center;
|
||
color: var(--ink-soft); font-size: 13px; cursor: pointer; transition: border-color 0.15s, color 0.15s;
|
||
}
|
||
.tax-add-category:hover { border-color: var(--blue); color: var(--blue); }
|
||
.cert-view-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 14px; }
|
||
.cert-add-btn {
|
||
width: 36px; height: 36px; border-radius: 50%; padding: 0;
|
||
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||
}
|
||
|
||
/* ---------- quote / invoice document preview (deliberately NOT themed like the app) ---------- */
|
||
.doc-preview {
|
||
background: #fff; color: #2d2d2d; padding: 46px 52px; max-width: 840px; margin: 0 auto;
|
||
font-family: 'Inter', Arial, sans-serif; border: 1px solid #e6e6e6; box-shadow: 0 2px 14px rgba(0,0,0,0.06);
|
||
border-radius: 4px;
|
||
}
|
||
.doc-row { display: flex; justify-content: space-between; gap: 24px; }
|
||
.doc-label { color: #9a9a9a; font-size: 12.5px; }
|
||
.doc-table { width: 100%; border-collapse: collapse; margin: 18px 0; }
|
||
.doc-table th { background: #2d2d2d; color: #fff; text-align: left; padding: 9px 12px; font-size: 12.5px; font-weight: 600; }
|
||
.doc-table th.num, .doc-table td.num { text-align: right; }
|
||
.doc-table td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
|
||
.doc-totals { width: 240px; margin-left: auto; font-size: 13px; }
|
||
.doc-totals-row { display: flex; justify-content: space-between; padding: 4px 0; color: #666; }
|
||
.doc-totals-row.total { font-weight: 700; color: #222; border-top: 1px solid #ddd; margin-top: 4px; padding-top: 8px; }
|
||
.doc-balance-due { background: #f2f2f2; border-radius: 6px; padding: 10px 14px; margin-top: 6px; }
|
||
|
||
@media print {
|
||
body * { visibility: hidden; }
|
||
.doc-preview, .doc-preview * { visibility: visible; }
|
||
.doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
|
||
}
|
||
|
||
/* ---------- vip ---------- */
|
||
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; transition: opacity 0.15s; }
|
||
.vip-toggle:hover { opacity: 0.65; }
|
||
.vip-toggle.active { color: #C9942E; }
|
||
.vip-toggle.active-red { color: var(--led-red); }
|
||
`}</style>
|
||
);
|
||
|
||
/* -------------------------------- 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 (
|
||
<div className="tax-row">
|
||
{label && <span className="tax-section-label">{label}</span>}
|
||
{values.map(v => (
|
||
<span key={v} className="tax-pill">
|
||
{v}
|
||
<span className="tax-pill-x" onClick={() => onRemove(catId, kind, v)}><X size={10} /></span>
|
||
</span>
|
||
))}
|
||
{isAdding ? (
|
||
<input
|
||
className="tax-add-input" autoFocus
|
||
value={addingPillValue}
|
||
onChange={e => setAddingPillValue(e.target.value)}
|
||
onBlur={onConfirmAdd}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
if (e.key === 'Escape') onCancelAdd();
|
||
}}
|
||
placeholder={kind === 'brand' ? '品牌名称' : '类型名称'}
|
||
/>
|
||
) : (
|
||
<span className="tax-add-btn" onClick={() => onOpenAdd(catId, kind)}><Plus size={12} /></span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({ num, label, tone }) {
|
||
return (
|
||
<div className="stat-card">
|
||
<div className={`led-dot ${tone}`}></div>
|
||
<div>
|
||
<div className="stat-num">{num}</div>
|
||
<div className="stat-label">{label}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProductPills({ value, onChange }) {
|
||
const toggle = (p) => {
|
||
const has = value.includes(p);
|
||
onChange(has ? value.filter(x => x !== p) : [...value, p]);
|
||
};
|
||
return (
|
||
<div className="checkbox-row">
|
||
{PRODUCT_LINES.map(p => (
|
||
<div key={p} className={`checkbox-pill ${value.includes(p) ? 'checked' : ''}`} onClick={() => toggle(p)}>
|
||
{value.includes(p) && <Check size={11} />} {p}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RegionPills({ value, onChange }) {
|
||
const toggle = (id) => {
|
||
const has = value.includes(id);
|
||
onChange(has ? value.filter(x => x !== id) : [...value, id]);
|
||
};
|
||
return (
|
||
<div className="checkbox-row">
|
||
{REGIONS.map(r => (
|
||
<div key={r.id} className={`checkbox-pill ${value.includes(r.id) ? 'checked' : ''}`} onClick={() => toggle(r.id)}>
|
||
{value.includes(r.id) && <Check size={11} />} {r.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --------------------------------- 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: '箱单', 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 (
|
||
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: 20 }}>
|
||
<Styles />
|
||
<form className="panel" style={{ width: 340, maxWidth: '100%', padding: 26 }} onSubmit={handleSubmit}>
|
||
<div className="crm-display" style={{ fontSize: 19, fontWeight: 700 }}>客户档案系统</div>
|
||
<div className="crm-subtitle" style={{ marginBottom: 18 }}>请登录后继续</div>
|
||
<div className="field">
|
||
<span className="field-label">用户名</span>
|
||
<input className="input" value={username} onChange={e => setUsername(e.target.value)} autoFocus autoCapitalize="off" />
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">密码</span>
|
||
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} />
|
||
</div>
|
||
{error && <div style={{ color: 'var(--led-red)', fontSize: 12.5, marginBottom: 10 }}>{error}</div>}
|
||
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}>
|
||
{loading ? '登录中…' : '登录'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --------------------------------- 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 (
|
||
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||
<Styles />
|
||
<div className="crm-mono" style={{ color: 'var(--ink-soft)', fontSize: 13 }}>加载中…</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (authState === 'out') {
|
||
return <LoginScreen onSuccess={(name) => { setUsername(name); setAuthState('in'); }} />;
|
||
}
|
||
return <MainApp username={username} onLogout={handleLogout} />;
|
||
}
|
||
|
||
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, extraNames: [] }); setSelectedProductId(p.id); }
|
||
function cancelProductDraft() { setProductDraft(null); }
|
||
function saveProductDraft() {
|
||
if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; }
|
||
if (!productDraft.category) { notify('请选择产品大类'); return; }
|
||
const extraNames = (productDraft.extraNames || []).map(n => n.trim()).filter(Boolean);
|
||
const { extraNames: _drop, ...primary } = productDraft;
|
||
const extras = extraNames.map((n, i) => ({
|
||
...primary,
|
||
id: 'p_' + Date.now() + '_' + i + '_' + Math.random().toString(36).slice(2, 7),
|
||
name: n,
|
||
}));
|
||
setProducts(prev => {
|
||
const exists = prev.some(p => p.id === primary.id);
|
||
const withPrimary = exists ? prev.map(p => (p.id === primary.id ? primary : p)) : [...prev, primary];
|
||
return [...withPrimary, ...extras];
|
||
});
|
||
setSelectedProductId(primary.id);
|
||
setProductDraft(null);
|
||
notify(extras.length > 0 ? `已保存,共新建 ${1 + extras.length} 个产品` : '已保存');
|
||
}
|
||
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 duplicateQuote(quote) {
|
||
const copy = {
|
||
...quote,
|
||
id: 'q_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||
lineItems: quote.lineItems.map(li => ({ ...li, id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) })),
|
||
};
|
||
copy.number = suggestDocNumber(copy.date, quotes, copy.type);
|
||
setQuoteDraft(copy);
|
||
setQuoteSubView('editor');
|
||
notify('已复制为新草稿,请检查单号后保存');
|
||
}
|
||
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 duplicatePackingList(pl) {
|
||
const copy = {
|
||
...pl,
|
||
id: 'pl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||
lineItems: pl.lineItems.map(li => ({ ...li, id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) })),
|
||
};
|
||
copy.number = suggestDocNumber(copy.date, packingLists, 'packing');
|
||
setPackingDraft(copy);
|
||
setPackingSubView('editor');
|
||
notify('已复制为新草稿,请检查单号后保存');
|
||
}
|
||
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('已基于该文档创建箱单草稿,补充箱数/重量/体积后保存');
|
||
}
|
||
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('确定删除这份箱单?此操作不可恢复。')) 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 (
|
||
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 320 }}>
|
||
<div className="crm-mono" style={{ color: 'var(--ink-soft)', fontSize: 13 }}>加载中…</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="crm-root">
|
||
<Styles />
|
||
<div className="crm-shell">
|
||
|
||
<div className="crm-topbar-mobile">
|
||
<button className="btn btn-ghost btn-sm" onClick={() => setMobileMenuOpen(true)}><Menu size={18} /></button>
|
||
<span className="crm-display" style={{ fontWeight: 700, fontSize: 14 }}>{viewTitle(view)}</span>
|
||
</div>
|
||
{mobileMenuOpen && <div className="crm-sidebar-overlay show" onClick={() => setMobileMenuOpen(false)} />}
|
||
|
||
<div className={`crm-sidebar ${mobileMenuOpen ? 'open' : ''}`}>
|
||
<div className="crm-sidebar-brand">
|
||
<div className="crm-display" style={{ fontWeight: 700, fontSize: 15 }}>客户档案系统</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-soft)', marginTop: 2 }}>Customer Console</div>
|
||
</div>
|
||
<div className="crm-sidebar-scroll">
|
||
{NAV_GROUPS.map(group => (
|
||
<div key={group.label}>
|
||
<div className="crm-sidebar-group-label">{group.label}</div>
|
||
{group.items.map(item => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`crm-sidebar-item ${view === item.id ? 'active' : ''}`}
|
||
onClick={() => { setView(item.id); setMobileMenuOpen(false); }}
|
||
>
|
||
<Icon size={15} />{item.label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
<div style={{ borderTop: '1px solid var(--line)', margin: '10px 16px' }} />
|
||
{STANDALONE_NAV.map(item => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`crm-sidebar-item ${view === item.id ? 'active' : ''}`}
|
||
onClick={() => { setView(item.id); setMobileMenuOpen(false); }}
|
||
>
|
||
<Icon size={15} />{item.label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="crm-sidebar-footer">
|
||
<div className="crm-sidebar-user"><Users size={12} />{username}</div>
|
||
<button className="btn btn-sm" style={{ width: '100%', justifyContent: 'center' }} onClick={onLogout}><LogOut size={12} />退出登录</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="crm-main">
|
||
<div className="crm-main-body">
|
||
|
||
{view === 'monitor' && (
|
||
<>
|
||
<div className="stat-row">
|
||
<StatCard num={counts.overdue} label="已逾期跟进" tone="red" />
|
||
<StatCard num={counts.due} label="即将到期" tone="amber" />
|
||
<StatCard num={counts.total} label="客户总数" tone="green" />
|
||
<StatCard num={counts.products} label="产品档案数量" tone="blue" />
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||
<div className="panel-title">全部客户</div>
|
||
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整顺序</span>
|
||
</div>
|
||
{customers.length === 0 ? (
|
||
<div className="panel"><div className="empty-state"><Inbox size={28} />暂无客户,去"客户管理"添加第一个</div></div>
|
||
) : (
|
||
<div className="card-grid">
|
||
{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 (
|
||
<div
|
||
key={c.id}
|
||
className="monitor-card"
|
||
style={{ opacity: dragCustomerIdx === idx ? 0.5 : 1 }}
|
||
draggable
|
||
onDragStart={() => setDragCustomerIdx(idx)}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={() => handleCustomerDrop(idx)}
|
||
onDragEnd={() => setDragCustomerIdx(null)}
|
||
>
|
||
<div className="monitor-card-head">
|
||
<GripVertical size={14} className="drag-handle" />
|
||
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
|
||
<div className="monitor-card-title">{cardTitle}</div>
|
||
{c.isVip && <Crown size={14} fill="#C9942E" color="#C9942E" style={{ flexShrink: 0 }} />}
|
||
{c.hasOrdered && <Crown size={14} fill="#C2403A" color="#C2403A" style={{ flexShrink: 0 }} />}
|
||
</div>
|
||
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
|
||
{showCompanyLine && <div className="row-sub">{c.company}</div>}
|
||
{c.contact && <div className="row-sub">{c.contact}</div>}
|
||
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
|
||
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
|
||
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
|
||
<div className="row-sub">上次联系 {fmtDate(c.lastContact)}</div>
|
||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => { setView('manage'); setDraft(null); setSelectedId(c.id); }}>查看</button>
|
||
<button className="btn btn-sm" title="标记今日已联系" onClick={() => markContacted(c.id)}><Check size={12} /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{view === 'manage' && (
|
||
<div className="crm-layout">
|
||
<div>
|
||
<div className="search-box"><Search size={14} color="var(--ink-soft)" /><input placeholder="搜索公司 / 联系人 / 邮箱 / 国家" value={search} onChange={e => setSearch(e.target.value)} /></div>
|
||
<div className="field">
|
||
<select className="select" value={regionFilter} onChange={e => setRegionFilter(e.target.value)}>
|
||
<option value="all">全部地区</option>
|
||
{REGIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={startNewCustomer}><Plus size={14} />新建客户档案</button>
|
||
<div className="panel">
|
||
<div className="panel-body">
|
||
{filteredCustomers.length === 0 ? (
|
||
<div className="empty-state"><Inbox size={26} />暂无客户,点击上方按钮添加第一个</div>
|
||
) : filteredCustomers.map(c => {
|
||
const s = statusOf(c, settings);
|
||
const showCompanyInSub = customerTitleIsModel(c) && c.company;
|
||
return (
|
||
<div key={c.id} className="row" style={{ cursor: 'pointer', background: selectedId === c.id ? '#FAFBFC' : undefined }} onClick={() => { setDraft(null); setSelectedId(c.id); }}>
|
||
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
|
||
<div className="row-main">
|
||
<div className="row-title">{c.isVip && <Crown size={12} fill="#C9942E" color="#C9942E" style={{ marginRight: 4, verticalAlign: -1 }} />}{c.hasOrdered && <Crown size={12} fill="#C2403A" color="#C2403A" style={{ marginRight: 4, verticalAlign: -1 }} />}{customerTitle(c)}</div>
|
||
<div className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
|
||
</div>
|
||
<ChevronRight size={15} color="var(--ink-soft)" />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
{draft ? (
|
||
<CustomerForm draft={draft} setDraft={setDraft} onSave={saveDraft} onCancel={cancelDraft} />
|
||
) : selected ? (
|
||
<CustomerDetail
|
||
customer={selected}
|
||
settings={settings}
|
||
now={now}
|
||
onEdit={() => 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)}
|
||
/>
|
||
) : (
|
||
<div className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情,或新建一个客户档案</div></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'groups' && (
|
||
<>
|
||
<div className="disclaimer">按产品大类分组浏览;每个类目右上角管理品牌,类目内第二行管理类型;新建产品时先选类目,再从对应的品牌 / 类型里选。</div>
|
||
{productCategories.map(cat => {
|
||
const items = products.filter(p => p.category === cat.id);
|
||
return (
|
||
<div className="panel section-gap" key={cat.id}>
|
||
<div className="panel-header" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||
<div className="panel-title"><Layers size={15} />{cat.name}({items.length})</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<TaxPillRow
|
||
label={null} values={cat.brands} catId={cat.id} kind="brand"
|
||
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
|
||
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
|
||
/>
|
||
<button className="btn btn-sm btn-ghost btn-danger" title="删除类目" onClick={() => deleteProductCategory(cat)}>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="tax-types-row">
|
||
<TaxPillRow
|
||
label="类型" values={cat.types} catId={cat.id} kind="type"
|
||
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
|
||
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
|
||
/>
|
||
</div>
|
||
<div className="panel-body">
|
||
{items.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 10px' }}>暂无产品</div>
|
||
) : items.map((p, idx) => (
|
||
<div
|
||
key={p.id}
|
||
className="row"
|
||
style={{ cursor: 'grab', opacity: (dragGroupState && dragGroupState.category === cat.id && dragGroupState.index === idx) ? 0.5 : 1 }}
|
||
draggable
|
||
onDragStart={() => setDragGroupState({ category: cat.id, index: idx })}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={() => handleGroupDrop(cat.id, idx)}
|
||
onDragEnd={() => setDragGroupState(null)}
|
||
>
|
||
<GripVertical size={14} className="drag-handle" />
|
||
<div className="row-main">
|
||
<div className="row-title">{p.name || '(未命名)'}</div>
|
||
<div className="row-sub">{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}</div>
|
||
</div>
|
||
<button className="btn btn-sm" onClick={() => { setView('products'); setProductDraft(null); setSelectedProductId(p.id); }}>查看</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{addingPill && addingPill.kind === 'category' ? (
|
||
<div className="tax-add-category">
|
||
<input
|
||
className="input" autoFocus style={{ maxWidth: 260, margin: '0 auto', textAlign: 'center' }}
|
||
value={addingPillValue}
|
||
onChange={e => setAddingPillValue(e.target.value)}
|
||
onBlur={confirmAddPill}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
if (e.key === 'Escape') cancelAddPill();
|
||
}}
|
||
placeholder="新类目名称,如:精密空调"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="tax-add-category" onClick={() => openAddPill(null, 'category')}>
|
||
<Plus size={14} style={{ verticalAlign: -2, marginRight: 4 }} />新增类目
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{view === 'products' && (
|
||
<div className="crm-layout">
|
||
<div>
|
||
<div className="search-box"><Search size={14} color="var(--ink-soft)" /><input placeholder="搜索型号 / 品牌 / 类别" value={productSearch} onChange={e => setProductSearch(e.target.value)} /></div>
|
||
<div className="field">
|
||
<select className="select" value={productCategoryFilter} onChange={e => setProductCategoryFilter(e.target.value)}>
|
||
<option value="all">全部类别</option>
|
||
{productCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={startNewProduct}><Plus size={14} />新建产品档案</button>
|
||
<div className="panel">
|
||
<div className="panel-body">
|
||
{filteredProducts.length === 0 ? (
|
||
<div className="empty-state"><Package size={26} />暂无产品,点击上方按钮添加第一个</div>
|
||
) : filteredProducts.map(p => (
|
||
<div key={p.id} className="row" style={{ cursor: 'pointer', background: selectedProductId === p.id ? '#FAFBFC' : undefined }} onClick={() => { setProductDraft(null); setSelectedProductId(p.id); }}>
|
||
<div className="led-dot blue" />
|
||
<div className="row-main">
|
||
<div className="row-title">{p.name || '(未命名)'}</div>
|
||
<div className="row-sub">{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}</div>
|
||
</div>
|
||
<ChevronRight size={15} color="var(--ink-soft)" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
{productDraft ? (
|
||
<ProductForm draft={productDraft} setDraft={setProductDraft} onSave={saveProductDraft} onCancel={cancelProductDraft} categories={productCategories} onManage={(mode) => setTaxManageMode(mode)} />
|
||
) : selectedProduct ? (
|
||
<ProductDetail product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} categories={productCategories} />
|
||
) : (
|
||
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情,或新建一个产品档案</div></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'catalog' && (
|
||
<>
|
||
<div className="disclaimer">拖拽卡片调整展示顺序,方便整理成给客户看的产品介绍顺序。</div>
|
||
{products.length === 0 ? (
|
||
<div className="panel"><div className="empty-state"><BookOpen size={28} />暂无产品,去"产品管理"添加第一个</div></div>
|
||
) : (
|
||
<div className="card-grid">
|
||
{products.map((p, idx) => {
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className="catalog-card"
|
||
style={{ opacity: dragCatalogIdx === idx ? 0.5 : 1 }}
|
||
draggable
|
||
onDragStart={() => setDragCatalogIdx(idx)}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={() => handleCatalogDrop(idx)}
|
||
onDragEnd={() => setDragCatalogIdx(null)}
|
||
>
|
||
<div className="catalog-card-head">
|
||
<GripVertical size={14} className="drag-handle" />
|
||
<div className="monitor-card-title">{p.name || '(未命名)'}</div>
|
||
</div>
|
||
<div className="row-sub">{categoryBreadcrumb(p, productCategories)}</div>
|
||
{p.brand && <div style={{ fontSize: 12.5 }}>品牌:{p.brand}</div>}
|
||
{p.powerOrCapacity && <div style={{ fontSize: 12.5 }}>功率 / 容量:{p.powerOrCapacity}</div>}
|
||
{p.voltage && <div style={{ fontSize: 12.5 }}>电压:{p.voltage}</div>}
|
||
{p.costPrice && <div style={{ fontSize: 12.5 }}>正本价格:¥{p.costPrice}</div>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{view === 'certs' && (
|
||
<>
|
||
<div className="cert-view-head">
|
||
<label className="btn btn-primary cert-add-btn" title={certUploading ? '上传中…' : '上传授权证书'}>
|
||
<input
|
||
type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={e => { handleCertFilesSelected(e.target.files); e.target.value = ''; }}
|
||
/>
|
||
<Plus size={16} />
|
||
</label>
|
||
</div>
|
||
{certifications.length === 0 ? (
|
||
<div className="panel"><div className="empty-state"><ShieldCheck size={28} />暂无授权证书,点击右上角"+"上传第一张</div></div>
|
||
) : (
|
||
<div className="card-grid">
|
||
{certifications.map(cert => (
|
||
<div key={cert.id} className="cert-card">
|
||
<div className="cert-card-img-wrap">
|
||
<a href={`/uploads/${cert.id}/${cert.fileName}`} target="_blank" rel="noreferrer">
|
||
<img src={`/uploads/${cert.id}/${cert.fileName}`} alt={cert.brand || '授权证书'} />
|
||
</a>
|
||
<button className="btn btn-sm btn-ghost btn-danger cert-card-delete" onClick={() => deleteCertification(cert)}>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
<div className="cert-card-brand">
|
||
{editingCertId === cert.id ? (
|
||
<input
|
||
className="input" autoFocus
|
||
value={certBrandDraft}
|
||
onChange={e => setCertBrandDraft(e.target.value)}
|
||
onBlur={saveCertBrand}
|
||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
|
||
placeholder="品牌名称"
|
||
/>
|
||
) : (
|
||
<div
|
||
className={`cert-card-brand-text ${cert.brand ? '' : 'empty'}`}
|
||
onClick={() => startEditCertBrand(cert)}
|
||
>
|
||
{cert.brand || '点击填写品牌名'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{view === 'quotes' && quoteSubView === 'list' && (
|
||
<>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||
<button className="btn btn-primary" onClick={() => startNewQuote('quote')}><Plus size={14} />新建报价单 Quote</button>
|
||
<button className="btn btn-primary" onClick={() => startNewQuote('invoice')}><Plus size={14} />新建 Invoice</button>
|
||
<button className="btn" onClick={() => setSellerManagerOpen(true)}><Building2 size={14} />管理卖方信息</button>
|
||
<select className="select" style={{ width: 140, marginLeft: 'auto' }} value={quoteTypeFilter} onChange={e => setQuoteTypeFilter(e.target.value)}>
|
||
<option value="all">全部类型</option>
|
||
<option value="quote">仅 Quote</option>
|
||
<option value="invoice">仅 Invoice</option>
|
||
</select>
|
||
</div>
|
||
<div className="panel">
|
||
<div className="panel-body">
|
||
{quotes.filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).length === 0 ? (
|
||
<div className="empty-state"><FileText size={28} />暂无文档,点击上方按钮新建一份</div>
|
||
) : [...quotes].filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).reverse().map(q => {
|
||
const { total } = quoteTotals(q);
|
||
return (
|
||
<div key={q.id} className="row" style={{ cursor: 'pointer' }} onClick={() => startEditQuote(q)}>
|
||
<span className="badge badge-region" style={{ flexShrink: 0 }}>{q.type === 'quote' ? 'Quote' : 'Invoice'}</span>
|
||
<div className="row-main">
|
||
<div className="row-title">{q.number || '(无单号)'} · {q.billToName || '(未填写客户)'}</div>
|
||
<div className="row-sub">{q.date} · {formatMoney(total, q.currency)}</div>
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={(e) => { e.stopPropagation(); deleteQuote(q.id); }}><Trash2 size={13} /></button>
|
||
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); duplicateQuote(q); }}><Copy size={12} />复制</button>
|
||
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); convertQuoteToPackingList(q); }}><Boxes size={12} />转为箱单</button>
|
||
{q.type === 'quote' && (
|
||
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); convertQuoteToInvoice(q); }}>转为Invoice</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{view === 'quotes' && quoteSubView === 'editor' && quoteDraft && (
|
||
<QuoteEditor
|
||
draft={quoteDraft}
|
||
onUpdate={updateQuoteDraft}
|
||
onBack={backToQuoteList}
|
||
onSave={saveQuoteDraft}
|
||
onPrint={handlePrint}
|
||
onExportPdf={handleExportPdf}
|
||
pdfExporting={pdfExporting}
|
||
customers={customers}
|
||
sellerProfiles={sellerProfiles}
|
||
onLinkCustomer={handleLinkCustomer}
|
||
onSellerChange={handleSellerChange}
|
||
onCurrencyChange={handleCurrencyChange}
|
||
onAddLineItem={addLineItem}
|
||
onRemoveLineItem={removeLineItem}
|
||
onUpdateLineItem={updateLineItem}
|
||
onManageSellers={() => setSellerManagerOpen(true)}
|
||
onToggleNoteLine={toggleQuoteNoteLine}
|
||
priceHistory={priceHistory}
|
||
/>
|
||
)}
|
||
|
||
{view === 'packing' && packingSubView === 'list' && (
|
||
<>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||
<button className="btn btn-primary" onClick={startNewPackingList}><Plus size={14} />新建箱单</button>
|
||
<button className="btn" onClick={() => setSellerManagerOpen(true)}><Building2 size={14} />管理卖方信息</button>
|
||
</div>
|
||
<div className="panel">
|
||
<div className="panel-body">
|
||
{packingLists.length === 0 ? (
|
||
<div className="empty-state"><Boxes size={28} />暂无箱单,点击上方按钮新建一份,也可以到"报价单"里把Quote/Invoice转成箱单</div>
|
||
) : [...packingLists].reverse().map(pl => {
|
||
const t = packingTotals(pl);
|
||
return (
|
||
<div key={pl.id} className="row" style={{ cursor: 'pointer' }} onClick={() => startEditPackingList(pl)}>
|
||
<span className="badge badge-region" style={{ flexShrink: 0 }}>P/L</span>
|
||
<div className="row-main">
|
||
<div className="row-title">{pl.number || '(无单号)'} · {pl.billToName || '(未填写客户)'}</div>
|
||
<div className="row-sub">{pl.date} · {t.cartons || 0} 箱 · {t.grossWeight || 0} kg 毛重</div>
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={(e) => { e.stopPropagation(); deletePackingList(pl.id); }}><Trash2 size={13} /></button>
|
||
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); duplicatePackingList(pl); }}><Copy size={12} />复制</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{view === 'packing' && packingSubView === 'editor' && packingDraft && (
|
||
<PackingListEditor
|
||
draft={packingDraft}
|
||
onUpdate={updatePackingDraft}
|
||
onBack={backToPackingList}
|
||
onSave={savePackingDraft}
|
||
onPrint={handlePrint}
|
||
onExportPdf={handleExportPackingPdf}
|
||
pdfExporting={packingPdfExporting}
|
||
customers={customers}
|
||
sellerProfiles={sellerProfiles}
|
||
onLinkCustomer={handleLinkPackingCustomer}
|
||
onSellerChange={(sellerId) => updatePackingDraft({ sellerProfileId: sellerId })}
|
||
onAddLineItem={addPackingLineItem}
|
||
onRemoveLineItem={removePackingLineItem}
|
||
onUpdateLineItem={updatePackingLineItem}
|
||
onManageSellers={() => setSellerManagerOpen(true)}
|
||
/>
|
||
)}
|
||
|
||
{view === 'settings' && (
|
||
<>
|
||
<div className="panel section-gap">
|
||
<div className="panel-header"><div className="panel-title">跟进与提醒设置</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<span className="field-label">默认跟进周期(天)</span>
|
||
<input type="number" min="1" className="input" value={settings.defaultFollowUpDays} onChange={e => setSettings(s => ({ ...s, defaultFollowUpDays: Number(e.target.value) || 1 }))} />
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">"即将到期"提前提醒天数</span>
|
||
<input type="number" min="1" className="input" value={settings.dueSoonWindow} onChange={e => setSettings(s => ({ ...s, dueSoonWindow: Number(e.target.value) || 1 }))} />
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
|
||
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
|
||
<button className="btn btn-danger" onClick={() => {
|
||
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
|
||
setCustomers([]); setProducts([]); setSettings(DEFAULT_SETTINGS);
|
||
notify('已清空');
|
||
}
|
||
}}><RefreshCw size={13} />清空所有数据</button>
|
||
</div>
|
||
<div className="disclaimer" style={{ marginTop: 18 }}>
|
||
说明:邮件功能用于<b>生成可复制的英文邮件文案</b>,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel section-gap">
|
||
<div className="panel-header"><div className="panel-title"><Lock size={15} />账号与密码</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>当前登录账号:{username}</div>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">当前密码</span><input type="password" className="input" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} /></div>
|
||
<div className="field"><span className="field-label">新密码(至少4位)</span><input type="password" className="input" value={pwNew} onChange={e => setPwNew(e.target.value)} /></div>
|
||
</div>
|
||
<button className="btn btn-primary" disabled={pwBusy} onClick={handleChangePassword}><Lock size={13} />{pwBusy ? '修改中…' : '修改密码'}</button>
|
||
<div className="disclaimer" style={{ marginTop: 14 }}>
|
||
这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panel-header"><div className="panel-title"><Sparkles size={15} />AI 跟进文案(DeepSeek)</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>
|
||
状态:{aiConfigHasKey ? <span style={{ color: 'var(--led-green)', fontWeight: 600 }}>已配置</span> : <span>未配置</span>}
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">DeepSeek API Key</span>
|
||
<input
|
||
type="password" className="input" value={aiKeyInput} onChange={e => setAiKeyInput(e.target.value)}
|
||
placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-primary" disabled={aiSaving} onClick={handleSaveAiKey}><Save size={13} />保存</button>
|
||
<button className="btn" disabled={!aiConfigHasKey || aiTesting} onClick={handleTestAiKey}>{aiTesting ? '测试中…' : '测试连接'}</button>
|
||
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
|
||
</div>
|
||
<div className="disclaimer" style={{ marginTop: 14 }}>
|
||
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{aiDraftModal && (
|
||
<div className="modal-overlay" onClick={() => setAiDraftModal(null)}>
|
||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div className="panel-title"><Sparkles size={15} />{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}</div>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => setAiDraftModal(null)}><X size={14} /></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
{aiDraftModal.channel === 'email' && (
|
||
<div className="field"><span className="field-label">主题 Subject</span><input className="input" value={aiDraftModal.subject} onChange={e => setAiDraftModal(m => ({ ...m, subject: e.target.value }))} /></div>
|
||
)}
|
||
<div className="field">
|
||
<span className="field-label">{aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}</span>
|
||
<textarea
|
||
className="textarea" rows={aiDraftModal.channel === 'email' ? 10 : 6}
|
||
value={aiDraftModal.body} onChange={e => setAiDraftModal(m => ({ ...m, body: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="modal-foot">
|
||
{aiDraftModal.channel === 'email' && <button className="btn" onClick={() => copyText(aiDraftModal.subject)}><Copy size={12} />复制主题</button>}
|
||
<button className="btn btn-primary" onClick={() => copyText(aiDraftModal.body)}><Copy size={12} />复制{aiDraftModal.channel === 'email' ? '正文' : '内容'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{emailModal && (
|
||
<div className="modal-overlay" onClick={() => setEmailModal(null)}>
|
||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head"><div className="panel-title"><Mail size={15} />邮件文案</div><button className="btn btn-ghost btn-sm" onClick={() => setEmailModal(null)}><X size={14} /></button></div>
|
||
<div className="modal-body">
|
||
<div className="field"><span className="field-label">主题 Subject</span><input className="input" readOnly value={emailModal.subject} /></div>
|
||
<div className="field"><span className="field-label">正文 Body</span><textarea className="textarea" rows={10} value={emailModal.body} onChange={e => setEmailModal(m => ({ ...m, body: e.target.value }))} /></div>
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn" onClick={() => copyText(emailModal.subject)}><Copy size={12} />复制主题</button>
|
||
<button className="btn btn-primary" onClick={() => copyText(emailModal.body)}><Copy size={12} />复制正文</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{exportOpen && (
|
||
<div className="modal-overlay" onClick={() => setExportOpen(false)}>
|
||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head"><div className="panel-title">数据备份(JSON)</div><button className="btn btn-ghost btn-sm" onClick={() => setExportOpen(false)}><X size={14} /></button></div>
|
||
<div className="modal-body">
|
||
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists }, null, 2)} />
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists }, null, 2))}><Copy size={12} />复制全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sellerManagerOpen && (
|
||
<div className="modal-overlay" onClick={() => setSellerManagerOpen(false)}>
|
||
<div className="modal-box" style={{ maxWidth: 620 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head"><div className="panel-title"><Building2 size={15} />管理卖方信息</div><button className="btn btn-ghost btn-sm" onClick={() => setSellerManagerOpen(false)}><X size={14} /></button></div>
|
||
<div className="modal-body">
|
||
{sellerProfiles.map(sp => (
|
||
<div key={sp.id} className="panel section-gap">
|
||
<div className="panel-body" style={{ padding: 14 }}>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">公司名称</span><input className="input" value={sp.name} onChange={e => updateSellerProfile(sp.id, { name: e.target.value })} /></div>
|
||
<div className="field">
|
||
<span className="field-label">Logo</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{sp.logoFileName && <img src={`/uploads/${sp.id}/${sp.logoFileName}`} alt="logo" style={{ height: 28, maxWidth: 90, objectFit: 'contain' }} />}
|
||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||
<ImageIcon size={12} />{sellerLogoUploading ? '上传中…' : '上传Logo'}
|
||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={e => handleSellerLogoPick(sp.id, e.target.files && e.target.files[0])} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="field"><span className="field-label">地址</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={sp.address} onChange={e => updateSellerProfile(sp.id, { address: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">银行信息(仅Invoice使用,可留空)</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={sp.bankInfo} onChange={e => updateSellerProfile(sp.id, { bankInfo: e.target.value })} /></div>
|
||
<button className="btn btn-sm btn-danger" onClick={() => deleteSellerProfile(sp.id)}><Trash2 size={12} />删除这个卖方信息</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button className="btn" style={{ width: '100%', justifyContent: 'center' }} onClick={addSellerProfile}><Plus size={13} />新增卖方信息</button>
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={() => setSellerManagerOpen(false)}>完成</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{taxManageMode && (() => {
|
||
const targetCat = productDraft ? productCategories.find(c => c.id === productDraft.category) : null;
|
||
const closeModal = () => { setTaxManageMode(null); cancelAddPill(); };
|
||
const titleMap = { category: '产品大类管理', brand: '品牌管理', type: '品类管理' };
|
||
return (
|
||
<div className="modal-overlay" onClick={closeModal}>
|
||
<div className="modal-box" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div className="panel-title"><Settings2 size={15} />{titleMap[taxManageMode]}{targetCat && taxManageMode !== 'category' ? ` · ${targetCat.name}` : ''}</div>
|
||
<button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
{taxManageMode === 'category' ? (
|
||
<>
|
||
{productCategories.length === 0 && <div className="empty-state" style={{ padding: '16px 0' }}>暂无类目</div>}
|
||
{productCategories.map(c => (
|
||
<div key={c.id} className="tax-manage-list-row">
|
||
<div>
|
||
<div style={{ fontWeight: 700, fontSize: 13 }}>{c.name}</div>
|
||
<div style={{ fontSize: 11.5, color: 'var(--ink-soft)', marginTop: 2 }}>{c.brands.length} 个品牌 · {c.types.length} 个品类</div>
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => deleteProductCategory(c)}><Trash2 size={12} /></button>
|
||
</div>
|
||
))}
|
||
<div style={{ marginTop: 10 }}>
|
||
{addingPill && addingPill.kind === 'category' ? (
|
||
<input
|
||
className="input" autoFocus value={addingPillValue}
|
||
onChange={e => setAddingPillValue(e.target.value)}
|
||
onBlur={confirmAddPill}
|
||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') cancelAddPill(); }}
|
||
placeholder="新类目名称,如:精密空调"
|
||
/>
|
||
) : (
|
||
<button className="btn" style={{ width: '100%', justifyContent: 'center' }} onClick={() => openAddPill(null, 'category')}><Plus size={13} />新增类目</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : targetCat ? (
|
||
<TaxPillRow
|
||
label={null} values={taxManageMode === 'brand' ? targetCat.brands : targetCat.types}
|
||
catId={targetCat.id} kind={taxManageMode}
|
||
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
|
||
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
|
||
/>
|
||
) : (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>请先在表单里选择产品大类,再来管理{taxManageMode === 'brand' ? '品牌' : '品类'}</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={closeModal}>完成</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- 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 (
|
||
<div className="panel">
|
||
<div className="panel-header"><div className="panel-title"><Edit2 size={14} />{draft.company ? '编辑客户' : '新建客户'}</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">公司名称</span><input className="input" value={draft.company} onChange={e => set({ company: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">联系人</span><input className="input" value={draft.contact} onChange={e => set({ contact: e.target.value })} /></div>
|
||
<div className="field">
|
||
<span className="field-label">国家</span>
|
||
<input
|
||
className="input" list="country-options" value={draft.country}
|
||
onChange={e => handleCountryChange(e.target.value)}
|
||
placeholder="输入国家名称,会自动匹配地区和电话区号"
|
||
/>
|
||
<datalist id="country-options">
|
||
{COUNTRIES.map(c => <option key={c.name} value={c.name}>{c.en}</option>)}
|
||
</datalist>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">地区{COUNTRY_BY_NAME[draft.country] ? '(已自动匹配)' : ''}</span>
|
||
<select className="select" value={draft.region} onChange={e => set({ region: e.target.value })}>
|
||
{REGIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field"><span className="field-label">邮箱</span><input className="input" value={draft.email} onChange={e => set({ email: e.target.value })} /></div>
|
||
<div className="field">
|
||
<span className="field-label">电话 / WhatsApp{COUNTRY_BY_NAME[draft.country] ? `(${COUNTRY_BY_NAME[draft.country].dial})` : ''}</span>
|
||
<input className="input" value={draft.phone} onChange={e => set({ phone: e.target.value })} />
|
||
</div>
|
||
<div className="field"><span className="field-label">网站</span><input className="input" value={draft.website} onChange={e => set({ website: e.target.value })} /></div>
|
||
<div className="field">
|
||
<span className="field-label">客户来源</span>
|
||
<select className="select" value={draft.source} onChange={e => set({ source: e.target.value })}>
|
||
<option value="">请选择</option>
|
||
{CUSTOMER_SOURCES.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field"><span className="field-label">关注产品线</span><ProductPills value={draft.productLines} onChange={v => set({ productLines: v })} /></div>
|
||
{draft.productLines.length > 0 && (
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">意向型号</span><input className="input" value={draft.intendedModel} onChange={e => set({ intendedModel: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">数量</span><input className="input" value={draft.intendedQty} onChange={e => set({ intendedQty: e.target.value })} /></div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">上次联系日期</span><input type="date" className="input" value={draft.lastContact} onChange={e => set({ lastContact: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">跟进周期(天,留空用默认值)</span><input type="number" min="1" className="input" value={draft.followUpDays} onChange={e => set({ followUpDays: e.target.value })} /></div>
|
||
</div>
|
||
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.tags} onChange={e => set({ tags: e.target.value })} /></div>
|
||
|
||
<CollapsibleSection title="货代信息" defaultOpen={!!(draft.warehouseAddress || draft.freightContact || draft.shippingMarkNote)}>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">仓库地址</span><input className="input" value={draft.warehouseAddress} onChange={e => set({ warehouseAddress: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">货代联系方式</span><input className="input" value={draft.freightContact} onChange={e => set({ freightContact: e.target.value })} /></div>
|
||
</div>
|
||
<div className="field"><span className="field-label">箱唛 / 备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.shippingMarkNote} onChange={e => set({ shippingMarkNote: e.target.value })} /></div>
|
||
</CollapsibleSection>
|
||
|
||
<div className="field"><span className="field-label">客户背景信息调查</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => set({ notes: e.target.value })} /></div>
|
||
</div>
|
||
<div className="modal-foot"><button className="btn" onClick={onCancel}>取消</button><button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContactInfo({ icon, label, value, active, onToggle }) {
|
||
return (
|
||
<div>
|
||
<div
|
||
className="field-label"
|
||
style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', color: active ? 'var(--led-green)' : undefined, userSelect: 'none' }}
|
||
onClick={onToggle}
|
||
title="点击标记 / 取消为常用联系方式"
|
||
>
|
||
{icon}{label}{active ? '(常用)' : ''}
|
||
</div>
|
||
<div style={{ fontSize: 13 }}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CollapsibleSection({ title, count, defaultOpen, children }) {
|
||
const [open, setOpen] = useState(!!defaultOpen);
|
||
return (
|
||
<div className="field">
|
||
<div
|
||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 0' }}
|
||
onClick={() => setOpen(o => !o)}
|
||
>
|
||
<span className="field-label" style={{ marginBottom: 0 }}>{title}{typeof count === 'number' ? `(${count})` : ''}</span>
|
||
<ChevronRight size={14} color="var(--ink-soft)" style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} />
|
||
</div>
|
||
{open && <div style={{ marginTop: 6 }}>{children}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="panel">
|
||
<div className="panel-header">
|
||
<div className="panel-title">
|
||
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
|
||
{customerTitle(c)}
|
||
<span className={`vip-toggle ${c.isVip ? 'active' : ''}`} onClick={onToggleVip} title={c.isVip ? '取消重点客户标记' : '标记为重点客户'}>
|
||
<Crown size={15} fill={c.isVip ? '#C9942E' : 'none'} />
|
||
</span>
|
||
<span className={`vip-toggle ${c.hasOrdered ? 'active-red' : ''}`} onClick={onToggleOrdered} title={c.hasOrdered ? '取消已下单标记' : '标记为已下单'}>
|
||
<Crown size={15} fill={c.hasOrdered ? '#C2403A' : 'none'} />
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={onDelete}><Trash2 size={12} /></button>
|
||
</div>
|
||
</div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2" style={{ marginBottom: 16 }}>
|
||
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
|
||
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} />
|
||
<Info icon={<Globe size={13} />} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
|
||
<ContactInfo icon={<Mail size={13} />} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
|
||
<ContactInfo icon={<Phone size={13} />} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
|
||
<Info icon={<Clock size={13} />} label="当地时间" value={lt ? `${lt.time}(${lt.en})` : '—'} />
|
||
<Info icon={<Globe size={13} />} label="网站" value={c.website ? <a href={c.website} target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>{c.website}</a> : '—'} />
|
||
</div>
|
||
{c.source && <div className="field"><span className="field-label">客户来源</span><span className="badge badge-region">{c.source}</span></div>}
|
||
{c.productLines && c.productLines.length > 0 && (
|
||
<div className="field">
|
||
<span className="field-label">关注产品线</span>
|
||
<div className="checkbox-row">{c.productLines.map(p => <span key={p} className="badge badge-region">{p}</span>)}</div>
|
||
{(c.intendedModel || c.intendedQty) && (
|
||
<div style={{ fontSize: 12.5, marginTop: 6, color: 'var(--ink-soft)' }}>
|
||
意向型号:{c.intendedModel || '—'}{c.intendedQty ? ` × ${c.intendedQty}` : ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{c.tags && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.tags}</div></div>}
|
||
|
||
<CollapsibleSection title="货代信息">
|
||
{!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
|
||
<div className="row-sub" style={{ padding: '8px 0' }}>暂无货代信息</div>
|
||
) : (
|
||
<>
|
||
<div className="grid-2">
|
||
{c.warehouseAddress && <Info label="仓库地址" value={c.warehouseAddress} />}
|
||
{c.freightContact && <Info label="货代联系方式" value={c.freightContact} />}
|
||
</div>
|
||
{c.shippingMarkNote && <div style={{ fontSize: 12.5, marginTop: 6, whiteSpace: 'pre-wrap' }}>箱唛 / 备注:{c.shippingMarkNote}</div>}
|
||
</>
|
||
)}
|
||
</CollapsibleSection>
|
||
|
||
{c.notes && <div className="field"><span className="field-label">客户背景信息调查</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.notes}</div></div>}
|
||
|
||
<div style={{ display: 'flex', gap: 8, margin: '14px 0' }}>
|
||
<span className="row-sub" style={{ flex: 1 }}>上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}</span>
|
||
<button className="btn btn-sm" onClick={onMarkContacted}><Check size={12} />标记今日已联系</button>
|
||
<button className="btn btn-sm" disabled={!c.email} onClick={onEmail}><Mail size={12} />生成跟进邮件</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '0 0 14px', flexWrap: 'wrap' }}>
|
||
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('email')}>
|
||
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成邮件'}
|
||
</button>
|
||
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('whatsapp')}>
|
||
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成WhatsApp话术'}
|
||
</button>
|
||
{!aiConfigHasKey && <span className="row-sub">未配置DeepSeek,去"设置"里填API Key</span>}
|
||
</div>
|
||
|
||
<div className="field">
|
||
<span className="field-label">客户文件管理</span>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
|
||
<label
|
||
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
|
||
onDragOver={onFileDragOver} onDragLeave={onFileDragLeave} onDrop={onFileDrop}
|
||
>
|
||
<input
|
||
key={fileInputKey} type="file"
|
||
onChange={e => onPickAttachment(e.target.files && e.target.files[0])}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<Paperclip size={15} style={{ marginBottom: 4 }} />
|
||
<div>{fileDragActive ? '松开鼠标完成选择' : attachmentFileName ? `已选择:${attachmentFileName}` : '点击选择文件,或把文件拖到这里'}</div>
|
||
</label>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: '1 1 160px' }}>
|
||
<select className="select" value={attachmentCategory} onChange={e => setAttachmentCategory(e.target.value)}>
|
||
{FILE_CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||
</select>
|
||
<input
|
||
className="input" placeholder="备注(可选)"
|
||
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
|
||
/>
|
||
<button className="btn btn-sm" disabled={uploading} onClick={onUploadAttachment} style={{ justifyContent: 'center' }}>
|
||
<Plus size={12} />{uploading ? '上传中…' : '上传'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<CollapsibleSection title="文件列表" count={(c.attachments || []).length} defaultOpen={(c.attachments || []).length > 0}>
|
||
{(!c.attachments || c.attachments.length === 0) ? (
|
||
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
|
||
) : (
|
||
<>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||
<input
|
||
className="input" style={{ flex: 1 }} placeholder="搜索文件名 / 备注"
|
||
value={fileSearch} onChange={e => setFileSearch(e.target.value)}
|
||
/>
|
||
<select className="select" style={{ flex: '0 1 120px' }} value={fileCategoryFilter} onChange={e => setFileCategoryFilter(e.target.value)}>
|
||
<option value="all">全部类别</option>
|
||
{FILE_CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||
</select>
|
||
</div>
|
||
{filteredAttachments.length === 0 ? (
|
||
<div className="row-sub" style={{ padding: '8px 0' }}>没有匹配的文件</div>
|
||
) : filteredAttachments.map((a, i) => (
|
||
<div key={a.fileName + i} style={{ display: 'flex', gap: 10, padding: '8px 0', borderBottom: '1px solid var(--line)', alignItems: 'flex-start' }}>
|
||
<Paperclip size={13} color="var(--ink-soft)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<span className="crm-mono" style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>{a.date}</span>
|
||
{' '}<span className="badge badge-region">{a.category || 'Other'}</span>
|
||
<div style={{ fontSize: 12.5, wordBreak: 'break-word', marginTop: 2 }}>
|
||
<a href={`/uploads/${c.id}/${a.fileName}`} target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>{a.originalName}</a>
|
||
{a.note && <span style={{ color: 'var(--ink-soft)' }}> — {a.note}</span>}
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</CollapsibleSection>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- 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 : [];
|
||
const extraNames = draft.extraNames || [];
|
||
function addExtraName() { set({ extraNames: [...extraNames, ''] }); }
|
||
function updateExtraName(idx, val) { const arr = [...extraNames]; arr[idx] = val; set({ extraNames: arr }); }
|
||
function removeExtraName(idx) { set({ extraNames: extraNames.filter((_, i) => i !== idx) }); }
|
||
return (
|
||
<div className="panel">
|
||
<div className="panel-header"><div className="panel-title"><Edit2 size={14} />{draft.name ? '编辑产品' : '新建产品'}</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">型号 / 产品名称 *</span><input className="input" value={draft.name} onChange={e => set({ name: e.target.value })} /></div>
|
||
<div className="field">
|
||
<div className="field-label-row">
|
||
<span className="field-label">产品大类 *</span>
|
||
<span className="field-manage-link" onClick={() => onManage('category')}><Settings2 size={11} />大类管理</span>
|
||
</div>
|
||
<select className="select" value={draft.category} onChange={e => set({ category: e.target.value, brand: '', type: '' })}>
|
||
<option value="">请选择</option>
|
||
{(categories || []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<div className="field-label-row">
|
||
<span className="field-label">同系列其他型号(可选,一次性批量新建)</span>
|
||
<span className="field-manage-link" onClick={addExtraName}><Plus size={11} />添加型号</span>
|
||
</div>
|
||
{extraNames.length === 0 ? (
|
||
<div style={{ fontSize: 12, color: 'var(--ink-soft)' }}>比如同一系列有4个型号,规格/品牌/类型都一样,填这里可以一次性建好4个产品档案</div>
|
||
) : extraNames.map((n, idx) => (
|
||
<div key={idx} style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
|
||
<input className="input" value={n} onChange={e => updateExtraName(idx, e.target.value)} placeholder="型号" />
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => removeExtraName(idx)}><Trash2 size={12} /></button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<div className="field-label-row">
|
||
<span className="field-label">品牌</span>
|
||
<span className="field-manage-link" onClick={() => onManage('brand')}><Settings2 size={11} />品牌管理</span>
|
||
</div>
|
||
<select className="select" value={draft.brand} disabled={!cat} onChange={e => set({ brand: e.target.value })}>
|
||
<option value="">{!cat ? '请先选择产品大类' : brandOptions.length === 0 ? '该类目暂无品牌,点右上角管理添加' : '请选择'}</option>
|
||
{brandOptions.map(b => <option key={b} value={b}>{b}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<div className="field-label-row">
|
||
<span className="field-label">细分品类</span>
|
||
<span className="field-manage-link" onClick={() => onManage('type')}><Settings2 size={11} />品类管理</span>
|
||
</div>
|
||
<select className="select" value={draft.type} disabled={!cat} onChange={e => set({ type: e.target.value })}>
|
||
<option value="">{!cat ? '请先选择产品大类' : typeOptions.length === 0 ? '该类目暂无品类,点右上角管理添加' : '请选择'}</option>
|
||
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field"><span className="field-label">功率 / 容量</span><input className="input" value={draft.powerOrCapacity} onChange={e => set({ powerOrCapacity: e.target.value })} placeholder="如:10kVA / 100Ah / 5kWh" /></div>
|
||
<div className="field"><span className="field-label">电压</span><input className="input" value={draft.voltage} onChange={e => set({ voltage: e.target.value })} placeholder="如:48V / 192V / 220V" /></div>
|
||
</div>
|
||
<div className="field"><span className="field-label">正本价格(元)</span><input className="input" value={draft.costPrice} onChange={e => set({ costPrice: e.target.value })} placeholder="采购/成本价,人民币" /></div>
|
||
<div className="field"><span className="field-label">备注 / 卖点说明</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => set({ notes: e.target.value })} /></div>
|
||
</div>
|
||
<div className="modal-foot"><button className="btn" onClick={onCancel}>取消</button><button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProductDetail({ product: p, onEdit, onDelete, categories }) {
|
||
return (
|
||
<div className="panel">
|
||
<div className="panel-header">
|
||
<div className="panel-title"><div className="led-dot blue" />{p.name || '(未命名)'}</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={onDelete}><Trash2 size={12} /></button>
|
||
</div>
|
||
</div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2" style={{ marginBottom: 16 }}>
|
||
<Info icon={<Layers size={13} />} label="分类" value={categoryBreadcrumb(p, categories)} />
|
||
<Info icon={<Building2 size={13} />} label="品牌" value={p.brand || '—'} />
|
||
<Info icon={<Zap size={13} />} label="功率 / 容量" value={p.powerOrCapacity || '—'} />
|
||
<Info icon={<Zap size={13} />} label="电压" value={p.voltage || '—'} />
|
||
<Info icon={<Tag size={13} />} label="正本价格(元)" value={p.costPrice || '—'} />
|
||
</div>
|
||
{p.notes && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{p.notes}</div></div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- 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 (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
|
||
<button className="btn btn-sm" onClick={onBack}><ChevronRight size={13} style={{ transform: 'rotate(180deg)' }} />返回列表</button>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn" onClick={onPrint}><Printer size={13} />打印</button>
|
||
<button className="btn" disabled={pdfExporting} onClick={onExportPdf}><FileText size={13} />{pdfExporting ? '导出中…' : '导出PDF'}</button>
|
||
<button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel section-gap">
|
||
<div className="panel-header"><div className="panel-title">{isInvoice ? 'Invoice 信息' : 'Quote 信息'}</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<span className="field-label">卖方</span>
|
||
<select className="select" value={draft.sellerProfileId} onChange={e => onSellerChange(e.target.value)}>
|
||
{sellerProfiles.map(sp => <option key={sp.id} value={sp.id}>{sp.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">关联客户档案(可选,仅用于自动填名称)</span>
|
||
<select className="select" value={draft.customerId} onChange={e => onLinkCustomer(e.target.value)}>
|
||
<option value="">不关联,手动填写</option>
|
||
{sortedCustomers.map(c => <option key={c.id} value={c.id}>{quoteLinkLabel(c)}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field"><span className="field-label">单号</span><input className="input" value={draft.number} onChange={e => onUpdate({ number: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">日期</span><input type="date" className="input" value={draft.date} onChange={e => onUpdate({ date: e.target.value })} /></div>
|
||
<div className="field">
|
||
<span className="field-label">币种</span>
|
||
<select className="select" value={draft.currency} onChange={e => onCurrencyChange(e.target.value)}>
|
||
{CURRENCIES.map(c => <option key={c.code} value={c.code}>{c.code}({c.symbol})</option>)}
|
||
</select>
|
||
</div>
|
||
{isInvoice && (
|
||
<>
|
||
<div className="field">
|
||
<span className="field-label">Payment Terms</span>
|
||
<select className="select" value={draft.paymentTerms} onChange={e => onUpdate({ paymentTerms: e.target.value })}>
|
||
<option value="">请选择</option>
|
||
{PAYMENT_TERMS_OPTIONS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">Due Date(自动计算:日期 + 1 个月)</span>
|
||
<input className="input" value={dueDate} readOnly disabled />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<button className="btn btn-sm" onClick={onManageSellers}><Building2 size={12} />管理卖方信息 / 上传Logo</button>
|
||
|
||
<div className="grid-2" style={{ marginTop: 16 }}>
|
||
<div className="field">
|
||
<span className="field-label">{isInvoice ? 'Bill To 名称' : 'Quote To 名称'}(可换行,第一行作为主标题)</span>
|
||
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={draft.billToName} onChange={e => onUpdate({ billToName: e.target.value })} />
|
||
</div>
|
||
{isInvoice && <div className="field"><span className="field-label">Ship To 名称(留空则单据上不显示这一项)</span><input className="input" value={draft.shipToName} onChange={e => onUpdate({ shipToName: e.target.value })} /></div>}
|
||
</div>
|
||
|
||
<div className="field-label" style={{ marginTop: 18, marginBottom: 8 }}>产品明细</div>
|
||
{draft.lineItems.map(li => {
|
||
const suggestions = suggestLineId === li.id ? suggestionsFor(li) : [];
|
||
return (
|
||
<div key={li.id} style={{ display: 'grid', gridTemplateColumns: '2.4fr 0.8fr 1fr 1fr auto', gap: 10, padding: '10px 0', borderBottom: '1px solid var(--line)', alignItems: 'start' }}>
|
||
<div className="field" style={{ marginBottom: 0, position: 'relative' }}>
|
||
<textarea
|
||
className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 40 }}
|
||
placeholder="产品描述,第一行会加粗显示,换行后的内容会作为附属说明(小字)"
|
||
value={li.description}
|
||
onChange={e => { onUpdateLineItem(li.id, { description: e.target.value }); setSuggestLineId(li.id); }}
|
||
onFocus={() => setSuggestLineId(li.id)}
|
||
onBlur={() => setTimeout(() => setSuggestLineId(s => (s === li.id ? null : s)), 150)}
|
||
/>
|
||
{suggestions.length > 0 && (
|
||
<div className="panel" style={{ position: 'absolute', zIndex: 10, left: 0, right: 0, top: '100%', marginTop: 2, maxHeight: 180, overflowY: 'auto' }}>
|
||
{suggestions.map((h, i) => (
|
||
<div key={i} className="row" style={{ cursor: 'pointer', padding: '8px 10px' }} onMouseDown={() => applySuggestion(li.id, h)}>
|
||
<div className="row-main">
|
||
<div className="row-title" style={{ fontSize: 12.5 }}>{h.description.split('\n')[0]}</div>
|
||
<div className="row-sub">历史单价 {h.rate} · {h.date}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="数量" value={li.quantity} onChange={e => onUpdateLineItem(li.id, { quantity: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="单价" value={li.rate} onChange={e => onUpdateLineItem(li.id, { rate: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0, fontSize: 13, paddingTop: 8, color: 'var(--ink-soft)' }}>
|
||
{formatMoney((parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0), draft.currency)}
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onRemoveLineItem(li.id)}><Trash2 size={13} /></button>
|
||
</div>
|
||
);
|
||
})}
|
||
<button className="btn btn-sm" onClick={onAddLineItem} style={{ marginTop: 8 }}><Plus size={12} />添加一行</button>
|
||
|
||
{isInvoice && (
|
||
<div className="grid-2" style={{ marginTop: 16 }}>
|
||
<div className="field"><span className="field-label">银行信息 Bank Information</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 70 }} value={draft.bankInfo} onChange={e => onUpdate({ bankInfo: e.target.value })} placeholder="切换卖方/新建Invoice时会自动带入该卖方预填的银行信息" /></div>
|
||
<div className="field"><span className="field-label">签署人姓名</span><input className="input" value={draft.signerName} onChange={e => onUpdate({ signerName: e.target.value })} /></div>
|
||
</div>
|
||
)}
|
||
<div className="field" style={{ marginTop: 16 }}>
|
||
<span className="field-label">{isInvoice ? 'Terms 条款' : 'Notes 备注'}</span>
|
||
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 70 }} value={draft.notes} onChange={e => onUpdate({ notes: e.target.value })} />
|
||
{!isInvoice && (
|
||
<div className="checkbox-row" style={{ marginTop: 8 }}>
|
||
{QUOTE_NOTE_OPTIONS.map(line => (
|
||
<div key={line} className={`checkbox-pill ${activeNoteLines.includes(line) ? 'checked' : ''}`} onClick={() => onToggleNoteLine(line)}>
|
||
{activeNoteLines.includes(line) && <Check size={11} />} {line}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="doc-preview">
|
||
<div className="doc-row" style={{ alignItems: 'flex-start', marginBottom: 28 }}>
|
||
<div>
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="logo" style={{ maxHeight: 64, maxWidth: 220, objectFit: 'contain' }} />
|
||
) : (
|
||
<div style={{ fontSize: 26, fontWeight: 800, letterSpacing: 1 }}>{(seller && seller.name) || ''}</div>
|
||
)}
|
||
</div>
|
||
<div style={{ textAlign: 'right' }}>
|
||
<div style={{ fontSize: 34, fontWeight: 300, color: '#333', letterSpacing: 2 }}>{isInvoice ? 'INVOICE' : 'QUOTE'}</div>
|
||
<div style={{ color: '#999', marginTop: 2 }}># {draft.number}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="doc-row" style={{ marginBottom: 24 }}>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{seller && seller.name}</div>
|
||
<div style={{ color: '#555', fontSize: 13, marginTop: 4, maxWidth: 380, whiteSpace: 'pre-wrap' }}>{seller && seller.address}</div>
|
||
</div>
|
||
<div style={{ textAlign: 'right', fontSize: 13, minWidth: 200 }}>
|
||
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Date:</span><span>{draft.date}</span></div>
|
||
{isInvoice && (
|
||
<>
|
||
{draft.paymentTerms && <div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Payment Terms:</span><span>{draft.paymentTerms}</span></div>}
|
||
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Due Date:</span><span>{dueDate}</span></div>
|
||
<div className="doc-balance-due doc-row"><span style={{ fontWeight: 700 }}>Balance Due:</span><span style={{ fontWeight: 700 }}>{formatMoney(total, draft.currency)}</span></div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{!isInvoice ? (
|
||
<div style={{ marginBottom: 28 }}>
|
||
<div className="doc-label">Quote To:</div>
|
||
{(() => {
|
||
const billLines = (draft.billToName || '').split('\n');
|
||
return (
|
||
<>
|
||
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
|
||
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="doc-row" style={{ marginBottom: 28 }}>
|
||
<div>
|
||
<div className="doc-label">Bill To:</div>
|
||
{(() => {
|
||
const billLines = (draft.billToName || '').split('\n');
|
||
return (
|
||
<>
|
||
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
|
||
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
{draft.shipToName && draft.shipToName.trim() && (
|
||
<div>
|
||
<div className="doc-label">Ship To:</div>
|
||
<div style={{ fontWeight: 700, marginTop: 4 }}>{draft.shipToName}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<table className="doc-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Item</th>
|
||
<th className="num">Quantity</th>
|
||
<th className="num">Rate</th>
|
||
<th className="num">Amount</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{draft.lineItems.map(li => {
|
||
const lines = (li.description || '').split('\n');
|
||
const amount = (parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0);
|
||
return (
|
||
<tr key={li.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 700 }}>{lines[0] || '—'}</div>
|
||
{lines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12 }}>{l}</div>)}
|
||
</td>
|
||
<td className="num">{li.quantity || '—'}</td>
|
||
<td className="num">{li.rate ? formatMoney(parseFloat(li.rate), draft.currency) : '—'}</td>
|
||
<td className="num">{formatMoney(amount, draft.currency)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="doc-totals">
|
||
<div className="doc-totals-row total"><span>Total:</span><span>{formatMoney(total, draft.currency)}</span></div>
|
||
</div>
|
||
|
||
{isInvoice && (draft.bankInfo || (seller && seller.bankInfo)) && (
|
||
<div style={{ margin: '24px 0' }}>
|
||
<div className="doc-label" style={{ marginBottom: 4 }}>Bank Information:</div>
|
||
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.bankInfo || (seller && seller.bankInfo)}</div>
|
||
</div>
|
||
)}
|
||
|
||
{draft.notes && (
|
||
<div style={{ margin: '24px 0' }}>
|
||
<div className="doc-label" style={{ marginBottom: 4 }}>{isInvoice ? 'Terms:' : 'Notes:'}</div>
|
||
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.notes}</div>
|
||
</div>
|
||
)}
|
||
|
||
{isInvoice && (
|
||
<div style={{ textAlign: 'right', marginTop: 30 }}>
|
||
<div style={{ color: '#3B5BA9', fontStyle: 'italic', fontSize: 13 }}>For and on behalf of</div>
|
||
<div style={{ color: '#3B5BA9', fontWeight: 700 }}>{seller && seller.name}</div>
|
||
{draft.signerName && <div style={{ fontStyle: 'italic', fontSize: 19, margin: '10px 0 2px', fontFamily: 'Georgia, serif' }}>{draft.signerName}</div>}
|
||
<div style={{ borderTop: '1px dotted #aaa', width: 200, marginLeft: 'auto', marginTop: 8 }} />
|
||
<div style={{ color: '#3B5BA9', fontStyle: 'italic', fontSize: 12, marginTop: 4 }}>Authorized Signature(s)</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
|
||
<button className="btn btn-sm" onClick={onBack}><ChevronRight size={13} style={{ transform: 'rotate(180deg)' }} />返回列表</button>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn" onClick={onPrint}><Printer size={13} />打印</button>
|
||
<button className="btn" disabled={pdfExporting} onClick={onExportPdf}><FileText size={13} />{pdfExporting ? '导出中…' : '导出PDF'}</button>
|
||
<button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel section-gap">
|
||
<div className="panel-header"><div className="panel-title">箱单信息</div></div>
|
||
<div className="panel-body" style={{ padding: 18 }}>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<div className="field-label-row">
|
||
<span className="field-label">卖方</span>
|
||
<label className="field-manage-link" style={{ cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={draft.showSeller} onChange={e => onUpdate({ showSeller: e.target.checked })} style={{ marginRight: 3 }} />
|
||
单据上显示
|
||
</label>
|
||
</div>
|
||
<select className="select" value={draft.sellerProfileId} onChange={e => onSellerChange(e.target.value)}>
|
||
{sellerProfiles.map(sp => <option key={sp.id} value={sp.id}>{sp.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">关联客户档案(可选,仅用于自动填名称)</span>
|
||
<select className="select" value={draft.customerId} onChange={e => onLinkCustomer(e.target.value)}>
|
||
<option value="">不关联,手动填写</option>
|
||
{sortedCustomers.map(c => <option key={c.id} value={c.id}>{quoteLinkLabel(c)}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field"><span className="field-label">单号</span><input className="input" value={draft.number} onChange={e => onUpdate({ number: e.target.value })} /></div>
|
||
<div className="field"><span className="field-label">日期</span><input type="date" className="input" value={draft.date} onChange={e => onUpdate({ date: e.target.value })} /></div>
|
||
</div>
|
||
<button className="btn btn-sm" onClick={onManageSellers}><Building2 size={12} />管理卖方信息 / 上传Logo</button>
|
||
|
||
<div className="field" style={{ marginTop: 16 }}>
|
||
<div className="field-label-row">
|
||
<span className="field-label">客户名称 / 地址(可换行,第一行作为主标题)</span>
|
||
<label className="field-manage-link" style={{ cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={draft.showBillTo} onChange={e => onUpdate({ showBillTo: e.target.checked })} style={{ marginRight: 3 }} />
|
||
单据上显示
|
||
</label>
|
||
</div>
|
||
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={draft.billToName} onChange={e => onUpdate({ billToName: e.target.value })} />
|
||
</div>
|
||
|
||
<div className="field-label" style={{ marginTop: 18, marginBottom: 8 }}>装箱明细(按产品行汇总)</div>
|
||
{draft.lineItems.map(li => (
|
||
<div key={li.id} style={{ display: 'grid', gridTemplateColumns: '2fr 0.8fr 0.8fr 1fr 1fr 1fr auto', gap: 10, padding: '10px 0', borderBottom: '1px solid var(--line)', alignItems: 'start' }}>
|
||
<div className="field" style={{ marginBottom: 0 }}>
|
||
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 40 }} placeholder="品名" value={li.description} onChange={e => onUpdateLineItem(li.id, { description: e.target.value })} />
|
||
</div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总件数" value={li.qty} onChange={e => onUpdateLineItem(li.id, { qty: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总箱数" value={li.cartons} onChange={e => onUpdateLineItem(li.id, { cartons: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总毛重(kg)" value={li.grossWeight} onChange={e => onUpdateLineItem(li.id, { grossWeight: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总净重(kg)" value={li.netWeight} onChange={e => onUpdateLineItem(li.id, { netWeight: e.target.value })} /></div>
|
||
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总体积(CBM)" value={li.volume} onChange={e => onUpdateLineItem(li.id, { volume: e.target.value })} /></div>
|
||
<button className="btn btn-sm btn-ghost btn-danger" style={{ marginTop: 2 }} onClick={() => onRemoveLineItem(li.id)}><Trash2 size={13} /></button>
|
||
</div>
|
||
))}
|
||
<button className="btn btn-sm" style={{ marginTop: 10 }} onClick={onAddLineItem}><Plus size={13} />新增一行</button>
|
||
|
||
<div className="field" style={{ marginTop: 16 }}><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => onUpdate({ notes: e.target.value })} /></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="doc-preview">
|
||
<div className="doc-row" style={{ alignItems: 'flex-start', marginBottom: 28 }}>
|
||
<div>
|
||
{draft.showSeller ? (
|
||
logoUrl ? (
|
||
<img src={logoUrl} alt="logo" style={{ maxHeight: 64, maxWidth: 220, objectFit: 'contain' }} />
|
||
) : (
|
||
<div style={{ fontSize: 26, fontWeight: 800, letterSpacing: 1 }}>{(seller && seller.name) || ''}</div>
|
||
)
|
||
) : <div />}
|
||
</div>
|
||
<div style={{ textAlign: 'right' }}>
|
||
<div style={{ fontSize: 30, fontWeight: 300, color: '#333', letterSpacing: 2 }}>PACKING LIST</div>
|
||
<div style={{ color: '#999', marginTop: 2 }}># {draft.number}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="doc-row" style={{ marginBottom: 24 }}>
|
||
<div>
|
||
{draft.showSeller && (
|
||
<>
|
||
<div style={{ fontWeight: 700 }}>{seller && seller.name}</div>
|
||
<div style={{ color: '#555', fontSize: 13, marginTop: 4, maxWidth: 380, whiteSpace: 'pre-wrap' }}>{seller && seller.address}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div style={{ textAlign: 'right', fontSize: 13, minWidth: 200 }}>
|
||
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Date:</span><span>{draft.date}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
{draft.showBillTo && (
|
||
<div style={{ marginBottom: 28 }}>
|
||
<div className="doc-label">Consignee / Customer:</div>
|
||
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
|
||
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
|
||
</div>
|
||
)}
|
||
|
||
<table className="doc-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Description of Goods</th>
|
||
<th className="num">Qty</th>
|
||
<th className="num">Cartons</th>
|
||
<th className="num">G.W. (kg)</th>
|
||
<th className="num">N.W. (kg)</th>
|
||
<th className="num">Volume (CBM)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{draft.lineItems.map(li => {
|
||
const lines = (li.description || '').split('\n');
|
||
return (
|
||
<tr key={li.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 700 }}>{lines[0] || '—'}</div>
|
||
{lines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12 }}>{l}</div>)}
|
||
</td>
|
||
<td className="num">{li.qty || '—'}</td>
|
||
<td className="num">{li.cartons || '—'}</td>
|
||
<td className="num">{li.grossWeight || '—'}</td>
|
||
<td className="num">{li.netWeight || '—'}</td>
|
||
<td className="num">{li.volume || '—'}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="doc-totals">
|
||
<div className="doc-totals-row total"><span>Total:</span><span>{totals.qty} pcs · {totals.cartons} ctns · {totals.grossWeight} kg G.W. · {totals.netWeight} kg N.W. · {totals.volume} CBM</span></div>
|
||
</div>
|
||
|
||
{draft.notes && (
|
||
<div style={{ margin: '24px 0' }}>
|
||
<div className="doc-label" style={{ marginBottom: 4 }}>Notes:</div>
|
||
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.notes}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Info({ icon, label, value }) {
|
||
return (
|
||
<div>
|
||
<div className="field-label" style={{ display: 'flex', alignItems: 'center', gap: 5 }}>{icon}{label}</div>
|
||
<div style={{ fontSize: 13 }}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|