sync: Add files via upload (A@87b0a27431d8596edf55a7e623f944ca2f39f234)

This commit is contained in:
github-actions[bot] 2026-05-28 05:54:23 +00:00
parent 37ecf1a056
commit 3f7377b142
83 changed files with 17105 additions and 966 deletions

View File

@ -0,0 +1,23 @@
The file /home/project/src/pages/MemberManage.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
44→ password: ''
45→ });
46→ const [editingMaster, setEditingMaster] = useState(false);
47→
48→ // 公司信息
49→ const [companyInfo, setCompanyInfo] = useState<{
50→ name: string;
51→ contact_phone: string | null;
52→ role: string;
53→ } | null>(null);
54→
55→ // 帮助系统弹窗状态
56→ const [showUserManual, setShowUserManual] = useState(false);
57→ const [showFeedback, setShowFeedback] = useState(false);
58→ const [showAbout, setShowAbout] = useState(false);
59→
60→ useEffect(() => {
61→ if (!auth.user) return;
62→ // 只有主账号可以管理子账号
63→ if (!auth.user.is_master) {

View File

@ -0,0 +1,68 @@
The file /home/project/src/pages/MemberManage.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
382→ </motion.button>
383→ </div>
384→ </div>
385→
386→ {/* 帮助与支持卡片 */}
387→ <div className="bg-white rounded-lg shadow-sm p-4 md:p-6 mb-6">
388→ <div className="flex items-center gap-2 mb-4">
389→ <BookOpen className="w-5 h-5 text-blue-600" />
390→ <h2 className="font-semibold text-gray-800">帮助与支持</h2>
391→ </div>
392→ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
393→ <motion.button
394→ whileHover={{ scale: 1.02 }}
395→ whileTap={{ scale: 0.98 }}
396→ onClick={() => setShowUserManual(true)}
397→ className="flex items-center gap-3 p-4 bg-blue-50 rounded-xl hover:bg-blue-100 transition-colors text-left"
398→ >
399→ <div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
400→ <BookOpen className="w-5 h-5 text-blue-600" />
401→ </div>
402→ <div>
403→ <p className="font-medium text-gray-800">用户手册</p>
404→ <p className="text-xs text-gray-500">详细使用指南</p>
405→ </div>
406→ </motion.button>
407→
408→ <motion.button
409→ whileHover={{ scale: 1.02 }}
410→ whileTap={{ scale: 0.98 }}
411→ onClick={() => setShowFeedback(true)}
412→ className="flex items-center gap-3 p-4 bg-emerald-50 rounded-xl hover:bg-emerald-100 transition-colors text-left"
413→ >
414→ <div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
415→ <MessageSquare className="w-5 h-5 text-emerald-600" />
416→ </div>
417→ <div>
418→ <p className="font-medium text-gray-800">用户反馈</p>
419→ <p className="text-xs text-gray-500">提交问题或建议</p>
420→ </div>
421→ </motion.button>
422→
423→ <motion.button
424→ whileHover={{ scale: 1.02 }}
425→ whileTap={{ scale: 0.98 }}
426→ onClick={() => setShowAbout(true)}
427→ className="flex items-center gap-3 p-4 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors text-left"
428→ >
429→ <div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
430→ <Info className="w-5 h-5 text-purple-600" />
431→ </div>
432→ <div>
433→ <p className="font-medium text-gray-800">关于</p>
434→ <p className="text-xs text-gray-500">版本信息与版权</p>
435→ </div>
436→ </motion.button>
437→ </div>
438→ </div>
439→
440→ {/* 子账号列表 */}
441→ {loading ? (
442→ <div className="text-center py-12 text-gray-400">加载中...</div>
443→ ) : members.length === 0 ? (
444→ <div className="bg-white rounded-lg shadow-sm p-8 text-center">
445→ <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
446→ <Users className="w-8 h-8 text-gray-400" />

View File

@ -0,0 +1,56 @@
The file /home/project/src/pages/MemberManage.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
784→ </div>
785→ )}
786→ </AnimatePresence>
787→
788→ {/* 删除确认弹窗 */}
789→ <AnimatePresence>
790→ {showDeleteConfirm && (
791→ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
792→ <motion.div
793→ initial={{ opacity: 0, scale: 0.95 }}
794→ animate={{ opacity: 1, scale: 1 }}
795→ exit={{ opacity: 0, scale: 0.95 }}
796→ transition={{ duration: 0.2 }}
797→ className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6"
798→ >
799→ <div className="text-center">
800→ <div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
801→ <AlertCircle className="w-6 h-6 text-red-500" />
802→ </div>
803→ <h3 className="text-lg font-bold text-gray-800 mb-2">确认删除</h3>
804→ <p className="text-gray-500 mb-6">删除后该子账号将无法登录,是否确认删除?</p>
805→ <div className="flex gap-3">
806→ <button
807→ onClick={() => setShowDeleteConfirm(null)}
808→ className="flex-1 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
809→ >
810→ 取消
811→ </button>
812→ <button
813→ onClick={() => handleDelete(showDeleteConfirm)}
814→ className="flex-1 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors font-medium"
815→ >
816→ 确认删除
817→ </button>
818→ </div>
819→ </div>
820→ </motion.div>
821→ </div>
822→ )}
823→ </AnimatePresence>
824→
825→ {/* 用户手册弹窗 */}
826→ <UserManual isOpen={showUserManual} onClose={() => setShowUserManual(false)} />
827→
828→ {/* 用户反馈弹窗 */}
829→ <FeedbackModal isOpen={showFeedback} onClose={() => setShowFeedback(false)} />
830→
831→ {/* 关于窗口 */}
832→ <AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
833→ </div>
834→ );
835→}
836→

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1 @@
{"files":["src/components/UserManual.tsx","src/types/index.ts","src/hooks/usePlanData.ts","src/styles/index.css","src/supabase/types.ts","src/utils/helpers.ts","src/components/plans/PlanGroup.tsx","src/components/warehouse/OutboundForm.tsx","src/components/warehouse/ProductList.tsx","src/components/warehouse/InboundRecords.tsx","src/components/warehouse/InboundForm.tsx","src/components/warehouse/ProductForm.tsx","src/pages/purchaser/WarehouseManage.tsx","src/pages/purchaser/NewPlan.tsx","src/pages/purchaser/NewWashingPlan.tsx","src/pages/textile/PlanOverview.tsx","src/pages/textile/FabricWarehouse.tsx","src/__tests__/utils/helpers.test.ts"],"pattern":"克重|weight","path":"src","totalFiles":18}

View File

@ -0,0 +1,29 @@
The file /home/project/src/components/warehouse/ProductList.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
29→ onDelete
30→}: ProductListProps) {
31→ const { isMobile } = useResponsive();
32→
33→ // 按名称和克重分组(优先使用 total_weight无克重的产品直接按名称分组
34→ const groupProductsByNameAndWeight = () => {
35→ const groups: Record<string, Record<string, ProductWithInventory[]>> = {};
36→ products.forEach(product => {
37→ if (!groups[product.product_name]) {
38→ groups[product.product_name] = {};
39→ }
40→ const weight = product.total_weight || product.weight;
41→ // 如果没有克重,使用特殊标记 '__no_weight__'
42→ const weightKey = weight ? String(weight) : '__no_weight__';
43→ if (!groups[product.product_name][weightKey]) {
44→ groups[product.product_name][weightKey] = [];
45→ }
46→ groups[product.product_name][weightKey].push(product);
47→ });
48→ return groups;
49→ };
50→
51→ // 格式化克重显示(含经线/纬线细分)
52→ const formatWeightDisplay = (product: ProductWithInventory) => {
53→ const total = product.total_weight || product.weight;
54→ if (product.warp_weight && product.weft_weight) {

View File

@ -0,0 +1,108 @@
The file /home/project/src/components/warehouse/ProductList.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
121→ exit={{ height: 0, opacity: 0 }}
122→ transition={animationConfig.transition}
123→ className="border-t border-gray-100"
124→ >
125→ <div className="p-4 space-y-3">
126→ {Object.entries(weightGroups).map(([weight, products]) => {
127→ const weightKey = `${productName}-${weight}`;
128→ const isWeightExpanded = expandedGroups[weightKey];
129→ const hasNoWeight = weight === '__no_weight__';
130→
131→ // 如果没有克重,直接显示产品列表,不显示克重折叠卡片
132→ if (hasNoWeight) {
133→ return (
134→ <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
135→ <table className="w-full">
136→ <thead className="bg-white/50 border-b border-gray-100">
137→ <tr>
138→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">颜色</th>
139→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">产品码</th>
140→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">库存</th>
141→ <th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
142→ </tr>
143→ </thead>
144→ <tbody className="divide-y divide-gray-100">
145→ {products.map(product => (
146→ <ProductRow
147→ key={product.id}
148→ product={product}
149→ ratios={productRatios[product.id]}
150→ onViewRecords={() => onViewRecords(product)}
151→ onViewPriceHistory={() => onViewPriceHistory(product)}
152→ onViewStockHistory={() => onViewStockHistory(product)}
153→ onEdit={() => onEdit(product)}
154→ onDelete={() => onDelete(product.id)}
155→ />
156→ ))}
157→ </tbody>
158→ </table>
159→ </div>
160→ );
161→ }
162→
163→ return (
164→ <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
165→ <div
166→ onClick={() => onToggleGroup(weightKey)}
167→ className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 transition-colors"
168→ >
169→ <div className="flex items-center gap-2">
170→ <div className={`w-6 h-6 rounded flex items-center justify-center transition-colors ${isWeightExpanded ? 'bg-blue-100' : 'bg-white'}`}>
171→ {isWeightExpanded ? <ChevronUp size={14} className="text-blue-600" /> : <ChevronDown size={14} className="text-gray-500" />}
172→ </div>
173→ <span className="font-medium text-gray-800">{weight}g</span>
174→ {products[0]?.warp_weight && products[0]?.weft_weight && (
175→ <span className="text-xs text-gray-400 ml-1">经{products[0].warp_weight}+纬{products[0].weft_weight}</span>
176→ )}
177→ <span className="text-xs text-gray-500">({products.length}个颜色)</span>
178→ </div>
179→ </div>
180→
181→ <AnimatePresence>
182→ {isWeightExpanded && (
183→ <motion.div
184→ initial={{ height: 0, opacity: 0 }}
185→ animate={{ height: 'auto', opacity: 1 }}
186→ exit={{ height: 0, opacity: 0 }}
187→ transition={animationConfig.fastTransition}
188→ className="border-t border-gray-200"
189→ >
190→ <table className="w-full">
191→ <thead className="bg-white/50 border-b border-gray-100">
192→ <tr>
193→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">颜色</th>
194→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">产品码</th>
195→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">库存</th>
196→ <th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
197→ </tr>
198→ </thead>
199→ <tbody className="divide-y divide-gray-100">
200→ {products.map(product => (
201→ <ProductRow
202→ key={product.id}
203→ product={product}
204→ ratios={productRatios[product.id]}
205→ onViewRecords={() => onViewRecords(product)}
206→ onViewPriceHistory={() => onViewPriceHistory(product)}
207→ onViewStockHistory={() => onViewStockHistory(product)}
208→ onEdit={() => onEdit(product)}
209→ onDelete={() => onDelete(product.id)}
210→ />
211→ ))}
212→ </tbody>
213→ </table>
214→ </motion.div>
215→ )}
216→ </AnimatePresence>
217→ </div>
218→ );
219→ })}
220→ </div>
221→ </motion.div>
222→ )}
223→ </AnimatePresence>
224→ </motion.div>
225→ );

View File

@ -0,0 +1,149 @@
The file /home/project/src/components/warehouse/ProductList.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
121→ exit={{ height: 0, opacity: 0 }}
122→ transition={animationConfig.transition}
123→ className="border-t border-gray-100"
124→ >
125→ <div className="p-4 space-y-3">
126→ {Object.entries(weightGroups).map(([weight, products]) => {
127→ const weightKey = `${productName}-${weight}`;
128→ const isWeightExpanded = expandedGroups[weightKey];
129→ const hasNoWeight = weight === '__no_weight__';
130→ const weightCount = Object.keys(weightGroups).length;
131→ const isSingleWeight = weightCount === 1;
132→
133→ // 如果没有克重,直接显示产品列表,不显示克重折叠卡片
134→ if (hasNoWeight) {
135→ return (
136→ <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
137→ <table className="w-full">
138→ <thead className="bg-white/50 border-b border-gray-100">
139→ <tr>
140→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">颜色</th>
141→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">产品码</th>
142→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">库存</th>
143→ <th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
144→ </tr>
145→ </thead>
146→ <tbody className="divide-y divide-gray-100">
147→ {products.map(product => (
148→ <ProductRow
149→ key={product.id}
150→ product={product}
151→ ratios={productRatios[product.id]}
152→ onViewRecords={() => onViewRecords(product)}
153→ onViewPriceHistory={() => onViewPriceHistory(product)}
154→ onViewStockHistory={() => onViewStockHistory(product)}
155→ onEdit={() => onEdit(product)}
156→ onDelete={() => onDelete(product.id)}
157→ />
158→ ))}
159→ </tbody>
160→ </table>
161→ </div>
162→ );
163→ }
164→
165→ // 如果只有一种克重,直接显示克重信息和产品列表,不需要折叠
166→ if (isSingleWeight) {
167→ return (
168→ <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
169→ <div className="flex items-center gap-2 p-3 bg-gray-100 border-b border-gray-200">
170→ <span className="font-medium text-gray-800">{weight}g</span>
171→ {products[0]?.warp_weight && products[0]?.weft_weight && (
172→ <span className="text-xs text-gray-500 ml-1">经{products[0].warp_weight}+纬{products[0].weft_weight}</span>
173→ )}
174→ <span className="text-xs text-gray-500">({products.length}个颜色)</span>
175→ </div>
176→ <table className="w-full">
177→ <thead className="bg-white/50 border-b border-gray-100">
178→ <tr>
179→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">颜色</th>
180→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">产品码</th>
181→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">库存</th>
182→ <th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
183→ </tr>
184→ </thead>
185→ <tbody className="divide-y divide-gray-100">
186→ {products.map(product => (
187→ <ProductRow
188→ key={product.id}
189→ product={product}
190→ ratios={productRatios[product.id]}
191→ onViewRecords={() => onViewRecords(product)}
192→ onViewPriceHistory={() => onViewPriceHistory(product)}
193→ onViewStockHistory={() => onViewStockHistory(product)}
194→ onEdit={() => onEdit(product)}
195→ onDelete={() => onDelete(product.id)}
196→ />
197→ ))}
198→ </tbody>
199→ </table>
200→ </div>
201→ );
202→ }
203→
204→ return (
205→ <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
206→ <div
207→ onClick={() => onToggleGroup(weightKey)}
208→ className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 transition-colors"
209→ >
210→ <div className="flex items-center gap-2">
211→ <div className={`w-6 h-6 rounded flex items-center justify-center transition-colors ${isWeightExpanded ? 'bg-blue-100' : 'bg-white'}`}>
212→ {isWeightExpanded ? <ChevronUp size={14} className="text-blue-600" /> : <ChevronDown size={14} className="text-gray-500" />}
213→ </div>
214→ <span className="font-medium text-gray-800">{weight}g</span>
215→ {products[0]?.warp_weight && products[0]?.weft_weight && (
216→ <span className="text-xs text-gray-400 ml-1">经{products[0].warp_weight}+纬{products[0].weft_weight}</span>
217→ )}
218→ <span className="text-xs text-gray-500">({products.length}个颜色)</span>
219→ </div>
220→ </div>
221→
222→ <AnimatePresence>
223→ {isWeightExpanded && (
224→ <motion.div
225→ initial={{ height: 0, opacity: 0 }}
226→ animate={{ height: 'auto', opacity: 1 }}
227→ exit={{ height: 0, opacity: 0 }}
228→ transition={animationConfig.fastTransition}
229→ className="border-t border-gray-200"
230→ >
231→ <table className="w-full">
232→ <thead className="bg-white/50 border-b border-gray-100">
233→ <tr>
234→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">颜色</th>
235→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">产品码</th>
236→ <th className="px-3 py-2 text-left text-xs font-medium text-gray-500">库存</th>
237→ <th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
238→ </tr>
239→ </thead>
240→ <tbody className="divide-y divide-gray-100">
241→ {products.map(product => (
242→ <ProductRow
243→ key={product.id}
244→ product={product}
245→ ratios={productRatios[product.id]}
246→ onViewRecords={() => onViewRecords(product)}
247→ onViewPriceHistory={() => onViewPriceHistory(product)}
248→ onViewStockHistory={() => onViewStockHistory(product)}
249→ onEdit={() => onEdit(product)}
250→ onDelete={() => onDelete(product.id)}
251→ />
252→ ))}
253→ </tbody>
254→ </table>
255→ </motion.div>
256→ )}
257→ </AnimatePresence>
258→ </div>
259→ );
260→ })}
261→ </div>
262→ </motion.div>
263→ )}
264→ </AnimatePresence>
265→ </motion.div>
266→ );

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1 @@
{"results":[{"file":"src/components/OnboardingGuide.tsx","matches":[{"line":42,"content":" description: '在\"原坯布仓库\"中查看和管理所有入库的坯布,支持出入库历史查询。',"}]},{"file":"src/components/UserManual.tsx","matches":[{"line":30,"content":" { title: '库存管理', content: '在\"原坯布仓库\"中管理所有入库的坯布,支持按产品名称、克重、颜色分组查看,可查询出入库历史。' },"}]},{"file":"src/pages/purchaser/WarehouseManage.tsx","matches":[{"line":418,"content":" <h1 className=\"text-base sm:text-lg font-bold text-gray-900\">原坯布仓库</h1>"}]},{"file":"src/pages/purchaser/NewWashingPlan.tsx","matches":[{"line":39,"content":" // 获取原坯布仓库产品(包含库存计算)"},{"line":298,"content":" 原坯布仓库中没有可用产品,请先创建产品并入库"}]},{"file":"src/pages/purchaser/NewPlan.tsx","matches":[{"line":16,"content":"// 产品类型定义(来自原坯布仓库)"},{"line":52,"content":" // 从原坯布仓库选择的产品"},{"line":81,"content":" // 从原坯布仓库获取产品数据"},{"line":291,"content":" {/* 原坯布仓库产品选择 - 搜索+筛选形式 */}"}]},{"file":"src/pages/purchaser/Dashboard.tsx","matches":[{"line":27,"content":" { id: 'warehouse', icon: Warehouse, label: '原坯布仓库', path: '/purchaser/warehouse', gradient: 'from-amber-500 to-orange-500', description: '管理原坯布库存' },"}]}],"pattern":"原坯布仓库","path":"src","totalMatches":10}

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1 @@
{"results":[{"file":"src/pages/purchaser/NewPlan.tsx","matches":[{"line":294,"content":" <span className=\"w-1 h-4 bg-blue-500 rounded\"></span>选择原坯布产品"},{"line":632,"content":" <h3 className=\"text-lg font-semibold text-gray-900\">选择原坯布产品</h3>"}]}],"pattern":"选择原坯布产品","path":"src","totalMatches":2}

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\nsrc/pages/purchaser/WarehouseManage.tsx(192,27): error TS2345: Argument of type 'number | null' is not assignable to parameter of type 'number'.\n Type 'null' is not assignable to type 'number'.\nELIFECYCLE Command failed with exit code 2."}

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,28 @@
The file /home/project/src/pages/purchaser/WarehouseManage.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
182→ alert(`产品码 "${productData.fabric_code}" 已存在,请使用其他产品码`);
183→ return;
184→ }
185→
186→ // 校验2色号在"成品名称-克重-颜色"组合中唯一
187→ // 即:同一个"成品名称-克重-颜色"下,色号不能重复
188→ const { data: existingColorCode } = await supabase
189→ .from('products')
190→ .select('id, fabric_code, color_code')
191→ .eq('company_id', auth.company.id)
192→ .eq('product_name', productData.product_name)
193→ .eq('total_weight', productData.total_weight || 0)
194→ .eq('color', productData.color)
195→ .neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
196→ .maybeSingle();
197→
198→ if (existingColorCode) {
199→ alert(`"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${existingColorCode.color_code}",请使用其他色号`);
200→ return;
201→ }
202→
203→ let productId: string;
204→
205→ if (editingProduct) {
206→ // 更新产品

View File

@ -0,0 +1,29 @@
The file /home/project/src/pages/purchaser/WarehouseManage.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
182→ alert(`产品码 "${productData.fabric_code}" 已存在,请使用其他产品码`);
183→ return;
184→ }
185→
186→ // 校验2色号在"成品名称-克重-颜色"组合中唯一
187→ // 即:同一个"成品名称-克重-颜色"下,色号不能重复
188→ const { data: existingColorCode } = await supabase
189→ .from('products')
190→ .select('id, fabric_code')
191→ .eq('company_id', auth.company.id)
192→ .eq('product_name', productData.product_name)
193→ .eq('total_weight', productData.total_weight || 0)
194→ .eq('color', productData.color)
195→ .eq('color_code', productData.color_code)
196→ .neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
197→ .maybeSingle();
198→
199→ if (existingColorCode) {
200→ alert(`"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号`);
201→ return;
202→ }
203→
204→ let productId: string;
205→
206→ if (editingProduct) {
207→ // 更新产品

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,49 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
208→ setProductSuggestions([]);
209→ }
210→ };
211→
212→ // 从数据库查询已存在的最大产品码数字,生成下一个
213→ const generateNextFabricCode = async (companyId: string, prefix: string): Promise<string> => {
214→ if (!prefix) return '';
215→
216→ try {
217→ // 查询该公司所有以该前缀开头的产品码
218→ const { data, error } = await supabase
219→ .from('products')
220→ .select('fabric_code')
221→ .eq('company_id', companyId)
222→ .ilike('fabric_code', `${prefix}%`);
223→
224→ if (error || !data || data.length === 0) {
225→ // 如果没有匹配的产品从1开始
226→ return `${prefix}1`;
227→ }
228→
229→ // 提取所有产品码中的数字,找出最大值
230→ let maxNum = 0;
231→ data.forEach(item => {
232→ const code = item.fabric_code;
233→ const match = code.match(/^(.*?)(\d+)$/);
234→ if (match && match[1].toUpperCase() === prefix.toUpperCase()) {
235→ const num = parseInt(match[2], 10);
236→ if (num > maxNum) {
237→ maxNum = num;
238→ }
239→ }
240→ });
241→
242→ // 返回前缀 + (最大数字 + 1)
243→ return `${prefix}${maxNum + 1}`;
244→ } catch (err) {
245→ console.error('生成产品码失败:', err);
246→ return `${prefix}1`;
247→ }
248→ };
249→
250→ // 生成递增的色号(如 02 → 03
251→ const generateNextColorCode = (currentCode: string): string => {
252→ if (!currentCode) return '01';
253→ const num = parseInt(currentCode, 10);

View File

@ -0,0 +1,58 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
255→ // 保持两位数格式
256→ return nextNum < 10 ? `0${nextNum}` : String(nextNum);
257→ };
258→
259→ // 选择产品建议
260→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
261→ // 获取该产品的纱线配比
262→ const ratios = productRatios[suggestion.id] || [];
263→
264→ // 提取产品码前缀并生成新的产品码
265→ let newFabricCode = '';
266→ const match = suggestion.fabric_code.match(/^(.*?)(\d+)$/);
267→ if (match && companyId) {
268→ const prefix = match[1];
269→ newFabricCode = await generateNextFabricCode(companyId, prefix);
270→ } else {
271→ newFabricCode = suggestion.fabric_code;
272→ }
273→
274→ setFormData(prev => ({
275→ ...prev,
276→ product_name: suggestion.product_name,
277→ // 自动填写克重
278→ total_weight: suggestion.total_weight ? String(suggestion.total_weight) : '',
279→ warp_weight: suggestion.warp_weight ? String(suggestion.warp_weight) : '',
280→ weft_weight: suggestion.weft_weight ? String(suggestion.weft_weight) : '',
281→ // 颜色需要用户自己输入,不自动填写
282→ color: '',
283→ // 产品码自动生成(基于数据库最大值+1
284→ fabric_code: newFabricCode,
285→ // 色号自动生成(递增)
286→ color_code: generateNextColorCode(suggestion.color_code),
287→ // 自动填写生产采购价
288→ production_price: suggestion.production_price ? String(suggestion.production_price) : '',
289→ // 自动填写纱线配比
290→ yarn_ratios: ratios.length > 0
291→ ? ratios.map(r => ({ yarn_name: r.yarn_name, ratio: r.ratio, yarn_type: (r.yarn_type as 'warp' | 'weft' | 'all') || 'all' }))
292→ : [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
293→ image_url: suggestion.image_url || undefined
294→ }));
295→
296→ if (suggestion.image_url) {
297→ setPreviewUrl(suggestion.image_url);
298→ }
299→
300→ setShowProductSuggestions(false);
301→
302→ // 加载该产品的克重建议
303→ searchWeights(suggestion.product_name);
304→ };
305→
306→ // 处理总克重输入
307→ const handleTotalWeightChange = (value: string) => {
308→ setFormData(prev => ({ ...prev, total_weight: value }));
309→

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,52 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
246→ return `${prefix}1`;
247→ }
248→ };
249→
250→ // 从数据库查询已存在的最大色号数字,生成下一个
251→ const generateNextColorCode = async (
252→ companyId: string,
253→ productName: string,
254→ totalWeight: number | null,
255→ color: string
256→ ): Promise<string> => {
257→ try {
258→ // 查询该产品名称-克重-颜色组合下的所有色号
259→ const { data, error } = await supabase
260→ .from('products')
261→ .select('color_code')
262→ .eq('company_id', companyId)
263→ .eq('product_name', productName)
264→ .eq('total_weight', totalWeight || 0)
265→ .eq('color', color);
266→
267→ if (error || !data || data.length === 0) {
268→ // 如果没有匹配的产品从01开始
269→ return '01';
270→ }
271→
272→ // 提取所有色号中的数字,找出最大值
273→ let maxNum = 0;
274→ data.forEach(item => {
275→ const code = item.color_code;
276→ const num = parseInt(code, 10);
277→ if (!isNaN(num) && num > maxNum) {
278→ maxNum = num;
279→ }
280→ });
281→
282→ // 返回最大数字 + 1保持两位数格式
283→ const nextNum = maxNum + 1;
284→ return nextNum < 10 ? `0${nextNum}` : String(nextNum);
285→ } catch (err) {
286→ console.error('生成色号失败:', err);
287→ return '01';
288→ }
289→ };
290→
291→ // 选择产品建议
292→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
293→ // 获取该产品的纱线配比
294→ const ratios = productRatios[suggestion.id] || [];

View File

@ -0,0 +1,71 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
287→ return '01';
288→ }
289→ };
290→
291→ // 选择产品建议
292→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
293→ // 获取该产品的纱线配比
294→ const ratios = productRatios[suggestion.id] || [];
295→
296→ // 提取产品码前缀并生成新的产品码
297→ let newFabricCode = '';
298→ const fabricMatch = suggestion.fabric_code.match(/^(.*?)(\d+)$/);
299→ if (fabricMatch && companyId) {
300→ const prefix = fabricMatch[1];
301→ newFabricCode = await generateNextFabricCode(companyId, prefix);
302→ } else {
303→ newFabricCode = suggestion.fabric_code;
304→ }
305→
306→ // 生成色号(基于该产品名称-克重-颜色组合的最大值+1
307→ // 注意:颜色字段为空,因为用户需要手动输入新颜色
308→ // 这里先使用建议产品的颜色来查询色号,实际保存时会用用户输入的颜色
309→ let newColorCode = '01';
310→ if (companyId) {
311→ newColorCode = await generateNextColorCode(
312→ companyId,
313→ suggestion.product_name,
314→ suggestion.total_weight,
315→ suggestion.color
316→ );
317→ }
318→
319→ setFormData(prev => ({
320→ ...prev,
321→ product_name: suggestion.product_name,
322→ // 自动填写克重
323→ total_weight: suggestion.total_weight ? String(suggestion.total_weight) : '',
324→ warp_weight: suggestion.warp_weight ? String(suggestion.warp_weight) : '',
325→ weft_weight: suggestion.weft_weight ? String(suggestion.weft_weight) : '',
326→ // 颜色需要用户自己输入,不自动填写
327→ color: '',
328→ // 产品码自动生成(基于数据库最大值+1
329→ fabric_code: newFabricCode,
330→ // 色号自动生成(基于该产品名称-克重-颜色组合的最大值+1
331→ color_code: newColorCode,
332→ // 自动填写生产采购价
333→ production_price: suggestion.production_price ? String(suggestion.production_price) : '',
334→ // 自动填写纱线配比
335→ yarn_ratios: ratios.length > 0
336→ ? ratios.map(r => ({ yarn_name: r.yarn_name, ratio: r.ratio, yarn_type: (r.yarn_type as 'warp' | 'weft' | 'all') || 'all' }))
337→ : [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
338→ image_url: suggestion.image_url || undefined
339→ }));
340→
341→ if (suggestion.image_url) {
342→ setPreviewUrl(suggestion.image_url);
343→ }
344→
345→ setShowProductSuggestions(false);
346→
347→ // 加载该产品的克重建议
348→ searchWeights(suggestion.product_name);
349→ };
350→
351→ // 处理总克重输入
352→ const handleTotalWeightChange = (value: string) => {
353→ setFormData(prev => ({ ...prev, total_weight: value }));
354→

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,50 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
246→ return `${prefix}1`;
247→ }
248→ };
249→
250→ // 从数据库查询已存在的最大色号数字,生成下一个
251→ const generateNextColorCode = async (
252→ companyId: string,
253→ productName: string,
254→ totalWeight: number | null
255→ ): Promise<string> => {
256→ try {
257→ // 查询该产品名称-克重组合下的所有色号(不区分颜色)
258→ const { data, error } = await supabase
259→ .from('products')
260→ .select('color_code')
261→ .eq('company_id', companyId)
262→ .eq('product_name', productName)
263→ .eq('total_weight', totalWeight || 0);
264→
265→ if (error || !data || data.length === 0) {
266→ // 如果没有匹配的产品从01开始
267→ return '01';
268→ }
269→
270→ // 提取所有色号中的数字,找出最大值
271→ let maxNum = 0;
272→ data.forEach(item => {
273→ const code = item.color_code;
274→ const num = parseInt(code, 10);
275→ if (!isNaN(num) && num > maxNum) {
276→ maxNum = num;
277→ }
278→ });
279→
280→ // 返回最大数字 + 1保持两位数格式
281→ const nextNum = maxNum + 1;
282→ return nextNum < 10 ? `0${nextNum}` : String(nextNum);
283→ } catch (err) {
284→ console.error('生成色号失败:', err);
285→ return '01';
286→ }
287→ };
288→
289→ // 选择产品建议
290→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
291→ // 获取该产品的纱线配比
292→ const ratios = productRatios[suggestion.id] || [];

View File

@ -0,0 +1,21 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
300→ } else {
301→ newFabricCode = suggestion.fabric_code;
302→ }
303→
304→ // 生成色号(基于该产品名称-克重组合的最大值+1
305→ let newColorCode = '01';
306→ if (companyId) {
307→ newColorCode = await generateNextColorCode(
308→ companyId,
309→ suggestion.product_name,
310→ suggestion.total_weight
311→ );
312→ }
313→
314→ setFormData(prev => ({
315→ ...prev,
316→ product_name: suggestion.product_name,
317→ // 自动填写克重

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,52 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
246→ return `${prefix}1`;
247→ }
248→ };
249→
250→ // 从数据库查询已存在的最大色号数字,生成下一个
251→ const generateNextColorCode = async (
252→ companyId: string,
253→ productName: string,
254→ totalWeight: number | null,
255→ color: string
256→ ): Promise<string> => {
257→ try {
258→ // 查询该产品名称-克重-颜色组合下的所有色号
259→ const { data, error } = await supabase
260→ .from('products')
261→ .select('color_code')
262→ .eq('company_id', companyId)
263→ .eq('product_name', productName)
264→ .eq('total_weight', totalWeight || 0)
265→ .eq('color', color);
266→
267→ if (error || !data || data.length === 0) {
268→ // 如果没有匹配的产品从01开始
269→ return '01';
270→ }
271→
272→ // 提取所有色号中的数字,找出最大值
273→ let maxNum = 0;
274→ data.forEach(item => {
275→ const code = item.color_code;
276→ const num = parseInt(code, 10);
277→ if (!isNaN(num) && num > maxNum) {
278→ maxNum = num;
279→ }
280→ });
281→
282→ // 返回最大数字 + 1保持两位数格式
283→ const nextNum = maxNum + 1;
284→ return nextNum < 10 ? `0${nextNum}` : String(nextNum);
285→ } catch (err) {
286→ console.error('生成色号失败:', err);
287→ return '01';
288→ }
289→ };
290→
291→ // 选择产品建议
292→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
293→ // 获取该产品的纱线配比
294→ const ratios = productRatios[suggestion.id] || [];

View File

@ -0,0 +1,50 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
246→ return `${prefix}1`;
247→ }
248→ };
249→
250→ // 从数据库查询已存在的最大色号数字,生成下一个
251→ const generateNextColorCode = async (
252→ companyId: string,
253→ productName: string,
254→ totalWeight: number | null
255→ ): Promise<string> => {
256→ try {
257→ // 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增)
258→ const { data, error } = await supabase
259→ .from('products')
260→ .select('color_code')
261→ .eq('company_id', companyId)
262→ .eq('product_name', productName)
263→ .eq('total_weight', totalWeight || 0);
264→
265→ if (error || !data || data.length === 0) {
266→ // 如果没有匹配的产品从01开始
267→ return '01';
268→ }
269→
270→ // 提取所有色号中的数字,找出最大值
271→ let maxNum = 0;
272→ data.forEach(item => {
273→ const code = item.color_code;
274→ const num = parseInt(code, 10);
275→ if (!isNaN(num) && num > maxNum) {
276→ maxNum = num;
277→ }
278→ });
279→
280→ // 返回最大数字 + 1保持两位数格式
281→ const nextNum = maxNum + 1;
282→ return nextNum < 10 ? `0${nextNum}` : String(nextNum);
283→ } catch (err) {
284→ console.error('生成色号失败:', err);
285→ return '01';
286→ }
287→ };
288→
289→ // 选择产品建议
290→ const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
291→ // 获取该产品的纱线配比
292→ const ratios = productRatios[suggestion.id] || [];

View File

@ -0,0 +1,21 @@
The file /home/project/src/components/warehouse/ProductForm.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
300→ } else {
301→ newFabricCode = suggestion.fabric_code;
302→ }
303→
304→ // 生成色号(基于该产品名称-克重组合的最大值+1色号在产品名称-克重下连续递增)
305→ let newColorCode = '01';
306→ if (companyId) {
307→ newColorCode = await generateNextColorCode(
308→ companyId,
309→ suggestion.product_name,
310→ suggestion.total_weight
311→ );
312→ }
313→
314→ setFormData(prev => ({
315→ ...prev,
316→ product_name: suggestion.product_name,
317→ // 自动填写克重

View File

@ -0,0 +1 @@
{"exitCode":0,"output":"[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production\n\n> procurement-planning@1.0.0 typecheck /home/project\n> tsc --noEmit\n\n[OpenTelemetry] Trace 已启动serviceName=oneday-sandbox-sdk-pro version=v0.1.0 env=production"}

View File

@ -0,0 +1,25 @@
The file /home/project/src/pages/washing/Dashboard.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
19→ processing: { label: '处理中', color: 'text-purple-600', bgColor: 'bg-purple-50', icon: TrendingUp },
20→ completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', icon: Package }
21→};
22→
23→const getQuickActions = (isMaster: boolean) => {
24→ const actions = [
25→ { id: 'plans', icon: FileText, label: '计划总览', path: '/washing/plans', gradient: 'from-violet-400 to-fuchsia-400', description: '查看所有计划' },
26→ { id: 'pending', icon: Package, label: '待处理坯布', path: '/washing/pending', gradient: 'from-amber-500 to-orange-500', description: '处理待水洗坯布' },
27→ { id: 'completed', icon: CheckCircle, label: '已完成坯布', path: '/washing/completed', gradient: 'from-emerald-500 to-green-500', description: '查看已完成水洗' },
28→ { id: 'finished', icon: Warehouse, label: '成品仓库', path: '/washing/finished-warehouse', gradient: 'from-violet-400 to-fuchsia-400', description: '管理成品库存' },
29→ { id: 'payment', icon: CreditCard, label: '待结款', path: '/washing/payments', gradient: 'from-rose-500 to-pink-500', description: '查看待结款项' }
30→ ];
31→ if (isMaster) {
32→ actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-fuchsia-400 to-purple-400', description: '管理团队账号' });
33→ }
34→ return actions;
35→};
36→
37→const containerVariants = {
38→ hidden: { opacity: 0 },
39→ visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
40→};

View File

@ -0,0 +1,13 @@
The file /home/project/src/pages/washing/Dashboard.tsx has been updated. Made 1 replacement.
Here's the result of running `cat -n` on a snippet of the edited file:
108→ <motion.button
109→ whileHover={{ scale: 1.05 }}
110→ whileTap={{ scale: 0.95 }}
111→ onClick={() => setShowAccountCard(true)}
112→ className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg shadow-violet-400/20 cursor-pointer overflow-hidden"
113→ >
114→ {(auth.user as any)?.image_url ? (
115→ <img
116→ src={(auth.user as any).image_url}
117→ alt="头像"

View File

@ -0,0 +1,38 @@
[
{
"id": "1",
"status": "completed",
"content": "分析纺织厂采纱环节和原料纱仓库的数据结构",
"priority": "high"
},
{
"id": "2",
"status": "completed",
"content": "设计纱线自动分配逻辑和库存检查机制",
"priority": "high"
},
{
"id": "3",
"status": "completed",
"content": "在确认环节添加纱线选择弹窗组件",
"priority": "high"
},
{
"id": "4",
"status": "completed",
"content": "实现纱线库存联动和自动分配功能",
"priority": "high"
},
{
"id": "5",
"status": "completed",
"content": "添加原料不足提示和采购提醒",
"priority": "medium"
},
{
"id": "6",
"status": "completed",
"content": "验证功能完整性和测试",
"priority": "medium"
}
]

View File

@ -0,0 +1,26 @@
[
{
"id": "1",
"content": "统一 PlanOverview.tsx 主题色为 emerald 绿色系",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "统一 FabricWarehouse.tsx 主题色为 emerald 绿色系",
"status": "completed",
"priority": "high"
},
{
"id": "3",
"content": "统一 YarnWarehouse.tsx 主题色为 emerald 绿色系",
"status": "completed",
"priority": "high"
},
{
"id": "4",
"content": "统一 PaymentPending.tsx 主题色为 emerald 绿色系",
"status": "completed",
"priority": "high"
}
]

View File

@ -0,0 +1,26 @@
[
{
"id": "1",
"status": "completed",
"content": "创建短链映射表 share_links",
"priority": "high"
},
{
"id": "2",
"status": "completed",
"content": "修改分享链接生成逻辑,使用短链",
"priority": "high"
},
{
"id": "3",
"status": "completed",
"content": "修改导入页面,支持短链解析",
"priority": "high"
},
{
"id": "4",
"status": "completed",
"content": "运行类型检查",
"priority": "medium"
}
]

View File

@ -1,26 +1,26 @@
[ [
{ {
"id": "1", "id": "1",
"content": "创建短链映射表 share_links",
"status": "completed", "status": "completed",
"content": "纺织厂工作台集成帮助系统",
"priority": "high" "priority": "high"
}, },
{ {
"id": "2", "id": "2",
"content": "修改分享链接生成逻辑,使用短链",
"status": "completed", "status": "completed",
"content": "水洗厂工作台集成帮助系统",
"priority": "high" "priority": "high"
}, },
{ {
"id": "3", "id": "3",
"content": "修改导入页面,支持短链解析",
"status": "completed", "status": "completed",
"content": "通知中心按钮适配主题色",
"priority": "high" "priority": "high"
}, },
{ {
"id": "4", "id": "4",
"status": "completed",
"content": "运行类型检查", "content": "运行类型检查",
"status": "completed",
"priority": "medium" "priority": "medium"
} }
] ]

View File

@ -0,0 +1,20 @@
[
{
"id": "1",
"content": "更新 AGENTS.md 文档,记录产品信息相关修改",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "更新 README.md 文档,同步产品信息名称变更",
"status": "completed",
"priority": "high"
},
{
"id": "3",
"content": "记录已知 Bug 和修复方式到 AGENTS.md",
"status": "completed",
"priority": "high"
}
]

View File

@ -0,0 +1,38 @@
[
{
"id": "1",
"content": "分析纺织厂采纱环节和原料纱仓库的数据结构",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "设计纱线自动分配逻辑和库存检查机制",
"status": "completed",
"priority": "high"
},
{
"id": "3",
"content": "在确认环节添加纱线选择弹窗组件",
"status": "completed",
"priority": "high"
},
{
"id": "4",
"content": "实现纱线库存联动和自动分配功能",
"status": "completed",
"priority": "high"
},
{
"id": "5",
"content": "添加原料不足提示和采购提醒",
"status": "completed",
"priority": "medium"
},
{
"id": "6",
"content": "验证功能完整性和测试",
"status": "completed",
"priority": "medium"
}
]

View File

@ -0,0 +1,20 @@
[
{
"id": "1",
"content": "优化水洗厂 Dashboard 布局,与采购商一致",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "统一水洗厂所有页面紫色主题色",
"status": "completed",
"priority": "high"
},
{
"id": "3",
"content": "检查并修复其他水洗厂页面主题色",
"status": "completed",
"priority": "medium"
}
]

View File

@ -0,0 +1,26 @@
[
{
"id": "1",
"content": "创建 yarn_stock_records 原料纱出入库记录表",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "添加 RLS 策略和索引",
"status": "completed",
"priority": "high"
},
{
"id": "3",
"content": "修改 YarnAllocationModal 记录出库到 yarn_stock_records",
"status": "completed",
"priority": "high"
},
{
"id": "4",
"content": "在原料纱仓库页面显示出入库记录",
"status": "completed",
"priority": "medium"
}
]

353
AGENTS.md
View File

@ -468,6 +468,23 @@ CREATE TYPE payment_status AS ENUM ('pending', 'completed');
## 更新日志 ## 更新日志
### 2025-05-28 (v1.0.3)
- **原料纱仓库出入库记录功能**
- 创建 `yarn_stock_records` 表,记录原料纱出入库历史
- 采纱环节自动记录出库,关联到对应生产计划
- 原料纱入库时自动记录入库
- 原料纱仓库页面新增"出入库记录"标签页
- 显示出入库类型、数量、关联计划、时间等信息
- **采纱环节优化**
- 添加自动/手动分配模式切换
- 自动匹配失败时提示切换到手动分配
- 手动模式支持输入实际使用纱线名称和重量
- 确认时显示实际消耗统计(各纱线用量+总计)
- 库存不足时强制阻止确认,必须先补充库存
- **Bug 修复**
- 修复纱线占比显示为10000%的问题
- 修复 YarnWarehouse.tsx TypeScript 类型错误
### 2025-05-27 (v1.0.2) ### 2025-05-27 (v1.0.2)
- **账号管理页面帮助系统集成** - **账号管理页面帮助系统集成**
- 新增"帮助与支持"卡片区域 - 新增"帮助与支持"卡片区域
@ -2233,4 +2250,338 @@ class ErrorBoundary extends React.Component<
- [ ] Lighthouse 评分 > 90 - [ ] Lighthouse 评分 > 90
- [ ] 移动端模拟器测试通过 - [ ] 移动端模拟器测试通过
- [ ] 类型检查通过 `pnpm run typecheck` - [ ] 类型检查通过 `pnpm run typecheck`
- [ ] 代码规范检查通过 `pnpm run lint` - [ ] 代码规范检查通过 `pnpm run lint`
---
## 2025-05-27 产品信息功能更新记录
### 功能变更
#### 1. 产品信息页面名称统一
**变更内容**:将"原坯布仓库"统一更名为"产品信息"
**涉及文件**
- `src/pages/purchaser/Dashboard.tsx` - 快捷操作按钮标签
- `src/pages/purchaser/WarehouseManage.tsx` - 页面标题
- `src/components/OnboardingGuide.tsx` - 新手指引描述
- `src/components/UserManual.tsx` - 用户手册内容
- `src/pages/purchaser/NewPlan.tsx` - 新建计划页面产品选择标题
#### 2. 产品列表折叠逻辑优化
**变更内容**
- 产品无克重时,直接显示产品列表,不显示克重折叠卡片
- 产品仅有一种克重时,直接展开显示,不需要点击折叠
**涉及文件**
- `src/components/warehouse/ProductList.tsx`
#### 3. 产品唯一性校验
**新增校验规则**
- **产品码 (fabric_code)**:全局唯一,保存时检查是否已存在
- **色号 (color_code)**:在"成品名称-克重-颜色"组合中唯一
**涉及文件**
- `src/pages/purchaser/WarehouseManage.tsx`
#### 4. 产品码自动生成优化
**变更内容**
- 选择已有产品后,自动生成产品码时查询数据库中该前缀的最大数字
- 生成规则:`前缀 + (最大数字 + 1)`
**涉及文件**
- `src/components/warehouse/ProductForm.tsx`
#### 5. 色号自动生成优化
**变更内容**
- 选择已有产品后,自动生成色号时查询"产品名称-克重"组合下的最大色号
- 生成规则:`(最大色号数字 + 1)`,保持两位数格式
**涉及文件**
- `src/components/warehouse/ProductForm.tsx`
---
## Bug 修复记录2025-05-27
### 分类二十一:产品唯一性校验问题
#### Bug: 产品码和色号重复问题
**现象**
- 可以创建相同产品码的产品
- 同一"成品名称-克重-颜色"组合下可以有重复色号
**根本原因**
- 保存产品时未进行唯一性校验
- 自动生成产品码/色号时未查询数据库最大值
**修复方案**
```typescript
// 1. 产品码全局唯一校验
const { data: existingFabricCode } = await supabase
.from('products')
.select('id')
.eq('company_id', auth.company.id)
.eq('fabric_code', productData.fabric_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingFabricCode) {
alert(`产品码 "${productData.fabric_code}" 已存在`);
return;
}
// 2. 色号在"成品名称-克重-颜色"组合中唯一校验
const { data: existingColorCode } = await supabase
.from('products')
.select('id')
.eq('company_id', auth.company.id)
.eq('product_name', productData.product_name)
.eq('total_weight', productData.total_weight || 0)
.eq('color', productData.color)
.eq('color_code', productData.color_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingColorCode) {
alert(`色号重复`);
return;
}
```
**相关文件**
- `src/pages/purchaser/WarehouseManage.tsx`
---
### 分类二十二:产品码/色号自动生成问题
#### Bug: 自动生成的产品码/色号可能重复
**现象**
- 选择产品后生成的产品码可能与已有产品码重复
- 色号生成仅基于当前产品递增,未考虑数据库中最大值
**根本原因**
- 原逻辑仅对当前选中的产品码进行简单递增(如 M2 → M3
- 未查询数据库中该前缀的实际最大值
**修复方案**
```typescript
// 产品码生成:查询数据库中该前缀的最大数字
const generateNextFabricCode = async (companyId: string, prefix: string): Promise<string> => {
const { data } = await supabase
.from('products')
.select('fabric_code')
.eq('company_id', companyId)
.ilike('fabric_code', `${prefix}%`);
let maxNum = 0;
data?.forEach(item => {
const match = item.fabric_code.match(/^(.*?)(\d+)$/);
if (match && match[1].toUpperCase() === prefix.toUpperCase()) {
const num = parseInt(match[2], 10);
if (num > maxNum) maxNum = num;
}
});
return `${prefix}${maxNum + 1}`;
};
// 色号生成:查询"产品名称-克重"组合下的最大色号
const generateNextColorCode = async (companyId: string, productName: string, totalWeight: number | null): Promise<string> => {
const { data } = await supabase
.from('products')
.select('color_code')
.eq('company_id', companyId)
.eq('product_name', productName)
.eq('total_weight', totalWeight || 0);
let maxNum = 0;
data?.forEach(item => {
const num = parseInt(item.color_code, 10);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
const nextNum = maxNum + 1;
return nextNum < 10 ? `0${nextNum}` : String(nextNum);
};
```
**相关文件**
- `src/components/warehouse/ProductForm.tsx`
---
## Bug 修复记录2025-05-28
### 分类二十三NotificationCenter主题色缺失问题
#### Bug: 水洗厂工作台崩溃 - Cannot read properties of undefined (reading 'bg')
**现象**
- 用户点击"水洗厂"角色进入工作台时页面白屏
- 控制台报错:`Cannot read properties of undefined (reading 'bg')`
- 错误位置:`NotificationCenter.tsx:289:98`
**根本原因**
- NotificationCenter组件的`themeConfig`中未定义`violet`主题
- 水洗厂Dashboard传入`theme="violet"`,导致`themeConfig[theme]`返回undefined
- 访问`colors.bg`时抛出TypeError
**修复方案**
```typescript
// 1. 扩展类型定义
interface NotificationCenterProps {
theme?: 'amber' | 'emerald' | 'purple' | 'blue' | 'violet'; // 添加violet
}
// 2. 添加violet主题配置
const themeConfig = {
amber: { bg: 'bg-amber-100', hover: 'hover:bg-amber-200', icon: 'text-amber-700' },
emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' },
purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' },
blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' },
violet: { bg: 'bg-violet-100', hover: 'hover:bg-violet-200', icon: 'text-violet-500' }, // 新增
};
```
**相关文件**
- `src/components/NotificationCenter.tsx`
- `src/pages/washing/Dashboard.tsx`
---
### 分类二十四:登录页角色颜色不一致问题
#### Bug: 登录页水洗厂颜色与Dashboard不一致
**现象**
- 登录页水洗厂演示账号按钮使用`from-cyan-500 to-blue-600`(蓝青色)
- 水洗厂Dashboard使用`from-violet-400 to-fuchsia-400`(紫罗兰色)
- 同一角色在不同页面颜色不统一
**根本原因**
- 登录页`demoAccounts`配置未随Dashboard主题色更新而同步调整
- 缺乏统一的主题色管理规范
**修复方案**
```typescript
// LoginPage.tsx
const demoAccounts = [
{ username: 'purchaser', label: '采购商(布行)', color: 'from-blue-500 to-blue-600', icon: Building2, enabled: true },
{ username: 'textile', label: '纺织厂', color: 'from-emerald-500 to-green-600', icon: Factory, enabled: true },
{ username: 'washing', label: '水洗厂', color: 'from-violet-400 to-fuchsia-400', icon: Droplets, enabled: true } // 更新为紫罗兰色
];
```
**相关文件**
- `src/pages/LoginPage.tsx`
---
## 学习反思与最佳实践2025-05-28
### 主题色统一设计模式
**问题背景**
三个角色(采购商、纺织厂、水洗厂)原本使用不同深度的颜色(-500/-600视觉上不够协调统一。
**解决方案**
统一使用`-400`色阶的柔和色调:
| 角色 | 原配色 | 新配色 | 特点 |
|------|--------|--------|------|
| 采购商 | `amber-500/600` | `amber-400/orange-400` | 温暖橙黄 |
| 纺织厂 | `emerald-500/600` | `emerald-400/teal-400` | 清新青绿 |
| 水洗厂 | `purple-500/600` | `violet-400/fuchsia-400` | 柔和紫粉 |
**设计原则**
1. **色阶统一**:所有主题色使用`-400`色阶,保持视觉一致性
2. **渐变搭配**使用相邻色系的柔和渐变如violet→fuchsia
3. **全站同步**登录页、Dashboard、MemberManage等页面统一更新
4. **组件适配**NotificationCenter等共享组件需支持所有主题色
**代码规范**
```typescript
// 主题色配置模板
const themeColors = {
purchaser: {
primary: 'amber-400',
gradient: 'from-amber-400 to-orange-400',
button: 'bg-amber-400 hover:bg-amber-500',
text: 'text-amber-500',
bg: 'bg-amber-50',
border: 'border-amber-200',
ring: 'focus:ring-amber-400'
},
textile: {
primary: 'emerald-400',
gradient: 'from-emerald-400 to-teal-400',
button: 'bg-emerald-400 hover:bg-emerald-500',
text: 'text-emerald-500',
bg: 'bg-emerald-50',
border: 'border-emerald-200',
ring: 'focus:ring-emerald-400'
},
washing: {
primary: 'violet-400',
gradient: 'from-violet-400 to-fuchsia-400',
button: 'bg-violet-400 hover:bg-violet-500',
text: 'text-violet-500',
bg: 'bg-violet-50',
border: 'border-violet-200',
ring: 'focus:ring-violet-400'
}
};
```
**经验教训**
1. **组件设计时考虑扩展性**NotificationCenter等共享组件应预留主题色扩展接口
2. **变更同步机制**主题色变更需同步更新所有相关页面登录页、Dashboard、设置页等
3. **类型安全**TypeScript类型定义需与实际配置保持一致避免运行时错误
4. **视觉回归测试**:主题色调整后需验证所有页面的视觉效果
---
## 项目统计截至2025-05-28
### Bug分类统计
| 分类 | 数量 | 占比 |
|------|------|------|
| JavaScript/TypeScript作用域问题 | 1 | 4% |
| 数据一致性问题 | 2 | 8% |
| 空值处理问题 | 1 | 4% |
| UI/UX问题 | 2 | 8% |
| 代码组织问题 | 1 | 4% |
| React组件生命周期问题 | 1 | 4% |
| Supabase RLS策略问题 | 1 | 4% |
| Webpack构建问题 | 2 | 8% |
| TypeScript类型问题 | 2 | 8% |
| 状态管理问题 | 1 | 4% |
| 表单处理问题 | 1 | 4% |
| CSS/Tailwind问题 | 1 | 4% |
| 性能优化问题 | 1 | 4% |
| 沙箱环境兼容性问题 | 1 | 4% |
| 帮助系统集成问题 | 1 | 4% |
| 计划拒绝状态管理问题 | 1 | 4% |
| 通知中心主题色适配问题 | 1 | 4% |
| 新手指引弹窗位置问题 | 1 | 4% |
| 浏览器兼容性问题 | 1 | 4% |
| 测试相关问题 | 1 | 4% |
| 产品唯一性校验问题 | 1 | 4% |
| 产品码/色号自动生成问题 | 1 | 4% |
| NotificationCenter主题色缺失问题 | 1 | 4% |
| 登录页角色颜色不一致问题 | 1 | 4% |
| **总计** | **24** | **100%** |
### 技术债务分析
**高频问题类别**
1. **UI/UX问题**3个主题色适配、弹窗位置、移动端适配
2. **TypeScript类型问题**2个类型定义不匹配、泛型约束
3. **Webpack构建问题**2个热更新失效、产物体积过大
**改进建议**
1. 建立主题色设计系统,统一管理颜色变量
2. 完善TypeScript严格模式配置提前发现类型问题
3. 优化Webpack配置建立构建性能监控
4. 增加组件级单元测试,覆盖主题色渲染场景

View File

@ -16,10 +16,11 @@
- 实时进度可视化支持97%完成度自动提醒 - 实时进度可视化支持97%完成度自动提醒
### 库存管理 ### 库存管理
- **原坯布仓库**:按坯布码分组管理,入库记录追溯 - **产品信息**:按坯布码分组管理,入库记录追溯
- 产品管理:支持按名称、克重、颜色多级分组展示 - 产品管理:支持按名称、克重、颜色多级分组展示
- 出入库历史:查看每个产品的完整库存变动记录 - 出入库历史:查看每个产品的完整库存变动记录
- 价格历史:追踪采购价格变动 - 价格历史:追踪采购价格变动
- 产品唯一性:产品码全局唯一,色号在"成品名称-克重-颜色"组合中唯一
- **原料纱仓库**:库存预警、最低库存提醒 - **原料纱仓库**:库存预警、最低库存提醒
- 实时库存数据同步,入库自动更新 - 实时库存数据同步,入库自动更新
@ -212,6 +213,22 @@ import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
## 更新日志 ## 更新日志
### 2025-05-28 (v1.0.3)
- **原料纱仓库出入库记录功能**
- 创建 `yarn_stock_records` 表,记录原料纱出入库历史
- 采纱环节自动记录出库,关联到对应生产计划
- 原料纱入库时自动记录入库
- 原料纱仓库页面新增"出入库记录"标签页
- 显示出入库类型、数量、关联计划、时间等信息
- **采纱环节优化**
- 添加自动/手动分配模式切换
- 自动匹配失败时提示切换到手动分配
- 手动模式支持输入实际使用纱线名称和重量
- 确认时显示实际消耗统计(各纱线用量+总计)
- 库存不足时强制阻止确认,必须先补充库存
- **Bug 修复**
- 修复纱线占比显示为10000%的问题
### 2025-05-27 (v1.0.2) ### 2025-05-27 (v1.0.2)
- **账号管理页面帮助系统集成** - **账号管理页面帮助系统集成**
- 新增"帮助与支持"卡片区域,集成用户手册、用户反馈、关于窗口 - 新增"帮助与支持"卡片区域,集成用户手册、用户反馈、关于窗口
@ -254,7 +271,7 @@ import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
### 2025-05-25 ### 2025-05-25
- **产品图片上传功能** - **产品图片上传功能**
- 为采购商原坯布仓库产品管理添加图片上传功能 - 为采购商产品信息管理添加图片上传功能
- 支持在产品创建和编辑时上传产品图片JPG、PNG格式最大5MB - 支持在产品创建和编辑时上传产品图片JPG、PNG格式最大5MB
- 产品列表展示40x40px缩略图无图片时显示默认占位图标 - 产品列表展示40x40px缩略图无图片时显示默认占位图标
- 产品表单支持图片预览、重新上传和删除功能 - 产品表单支持图片预览、重新上传和删除功能
@ -265,7 +282,7 @@ import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
### 2025-05-24 ### 2025-05-24
- **新增出入库历史功能** - **新增出入库历史功能**
- 采购商原坯布仓库产品列表新增"出入库历史"按钮 - 采购商产品信息列表新增"出入库历史"按钮
- 支持查看每个产品的完整库存变动记录(批号、匹数、米数、时间) - 支持查看每个产品的完整库存变动记录(批号、匹数、米数、时间)
- 桌面端和移动端均支持 - 桌面端和移动端均支持
- **类型系统优化** - **类型系统优化**

View File

@ -0,0 +1,24 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 修改 production_plans 的 SELECT 策略,允许分享链接导入时查询
DROP POLICY IF EXISTS users_select_plans ON production_plans;
CREATE POLICY users_select_plans ON production_plans
FOR SELECT
USING (
purchaser_id = get_user_master_company_id()
OR EXISTS (
SELECT 1 FROM plan_factories
WHERE plan_factories.plan_id = production_plans.id
AND plan_factories.factory_id = get_user_master_company_id()
)
OR EXISTS (
SELECT 1 FROM plan_factories pf
WHERE pf.plan_id = production_plans.id
)
);

View File

@ -0,0 +1,38 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 创建短链映射表
CREATE TABLE IF NOT EXISTS share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
short_code TEXT UNIQUE NOT NULL,
plan_id UUID NOT NULL,
factory_type TEXT NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
click_count INTEGER DEFAULT 0
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_share_links_short_code ON share_links(short_code);
CREATE INDEX IF NOT EXISTS idx_share_links_plan_id ON share_links(plan_id);
-- 启用 RLS
ALTER TABLE share_links ENABLE ROW LEVEL SECURITY;
-- 添加 RLS 策略
CREATE POLICY users_select_share_links ON share_links
FOR SELECT
USING (true);
CREATE POLICY users_insert_share_links ON share_links
FOR INSERT
WITH CHECK (created_by = auth.uid());
CREATE POLICY users_delete_own_share_links ON share_links
FOR DELETE
USING (created_by = auth.uid());

View File

@ -0,0 +1,16 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 创建短链点击计数函数
CREATE OR REPLACE FUNCTION increment_share_link_clicks(code TEXT)
RETURNS VOID AS $$
BEGIN
UPDATE share_links
SET click_count = click_count + 1
WHERE short_code = code;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@ -0,0 +1,18 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 修复 share_links 表的 RLS 策略,使用 auth.uid()
DROP POLICY IF EXISTS users_insert_share_links ON share_links;
DROP POLICY IF EXISTS users_delete_own_share_links ON share_links;
CREATE POLICY users_insert_share_links ON share_links
FOR INSERT
WITH CHECK (created_by = auth.uid());
CREATE POLICY users_delete_own_share_links ON share_links
FOR DELETE
USING (created_by = auth.uid());

View File

@ -0,0 +1,28 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 创建 avatars 存储桶(如果不存在)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true)
ON CONFLICT (id) DO NOTHING;
-- 添加 RLS 策略
CREATE POLICY avatars_select ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');
CREATE POLICY avatars_insert ON storage.objects
FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid() = owner);
CREATE POLICY avatars_update ON storage.objects
FOR UPDATE
USING (bucket_id = 'avatars' AND auth.uid() = owner);
CREATE POLICY avatars_delete ON storage.objects
FOR DELETE
USING (bucket_id = 'avatars' AND auth.uid() = owner);

View File

@ -0,0 +1,6 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
ALTER TABLE profiles ADD COLUMN image_url TEXT

View File

@ -0,0 +1,26 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
CREATE TABLE IF NOT EXISTS public.yarn_stock_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
yarn_stock_id UUID REFERENCES public.yarn_stock(id) ON DELETE SET NULL,
plan_id UUID REFERENCES public.production_plans(id) ON DELETE SET NULL,
record_type TEXT NOT NULL CHECK (record_type IN ('inbound', 'outbound')),
quantity DECIMAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'kg',
batch_no TEXT,
notes TEXT,
operator_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_yarn_stock_records_company_id ON public.yarn_stock_records(company_id);
CREATE INDEX IF NOT EXISTS idx_yarn_stock_records_yarn_stock_id ON public.yarn_stock_records(yarn_stock_id);
CREATE INDEX IF NOT EXISTS idx_yarn_stock_records_plan_id ON public.yarn_stock_records(plan_id);
CREATE INDEX IF NOT EXISTS idx_yarn_stock_records_created_at ON public.yarn_stock_records(created_at DESC);

View File

@ -0,0 +1,25 @@
-- ============================================
-- 此文件由 Meoo Cloud 自动生成,请勿修改
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
-- ============================================
-- 启用 RLS
ALTER TABLE public.yarn_stock_records ENABLE ROW LEVEL SECURITY;
-- SELECT 策略:本公司成员可查看
CREATE POLICY users_select_yarn_stock_records ON public.yarn_stock_records
FOR SELECT USING (company_id = public.get_user_master_company_id());
-- INSERT 策略:本公司成员可插入
CREATE POLICY users_insert_yarn_stock_records ON public.yarn_stock_records
FOR INSERT WITH CHECK (company_id = public.get_user_master_company_id());
-- UPDATE 策略:本公司成员可更新自己的记录
CREATE POLICY users_update_yarn_stock_records ON public.yarn_stock_records
FOR UPDATE USING (company_id = public.get_user_master_company_id());
-- DELETE 策略:本公司成员可删除自己的记录
CREATE POLICY users_delete_yarn_stock_records ON public.yarn_stock_records
FOR DELETE USING (company_id = public.get_user_master_company_id());

12578
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@ const typeConfig: Record<string, { icon: any; color: string; bgColor: string }>
}; };
interface NotificationCenterProps { interface NotificationCenterProps {
theme?: 'amber' | 'emerald' | 'purple' | 'blue'; theme?: 'amber' | 'emerald' | 'purple' | 'blue' | 'violet';
} }
const themeConfig = { const themeConfig = {
@ -38,6 +38,7 @@ const themeConfig = {
emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' }, emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' },
purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' }, purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' },
blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' }, blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' },
violet: { bg: 'bg-violet-100', hover: 'hover:bg-violet-200', icon: 'text-violet-500' },
}; };
export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps = {}) { export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps = {}) {
@ -51,7 +52,11 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
useEffect(() => { useEffect(() => {
if (!auth.user) return; if (!auth.user) return;
fetchNotifications(); fetchNotifications();
subscribeToNotifications(); const channel = subscribeToNotifications();
return () => {
channel.unsubscribe();
};
}, [auth.user]); }, [auth.user]);
const fetchNotifications = async () => { const fetchNotifications = async () => {
@ -79,8 +84,9 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
}; };
const subscribeToNotifications = () => { const subscribeToNotifications = () => {
supabase const channel = supabase.channel('notifications');
.channel('notifications')
channel
.on('postgres_changes', { .on('postgres_changes', {
event: 'INSERT', event: 'INSERT',
schema: 'public', schema: 'public',
@ -89,7 +95,13 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
}, () => { }, () => {
fetchNotifications(); fetchNotifications();
}) })
.subscribe(); .subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('Successfully subscribed to notifications');
}
});
return channel;
}; };
const markAsRead = async (id: string) => { const markAsRead = async (id: string) => {

View File

@ -39,7 +39,7 @@ const purchaserSteps: OnboardingStep[] = [
{ {
id: 'manage-inventory', id: 'manage-inventory',
title: '管理库存', title: '管理库存',
description: '在"原坯布仓库"中查看和管理所有入库的坯布,支持出入库历史查询。', description: '在"产品信息"中查看和管理所有入库的坯布,支持出入库历史查询。',
icon: Package, icon: Package,
action: '去仓库管理', action: '去仓库管理',
actionPath: '/purchaser/warehouse', actionPath: '/purchaser/warehouse',

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Check, Clock, Loader2, Package, AlertCircle, List } from 'lucide-react'; import { Check, Clock, Loader2, Package, AlertCircle, List } from 'lucide-react';
import type { StepType, StepStatus } from '../types'; import { YarnAllocationModal } from './YarnAllocationModal';
import type { StepType, StepStatus, YarnRatio } from '../types';
interface ProcessNode { interface ProcessNode {
id: string; id: string;
@ -23,6 +24,9 @@ interface ProcessFlowProps {
isPlanLocked?: boolean; isPlanLocked?: boolean;
lastInventoryTime?: string | null; lastInventoryTime?: string | null;
inventoryRecords?: { time: string; quantity: number; rolls: number | null }[]; inventoryRecords?: { time: string; quantity: number; rolls: number | null }[];
yarnRatios?: YarnRatio[];
companyId?: string;
planId?: string;
} }
const stepTypeConfig: Record<StepType, { name: string; icon: React.ReactNode }> = { const stepTypeConfig: Record<StepType, { name: string; icon: React.ReactNode }> = {
@ -43,7 +47,10 @@ export function ProcessFlow({
planCompletedQuantity = 0, planCompletedQuantity = 0,
isPlanLocked = false, isPlanLocked = false,
lastInventoryTime, lastInventoryTime,
inventoryRecords = [] inventoryRecords = [],
yarnRatios = [],
companyId,
planId
}: ProcessFlowProps) { }: ProcessFlowProps) {
const [inputQuantity, setInputQuantity] = useState<string>(''); const [inputQuantity, setInputQuantity] = useState<string>('');
const [inputRolls, setInputRolls] = useState<string>(''); const [inputRolls, setInputRolls] = useState<string>('');
@ -52,6 +59,8 @@ export function ProcessFlow({
const [pendingQuantity, setPendingQuantity] = useState<number>(0); const [pendingQuantity, setPendingQuantity] = useState<number>(0);
const [pendingRolls, setPendingRolls] = useState<number>(0); const [pendingRolls, setPendingRolls] = useState<number>(0);
const [pendingNodeId, setPendingNodeId] = useState<string>(''); const [pendingNodeId, setPendingNodeId] = useState<string>('');
const [showYarnModal, setShowYarnModal] = useState(false);
const [pendingYarnStepId, setPendingYarnStepId] = useState<string>('');
// 按顺序排序节点 // 按顺序排序节点
const sortedNodes = React.useMemo(() => { const sortedNodes = React.useMemo(() => {
@ -76,12 +85,27 @@ export function ProcessFlow({
// 坯布入库需要输入数量 // 坯布入库需要输入数量
setShowInput(nodeId); setShowInput(nodeId);
setInputQuantity(''); setInputQuantity('');
} else if (stepType === 'yarn_purchase') {
// 采纱环节需要检查纱线库存
if (yarnRatios.length > 0 && companyId) {
setPendingYarnStepId(nodeId);
setShowYarnModal(true);
} else {
// 没有纱线配比数据,直接确认
onConfirm?.(nodeId);
}
} else { } else {
// 其他节点直接确认 // 其他节点直接确认
onConfirm?.(nodeId); onConfirm?.(nodeId);
} }
}; };
const handleYarnAllocationConfirm = () => {
// 纱线分配确认后,继续确认采纱步骤
onConfirm?.(pendingYarnStepId);
setPendingYarnStepId('');
};
const handleWarehouseConfirm = (nodeId: string) => { const handleWarehouseConfirm = (nodeId: string) => {
const quantity = parseFloat(inputQuantity); const quantity = parseFloat(inputQuantity);
const rolls = parseInt(inputRolls) || 0; const rolls = parseInt(inputRolls) || 0;
@ -377,6 +401,17 @@ export function ProcessFlow({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* 纱线分配弹窗 */}
<YarnAllocationModal
isOpen={showYarnModal}
onClose={() => setShowYarnModal(false)}
onConfirm={handleYarnAllocationConfirm}
yarnRatios={yarnRatios}
companyId={companyId || ''}
planTargetQuantity={planTargetQuantity}
planId={planId}
/>
</div> </div>
); );
} }

View File

@ -12,7 +12,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, status }) =>
case 'completed': case 'completed':
return 'bg-green-500'; return 'bg-green-500';
case 'producing': case 'producing':
return 'bg-blue-500'; return 'bg-amber-500';
default: default:
return 'bg-gray-400'; return 'bg-gray-400';
} }

View File

@ -9,7 +9,7 @@ interface StatusBadgeProps {
const statusConfig: Record<string, { label: string; gradient: string }> = { const statusConfig: Record<string, { label: string; gradient: string }> = {
pending: { label: '待确定', gradient: 'from-amber-400 to-orange-400' }, pending: { label: '待确定', gradient: 'from-amber-400 to-orange-400' },
producing: { label: '生产中', gradient: 'from-blue-400 to-indigo-400' }, producing: { label: '生产中', gradient: 'from-amber-400 to-orange-400' },
completed: { label: '已完成', gradient: 'from-green-400 to-emerald-400' } completed: { label: '已完成', gradient: 'from-green-400 to-emerald-400' }
}; };

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, BookOpen, FileText, Download, ExternalLink, ChevronRight, Search } from 'lucide-react'; import { X, BookOpen, FileText, Download, ExternalLink, ChevronRight, Search, ChevronLeft } from 'lucide-react';
interface UserManualProps { interface UserManualProps {
isOpen: boolean; isOpen: boolean;
@ -27,7 +27,7 @@ const manualChapters = [
{ title: '创建生产计划', content: '点击"新建计划"按钮,填写产品名称、颜色、坯布码、色号、计划产量等信息,配置纱线配比后提交。' }, { title: '创建生产计划', content: '点击"新建计划"按钮,填写产品名称、颜色、坯布码、色号、计划产量等信息,配置纱线配比后提交。' },
{ title: '分享计划', content: '在计划总览页面,点击"分享计划"按钮,通过二次确认后将链接发送给纺织厂。纺织厂确认后即建立关联。' }, { title: '分享计划', content: '在计划总览页面,点击"分享计划"按钮,通过二次确认后将链接发送给纺织厂。纺织厂确认后即建立关联。' },
{ title: '查看进度', content: '在计划总览页面实时查看各工厂的生产进度,包括当前环节、完成米数、入库记录等。' }, { title: '查看进度', content: '在计划总览页面实时查看各工厂的生产进度,包括当前环节、完成米数、入库记录等。' },
{ title: '库存管理', content: '在"原坯布仓库"中管理所有入库的坯布,支持按产品名称、克重、颜色分组查看,可查询出入库历史。' }, { title: '库存管理', content: '在"产品信息"中管理所有入库的坯布,支持按产品名称、克重、颜色分组查看,可查询出入库历史。' },
{ title: '结款管理', content: '生产完成后,在"待结款"页面查看应付款项,确认后完成结款流程。' }, { title: '结款管理', content: '生产完成后,在"待结款"页面查看应付款项,确认后完成结款流程。' },
] ]
}, },
@ -70,6 +70,7 @@ export function UserManual({ isOpen, onClose }: UserManualProps) {
const [activeChapter, setActiveChapter] = useState(manualChapters[0]); const [activeChapter, setActiveChapter] = useState(manualChapters[0]);
const [expandedSection, setExpandedSection] = useState<number | null>(0); const [expandedSection, setExpandedSection] = useState<number | null>(0);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showChapterList, setShowChapterList] = useState(true);
// 搜索过滤 // 搜索过滤
const filteredChapters = searchQuery const filteredChapters = searchQuery
@ -82,46 +83,57 @@ export function UserManual({ isOpen, onClose }: UserManualProps) {
})).filter(ch => ch.sections.length > 0) })).filter(ch => ch.sections.length > 0)
: manualChapters; : manualChapters;
const handleChapterSelect = (chapter: typeof manualChapters[0]) => {
setActiveChapter(chapter);
setExpandedSection(0);
setShowChapterList(false);
};
const handleBackToChapters = () => {
setShowChapterList(true);
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4"> <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-0 sm:p-4">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl w-full max-w-4xl max-h-[85vh] overflow-hidden shadow-2xl" className="bg-white w-full h-full sm:h-auto sm:max-h-[90vh] sm:rounded-2xl sm:max-w-4xl overflow-hidden shadow-2xl"
> >
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100"> <div className="flex items-center justify-between p-3 sm:p-4 border-b border-gray-100">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-xl flex items-center justify-center">
<BookOpen className="w-5 h-5 text-blue-600" /> <BookOpen className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-900"></h2> <h2 className="text-base sm:text-lg font-semibold text-gray-900"></h2>
<p className="text-xs text-gray-500">使</p> <p className="text-xs text-gray-500 hidden sm:block">使</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 sm:gap-2">
<button <button
onClick={() => window.open('/manual.pdf', '_blank')} onClick={() => window.open('/manual.pdf', '_blank')}
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" className="flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
> >
<Download className="w-4 h-4" /> <Download className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
PDF <span className="hidden sm:inline">PDF</span>
<span className="sm:hidden"></span>
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-1.5 sm:p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<X className="w-5 h-5 text-gray-500" /> <X className="w-4 h-4 sm:w-5 sm:h-5 text-gray-500" />
</button> </button>
</div> </div>
</div> </div>
{/* 搜索栏 */} {/* 搜索栏 */}
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50"> <div className="px-3 sm:px-4 py-2 sm:py-3 border-b border-gray-100 bg-gray-50">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input <input
@ -134,65 +146,111 @@ export function UserManual({ isOpen, onClose }: UserManualProps) {
</div> </div>
</div> </div>
{/* 内容区域 */} {/* 移动端章节导航栏 */}
<div className="flex h-[60vh]"> {!showChapterList && (
{/* 左侧导航 */} <div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50">
<div className="w-64 border-r border-gray-100 overflow-y-auto bg-gray-50/50"> <button
{filteredChapters.map((chapter) => ( onClick={handleBackToChapters}
<button className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
key={chapter.id} >
onClick={() => { <ChevronLeft className="w-4 h-4" />
setActiveChapter(chapter);
setExpandedSection(0); </button>
}} <span className="text-gray-400">|</span>
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${ <span className="text-sm font-medium text-gray-700 truncate">{activeChapter.title}</span>
activeChapter.id === chapter.id
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-500'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<chapter.icon className={`w-4 h-4 ${activeChapter.id === chapter.id ? 'text-blue-600' : 'text-gray-400'}`} />
<span className="text-sm font-medium">{chapter.title}</span>
</button>
))}
</div> </div>
)}
{/* 右侧内容 */} {/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex h-[calc(100vh-140px)] sm:h-[60vh]">
<h3 className="text-xl font-bold text-gray-900 mb-4">{activeChapter.title}</h3> {/* 左侧章节列表 - 移动端全屏显示,桌面端固定宽度 */}
<div className="space-y-3"> <AnimatePresence mode="wait">
{activeChapter.sections.map((section, index) => ( {(showChapterList || window.innerWidth >= 640) && (
<div <motion.div
key={index} initial={{ x: -100, opacity: 0 }}
className="border border-gray-200 rounded-xl overflow-hidden" animate={{ x: 0, opacity: 1 }}
> exit={{ x: -100, opacity: 0 }}
<button className={`${showChapterList ? 'flex' : 'hidden sm:flex'} w-full sm:w-64 border-r border-gray-100 overflow-y-auto bg-gray-50/50 flex-col`}
onClick={() => setExpandedSection(expandedSection === index ? null : index)} >
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors" <div className="p-2 space-y-1">
> {filteredChapters.map((chapter) => (
<span className="font-medium text-gray-900">{section.title}</span> <button
<ChevronRight key={chapter.id}
className={`w-4 h-4 text-gray-400 transition-transform ${ onClick={() => handleChapterSelect(chapter)}
expandedSection === index ? 'rotate-90' : '' className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors rounded-xl ${
activeChapter.id === chapter.id
? 'bg-blue-50 text-blue-700 border-l-4 border-blue-500'
: 'text-gray-700 hover:bg-gray-100'
}`} }`}
/> >
</button> <chapter.icon className={`w-5 h-5 shrink-0 ${activeChapter.id === chapter.id ? 'text-blue-600' : 'text-gray-400'}`} />
<AnimatePresence> <div className="flex-1 min-w-0">
{expandedSection === index && ( <span className="text-sm font-medium block">{chapter.title}</span>
<motion.div <span className="text-xs text-gray-400 block mt-0.5">{chapter.sections.length} </span>
initial={{ height: 0 }} </div>
animate={{ height: 'auto' }} <ChevronRight className={`w-4 h-4 shrink-0 ${activeChapter.id === chapter.id ? 'text-blue-500' : 'text-gray-300'}`} />
exit={{ height: 0 }} </button>
className="overflow-hidden" ))}
>
<div className="p-4 pt-0 text-sm text-gray-600 leading-relaxed border-t border-gray-100">
{section.content}
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
))} </motion.div>
)}
</AnimatePresence>
{/* 右侧内容详情 */}
<div className={`${!showChapterList ? 'flex' : 'hidden sm:flex'} flex-1 overflow-y-auto bg-white flex-col w-full`}>
<div className="p-4 sm:p-6">
{/* 章节标题 */}
<div className="flex items-center gap-3 mb-4 sm:mb-6">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center">
<activeChapter.icon className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div>
<div>
<h3 className="text-lg sm:text-xl font-bold text-gray-900">{activeChapter.title}</h3>
<p className="text-xs sm:text-sm text-gray-500">{activeChapter.sections.length} </p>
</div>
</div>
{/* 章节内容列表 */}
<div className="space-y-2 sm:space-y-3">
{activeChapter.sections.map((section, index) => (
<div
key={index}
className="border border-gray-200 rounded-xl overflow-hidden bg-white shadow-sm"
>
<button
onClick={() => setExpandedSection(expandedSection === index ? null : index)}
className="w-full flex items-center justify-between p-3 sm:p-4 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2 sm:gap-3">
<span className="w-5 h-5 sm:w-6 sm:h-6 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center text-xs sm:text-sm font-medium shrink-0">
{index + 1}
</span>
<span className="font-medium text-gray-900 text-sm sm:text-base">{section.title}</span>
</div>
<ChevronRight
className={`w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform shrink-0 ${
expandedSection === index ? 'rotate-90' : ''
}`}
/>
</button>
<AnimatePresence>
{expandedSection === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="p-3 sm:p-4 pt-0 text-sm text-gray-600 leading-relaxed border-t border-gray-100 bg-gray-50/30">
{section.content}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -164,26 +164,9 @@ export function VersionUpdate({ isOpen, onClose, onUpdate }: VersionUpdateProps)
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
onClick={handleSkip} onClick={handleSkip}
className="flex-1 py-2.5 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors text-sm font-medium" className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-colors text-sm font-medium"
> >
</button>
<button
onClick={handleUpdate}
disabled={isUpdating}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
>
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</>
) : (
<>
<Download className="w-4 h-4" />
</>
)}
</button> </button>
</div> </div>
</> </>

View File

@ -0,0 +1,564 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertTriangle, Package, Check, Loader2, Edit3, Search } from 'lucide-react';
import { supabase } from '../supabase/client';
import type { YarnRatio, YarnStock } from '../types';
interface YarnAllocation {
yarnRatio: YarnRatio;
allocatedYarn: YarnStock | null;
requiredQuantity: number;
availableQuantity: number;
isSufficient: boolean;
manualName?: string;
manualQuantity?: number;
}
interface YarnAllocationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (allocations: YarnAllocation[]) => void;
yarnRatios: YarnRatio[];
companyId: string;
planTargetQuantity: number;
planId?: string;
}
export function YarnAllocationModal({
isOpen,
onClose,
onConfirm,
yarnRatios,
companyId,
planTargetQuantity,
planId
}: YarnAllocationModalProps) {
const [yarnStock, setYarnStock] = useState<YarnStock[]>([]);
const [allocations, setAllocations] = useState<YarnAllocation[]>([]);
const [loading, setLoading] = useState(true);
const [confirming, setConfirming] = useState(false);
const [isManualMode, setIsManualMode] = useState(false);
const [autoMatchFailed, setAutoMatchFailed] = useState(false);
useEffect(() => {
if (isOpen && companyId) {
fetchYarnStock();
}
}, [isOpen, companyId]);
useEffect(() => {
if (yarnStock.length > 0 && yarnRatios.length > 0) {
calculateAllocations();
}
}, [yarnStock, yarnRatios]);
const fetchYarnStock = async () => {
setLoading(true);
const { data } = await supabase
.from('yarn_stock')
.select('*')
.eq('company_id', companyId)
.order('name');
setYarnStock(data || []);
setLoading(false);
};
const calculateAllocations = () => {
const newAllocations: YarnAllocation[] = yarnRatios.map(ratio => {
const requiredQuantity = ratio.total_amount || 0;
// 尝试匹配相同名称的纱线
const matchingYarn = yarnStock.find(yarn =>
yarn.name.toLowerCase().includes(ratio.yarn_name.toLowerCase()) ||
ratio.yarn_name.toLowerCase().includes(yarn.name.toLowerCase())
);
const availableQuantity = matchingYarn?.quantity || 0;
return {
yarnRatio: ratio,
allocatedYarn: matchingYarn || null,
requiredQuantity,
availableQuantity,
isSufficient: availableQuantity >= requiredQuantity,
manualName: ratio.yarn_name,
manualQuantity: requiredQuantity
};
});
setAllocations(newAllocations);
// 检查是否有自动匹配失败的情况
const hasUnmatched = newAllocations.some(a => !a.allocatedYarn);
const hasInsufficient = newAllocations.some(a => a.allocatedYarn && !a.isSufficient);
setAutoMatchFailed(hasUnmatched || hasInsufficient);
};
const handleYarnSelect = (ratioId: string, yarnId: string) => {
const selectedYarn = yarnStock.find(y => y.id === yarnId);
if (!selectedYarn) return;
setAllocations(prev => prev.map(alloc => {
if (alloc.yarnRatio.id === ratioId) {
return {
...alloc,
allocatedYarn: selectedYarn,
availableQuantity: selectedYarn.quantity,
isSufficient: selectedYarn.quantity >= alloc.requiredQuantity
};
}
return alloc;
}));
};
const handleManualNameChange = (ratioId: string, name: string) => {
setAllocations(prev => prev.map(alloc => {
if (alloc.yarnRatio.id === ratioId) {
return { ...alloc, manualName: name };
}
return alloc;
}));
};
const handleManualQuantityChange = (ratioId: string, quantity: string) => {
const numQuantity = parseFloat(quantity) || 0;
setAllocations(prev => prev.map(alloc => {
if (alloc.yarnRatio.id === ratioId) {
return { ...alloc, manualQuantity: numQuantity };
}
return alloc;
}));
};
const handleConfirm = async () => {
if (isManualMode) {
// 手动模式:验证输入
const emptyName = allocations.filter(a => !a.manualName?.trim());
if (emptyName.length > 0) {
alert('请为所有纱线输入名称');
return;
}
const emptyQuantity = allocations.filter(a => !a.manualQuantity || a.manualQuantity <= 0);
if (emptyQuantity.length > 0) {
alert('请为所有纱线输入有效的重量');
return;
}
} else {
// 自动模式:检查是否所有纱线都有分配
const unallocated = allocations.filter(a => !a.allocatedYarn);
if (unallocated.length > 0) {
alert(`请为以下纱线选择库存:${unallocated.map(a => a.yarnRatio.yarn_name).join(', ')}`);
return;
}
// 检查库存是否足够
const insufficient = allocations.filter(a => !a.isSufficient);
if (insufficient.length > 0) {
alert(
`以下纱线库存不足,无法确认采纱:\n${insufficient.map(a =>
`${a.yarnRatio.yarn_name}: 需要 ${a.requiredQuantity}kg库存 ${a.availableQuantity}kg`
).join('\n')}\n\n请切换到手动分配模式或补充库存后再确认`
);
return;
}
}
// 计算实际消耗总量
const totalConsumption = isManualMode
? allocations.reduce((sum, a) => sum + (a.manualQuantity || 0), 0)
: allocations.reduce((sum, a) => sum + a.requiredQuantity, 0);
// 显示确认对话框,包含实际消耗统计
const confirmMessage = isManualMode
? `确认采纱完成?\n\n实际消耗统计\n${allocations.map(a =>
`${a.manualName}: ${a.manualQuantity}kg`
).join('\n')}\n\n总计消耗: ${totalConsumption}kg`
: `确认采纱完成?\n\n实际消耗统计\n${allocations.map(a =>
`${a.yarnRatio.yarn_name}: ${a.requiredQuantity}kg`
).join('\n')}\n\n总计消耗: ${totalConsumption}kg`;
if (!window.confirm(confirmMessage)) {
return;
}
setConfirming(true);
try {
// 获取当前用户ID
const { data: { user } } = await supabase.auth.getUser();
const operatorId = user?.id;
if (isManualMode) {
// 手动模式:在仓库中查找或创建纱线并扣除库存
for (const alloc of allocations) {
const manualName = alloc.manualName!;
const manualQty = alloc.manualQuantity!;
// 查找是否已有同名纱线
const existingYarn = yarnStock.find(y =>
y.name.toLowerCase() === manualName.toLowerCase()
);
if (existingYarn) {
// 扣除现有纱线库存
if (existingYarn.quantity >= manualQty) {
const newQuantity = existingYarn.quantity - manualQty;
await supabase
.from('yarn_stock')
.update({ quantity: newQuantity })
.eq('id', existingYarn.id);
// 记录出库
await supabase.from('yarn_stock_records').insert({
company_id: companyId,
yarn_stock_id: existingYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: manualQty,
unit: 'kg',
notes: `采纱出库 - 计划: ${planId || '未知'}`,
operator_id: operatorId
});
} else {
alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg库存 ${existingYarn.quantity}kg`);
setConfirming(false);
return;
}
} else {
// 创建新纱线记录并扣除(负库存表示已使用但未入库)
const { data: newYarn } = await supabase
.from('yarn_stock')
.insert({
company_id: companyId,
name: manualName,
quantity: -manualQty,
unit: 'kg',
min_stock: 0
})
.select()
.single();
// 记录出库
if (newYarn) {
await supabase.from('yarn_stock_records').insert({
company_id: companyId,
yarn_stock_id: newYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: manualQty,
unit: 'kg',
notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`,
operator_id: operatorId
});
}
}
}
} else {
// 自动模式:扣除库存
for (const alloc of allocations) {
if (alloc.allocatedYarn && alloc.isSufficient) {
const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity;
await supabase
.from('yarn_stock')
.update({ quantity: newQuantity })
.eq('id', alloc.allocatedYarn.id);
// 记录出库
await supabase.from('yarn_stock_records').insert({
company_id: companyId,
yarn_stock_id: alloc.allocatedYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: alloc.requiredQuantity,
unit: 'kg',
notes: `采纱出库 - 计划: ${planId || '未知'}`,
operator_id: operatorId
});
}
}
}
onConfirm(allocations);
onClose();
} catch (error) {
console.error('库存扣减失败:', error);
alert('库存扣减失败,请重试');
} finally {
setConfirming(false);
}
};
const hasInsufficientYarn = allocations.some(a => !a.isSufficient && !isManualMode);
const hasUnallocatedYarn = allocations.some(a => !a.allocatedYarn && !isManualMode);
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
className="bg-white rounded-2xl w-full max-w-lg shadow-xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-4 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="w-5 h-5" />
<h2 className="text-lg font-semibold">
{isManualMode ? '手动纱线分配' : '纱线分配确认'}
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-white/80 mt-1">
: {planTargetQuantity} | {isManualMode ? '请手动输入纱线信息' : '请确认纱线库存分配'}
</p>
</div>
{/* 模式切换按钮 */}
{!loading && allocations.length > 0 && (
<div className="px-4 pt-4">
<div className="flex gap-2">
<button
onClick={() => setIsManualMode(false)}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
!isManualMode
? 'bg-emerald-100 text-emerald-700 border border-emerald-300'
: 'bg-gray-100 text-gray-600 border border-gray-200'
}`}
>
<Search className="w-4 h-4" />
</button>
<button
onClick={() => setIsManualMode(true)}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
isManualMode
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-gray-100 text-gray-600 border border-gray-200'
}`}
>
<Edit3 className="w-4 h-4" />
</button>
</div>
{autoMatchFailed && !isManualMode && (
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-700 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
</p>
</div>
)}
</div>
)}
{/* 内容 */}
<div className="p-4 max-h-[60vh] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-emerald-500" />
<span className="ml-2 text-gray-500">...</span>
</div>
) : allocations.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-amber-400" />
<p>线</p>
</div>
) : isManualMode ? (
/* 手动分配模式 */
<div className="space-y-3">
{allocations.map((alloc, index) => (
<div
key={alloc.yarnRatio.id}
className="border border-blue-200 bg-blue-50/30 rounded-xl p-3"
>
<div className="mb-2">
<h3 className="font-medium text-gray-800">
{index + 1}. 线
</h3>
<p className="text-xs text-gray-500">
: {alloc.yarnRatio.yarn_name} |
: {alloc.requiredQuantity}kg
</p>
</div>
{/* 手动输入纱线名称 */}
<div className="mt-2">
<label className="text-xs text-gray-600 mb-1 block font-medium">
使线
</label>
<input
type="text"
value={alloc.manualName || ''}
onChange={(e) => handleManualNameChange(alloc.yarnRatio.id, e.target.value)}
placeholder="输入纱线名称"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 手动输入重量 */}
<div className="mt-2">
<label className="text-xs text-gray-600 mb-1 block font-medium">
使 (kg)
</label>
<input
type="number"
value={alloc.manualQuantity || ''}
onChange={(e) => handleManualQuantityChange(alloc.yarnRatio.id, e.target.value)}
placeholder="输入重量"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
))}
</div>
) : (
/* 自动分配模式 */
<div className="space-y-3">
{allocations.map((alloc, index) => (
<div
key={alloc.yarnRatio.id}
className={`border rounded-xl p-3 ${
alloc.isSufficient
? 'border-emerald-200 bg-emerald-50/30'
: alloc.allocatedYarn
? 'border-red-200 bg-red-50/30'
: 'border-amber-200 bg-amber-50/30'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-medium text-gray-800">
{index + 1}. {alloc.yarnRatio.yarn_name}
</h3>
<p className="text-xs text-gray-500">
: {Number(alloc.yarnRatio.ratio).toFixed(0)}% |
: {alloc.yarnRatio.amount_per_meter}g/m |
: {alloc.requiredQuantity}kg
</p>
</div>
{!alloc.allocatedYarn ? (
<span className="text-xs bg-amber-100 text-amber-600 px-2 py-0.5 rounded-full">
</span>
) : !alloc.isSufficient ? (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full">
</span>
) : (
<span className="text-xs bg-emerald-100 text-emerald-600 px-2 py-0.5 rounded-full">
</span>
)}
</div>
{/* 纱线选择 */}
<div className="mt-2">
<label className="text-xs text-gray-500 mb-1 block">
线
</label>
<select
value={alloc.allocatedYarn?.id || ''}
onChange={(e) => handleYarnSelect(alloc.yarnRatio.id, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="">线...</option>
{yarnStock.map(yarn => (
<option key={yarn.id} value={yarn.id}>
{yarn.name} {yarn.spec ? `(${yarn.spec})` : ''} -
: {yarn.quantity}kg
</option>
))}
</select>
</div>
{/* 库存状态 */}
{alloc.allocatedYarn && (
<div className="mt-2 flex items-center gap-2 text-xs">
<span className={alloc.isSufficient ? 'text-emerald-600' : 'text-red-600'}>
{alloc.isSufficient ? (
<span className="flex items-center gap-1">
<Check className="w-3 h-3" />
({alloc.availableQuantity}kg)
</span>
) : (
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
( {alloc.requiredQuantity - alloc.availableQuantity}kg)
</span>
)}
</span>
</div>
)}
</div>
))}
</div>
)}
{/* 提示信息 */}
{hasInsufficientYarn && !isManualMode && (
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-700">
线
</p>
</div>
</div>
)}
</div>
{/* 底部按钮 */}
<div className="p-4 border-t border-gray-100 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium"
>
</button>
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleConfirm}
disabled={confirming || allocations.length === 0}
className={`flex-1 py-2.5 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center justify-center gap-2 ${
isManualMode
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-emerald-500 hover:bg-emerald-600'
}`}
>
{confirming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Check className="w-4 h-4" />
{isManualMode ? '确认手动分配' : '确认采纱'}
</>
)}
</motion.button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@ -352,7 +352,7 @@ function PlanCard({ plan, isExpanded, onToggle, onShare, onPlanUpdated, operator
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{plan.yarn_ratios.map((yarn, index) => ( {plan.yarn_ratios.map((yarn, index) => (
<span key={index} className="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-lg"> <span key={index} className="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-lg">
{yarnTypeLabels[yarn.yarn_type || 'all']}/{yarn.yarn_name}:{yarn.ratio}% {yarnTypeLabels[yarn.yarn_type || 'all']}/{yarn.yarn_name}:{Number(yarn.ratio).toFixed(0)}%
{yarn.amount_per_meter && `(${yarn.amount_per_meter}g/m)`} {yarn.amount_per_meter && `(${yarn.amount_per_meter}g/m)`}
</span> </span>
))} ))}

View File

@ -1,8 +1,8 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Share2, Copy, Check, X, AlertTriangle, Building2 } from 'lucide-react'; import { Share2, Copy, Check, X, AlertTriangle, Building2 } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { encryptShareParams } from '../../utils/shareCrypto'; import { encryptShareParamsSync } from '../../utils/shareCrypto';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
interface ShareModalProps { interface ShareModalProps {
@ -21,6 +21,8 @@ export function ShareModal({ isOpen, onClose, planId, factoryType, factoryName }
const [confirmFactoryName, setConfirmFactoryName] = useState(''); const [confirmFactoryName, setConfirmFactoryName] = useState('');
const [confirmCompanyName, setConfirmCompanyName] = useState(''); const [confirmCompanyName, setConfirmCompanyName] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [shortCode, setShortCode] = useState<string>('');
const shortLinkGenerated = useRef(false);
// 获取当前用户的公司名称 // 获取当前用户的公司名称
useEffect(() => { useEffect(() => {
@ -46,14 +48,61 @@ export function ShareModal({ isOpen, onClose, planId, factoryType, factoryName }
}; };
if (isOpen) { if (isOpen) {
fetchCompanyName(); fetchCompanyName();
// 生成短链
if (!shortLinkGenerated.current) {
shortLinkGenerated.current = true;
generateShortLink();
}
} else {
// 弹窗关闭时重置
shortLinkGenerated.current = false;
setShortCode('');
} }
}, [isOpen]); }, [isOpen]);
// 生成短链
const generateShortLink = useCallback(async () => {
const baseUrl = window.location.origin + window.location.pathname;
// 生成短码6位随机字符
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
// 保存到数据库
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { error } = await supabase.from('share_links').insert({
short_code: code,
plan_id: planId,
factory_type: factoryType,
created_by: user.id,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天过期
});
if (!error) {
setShortCode(code);
console.log('短链生成成功:', code);
} else {
console.error('短链保存失败:', error);
}
} else {
console.error('用户未登录,无法生成短链');
}
}, [planId, factoryType]);
// 生成分享链接
const generateShareLink = useCallback(() => { const generateShareLink = useCallback(() => {
const baseUrl = window.location.origin + window.location.pathname; const baseUrl = window.location.origin + window.location.pathname;
const token = encryptShareParams(planId, factoryType); if (shortCode) {
return `${baseUrl}#/import?s=${shortCode}`;
}
// 如果短链还未生成,使用长链接
const token = encryptShareParamsSync(planId, factoryType);
return `${baseUrl}#/import?token=${token}`; return `${baseUrl}#/import?token=${token}`;
}, [planId, factoryType]); }, [planId, factoryType, shortCode]);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
const link = generateShareLink(); const link = generateShareLink();
@ -130,12 +179,6 @@ export function ShareModal({ isOpen, onClose, planId, factoryType, factoryName }
> >
</button> </button>
<button
onClick={() => setShowConfirm(true)}
className="flex-1 py-2 sm:py-2.5 bg-amber-500 hover:bg-amber-600 rounded-xl text-white text-xs sm:text-sm font-medium transition-colors"
>
</button>
<button <button
onClick={handleCopy} onClick={handleCopy}
className={`flex-1 py-2 sm:py-2.5 rounded-xl text-white flex items-center justify-center gap-1.5 sm:gap-2 transition-colors text-xs sm:text-sm font-medium ${ className={`flex-1 py-2 sm:py-2.5 rounded-xl text-white flex items-center justify-center gap-1.5 sm:gap-2 transition-colors text-xs sm:text-sm font-medium ${

View File

@ -209,34 +209,108 @@ export function ProductForm({
} }
}; };
// 生成递增的产品码(如 M2 → M3 // 从数据库查询已存在的最大产品码数字,生成下一个
const generateNextFabricCode = (currentCode: string): string => { const generateNextFabricCode = async (companyId: string, prefix: string): Promise<string> => {
if (!currentCode) return ''; if (!prefix) return '';
// 匹配末尾的数字
const match = currentCode.match(/^(.*?)(\d+)$/); try {
if (match) { // 查询该公司所有以该前缀开头的产品码
const prefix = match[1]; const { data, error } = await supabase
const num = parseInt(match[2], 10); .from('products')
return `${prefix}${num + 1}`; .select('fabric_code')
.eq('company_id', companyId)
.ilike('fabric_code', `${prefix}%`);
if (error || !data || data.length === 0) {
// 如果没有匹配的产品从1开始
return `${prefix}1`;
}
// 提取所有产品码中的数字,找出最大值
let maxNum = 0;
data.forEach(item => {
const code = item.fabric_code;
const match = code.match(/^(.*?)(\d+)$/);
if (match && match[1].toUpperCase() === prefix.toUpperCase()) {
const num = parseInt(match[2], 10);
if (num > maxNum) {
maxNum = num;
}
}
});
// 返回前缀 + (最大数字 + 1)
return `${prefix}${maxNum + 1}`;
} catch (err) {
console.error('生成产品码失败:', err);
return `${prefix}1`;
} }
// 如果没有数字,直接加 1
return `${currentCode}1`;
}; };
// 生成递增的色号(如 02 → 03 // 从数据库查询已存在的最大色号数字,生成下一个
const generateNextColorCode = (currentCode: string): string => { const generateNextColorCode = async (
if (!currentCode) return '01'; companyId: string,
const num = parseInt(currentCode, 10); productName: string,
const nextNum = num + 1; totalWeight: number | null
// 保持两位数格式 ): Promise<string> => {
return nextNum < 10 ? `0${nextNum}` : String(nextNum); try {
// 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增)
const { data, error } = await supabase
.from('products')
.select('color_code')
.eq('company_id', companyId)
.eq('product_name', productName)
.eq('total_weight', totalWeight || 0);
if (error || !data || data.length === 0) {
// 如果没有匹配的产品从01开始
return '01';
}
// 提取所有色号中的数字,找出最大值
let maxNum = 0;
data.forEach(item => {
const code = item.color_code;
const num = parseInt(code, 10);
if (!isNaN(num) && num > maxNum) {
maxNum = num;
}
});
// 返回最大数字 + 1保持两位数格式
const nextNum = maxNum + 1;
return nextNum < 10 ? `0${nextNum}` : String(nextNum);
} catch (err) {
console.error('生成色号失败:', err);
return '01';
}
}; };
// 选择产品建议 // 选择产品建议
const selectProductSuggestion = (suggestion: ProductSuggestion) => { const selectProductSuggestion = async (suggestion: ProductSuggestion) => {
// 获取该产品的纱线配比 // 获取该产品的纱线配比
const ratios = productRatios[suggestion.id] || []; const ratios = productRatios[suggestion.id] || [];
// 提取产品码前缀并生成新的产品码
let newFabricCode = '';
const fabricMatch = suggestion.fabric_code.match(/^(.*?)(\d+)$/);
if (fabricMatch && companyId) {
const prefix = fabricMatch[1];
newFabricCode = await generateNextFabricCode(companyId, prefix);
} else {
newFabricCode = suggestion.fabric_code;
}
// 生成色号(基于该产品名称-克重组合的最大值+1色号在产品名称-克重下连续递增)
let newColorCode = '01';
if (companyId) {
newColorCode = await generateNextColorCode(
companyId,
suggestion.product_name,
suggestion.total_weight
);
}
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
product_name: suggestion.product_name, product_name: suggestion.product_name,
@ -246,10 +320,10 @@ export function ProductForm({
weft_weight: suggestion.weft_weight ? String(suggestion.weft_weight) : '', weft_weight: suggestion.weft_weight ? String(suggestion.weft_weight) : '',
// 颜色需要用户自己输入,不自动填写 // 颜色需要用户自己输入,不自动填写
color: '', color: '',
// 产品码自动生成(递增 // 产品码自动生成(基于数据库最大值+1
fabric_code: generateNextFabricCode(suggestion.fabric_code), fabric_code: newFabricCode,
// 色号自动生成(递增 // 色号自动生成(基于该产品名称-克重-颜色组合的最大值+1
color_code: generateNextColorCode(suggestion.color_code), color_code: newColorCode,
// 自动填写生产采购价 // 自动填写生产采购价
production_price: suggestion.production_price ? String(suggestion.production_price) : '', production_price: suggestion.production_price ? String(suggestion.production_price) : '',
// 自动填写纱线配比 // 自动填写纱线配比

View File

@ -1,8 +1,6 @@
import React from 'react'; import React, { memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronUp, List, Image as ImageIcon } from 'lucide-react'; import { ChevronDown, ChevronUp, List, Image as ImageIcon } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { animationConfig } from '../../utils/constants';
import type { ProductWithInventory, ProductYarnRatio, ProductInRecord } from '../../types'; import type { ProductWithInventory, ProductYarnRatio, ProductInRecord } from '../../types';
interface ProductListProps { interface ProductListProps {
@ -30,14 +28,16 @@ export function ProductList({
}: ProductListProps) { }: ProductListProps) {
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
// 按名称和克重分组(优先使用 total_weight // 按名称和克重分组(优先使用 total_weight,无克重的产品直接按名称分组
const groupProductsByNameAndWeight = () => { const groupProductsByNameAndWeight = () => {
const groups: Record<string, Record<string, ProductWithInventory[]>> = {}; const groups: Record<string, Record<string, ProductWithInventory[]>> = {};
products.forEach(product => { products.forEach(product => {
if (!groups[product.product_name]) { if (!groups[product.product_name]) {
groups[product.product_name] = {}; groups[product.product_name] = {};
} }
const weightKey = String(product.total_weight || product.weight); const weight = product.total_weight || product.weight;
// 如果没有克重,使用特殊标记 '__no_weight__'
const weightKey = weight ? String(weight) : '__no_weight__';
if (!groups[product.product_name][weightKey]) { if (!groups[product.product_name][weightKey]) {
groups[product.product_name][weightKey] = []; groups[product.product_name][weightKey] = [];
} }
@ -46,15 +46,6 @@ export function ProductList({
return groups; return groups;
}; };
// 格式化克重显示(含经线/纬线细分)
const formatWeightDisplay = (product: ProductWithInventory) => {
const total = product.total_weight || product.weight;
if (product.warp_weight && product.weft_weight) {
return `${total}g (经${product.warp_weight}+纬${product.weft_weight})`;
}
return `${total}g`;
};
const productGroups = groupProductsByNameAndWeight(); const productGroups = groupProductsByNameAndWeight();
if (isMobile) { if (isMobile) {
@ -69,32 +60,27 @@ export function ProductList({
{Object.entries(productGroups).map(([productName, weightGroups]) => { {Object.entries(productGroups).map(([productName, weightGroups]) => {
const isNameExpanded = expandedGroups[productName]; const isNameExpanded = expandedGroups[productName];
const allProducts = Object.values(weightGroups).flat(); const allProducts = Object.values(weightGroups).flat();
// 计算总入库量(从入库记录)
const totalInRolls = allProducts.reduce((sum, p) => sum + (p.total_in_rolls || 0), 0); const totalInRolls = allProducts.reduce((sum, p) => sum + (p.total_in_rolls || 0), 0);
const totalInMeters = allProducts.reduce((sum, p) => sum + (p.total_in_meters || 0), 0); const totalInMeters = allProducts.reduce((sum, p) => sum + (p.total_in_meters || 0), 0);
// 计算出库量
const totalOutRolls = allProducts.reduce((sum, p) => sum + (p.total_out_rolls || 0), 0); const totalOutRolls = allProducts.reduce((sum, p) => sum + (p.total_out_rolls || 0), 0);
const totalOutMeters = allProducts.reduce((sum, p) => sum + (p.total_out_meters || 0), 0); const totalOutMeters = allProducts.reduce((sum, p) => sum + (p.total_out_meters || 0), 0);
// 可用库存
const availableRolls = allProducts.reduce((sum, p) => sum + (p.raw_fabric_rolls || 0), 0); const availableRolls = allProducts.reduce((sum, p) => sum + (p.raw_fabric_rolls || 0), 0);
const availableMeters = allProducts.reduce((sum, p) => sum + (p.raw_fabric_meters || 0), 0); const availableMeters = allProducts.reduce((sum, p) => sum + (p.raw_fabric_meters || 0), 0);
const weightCount = Object.keys(weightGroups).length; const weightCount = Object.keys(weightGroups).length;
return ( return (
<motion.div <div
key={productName} key={productName}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden" className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
> >
{/* 一级分组头部 */} {/* 一级分组头部 */}
<div <div
onClick={() => onToggleGroup(productName)} onClick={() => onToggleGroup(productName)}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors" className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isNameExpanded ? 'bg-blue-100' : 'bg-gray-100'}`}> <div className={`w-8 h-8 rounded-lg flex items-center justify-center ${isNameExpanded ? 'bg-amber-100' : 'bg-gray-100'}`}>
{isNameExpanded ? <ChevronUp size={18} className="text-blue-600" /> : <ChevronDown size={18} className="text-gray-500" />} {isNameExpanded ? <ChevronUp size={18} className="text-amber-600" /> : <ChevronDown size={18} className="text-gray-500" />}
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900">{productName}</h3> <h3 className="font-semibold text-gray-900">{productName}</h3>
@ -110,83 +96,154 @@ export function ProductList({
</span> </span>
</div> </div>
{/* 二级分组 */} {/* 二级分组 - 使用CSS Grid实现平滑高度动画 */}
<AnimatePresence> <div
{isNameExpanded && ( className="border-t border-gray-100 grid transition-all duration-300 ease-out"
<motion.div style={{
initial={{ height: 0, opacity: 0 }} gridTemplateRows: isNameExpanded ? '1fr' : '0fr',
animate={{ height: 'auto', opacity: 1 }} opacity: isNameExpanded ? 1 : 0
exit={{ height: 0, opacity: 0 }} }}
transition={animationConfig.transition} >
className="border-t border-gray-100" <div className="overflow-hidden">
> <div className="p-4 space-y-3">
<div className="p-4 space-y-3"> {Object.entries(weightGroups).map(([weight, products]) => {
{Object.entries(weightGroups).map(([weight, products]) => { const weightKey = `${productName}-${weight}`;
const weightKey = `${productName}-${weight}`; const isWeightExpanded = expandedGroups[weightKey];
const isWeightExpanded = expandedGroups[weightKey]; const hasNoWeight = weight === '__no_weight__';
const isSingleWeight = Object.keys(weightGroups).length === 1;
return ( // 如果没有克重,直接显示产品列表
<div key={weight} className="bg-gray-50 rounded-xl overflow-hidden"> if (hasNoWeight) {
<div return (
onClick={() => onToggleGroup(weightKey)} <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 transition-colors" <table className="w-full">
> <thead className="bg-white/50 border-b border-gray-100">
<div className="flex items-center gap-2"> <tr>
<div className={`w-6 h-6 rounded flex items-center justify-center transition-colors ${isWeightExpanded ? 'bg-blue-100' : 'bg-white'}`}> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
{isWeightExpanded ? <ChevronUp size={14} className="text-blue-600" /> : <ChevronDown size={14} className="text-gray-500" />} <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
</div> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<span className="font-medium text-gray-800">{weight}g</span> <th className="px-3 py-2 text-right text-xs font-medium text-gray-500"></th>
{products[0]?.warp_weight && products[0]?.weft_weight && ( </tr>
<span className="text-xs text-gray-400 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span> </thead>
)} <tbody className="divide-y divide-gray-100">
<span className="text-xs text-gray-500">({products.length})</span> {products.map(product => (
</div> <ProductRow
</div> key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</tbody>
</table>
</div>
);
}
<AnimatePresence> // 如果只有一种克重,直接显示
{isWeightExpanded && ( if (isSingleWeight) {
<motion.div return (
initial={{ height: 0, opacity: 0 }} <div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
animate={{ height: 'auto', opacity: 1 }} <div className="flex items-center gap-2 p-3 bg-gray-100 border-b border-gray-200">
exit={{ height: 0, opacity: 0 }} <span className="font-medium text-gray-800">{weight}g</span>
transition={animationConfig.fastTransition} {products[0]?.warp_weight && products[0]?.weft_weight && (
className="border-t border-gray-200" <span className="text-xs text-gray-500 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span>
> )}
<table className="w-full"> <span className="text-xs text-gray-500">({products.length})</span>
<thead className="bg-white/50 border-b border-gray-100">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map(product => (
<ProductRow
key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</tbody>
</table>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); <table className="w-full">
})} <thead className="bg-white/50 border-b border-gray-100">
</div> <tr>
</motion.div> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
)} <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
</AnimatePresence> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
</motion.div> <th className="px-3 py-2 text-right text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map(product => (
<ProductRow
key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</tbody>
</table>
</div>
);
}
// 多种克重,显示可折叠的克重卡片
return (
<div key={weight} className="bg-gray-50 rounded-xl overflow-hidden">
<div
onClick={() => onToggleGroup(weightKey)}
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center ${isWeightExpanded ? 'bg-amber-100' : 'bg-white'}`}>
{isWeightExpanded ? <ChevronUp size={14} className="text-amber-600" /> : <ChevronDown size={14} className="text-gray-500" />}
</div>
<span className="font-medium text-gray-800">{weight}g</span>
{products[0]?.warp_weight && products[0]?.weft_weight && (
<span className="text-xs text-gray-400 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span>
)}
<span className="text-xs text-gray-500">({products.length})</span>
</div>
</div>
{/* 克重展开内容 - 使用CSS Grid实现平滑高度动画 */}
<div
className="border-t border-gray-200 grid transition-all duration-300 ease-out"
style={{
gridTemplateRows: isWeightExpanded ? '1fr' : '0fr',
opacity: isWeightExpanded ? 1 : 0
}}
>
<div className="overflow-hidden">
<table className="w-full">
<thead className="bg-white/50 border-b border-gray-100">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map(product => (
<ProductRow
key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</tbody>
</table>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
); );
})} })}
</div> </div>
@ -198,23 +255,20 @@ export function ProductList({
<div className="space-y-3"> <div className="space-y-3">
{Object.entries(productGroups).map(([productName, weightGroups]) => { {Object.entries(productGroups).map(([productName, weightGroups]) => {
const isNameExpanded = expandedGroups[productName]; const isNameExpanded = expandedGroups[productName];
const allProducts = Object.values(weightGroups).flat();
return ( return (
<motion.div <div
key={productName} key={productName}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"
> >
<div <div
onClick={() => onToggleGroup(productName)} onClick={() => onToggleGroup(productName)}
className="p-4 cursor-pointer hover:bg-gray-50 transition-colors" className="p-4 cursor-pointer hover:bg-gray-50"
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center transition-colors flex-shrink-0 ${isNameExpanded ? 'bg-blue-100' : 'bg-gray-100'}`}> <div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${isNameExpanded ? 'bg-amber-100' : 'bg-gray-100'}`}>
{isNameExpanded ? <ChevronUp size={16} className="text-blue-600" /> : <ChevronDown size={16} className="text-gray-500" />} {isNameExpanded ? <ChevronUp size={16} className="text-amber-600" /> : <ChevronDown size={16} className="text-gray-500" />}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-gray-900 text-sm truncate">{productName}</p> <p className="font-medium text-gray-900 text-sm truncate">{productName}</p>
@ -223,21 +277,46 @@ export function ProductList({
</div> </div>
</div> </div>
<AnimatePresence> {/* 移动端展开内容 - 使用CSS Grid实现平滑高度动画 */}
{isNameExpanded && ( <div
<motion.div className="border-t border-gray-100 grid transition-all duration-300 ease-out"
initial={{ height: 0, opacity: 0 }} style={{
animate={{ height: 'auto', opacity: 1 }} gridTemplateRows: isNameExpanded ? '1fr' : '0fr',
exit={{ height: 0, opacity: 0 }} opacity: isNameExpanded ? 1 : 0
className="border-t border-gray-100 bg-gray-50" }}
> >
<div className="p-3 space-y-2"> <div className="overflow-hidden bg-gray-50">
{Object.entries(weightGroups).map(([weight, products]) => ( <div className="p-3 space-y-2">
{Object.entries(weightGroups).map(([weight, products]) => {
const hasNoWeight = weight === '__no_weight__';
const isSingleWeight = Object.keys(weightGroups).length === 1;
if (hasNoWeight) {
return (
<div key={weight} className="space-y-2">
{products.map(product => (
<ProductCardMobile
key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</div>
);
}
if (isSingleWeight) {
return (
<div key={weight} className="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div key={weight} className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="p-3"> <div className="p-3 bg-gray-50 border-b border-gray-100">
<span className="font-medium text-gray-800 text-sm">{weight}g</span> <span className="font-medium text-gray-800 text-sm">{weight}g</span>
{products[0]?.warp_weight && products[0]?.weft_weight && ( {products[0]?.warp_weight && products[0]?.weft_weight && (
<span className="text-xs text-gray-400 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span> <span className="text-xs text-gray-500 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span>
)} )}
</div> </div>
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">
@ -255,12 +334,38 @@ export function ProductList({
))} ))}
</div> </div>
</div> </div>
))} );
</div> }
</motion.div>
)} return (
</AnimatePresence> <div key={weight} className="bg-white rounded-lg border border-gray-200 overflow-hidden">
</motion.div> <div className="p-3">
<span className="font-medium text-gray-800 text-sm">{weight}g</span>
{products[0]?.warp_weight && products[0]?.weft_weight && (
<span className="text-xs text-gray-400 ml-1">{products[0].warp_weight}+{products[0].weft_weight}</span>
)}
</div>
<div className="p-2 space-y-2">
{products.map(product => (
<ProductCardMobile
key={product.id}
product={product}
ratios={productRatios[product.id]}
onViewRecords={() => onViewRecords(product)}
onViewPriceHistory={() => onViewPriceHistory(product)}
onViewStockHistory={() => onViewStockHistory(product)}
onEdit={() => onEdit(product)}
onDelete={() => onDelete(product.id)}
/>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
); );
})} })}
</div> </div>
@ -281,7 +386,7 @@ interface ProductRowProps {
function ProductRow({ product, ratios, onViewRecords, onViewPriceHistory, onViewStockHistory, onEdit, onDelete }: ProductRowProps) { function ProductRow({ product, ratios, onViewRecords, onViewPriceHistory, onViewStockHistory, onEdit, onDelete }: ProductRowProps) {
return ( return (
<tr className="bg-white hover:bg-gray-50/50 transition-colors"> <tr className="bg-white hover:bg-gray-50/50">
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{product.image_url ? ( {product.image_url ? (
@ -316,7 +421,7 @@ function ProductRow({ product, ratios, onViewRecords, onViewPriceHistory, onView
<div className="text-xs text-gray-400 mt-1"> <div className="text-xs text-gray-400 mt-1">
<button <button
onClick={(e) => { e.stopPropagation(); onViewRecords(); }} onClick={(e) => { e.stopPropagation(); onViewRecords(); }}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs font-medium hover:bg-blue-100 transition-colors" className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-50 text-amber-600 rounded text-xs font-medium hover:bg-amber-100"
> >
<List className="w-3 h-3" /> <List className="w-3 h-3" />
{product.in_records?.length || 0} {product.in_records?.length || 0}
@ -328,7 +433,7 @@ function ProductRow({ product, ratios, onViewRecords, onViewPriceHistory, onView
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<button onClick={onViewStockHistory} className="text-purple-600 hover:text-purple-800 text-sm font-medium mr-2"></button> <button onClick={onViewStockHistory} className="text-purple-600 hover:text-purple-800 text-sm font-medium mr-2"></button>
<button onClick={onViewPriceHistory} className="text-emerald-600 hover:text-emerald-800 text-sm font-medium mr-2"></button> <button onClick={onViewPriceHistory} className="text-emerald-600 hover:text-emerald-800 text-sm font-medium mr-2"></button>
<button onClick={onEdit} className="text-blue-600 hover:text-blue-800 text-sm font-medium mr-2"></button> <button onClick={onEdit} className="text-amber-600 hover:text-amber-800 text-sm font-medium mr-2"></button>
<button onClick={onDelete} className="text-red-600 hover:text-red-800 text-sm font-medium"></button> <button onClick={onDelete} className="text-red-600 hover:text-red-800 text-sm font-medium"></button>
</td> </td>
</tr> </tr>
@ -378,7 +483,7 @@ function ProductCardMobile({ product, ratios, onViewRecords, onViewPriceHistory,
<div className="text-xs text-gray-400 mb-1"> <div className="text-xs text-gray-400 mb-1">
<button <button
onClick={(e) => { e.stopPropagation(); onViewRecords(); }} onClick={(e) => { e.stopPropagation(); onViewRecords(); }}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs font-medium hover:bg-blue-100 transition-colors" className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs font-medium hover:bg-blue-100"
> >
<List className="w-3 h-3" /> <List className="w-3 h-3" />
{product.in_records?.length || 0} {product.in_records?.length || 0}

View File

@ -4,7 +4,7 @@ import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase/client'; import { supabase } from '../supabase/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2 } from 'lucide-react'; import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2 } from 'lucide-react';
import { decryptShareParams } from '../utils/shareCrypto'; import { decryptShareParamsSync } from '../utils/shareCrypto';
import { useResponsive } from '../hooks/useResponsive'; import { useResponsive } from '../hooks/useResponsive';
import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types'; import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types';
@ -25,22 +25,47 @@ export function ImportPlanPage() {
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const token = searchParams.get('token') || ''; const token = searchParams.get('token') || '';
const shortCode = searchParams.get('s') || '';
const [planId, setPlanId] = useState(''); const [planId, setPlanId] = useState('');
const [factoryType, setFactoryType] = useState<FactoryType | null>(null); const [factoryType, setFactoryType] = useState<FactoryType | null>(null);
const [decrypting, setDecrypting] = useState(true);
useEffect(() => { useEffect(() => {
if (token) { const resolveLink = async () => {
decryptShareParams(token).then(decrypted => { if (shortCode) {
// 短链解析
const { data, error } = await supabase
.from('share_links')
.select('plan_id, factory_type')
.eq('short_code', shortCode)
.maybeSingle();
if (data) {
setPlanId(data.plan_id);
setFactoryType(data.factory_type as FactoryType);
// 增加点击计数
await supabase.rpc('increment_share_link_clicks', { code: shortCode });
} else {
console.error('短链查询失败:', error);
}
setDecrypting(false);
} else if (token) {
// 长链接解析
const decrypted = decryptShareParamsSync(token);
if (decrypted) { if (decrypted) {
setPlanId(decrypted.planId); setPlanId(decrypted.planId);
setFactoryType(decrypted.factoryType as FactoryType); setFactoryType(decrypted.factoryType as FactoryType);
} }
}); setDecrypting(false);
} else { } else {
setPlanId(searchParams.get('plan') || ''); setPlanId(searchParams.get('plan') || '');
setFactoryType(searchParams.get('type') as FactoryType | null); setFactoryType(searchParams.get('type') as FactoryType | null);
} setDecrypting(false);
}, [token]); }
};
resolveLink();
}, [token, shortCode]);
const [plan, setPlan] = useState<ProductionPlan | WashingPlan | null>(null); const [plan, setPlan] = useState<ProductionPlan | WashingPlan | null>(null);
const [yarns, setYarns] = useState<YarnRatio[]>([]); const [yarns, setYarns] = useState<YarnRatio[]>([]);
@ -93,6 +118,11 @@ export function ImportPlanPage() {
setLoading(false); setLoading(false);
}; };
// 等待解密完成且 factoryType 有值
if (decrypting || !factoryType) {
return <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center"><p className="text-gray-500">...</p></div>;
}
// 角色匹配当前角色必须与链接中的factoryType一致 // 角色匹配当前角色必须与链接中的factoryType一致
const roleMatches = auth.company && auth.currentRole === factoryType; const roleMatches = auth.company && auth.currentRole === factoryType;

View File

@ -9,7 +9,7 @@ import { ComingSoonModal } from '../components/ComingSoonModal';
const demoAccounts = [ const demoAccounts = [
{ username: 'purchaser', label: '采购商(布行)', color: 'from-blue-500 to-blue-600', icon: Building2, enabled: true }, { username: 'purchaser', label: '采购商(布行)', color: 'from-blue-500 to-blue-600', icon: Building2, enabled: true },
{ username: 'textile', label: '纺织厂', color: 'from-emerald-500 to-green-600', icon: Factory, enabled: true }, { username: 'textile', label: '纺织厂', color: 'from-emerald-500 to-green-600', icon: Factory, enabled: true },
{ username: 'washing', label: '水洗厂', color: 'from-cyan-500 to-blue-600', icon: Droplets, enabled: true } { username: 'washing', label: '水洗厂', color: 'from-violet-400 to-fuchsia-400', icon: Droplets, enabled: true }
]; ];
// 纺织主题背景动画组件 // 纺织主题背景动画组件

View File

@ -307,6 +307,48 @@ export function MemberManage() {
} }
}; };
// 根据角色获取主题色
const getThemeColors = () => {
const role = companyInfo?.role || 'purchaser';
switch (role) {
case 'textile':
return {
primary: 'emerald',
bg: 'bg-emerald-50',
bgHover: 'hover:bg-emerald-100',
text: 'text-emerald-500',
border: 'border-emerald-200',
ring: 'focus:ring-emerald-400',
button: 'bg-emerald-400 hover:bg-emerald-500',
gradient: 'from-emerald-400 to-teal-400'
};
case 'washing':
return {
primary: 'violet',
bg: 'bg-violet-50',
bgHover: 'hover:bg-violet-100',
text: 'text-violet-500',
border: 'border-violet-200',
ring: 'focus:ring-violet-400',
button: 'bg-violet-400 hover:bg-violet-500',
gradient: 'from-violet-400 to-fuchsia-400'
};
default:
return {
primary: 'amber',
bg: 'bg-amber-50',
bgHover: 'hover:bg-amber-100',
text: 'text-amber-500',
border: 'border-amber-200',
ring: 'focus:ring-amber-400',
button: 'bg-amber-400 hover:bg-amber-500',
gradient: 'from-amber-400 to-orange-400'
};
}
};
const theme = getThemeColors();
// 如果不是主账号,显示无权限 // 如果不是主账号,显示无权限
if (!auth.user?.is_master) { if (!auth.user?.is_master) {
return ( return (
@ -319,7 +361,7 @@ export function MemberManage() {
<p className="text-gray-500 mb-4"></p> <p className="text-gray-500 mb-4"></p>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className={`px-6 py-2 ${theme.button} text-white rounded-lg transition-colors`}
> >
</button> </button>
@ -349,7 +391,7 @@ export function MemberManage() {
whileHover={{ scale: isMobile ? 1 : 1.05 }} whileHover={{ scale: isMobile ? 1 : 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={handleOpenModal} onClick={handleOpenModal}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm md:text-base" className={`flex items-center gap-2 px-4 py-2 ${theme.button} text-white rounded-lg transition-colors text-sm md:text-base`}
> >
<Plus size={18} /> <Plus size={18} />
@ -357,14 +399,14 @@ export function MemberManage() {
</div> </div>
{/* 主账号信息卡片 */} {/* 主账号信息卡片 */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-4 md:p-6 mb-6 text-white"> <div className={`bg-gradient-to-r ${theme.gradient} rounded-xl p-4 md:p-6 mb-6 text-white`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
<Shield className="w-6 h-6" /> <Shield className="w-6 h-6" />
</div> </div>
<div> <div>
<p className="text-sm text-blue-100"></p> <p className="text-sm text-white/70"></p>
<p className="font-semibold text-lg">{auth.user?.username}</p> <p className="font-semibold text-lg">{auth.user?.username}</p>
<span className="inline-flex items-center gap-1 text-xs bg-white/20 px-2 py-0.5 rounded mt-1"> <span className="inline-flex items-center gap-1 text-xs bg-white/20 px-2 py-0.5 rounded mt-1">
<Shield size={12} /> <Shield size={12} />
@ -386,7 +428,7 @@ export function MemberManage() {
{/* 帮助与支持卡片 */} {/* 帮助与支持卡片 */}
<div className="bg-white rounded-lg shadow-sm p-4 md:p-6 mb-6"> <div className="bg-white rounded-lg shadow-sm p-4 md:p-6 mb-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BookOpen className="w-5 h-5 text-blue-600" /> <BookOpen className={`w-5 h-5 ${theme.text}`} />
<h2 className="font-semibold text-gray-800"></h2> <h2 className="font-semibold text-gray-800"></h2>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
@ -394,10 +436,10 @@ export function MemberManage() {
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setShowUserManual(true)} onClick={() => setShowUserManual(true)}
className="flex items-center gap-3 p-4 bg-blue-50 rounded-xl hover:bg-blue-100 transition-colors text-left" className={`flex items-center gap-3 p-4 ${theme.bg} rounded-xl ${theme.bgHover} transition-colors text-left`}
> >
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center"> <div className={`w-10 h-10 ${theme.bg} rounded-lg flex items-center justify-center`}>
<BookOpen className="w-5 h-5 text-blue-600" /> <BookOpen className={`w-5 h-5 ${theme.text}`} />
</div> </div>
<div> <div>
<p className="font-medium text-gray-800"></p> <p className="font-medium text-gray-800"></p>
@ -409,10 +451,10 @@ export function MemberManage() {
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setShowFeedback(true)} onClick={() => setShowFeedback(true)}
className="flex items-center gap-3 p-4 bg-emerald-50 rounded-xl hover:bg-emerald-100 transition-colors text-left" className={`flex items-center gap-3 p-4 ${theme.bg} rounded-xl ${theme.bgHover} transition-colors text-left`}
> >
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center"> <div className={`w-10 h-10 ${theme.bg} rounded-lg flex items-center justify-center`}>
<MessageSquare className="w-5 h-5 text-emerald-600" /> <MessageSquare className={`w-5 h-5 ${theme.text}`} />
</div> </div>
<div> <div>
<p className="font-medium text-gray-800"></p> <p className="font-medium text-gray-800"></p>
@ -424,10 +466,10 @@ export function MemberManage() {
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setShowAbout(true)} onClick={() => setShowAbout(true)}
className="flex items-center gap-3 p-4 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors text-left" className={`flex items-center gap-3 p-4 ${theme.bg} rounded-xl ${theme.bgHover} transition-colors text-left`}
> >
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center"> <div className={`w-10 h-10 ${theme.bg} rounded-lg flex items-center justify-center`}>
<Info className="w-5 h-5 text-purple-600" /> <Info className={`w-5 h-5 ${theme.text}`} />
</div> </div>
<div> <div>
<p className="font-medium text-gray-800"></p> <p className="font-medium text-gray-800"></p>
@ -451,7 +493,7 @@ export function MemberManage() {
whileHover={{ scale: isMobile ? 1 : 1.05 }} whileHover={{ scale: isMobile ? 1 : 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={handleOpenModal} onClick={handleOpenModal}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className={`px-6 py-2 ${theme.button} text-white rounded-lg transition-colors`}
> >
</motion.button> </motion.button>
@ -535,7 +577,7 @@ export function MemberManage() {
type="text" type="text"
value={formData.username} value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入用户名至少3个字符" placeholder="请输入用户名至少3个字符"
required required
/> />
@ -552,7 +594,7 @@ export function MemberManage() {
type="text" type="text"
value={formData.displayName} value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })} onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入姓名" placeholder="请输入姓名"
required required
/> />
@ -569,7 +611,7 @@ export function MemberManage() {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入手机号" placeholder="请输入手机号"
required required
/> />
@ -584,7 +626,7 @@ export function MemberManage() {
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full px-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入密码至少6个字符" placeholder="请输入密码至少6个字符"
required required
/> />
@ -614,7 +656,7 @@ export function MemberManage() {
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed" className={`flex-1 py-2.5 ${theme.button} text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed`}
> >
{submitting ? '创建中...' : '创建子账号'} {submitting ? '创建中...' : '创建子账号'}
</motion.button> </motion.button>
@ -705,14 +747,14 @@ export function MemberManage() {
</label> </label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
type="text" type="text"
value={masterFormData.displayName} value={masterFormData.displayName}
onChange={(e) => setMasterFormData({ ...masterFormData, displayName: e.target.value })} onChange={(e) => setMasterFormData({ ...masterFormData, displayName: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入姓名" placeholder="请输入姓名"
required required
/> />
</div> </div>
</div> </div>
@ -723,14 +765,14 @@ export function MemberManage() {
</label> </label>
<div className="relative"> <div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> <Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
type="tel" type="tel"
value={masterFormData.phone} value={masterFormData.phone}
onChange={(e) => setMasterFormData({ ...masterFormData, phone: e.target.value })} onChange={(e) => setMasterFormData({ ...masterFormData, phone: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入手机号" placeholder="请输入手机号"
required required
/> />
</div> </div>
</div> </div>
@ -743,7 +785,7 @@ export function MemberManage() {
type="password" type="password"
value={masterFormData.password || ''} value={masterFormData.password || ''}
onChange={(e) => setMasterFormData({ ...masterFormData, password: e.target.value })} onChange={(e) => setMasterFormData({ ...masterFormData, password: e.target.value })}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className={`w-full px-4 py-2.5 border border-gray-300 rounded-lg ${theme.ring} focus:border-transparent transition-all`}
placeholder="请输入新密码至少6个字符" placeholder="请输入新密码至少6个字符"
/> />
<p className="text-xs text-gray-400 mt-1"></p> <p className="text-xs text-gray-400 mt-1"></p>
@ -774,7 +816,7 @@ export function MemberManage() {
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
type="submit" type="submit"
disabled={editingMaster} disabled={editingMaster}
className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed" className={`flex-1 py-2.5 ${theme.button} text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed`}
> >
{editingMaster ? '保存中...' : '保存修改'} {editingMaster ? '保存中...' : '保存修改'}
</motion.button> </motion.button>

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FileText, Plus, Warehouse, Factory, ArrowLeft, Users, TrendingUp, Package, Clock, Building2, CheckCircle2, DollarSign, Droplets, LogOut, HelpCircle } from 'lucide-react'; import { FileText, Plus, Warehouse, Factory, ArrowLeft, Users, TrendingUp, Package, Clock, Building2, CheckCircle2, DollarSign, Droplets, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter'; import { NotificationCenter } from '../../components/NotificationCenter';
@ -14,25 +14,21 @@ import { UserManual } from '../../components/UserManual';
import type { ProductionPlan } from '../../types'; import type { ProductionPlan } from '../../types';
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any }> = { const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any }> = {
pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', icon: Clock }, pending: { label: '待确定', color: 'text-amber-500', bgColor: 'bg-amber-50', icon: Clock },
producing: { label: '生产中', color: 'text-blue-600', bgColor: 'bg-blue-50', icon: TrendingUp }, producing: { label: '生产中', color: 'text-amber-500', bgColor: 'bg-amber-50', icon: TrendingUp },
completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', icon: Package } completed: { label: '已完成', color: 'text-emerald-500', bgColor: 'bg-emerald-50', icon: Package }
}; };
const getQuickActions = (isMaster: boolean) => { const getQuickActions = () => {
const actions = [ return [
{ id: 'plans', icon: FileText, label: '计划总览', path: '/purchaser/plans', gradient: 'from-blue-500 to-cyan-500', description: '查看所有计划' }, { id: 'plans', icon: FileText, label: '计划总览', path: '/purchaser/plans', gradient: 'from-amber-400 to-orange-400', description: '查看所有计划' },
{ id: 'new', icon: Plus, label: '新建纺织计划', path: '/purchaser/plans/new', gradient: 'from-emerald-500 to-green-500', description: '创建新纺织计划' }, { id: 'new', icon: Plus, label: '新建纺织计划', path: '/purchaser/plans/new', gradient: 'from-orange-400 to-amber-400', description: '创建新纺织计划' },
{ id: 'washing', icon: Droplets, label: '新建水洗', path: '/purchaser/washing-plans/new', gradient: 'from-cyan-500 to-blue-500', description: '创建水洗计划' }, { id: 'washing', icon: Droplets, label: '新建水洗', path: '/purchaser/washing-plans/new', gradient: 'from-cyan-400 to-blue-400', description: '创建水洗计划' },
{ id: 'warehouse', icon: Warehouse, label: '原坯布仓库', path: '/purchaser/warehouse', gradient: 'from-amber-500 to-orange-500', description: '管理原坯布库存' }, { id: 'warehouse', icon: Warehouse, label: '产品信息', path: '/purchaser/warehouse', gradient: 'from-amber-400 to-yellow-400', description: '管理产品库存' },
{ id: 'finished', icon: Package, label: '水洗仓库', path: '/purchaser/finished-warehouse', gradient: 'from-emerald-500 to-teal-500', description: '管理水洗厂内坯布库存' }, { id: 'finished', icon: Package, label: '水洗仓库', path: '/purchaser/finished-warehouse', gradient: 'from-yellow-400 to-amber-400', description: '管理水洗厂内坯布库存' },
{ id: 'factories', icon: Factory, label: '工厂管理', path: '/purchaser/factories', gradient: 'from-purple-500 to-violet-500', description: '管理合作工厂' }, { id: 'factories', icon: Factory, label: '工厂管理', path: '/purchaser/factories', gradient: 'from-orange-400 to-rose-400', description: '管理合作工厂' },
{ id: 'accounts-payable', icon: DollarSign, label: '应付账款', path: '/purchaser/accounts-payable', gradient: 'from-rose-500 to-pink-500', description: '查看应付账款' } { id: 'accounts-payable', icon: DollarSign, label: '应付账款', path: '/purchaser/accounts-payable', gradient: 'from-rose-400 to-pink-400', description: '查看应付账款' }
]; ];
if (isMaster) {
actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-gray-500 to-slate-500', description: '管理团队成员' });
}
return actions;
}; };
const containerVariants = { const containerVariants = {
@ -59,6 +55,9 @@ export function PurchaserDashboard() {
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(false);
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// 账户卡片弹窗状态
const [showAccountCard, setShowAccountCard] = useState(false);
// 版本更新检查 // 版本更新检查
const { showUpdate, setShowUpdate } = useVersionCheck(); const { showUpdate, setShowUpdate } = useVersionCheck();
@ -107,7 +106,9 @@ export function PurchaserDashboard() {
completed: plans.filter(p => p.status === 'completed').length completed: plans.filter(p => p.status === 'completed').length
}; };
const recentPlans = plans.slice(0, 5); // 获取5条进行中的计划用于滑动展示
const producingPlans = plans.filter(p => p.status === 'producing').slice(0, 5);
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/30"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/30">
@ -116,15 +117,32 @@ export function PurchaserDashboard() {
<header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center"> <div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20"> <motion.button
<Building2 className="w-4 h-4 sm:w-5 sm:h-5 text-white" /> whileHover={{ scale: 1.05 }}
</div> whileTap={{ scale: 0.95 }}
onClick={() => setShowAccountCard(true)}
className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20 cursor-pointer overflow-hidden"
>
{(auth.user as any)?.image_url ? (
<img
src={(auth.user as any).image_url}
alt="头像"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Building2 className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
)}
</motion.button>
<div> <div>
<h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap"> <h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap">
{auth.company?.name || '采购商'} <span className="sm:hidden truncate max-w-[80px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</span>
<span className="hidden sm:inline">{auth.company?.name || '采购商'}</span>
<span className="text-[10px] sm:text-xs bg-gradient-to-r from-amber-500 to-orange-500 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span> <span className="text-[10px] sm:text-xs bg-gradient-to-r from-amber-500 to-orange-500 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span>
</h1> </h1>
<p className="text-xs text-gray-500 hidden sm:block">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p> <p className="text-xs text-gray-500 hidden sm:block truncate max-w-[150px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -200,11 +218,118 @@ export function PurchaserDashboard() {
</motion.div> </motion.div>
</div> </div>
{/* 最近计划 - 左右滑动展示 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<div className="flex justify-between items-center mb-3 sm:mb-4">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/purchaser/plans')}
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1 whitespace-nowrap"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
</button>
</div>
{producingPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-gray-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"></p>
<button
onClick={() => navigate('/purchaser/plans/new')}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : (
<div className="relative">
{/* 滑动容器 */}
<div className="overflow-hidden">
<motion.div
className="flex"
animate={{ x: -currentPlanIndex * 100 + '%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{producingPlans.map((plan, i) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<div
key={plan.id}
className="w-full flex-shrink-0 px-1"
onClick={() => navigate(`/purchaser/plans?id=${plan.id}`)}
>
<div className="group p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-blue-200 hover:bg-blue-50/30 cursor-pointer transition-all">
{/* 计划标题行 */}
<div className="flex items-center gap-2 sm:gap-3">
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center ${status.bgColor}`}>
<StatusIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<h3 className="font-medium text-gray-900 text-sm sm:text-base truncate">{plan.product_name}-{plan.color}</h3>
<span className={`px-1.5 py-0.5 text-xs rounded-full whitespace-nowrap ${status.bgColor} ${status.color}`}>
{status.label}
</span>
</div>
<p className="text-xs text-gray-500">{plan.fabric_code}-{plan.color_code}</p>
</div>
</div>
</div>
</div>
);
})}
</motion.div>
</div>
{/* 左右滑动按钮 */}
{producingPlans.length > 1 && (
<>
<button
onClick={() => setCurrentPlanIndex(prev => Math.max(0, prev - 1))}
disabled={currentPlanIndex === 0}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => setCurrentPlanIndex(prev => Math.min(producingPlans.length - 1, prev + 1))}
disabled={currentPlanIndex === producingPlans.length - 1}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600 rotate-180" />
</button>
</>
)}
{/* 指示器 */}
{producingPlans.length > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-4">
{producingPlans.map((_, i) => (
<button
key={i}
onClick={() => setCurrentPlanIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === currentPlanIndex ? 'bg-blue-500 w-4' : 'bg-gray-300 hover:bg-gray-400'
}`}
/>
))}
</div>
)}
</div>
)}
</motion.div>
{/* 快捷操作 - 移动端优化 */} {/* 快捷操作 - 移动端优化 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6"> <motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4 whitespace-nowrap"></h2> <h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4 whitespace-nowrap"></h2>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3"> <div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
{getQuickActions(auth.user?.is_master || false).map((action, index) => ( {getQuickActions().map((action, index) => (
<motion.button <motion.button
key={action.id} key={action.id}
whileHover={isMobile ? {} : { scale: 1.03, y: -2 }} whileHover={isMobile ? {} : { scale: 1.03, y: -2 }}
@ -220,88 +345,6 @@ export function PurchaserDashboard() {
))} ))}
</div> </div>
</motion.div> </motion.div>
{/* 最近计划 - 移动端优化 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<div className="flex justify-between items-center mb-3 sm:mb-4">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/purchaser/plans')}
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1 whitespace-nowrap"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
</button>
</div>
{recentPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-gray-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"></p>
<button
onClick={() => navigate('/purchaser/plans/new')}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{recentPlans.map((plan, i) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<motion.div
key={plan.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
onClick={() => navigate(`/purchaser/plans?id=${plan.id}`)}
className="group flex items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-blue-200 hover:bg-blue-50/30 cursor-pointer transition-all"
>
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center ${status.bgColor}`}>
<StatusIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2 mb-0.5 sm:mb-1">
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate">{plan.product_name}-{plan.color}</h3>
<span className={`px-1.5 py-0.5 text-xs rounded-full whitespace-nowrap ${status.bgColor} ${status.color}`}>
{status.label}
</span>
</div>
<p className="text-xs text-gray-500">{plan.fabric_code}-{plan.color_code}</p>
</div>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-gray-900">{plan.target_quantity}</p>
<p className="text-xs text-gray-400"></p>
</div>
<div className="w-16 sm:w-24">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span className="hidden sm:inline"></span>
<span>{progress}%</span>
</div>
<div className="h-1 sm:h-1.5 bg-gray-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, delay: i * 0.1 }}
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 rounded-full"
/>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</motion.div>
</motion.div> </motion.div>
)} )}
</div> </div>
@ -330,6 +373,173 @@ export function PurchaserDashboard() {
isOpen={showUpdate} isOpen={showUpdate}
onClose={() => setShowUpdate(false)} onClose={() => setShowUpdate(false)}
/> />
{/* 账户卡片弹窗 */}
{showAccountCard && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="bg-gradient-to-r from-blue-500 to-indigo-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"></h2>
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* 账户详情 */}
<div className="p-6 space-y-4">
{/* 用户信息 */}
<div className="flex items-center gap-4">
<label className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
{(auth.user as any)?.image_url ? (
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
) : (
<Building2 className="w-8 h-8 text-white relative z-10" />
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// 上传头像到 Supabase Storage
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
// 读取文件为 ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, arrayBuffer, {
contentType: file.type,
upsert: true
});
if (uploadError) {
console.error('头像上传失败:', uploadError);
alert('头像上传失败: ' + uploadError.message);
return;
}
// 获取头像 URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// 更新用户头像
const { error: updateError } = await supabase
.from('profiles')
.update({ image_url: publicUrl } as any)
.eq('id', user.id);
if (updateError) {
console.error('头像更新失败:', updateError);
alert('头像更新失败: ' + updateError.message);
} else {
// 刷新页面以显示新头像
window.location.reload();
}
} catch (err) {
console.error('头像上传出错:', err);
alert('头像上传出错,请重试');
}
}}
/>
</label>
<div>
<p className="font-semibold text-gray-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
<p className="text-sm text-gray-500">{auth.company?.name || '采购商'}</p>
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full mt-1">
{auth.user?.is_master ? '主账号' : '子账号'}
</span>
</div>
</div>
{/* 功能菜单 */}
<div className="space-y-2 pt-4 border-t border-gray-100">
<button
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<BookOpen className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500">使</p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Info className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
{/* 退出登录 */}
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium"
>
<LogOut className="w-5 h-5" />
退
</button>
</div>
</motion.div>
</div>
)}
</div> </div>
); );
} }

View File

@ -13,7 +13,7 @@ interface YarnInput {
yarn_type?: 'warp' | 'weft' | 'all'; yarn_type?: 'warp' | 'weft' | 'all';
} }
// 产品类型定义(来自原坯布仓库 // 产品类型定义(来自产品信息
interface Product { interface Product {
id: string; id: string;
product_name: string; product_name: string;
@ -49,7 +49,7 @@ export function NewPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
// 从原坯布仓库选择的产品 // 从产品信息选择的产品
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [productRatios, setProductRatios] = useState<Record<string, ProductYarnRatio[]>>({}); const [productRatios, setProductRatios] = useState<Record<string, ProductYarnRatio[]>>({});
@ -78,7 +78,7 @@ export function NewPlan() {
fetchProducts(); fetchProducts();
}, []); }, []);
// 从原坯布仓库获取产品数据 // 从产品信息获取产品数据
const fetchProducts = async () => { const fetchProducts = async () => {
if (!auth.company) return; if (!auth.company) return;
setLoading(true); setLoading(true);
@ -288,10 +288,10 @@ export function NewPlan() {
<div className="max-w-4xl mx-auto px-4 py-6"> <div className="max-w-4xl mx-auto px-4 py-6">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* 原坯布仓库产品选择 - 搜索+筛选形式 */} {/* 产品信息选择 - 搜索+筛选形式 */}
<div className="bg-white rounded-lg shadow-sm p-6"> <div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-base font-semibold mb-4 flex items-center gap-2"> <h2 className="text-base font-semibold mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-500 rounded"></span> <span className="w-1 h-4 bg-blue-500 rounded"></span>
</h2> </h2>
{loading ? ( {loading ? (
<div className="text-center py-8 text-gray-400">...</div> <div className="text-center py-8 text-gray-400">...</div>
@ -511,7 +511,7 @@ export function NewPlan() {
<p className="text-xs text-gray-400 mt-1"></p> <p className="text-xs text-gray-400 mt-1"></p>
</div> </div>
<div> <div>
<label className="block text-sm text-gray-600 mb-1">g/m</label> <label className="block text-sm text-gray-600 mb-1">g/m</label>
<input <input
type="text" type="text"
value={selectedProduct?.total_weight ? `${selectedProduct.total_weight}g/m` : (selectedProduct?.weight ? `${selectedProduct.weight}g/m` : '-')} value={selectedProduct?.total_weight ? `${selectedProduct.total_weight}g/m` : (selectedProduct?.weight ? `${selectedProduct.weight}g/m` : '-')}
@ -520,22 +520,12 @@ export function NewPlan() {
/> />
<p className="text-xs text-gray-400 mt-1"></p> <p className="text-xs text-gray-400 mt-1"></p>
</div> </div>
<div> <div className="col-span-2">
<label className="block text-sm text-gray-600 mb-1">g/m</label>
<input
type="text"
value={selectedProduct?.yarn_usage_per_meter ? `${selectedProduct.yarn_usage_per_meter}g/m` : '-'}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">线/</label> <label className="block text-sm text-gray-600 mb-1">线/</label>
<div className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700 min-h-[42px]"> <div className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700 min-h-[42px] flex flex-wrap items-center gap-x-4">
{yarns.filter(y => y.name).map((yarn, idx) => ( {yarns.filter(y => y.name).map((yarn, idx) => (
<span key={yarn.id} className="inline-block mr-2"> <span key={yarn.id} className="inline-block whitespace-nowrap">
{yarnTypeLabels[yarn.yarn_type || 'all']}/{yarn.name}:{yarn.ratio}{idx < yarns.filter(y => y.name).length - 1 ? '、' : ''} {yarnTypeLabels[yarn.yarn_type || 'all']}:{yarn.name}&nbsp;&nbsp;&nbsp;:{Number(yarn.ratio).toFixed(0)}%
</span> </span>
))} ))}
{!yarns.some(y => y.name) && <span className="text-gray-400">-</span>} {!yarns.some(y => y.name) && <span className="text-gray-400">-</span>}
@ -629,7 +619,7 @@ export function NewPlan() {
> >
{/* 弹窗头部 */} {/* 弹窗头部 */}
<div className="p-4 border-b flex items-center justify-between bg-gray-50"> <div className="p-4 border-b flex items-center justify-between bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900"></h3> <h3 className="text-lg font-semibold text-gray-900"></h3>
<button <button
onClick={() => setShowProductModal(false)} onClick={() => setShowProductModal(false)}
className="p-2 hover:bg-gray-200 rounded-full transition-colors" className="p-2 hover:bg-gray-200 rounded-full transition-colors"

View File

@ -36,7 +36,7 @@ export function NewWashingPlan() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// 获取原坯布仓库产品(包含库存计算) // 获取产品信息(包含库存计算)
const fetchProducts = useCallback(async () => { const fetchProducts = useCallback(async () => {
if (!auth.company) return; if (!auth.company) return;
@ -295,7 +295,7 @@ export function NewWashingPlan() {
{products.length === 0 && ( {products.length === 0 && (
<p className="text-xs text-amber-600 mt-2 flex items-center gap-1"> <p className="text-xs text-amber-600 mt-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />
</p> </p>
)} )}
</div> </div>

View File

@ -169,6 +169,38 @@ export function WarehouseManage() {
image_url: formData.image_url || null image_url: formData.image_url || null
}; };
// 校验1产品码全局唯一
const { data: existingFabricCode } = await supabase
.from('products')
.select('id, product_name, color')
.eq('company_id', auth.company.id)
.eq('fabric_code', productData.fabric_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingFabricCode) {
alert(`产品码 "${productData.fabric_code}" 已存在,请使用其他产品码`);
return;
}
// 校验2色号在"成品名称-克重-颜色"组合中唯一
// 即:同一个"成品名称-克重-颜色"下,色号不能重复
const { data: existingColorCode } = await supabase
.from('products')
.select('id, fabric_code')
.eq('company_id', auth.company.id)
.eq('product_name', productData.product_name)
.eq('total_weight', productData.total_weight || 0)
.eq('color', productData.color)
.eq('color_code', productData.color_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingColorCode) {
alert(`"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号`);
return;
}
let productId: string; let productId: string;
if (editingProduct) { if (editingProduct) {
@ -415,7 +447,7 @@ export function WarehouseManage() {
> >
<ArrowLeft className="w-5 h-5 text-gray-600" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</button> </button>
<h1 className="text-base sm:text-lg font-bold text-gray-900"></h1> <h1 className="text-base sm:text-lg font-bold text-gray-900"></h1>
</div> </div>
</header> </header>

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Link2, Warehouse, CreditCard, ArrowLeft, Users, Factory, LogOut, HelpCircle } from 'lucide-react'; import { Link2, Warehouse, CreditCard, ArrowLeft, Users, Factory, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera, FileText, TrendingUp, Package } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter'; import { NotificationCenter } from '../../components/NotificationCenter';
@ -14,60 +14,29 @@ import { UserManual } from '../../components/UserManual';
import { CrashReporter } from '../../components/CrashReporter'; import { CrashReporter } from '../../components/CrashReporter';
import type { ProductionPlan } from '../../types'; import type { ProductionPlan } from '../../types';
const getQuickActions = (isMaster: boolean) => { const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any }> = {
const actions = [ pending: { label: '待确定', color: 'text-emerald-500', bgColor: 'bg-emerald-50', icon: HelpCircle },
{ id: 'plans', icon: Link2, label: '计划总览', path: '/textile/plans', gradient: 'from-emerald-500 to-green-500' }, producing: { label: '生产中', color: 'text-emerald-500', bgColor: 'bg-emerald-50', icon: TrendingUp },
{ id: 'fabric', icon: Warehouse, label: '已生产坯布', path: '/textile/fabric-warehouse', gradient: 'from-indigo-500 to-purple-500' }, completed: { label: '已完成', color: 'text-teal-500', bgColor: 'bg-teal-50', icon: Package }
{ id: 'yarn', icon: Warehouse, label: '原料纱仓库', path: '/textile/yarn-warehouse', gradient: 'from-blue-500 to-cyan-500' },
{ id: 'payment', icon: CreditCard, label: '待结款', path: '/textile/payments', gradient: 'from-amber-500 to-orange-500' }
];
if (isMaster) {
actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-rose-500 to-pink-500' });
}
return actions;
}; };
const statusMap: Record<string, { label: string; color: string }> = { const getQuickActions = () => {
pending: { label: '待确定', color: 'bg-yellow-100 text-yellow-700' }, return [
producing: { label: '生产中', color: 'bg-blue-100 text-blue-700' }, { id: 'plans', icon: FileText, label: '计划总览', path: '/textile/plans', gradient: 'from-emerald-400 to-teal-400', description: '查看所有计划' },
completed: { label: '已完成', color: 'bg-green-100 text-green-700' } { id: 'fabric', icon: Warehouse, label: '已生产坯布', path: '/textile/fabric-warehouse', gradient: 'from-teal-400 to-cyan-400', description: '管理坯布库存' },
{ id: 'yarn', icon: Warehouse, label: '原料纱仓库', path: '/textile/yarn-warehouse', gradient: 'from-cyan-400 to-emerald-400', description: '管理纱线库存' },
{ id: 'payment', icon: CreditCard, label: '待结款', path: '/textile/payments', gradient: 'from-emerald-400 to-green-400', description: '查看待结款项' }
];
}; };
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.05
}
}
}; };
const itemVariants = { const itemVariants = {
hidden: { opacity: 0, y: 15, scale: 0.98 }, hidden: { opacity: 0, y: 20 },
visible: { visible: { opacity: 1, y: 0, transition: { duration: 0.4 } }
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.35,
ease: [0.25, 0.46, 0.45, 0.94]
}
}
};
const cardHoverVariants = {
rest: { scale: 1, y: 0 },
hover: {
scale: 1.02,
y: -4,
transition: {
duration: 0.25,
ease: [0.25, 0.46, 0.45, 0.94]
}
},
tap: { scale: 0.98, y: 0 }
}; };
export function TextileDashboard() { export function TextileDashboard() {
@ -85,6 +54,9 @@ export function TextileDashboard() {
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(false);
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// 账户卡片弹窗状态
const [showAccountCard, setShowAccountCard] = useState(false);
// 版本更新检查 // 版本更新检查
const { showUpdate, setShowUpdate } = useVersionCheck(); const { showUpdate, setShowUpdate } = useVersionCheck();
@ -152,23 +124,41 @@ export function TextileDashboard() {
// 过滤掉被拒绝的计划 // 过滤掉被拒绝的计划
const filteredPlans = plans.filter(p => !rejectedPlanIds.has(p.id)); const filteredPlans = plans.filter(p => !rejectedPlanIds.has(p.id));
const recentPlans = filteredPlans.slice(0, 5); const producingPlans = filteredPlans.filter(p => p.status === 'producing').slice(0, 5);
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-green-50 to-emerald-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50/30 to-green-50/30">
{/* 新用户引导动画 - 已更新为弹窗模式 */} {/* Header - 与采购商保持一致 */}
<header className="bg-gradient-to-r from-emerald-600 to-green-600 shadow-lg"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 md:py-5 flex justify-between items-center"> <div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-white/20 rounded-xl flex items-center justify-center"> <motion.button
<Factory className="w-4 h-4 sm:w-5 sm:h-5 text-white" /> whileHover={{ scale: 1.05 }}
</div> whileTap={{ scale: 0.95 }}
onClick={() => setShowAccountCard(true)}
className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 cursor-pointer overflow-hidden"
>
{(auth.user as any)?.image_url ? (
<img
src={(auth.user as any).image_url}
alt="头像"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Factory className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
)}
</motion.button>
<div> <div>
<h1 className="text-sm sm:text-lg font-bold text-white flex items-center gap-2 whitespace-nowrap"> <h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap">
{auth.company?.name || '纺织厂'} <span className="sm:hidden truncate max-w-[80px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</span>
<span className="text-[10px] sm:text-xs bg-white/20 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span> <span className="hidden sm:inline">{auth.company?.name || '纺织厂'}</span>
</h1> <span className="text-[10px] sm:text-xs bg-gradient-to-r from-emerald-500 to-green-500 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span>
<p className="text-xs text-white/70 hidden sm:block">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p> </h1>
<p className="text-xs text-gray-500 hidden sm:block truncate max-w-[150px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -176,130 +166,209 @@ export function TextileDashboard() {
<NotificationCenter theme="emerald" /> <NotificationCenter theme="emerald" />
<button <button
onClick={async () => { await logout(); navigate('/login'); }} onClick={async () => { await logout(); navigate('/login'); }}
className="p-2 sm:px-3 sm:py-1 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors" className="p-2 sm:px-3 sm:py-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
title="退出登录" title="退出登录"
> >
<LogOut className="w-5 h-5 sm:hidden" /> <LogOut className="w-5 h-5 sm:hidden" />
<span className="hidden sm:inline text-xs md:text-sm">退</span> <span className="hidden sm:inline text-xs sm:text-sm">退</span>
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<div className="p-4 md:p-8 max-w-7xl mx-auto"> <div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
{loading ? ( {loading ? (
<div className="text-center py-12 text-gray-400">...</div> <div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
</div>
) : ( ) : (
<motion.div variants={containerVariants} initial="hidden" animate="visible"> <motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:gap-6 mb-4 sm:mb-6 md:mb-8"> {/* 统计卡片 - 与采购商保持一致 */}
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<motion.div <motion.div
variants={itemVariants} variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }} whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }} onClick={() => navigate('/textile/plans')}
className="bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl md:rounded-2xl shadow-lg shadow-emerald-500/20 p-3 sm:p-4 md:p-6 text-white will-change-transform" className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-emerald-300 hover:shadow-md transition-all"
> >
<p className="text-[10px] sm:text-xs md:text-sm text-emerald-100 mb-0.5 sm:mb-1 md:mb-2"></p> <div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xl sm:text-2xl md:text-4xl font-bold">{stats.all}</p> <p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-emerald-50 rounded-lg flex items-center justify-center">
<FileText className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-600" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.all}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
<motion.div <motion.div
variants={itemVariants} variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }} whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }} onClick={() => navigate('/textile/plans?filter=producing')}
className="bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl md:rounded-2xl shadow-lg shadow-blue-500/20 p-3 sm:p-4 md:p-6 text-white will-change-transform" className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-emerald-300 hover:shadow-md transition-all"
> >
<p className="text-[10px] sm:text-xs md:text-sm text-blue-100 mb-0.5 sm:mb-1 md:mb-2"></p> <div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xl sm:text-2xl md:text-4xl font-bold">{stats.producing}</p> <p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-emerald-50 rounded-lg flex items-center justify-center">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-600" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.producing}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
<motion.div <motion.div
variants={itemVariants} variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }} whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }} onClick={() => navigate('/textile/plans?filter=completed')}
className="bg-gradient-to-br from-amber-500 to-orange-500 rounded-xl md:rounded-2xl shadow-lg shadow-amber-500/20 p-3 sm:p-4 md:p-6 text-white will-change-transform" className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-amber-300 hover:shadow-md transition-all"
> >
<p className="text-[10px] sm:text-xs md:text-sm text-amber-100 mb-0.5 sm:mb-1 md:mb-2"></p> <div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xl sm:text-2xl md:text-4xl font-bold">{stats.completed}</p> <p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-amber-50 rounded-lg flex items-center justify-center">
<Package className="w-3 h-3 sm:w-4 sm:h-4 text-amber-600" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.completed}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
</div> </div>
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-3 sm:p-4 md:p-6 mb-4 sm:mb-6 md:mb-8 border border-white/50"> {/* 最近计划 - 左右滑动展示 - 与采购商保持一致 */}
<h2 className="text-sm sm:text-base md:text-lg font-semibold text-gray-800 mb-3 sm:mb-4 md:mb-6"></h2> <motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:gap-6"> <div className="flex justify-between items-center mb-3 sm:mb-4">
{getQuickActions(auth.user?.is_master || false).map((action, index) => ( <h2 className="text-sm sm:text-base font-semibold text-gray-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/textile/plans')}
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-medium flex items-center gap-1 whitespace-nowrap"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
</button>
</div>
{producingPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-gray-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"></p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
) : (
<div className="relative">
{/* 滑动容器 */}
<div className="overflow-hidden">
<motion.div
className="flex"
animate={{ x: -currentPlanIndex * 100 + '%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{producingPlans.map((plan, i) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<div
key={plan.id}
className="w-full flex-shrink-0 px-1"
onClick={() => navigate(`/textile/plans?id=${plan.id}`)}
>
<div className="group p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-emerald-200 hover:bg-emerald-50/30 cursor-pointer transition-all">
{/* 计划标题行 */}
<div className="flex items-center gap-2 sm:gap-3">
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center ${status.bgColor}`}>
<StatusIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<h3 className="font-medium text-gray-900 text-sm sm:text-base truncate">{plan.fabric_code}</h3>
<span className={`px-1.5 py-0.5 text-xs rounded-full whitespace-nowrap ${status.bgColor} ${status.color}`}>
{status.label}
</span>
</div>
<p className="text-xs text-gray-500">{plan.plan_code}</p>
</div>
</div>
{/* 进度信息 */}
<div className="mt-3 sm:mt-4">
<div className="flex justify-between text-xs text-gray-500 mb-1.5">
<span> {progress}%</span>
<span>{plan.completed_quantity}/{plan.target_quantity}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 sm:h-2 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-green-500 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</div>
);
})}
</motion.div>
</div>
{/* 左右滑动按钮 */}
{producingPlans.length > 1 && (
<>
<button
onClick={() => setCurrentPlanIndex(prev => Math.max(0, prev - 1))}
disabled={currentPlanIndex === 0}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => setCurrentPlanIndex(prev => Math.min(producingPlans.length - 1, prev + 1))}
disabled={currentPlanIndex === producingPlans.length - 1}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600 rotate-180" />
</button>
</>
)}
{/* 指示器 */}
{producingPlans.length > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-4">
{producingPlans.map((_, i) => (
<button
key={i}
onClick={() => setCurrentPlanIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === currentPlanIndex ? 'bg-emerald-500 w-4' : 'bg-gray-300 hover:bg-gray-400'
}`}
/>
))}
</div>
)}
</div>
)}
</motion.div>
{/* 快捷操作 - 与采购商保持一致 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4 whitespace-nowrap"></h2>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
{getQuickActions().map((action, index) => (
<motion.button <motion.button
key={action.id} key={action.id}
initial={{ opacity: 0, y: 10 }} whileHover={isMobile ? {} : { scale: 1.03, y: -2 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
whileHover={isMobile ? {} : { scale: 1.03, y: -3 }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={() => navigate(action.path)} onClick={() => navigate(action.path)}
className="flex flex-col items-center justify-center p-3 sm:p-4 md:p-6 rounded-xl border border-gray-200 hover:border-transparent hover:shadow-md transition-all duration-200 group will-change-transform" className="group flex flex-col items-center p-2 sm:p-3 rounded-lg sm:rounded-xl border border-gray-100 hover:border-transparent hover:shadow-md transition-all bg-gray-50/50 hover:bg-white"
> >
<div className={`w-10 h-10 sm:w-12 sm:h-12 md:w-14 md:h-14 rounded-xl flex items-center justify-center mb-2 sm:mb-3 md:mb-4 bg-gradient-to-br ${action.gradient} shadow-lg transition-shadow duration-200 group-hover:shadow-xl`}> <div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl flex items-center justify-center mb-1 sm:mb-2 bg-gradient-to-br ${action.gradient} shadow-sm group-hover:shadow-md transition-shadow`}>
<action.icon className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-white" /> <action.icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div> </div>
<span className="text-xs sm:text-sm md:text-base text-gray-700 group-hover:text-gray-900 transition-colors">{action.label}</span> <span className="text-[10px] sm:text-xs font-medium text-gray-700 group-hover:text-gray-900 text-center whitespace-nowrap">{action.label}</span>
</motion.button> </motion.button>
))} ))}
</div> </div>
</motion.div> </motion.div>
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-3 sm:p-4 md:p-6 border border-white/50">
<div className="flex justify-between items-center mb-3 sm:mb-4 md:mb-6">
<h2 className="text-sm sm:text-base md:text-lg font-semibold text-gray-800"></h2>
<button onClick={() => navigate('/textile/plans')} className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-medium"></button>
</div>
{recentPlans.length === 0 ? (
<p className="text-center text-gray-400 py-4 sm:py-6 md:py-8"></p>
) : (
<div className="space-y-2 sm:space-y-3 md:space-y-4">
{recentPlans.map((plan, i) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
return (
<motion.div
key={plan.id}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
whileHover={isMobile ? {} : { x: 4, backgroundColor: 'rgba(236, 253, 245, 0.8)' }}
whileTap={{ scale: 0.99 }}
onClick={() => navigate(`/textile/plans?id=${plan.id}`)}
className="border border-gray-200 rounded-xl p-3 sm:p-4 md:p-5 hover:border-emerald-300 cursor-pointer transition-all duration-200 will-change-transform"
>
<div className="flex justify-between items-start mb-1.5 sm:mb-2 md:mb-3">
<div className="min-w-0 flex-1">
<p className="text-xs sm:text-sm text-gray-600 truncate">{plan.fabric_code}</p>
<p className="text-[10px] sm:text-xs text-gray-400 mt-0.5 sm:mt-1">{plan.plan_code}</p>
</div>
<span className={`px-2 py-0.5 sm:py-1 md:px-3 md:py-1.5 rounded-lg text-[10px] sm:text-xs md:text-sm font-medium flex-shrink-0 ml-2 ${statusMap[plan.status]?.color || 'bg-gray-100'}`}>
{statusMap[plan.status]?.label || plan.status}
</span>
</div>
<div className="flex items-center gap-2 sm:gap-3 md:gap-4 text-[10px] sm:text-xs md:text-sm text-gray-600">
<span>: {plan.target_quantity}</span>
<span>: {plan.completed_quantity}</span>
</div>
{plan.status === 'producing' && (
<div className="mt-2 sm:mt-3 md:mt-4">
<div className="w-full bg-gray-200 rounded-full h-1.5 sm:h-2 md:h-3">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, delay: i * 0.08, ease: [0.25, 0.46, 0.45, 0.94] }}
className="bg-gradient-to-r from-emerald-500 to-green-500 h-1.5 sm:h-2 md:h-3 rounded-full will-change-transform"
/>
</div>
<p className="text-[10px] sm:text-xs md:text-sm text-gray-500 mt-1 sm:mt-2"> {progress}%</p>
</div>
)}
</motion.div>
);
})}
</div>
)}
</motion.div>
</motion.div> </motion.div>
)} )}
</div> </div>
@ -331,6 +400,160 @@ export function TextileDashboard() {
{/* 崩溃报告 */} {/* 崩溃报告 */}
<CrashReporter /> <CrashReporter />
{/* 账户卡片弹窗 */}
{showAccountCard && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="bg-gradient-to-r from-emerald-500 to-green-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"></h2>
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* 账户详情 */}
<div className="p-6 space-y-4">
{/* 用户信息 */}
<div className="flex items-center gap-4">
<label className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
{(auth.user as any)?.image_url ? (
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
) : (
<Factory className="w-8 h-8 text-white relative z-10" />
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const arrayBuffer = await file.arrayBuffer();
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, arrayBuffer, {
contentType: file.type,
upsert: true
});
if (uploadError) {
console.error('头像上传失败:', uploadError);
alert('头像上传失败: ' + uploadError.message);
return;
}
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
const { error: updateError } = await supabase
.from('profiles')
.update({ image_url: publicUrl } as any)
.eq('id', user.id);
if (updateError) {
console.error('头像更新失败:', updateError);
alert('头像更新失败: ' + updateError.message);
} else {
window.location.reload();
}
} catch (err) {
console.error('头像上传出错:', err);
alert('头像上传出错,请重试');
}
}}
/>
</label>
<div>
<p className="font-semibold text-gray-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
<p className="text-sm text-gray-500">{auth.company?.name || '纺织厂'}</p>
<span className="inline-flex items-center gap-1 text-xs bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full mt-1">
{auth.user?.is_master ? '主账号' : '子账号'}
</span>
</div>
</div>
{/* 功能菜单 */}
<div className="space-y-2 pt-4 border-t border-gray-100">
<button
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<BookOpen className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500">使</p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Info className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
{/* 退出登录 */}
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="w-full flex items-center justify-center gap-2 p-3 rounded-xl bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">退</span>
</button>
</div>
</motion.div>
</div>
)}
</div> </div>
); );
} }

View File

@ -258,7 +258,7 @@ export function FabricWarehouse() {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3" className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-emerald-500 border-t-transparent rounded-full mb-3"
/> />
<p className="text-gray-500 text-xs sm:text-sm">...</p> <p className="text-gray-500 text-xs sm:text-sm">...</p>
</div> </div>
@ -290,30 +290,33 @@ export function FabricWarehouse() {
transition={{ delay: groupIndex * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }} transition={{ delay: groupIndex * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden will-change-transform" className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden will-change-transform"
> >
{/* 分组头部 */} {/* 分组头部 */}
<motion.div <motion.div
onClick={() => toggleExpand(fabricCode)} onClick={() => toggleExpand(fabricCode)}
whileTap={{ scale: 0.995 }} whileTap={{ scale: 0.995 }}
className="flex items-center justify-between p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors will-change-transform" className="flex items-center justify-between p-4 sm:p-5 cursor-pointer hover:bg-gray-50/50 transition-colors will-change-transform"
> >
<div className="flex items-center gap-2 sm:gap-3 min-w-0"> <div className="flex items-center gap-3 sm:gap-4 min-w-0">
<motion.div <motion.div
animate={{ rotate: isExpanded ? 180 : 0 }} animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center transition-colors flex-shrink-0 ${isExpanded ? 'bg-blue-100' : 'bg-gray-100'}`} className={`w-9 h-9 sm:w-10 sm:h-10 rounded-xl flex items-center justify-center transition-colors flex-shrink-0 ${isExpanded ? 'bg-emerald-100' : 'bg-gray-100'}`}
> >
<ChevronDown size={16} className="sm:w-[18px] sm:h-[18px] text-gray-500" style={{ color: isExpanded ? '#2563eb' : undefined }} /> <ChevronDown size={18} className="sm:w-5 sm:h-5 text-gray-500" style={{ color: isExpanded ? '#059669' : undefined }} />
</motion.div> </motion.div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs sm:text-sm text-gray-500 truncate"> <p className="text-sm font-medium text-gray-900 truncate">
: {fabricCode} · {totalInMeters} · {totalTargetMeters} {fabricCode}
</p> </p>
</div> <p className="text-xs text-gray-500 mt-0.5">
</div> <span className="text-emerald-600 font-medium">{totalInMeters.toLocaleString()}</span> · {totalTargetMeters.toLocaleString()}
<span className="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full flex-shrink-0 ml-2"> </p>
{items.length} </div>
</span> </div>
</motion.div> <span className="px-3 py-1 bg-emerald-50 text-emerald-700 text-xs font-medium rounded-full flex-shrink-0 ml-2">
{items.length}
</span>
</motion.div>
{/* 展开的库存列表 - 移动端卡片式,桌面端表格 */} {/* 展开的库存列表 - 移动端卡片式,桌面端表格 */}
<AnimatePresence> <AnimatePresence>
@ -328,43 +331,49 @@ export function FabricWarehouse() {
{/* 桌面端表格 */} {/* 桌面端表格 */}
<div className="hidden sm:block overflow-x-auto"> <div className="hidden sm:block overflow-x-auto">
<table className="w-full min-w-[500px]"> <table className="w-full min-w-[500px]">
<thead className="bg-gray-50/50 border-b border-gray-100"> <thead className="bg-gray-50 border-b border-gray-100">
<tr> <tr>
<th className="px-3 sm:px-4 py-2 sm:py-3 text-left text-xs font-medium text-gray-500"></th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th className="px-3 sm:px-4 py-2 sm:py-3 text-left text-xs font-medium text-gray-500"></th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th className="px-3 sm:px-4 py-2 sm:py-3 text-left text-xs font-medium text-gray-500">/</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">/</th>
<th className="px-3 sm:px-4 py-2 sm:py-3 text-left text-xs font-medium text-gray-500"></th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-100">
{items.map((item, itemIndex) => ( {items.map((item, itemIndex) => (
<motion.tr <motion.tr
key={item.plan_id} key={item.plan_id}
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: itemIndex * 0.05, duration: 0.25 }} transition={{ delay: itemIndex * 0.05, duration: 0.25 }}
className="hover:bg-gray-50/50 transition-colors" className="hover:bg-emerald-50/30 transition-colors"
> >
<td className="px-3 sm:px-4 py-2 sm:py-3"> <td className="px-4 py-3">
<span className="px-2 py-1 bg-gray-100 rounded-lg text-xs sm:text-sm font-mono text-gray-700"> <span className="px-2.5 py-1 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-mono font-medium">
{item.plan_code} {item.plan_code}
</span> </span>
</td> </td>
<td className="px-3 sm:px-4 py-2 sm:py-3"> <td className="px-4 py-3">
<p className="text-xs sm:text-sm text-gray-900">{item.weight}g</p> <p className="text-sm text-gray-900 font-medium">{item.weight}g</p>
</td> </td>
<td className="px-3 sm:px-4 py-2 sm:py-3"> <td className="px-4 py-3">
<div className="text-xs sm:text-sm"> <div className="text-sm">
<span className="text-gray-600">{item.target_quantity}</span> <span className="text-gray-500">{item.target_quantity.toLocaleString()}</span>
<span className="text-gray-400 mx-1">/</span> <span className="text-gray-300 mx-2">/</span>
<span className="text-emerald-600 font-medium">{item.completed_quantity}</span> <span className="text-emerald-600 font-semibold">{item.completed_quantity.toLocaleString()}</span>
</div> </div>
</td> </td>
<td className="px-3 sm:px-4 py-2 sm:py-3"> <td className="px-4 py-3">
{item.in_records.length > 0 ? ( {item.in_records.length > 0 ? (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-600">
<span className="inline-block"> <span className="inline-flex items-center gap-1.5">
{new Date(item.in_records[0].time).toLocaleDateString('zh-CN')}({item.in_records[0].quantity}/{item.in_records[0].rolls}/) <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{new Date(item.in_records[0].time).toLocaleDateString('zh-CN')}
<span className="text-emerald-600 font-medium">{item.in_records[0].quantity}</span>
<span className="text-gray-400">/</span>
<span>{item.in_records[0].rolls}/</span>
</span> </span>
{item.in_records.length > 1 && ( {item.in_records.length > 1 && (
<button <button
@ -374,15 +383,20 @@ export function FabricWarehouse() {
setSelectedPlanCode(item.plan_code); setSelectedPlanCode(item.plan_code);
setRecordsModalOpen(true); setRecordsModalOpen(true);
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs font-medium hover:bg-blue-100 transition-colors ml-2" className="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-medium hover:bg-emerald-100 transition-colors ml-2"
> >
<List className="w-3 h-3" /> <List className="w-3.5 h-3.5" />
({item.in_records.length}) ({item.in_records.length})
</button> </button>
)} )}
</div> </div>
) : ( ) : (
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</span>
)} )}
</td> </td>
</motion.tr> </motion.tr>
@ -392,36 +406,41 @@ export function FabricWarehouse() {
</div> </div>
{/* 移动端卡片 */} {/* 移动端卡片 */}
<div className="sm:hidden p-3 space-y-2"> <div className="sm:hidden p-4 space-y-3">
{items.map((item, itemIndex) => ( {items.map((item, itemIndex) => (
<motion.div <motion.div
key={item.plan_id} key={item.plan_id}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: itemIndex * 0.05, duration: 0.25 }} transition={{ delay: itemIndex * 0.05, duration: 0.25 }}
className="bg-white rounded-xl border border-gray-100 p-3" className="bg-white rounded-2xl border border-gray-100 p-4 shadow-sm"
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-3">
<span className="px-2 py-1 bg-gray-100 rounded-lg text-xs font-mono text-gray-700"> <span className="px-2.5 py-1 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-mono font-medium">
{item.plan_code} {item.plan_code}
</span> </span>
<span className="text-xs text-gray-500">{item.weight}g</span> <span className="text-xs text-gray-600 font-medium">{item.weight}g</span>
</div> </div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-3">
<div className="text-xs"> <div className="text-sm">
<span className="text-gray-500">:</span> <span className="text-gray-400"></span>
<span className="text-gray-700 ml-1">{item.target_quantity}</span> <span className="text-gray-700 font-medium ml-1.5">{item.target_quantity.toLocaleString()}</span>
</div> </div>
<div className="text-xs"> <div className="text-sm">
<span className="text-gray-500">:</span> <span className="text-gray-400"></span>
<span className="text-emerald-600 font-medium ml-1">{item.completed_quantity}</span> <span className="text-emerald-600 font-semibold ml-1.5">{item.completed_quantity.toLocaleString()}</span>
</div> </div>
</div> </div>
{item.in_records.length > 0 ? ( {item.in_records.length > 0 ? (
<div className="flex items-center justify-between pt-2 border-t border-gray-50"> <div className="flex items-center justify-between pt-3 border-t border-gray-100">
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-600 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{new Date(item.in_records[0].time).toLocaleDateString('zh-CN')}</span> <span>{new Date(item.in_records[0].time).toLocaleDateString('zh-CN')}</span>
<span className="text-gray-700 ml-1">({item.in_records[0].quantity}/{item.in_records[0].rolls}/)</span> <span className="text-emerald-600 font-medium">{item.in_records[0].quantity}</span>
<span className="text-gray-400">/</span>
<span>{item.in_records[0].rolls}/</span>
</div> </div>
{item.in_records.length > 1 && ( {item.in_records.length > 1 && (
<button <button
@ -431,15 +450,20 @@ export function FabricWarehouse() {
setSelectedPlanCode(item.plan_code); setSelectedPlanCode(item.plan_code);
setRecordsModalOpen(true); setRecordsModalOpen(true);
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs font-medium hover:bg-blue-100 transition-colors" className="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-medium hover:bg-emerald-100 transition-colors"
> >
<List className="w-3 h-3" /> <List className="w-3.5 h-3.5" />
({item.in_records.length}) ({item.in_records.length})
</button> </button>
)} )}
</div> </div>
) : ( ) : (
<div className="text-xs text-gray-400 pt-2 border-t border-gray-50"></div> <div className="text-xs text-gray-400 pt-3 border-t border-gray-100 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</div>
)} )}
</motion.div> </motion.div>
))} ))}

View File

@ -89,7 +89,7 @@ export function PaymentPending() {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3" className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-emerald-500 border-t-transparent rounded-full mb-3"
/> />
<p className="text-gray-400 text-sm">...</p> <p className="text-gray-400 text-sm">...</p>
</div> </div>
@ -99,12 +99,12 @@ export function PaymentPending() {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-4 sm:p-5 md:p-6 text-white mb-4 sm:mb-5 md:mb-6" className="bg-gradient-to-r from-emerald-500 to-green-600 rounded-xl p-4 sm:p-5 md:p-6 text-white mb-4 sm:mb-5 md:mb-6"
> >
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<DollarSign className="w-6 h-6 sm:w-7 sm:h-7 md:w-8 md:h-8" /> <DollarSign className="w-6 h-6 sm:w-7 sm:h-7 md:w-8 md:h-8" />
<div> <div>
<p className="text-blue-100 text-xs sm:text-sm"></p> <p className="text-emerald-100 text-xs sm:text-sm"></p>
<p className="text-2xl sm:text-3xl md:text-4xl font-bold">¥{totalAmount.toLocaleString()}</p> <p className="text-2xl sm:text-3xl md:text-4xl font-bold">¥{totalAmount.toLocaleString()}</p>
</div> </div>
</div> </div>
@ -139,7 +139,7 @@ export function PaymentPending() {
<p className="text-xs sm:text-sm text-gray-500">: {item.quantity} × ¥{item.price_per_meter}/</p> <p className="text-xs sm:text-sm text-gray-500">: {item.quantity} × ¥{item.price_per_meter}/</p>
</div> </div>
<div className="text-right flex-shrink-0"> <div className="text-right flex-shrink-0">
<p className="text-lg sm:text-xl md:text-2xl font-bold text-blue-600">¥{item.amount.toLocaleString()}</p> <p className="text-lg sm:text-xl md:text-2xl font-bold text-emerald-600">¥{item.amount.toLocaleString()}</p>
</div> </div>
</div> </div>
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-100 flex justify-end"> <div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-100 flex justify-end">

View File

@ -10,8 +10,8 @@ import { getSupabaseUrl } from '../../supabase/client';
import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types'; import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types';
const statusMap: Record<string, { label: string; color: string }> = { const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: '待确定', color: 'bg-yellow-100 text-yellow-700' }, pending: { label: '待确定', color: 'bg-amber-100 text-amber-700' },
producing: { label: '生产中', color: 'bg-blue-100 text-blue-700' }, producing: { label: '生产中', color: 'bg-emerald-100 text-emerald-700' },
completed: { label: '已完成', color: 'bg-green-100 text-green-700' }, completed: { label: '已完成', color: 'bg-green-100 text-green-700' },
rejected: { label: '已拒绝', color: 'bg-gray-100 text-gray-600' } rejected: { label: '已拒绝', color: 'bg-gray-100 text-gray-600' }
}; };
@ -236,7 +236,8 @@ export function TextilePlanOverview() {
return { return {
token: params.get('token'), token: params.get('token'),
plan: params.get('plan'), plan: params.get('plan'),
type: params.get('type') type: params.get('type'),
shortCode: params.get('s')
}; };
} }
try { try {
@ -244,24 +245,27 @@ export function TextilePlanOverview() {
return { return {
token: url.searchParams.get('token'), token: url.searchParams.get('token'),
plan: url.searchParams.get('plan'), plan: url.searchParams.get('plan'),
type: url.searchParams.get('type') type: url.searchParams.get('type'),
shortCode: url.searchParams.get('s')
}; };
} catch { } catch {
return { token: null, plan: null, type: null }; return { token: null, plan: null, type: null, shortCode: null };
} }
}; };
const handleImportFromLink = () => { const handleImportFromLink = () => {
if (!importLink.trim()) return; if (!importLink.trim()) return;
const { token, plan, type } = extractParamsFromLink(importLink); const { token, plan, type, shortCode } = extractParamsFromLink(importLink);
if (token) { if (shortCode) {
navigate(`/import?s=${shortCode}`);
} else if (token) {
navigate(`/import?token=${token}`); navigate(`/import?token=${token}`);
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } else {
alert('无效的链接格式,请确保链接包含 token 或 plan 参数'); alert('无效的链接格式,请确保链接包含 token、s 或 plan 参数');
} }
}; };
@ -270,9 +274,11 @@ export function TextilePlanOverview() {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (text.includes('/import?') || text.includes('#/import?')) { if (text.includes('/import?') || text.includes('#/import?')) {
setImportLink(text); setImportLink(text);
const { token, plan, type } = extractParamsFromLink(text); const { token, plan, type, shortCode } = extractParamsFromLink(text);
if (token) { if (shortCode) {
navigate(`/import?s=${shortCode}`);
} else if (token) {
navigate(`/import?token=${token}`); navigate(`/import?token=${token}`);
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
@ -647,13 +653,13 @@ export function TextilePlanOverview() {
> >
<div className="flex items-center justify-between mb-2 sm:mb-3"> <div className="flex items-center justify-between mb-2 sm:mb-3">
<h2 className="font-semibold text-gray-800 flex items-center gap-2 text-sm sm:text-base"> <h2 className="font-semibold text-gray-800 flex items-center gap-2 text-sm sm:text-base">
<Link2 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" /> <Link2 className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-600" />
</h2> </h2>
<motion.button <motion.button
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={handlePasteFromClipboard} onClick={handlePasteFromClipboard}
className="flex items-center gap-1 px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors will-change-transform" className="flex items-center gap-1 px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors will-change-transform"
> >
<Clipboard className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <Clipboard className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
@ -665,13 +671,13 @@ export function TextilePlanOverview() {
value={importLink} value={importLink}
onChange={(e) => setImportLink(e.target.value)} onChange={(e) => setImportLink(e.target.value)}
placeholder="粘贴采购商分享的链接..." placeholder="粘贴采购商分享的链接..."
className="flex-1 px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-xs sm:text-sm" className="flex-1 px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-xs sm:text-sm"
/> />
<motion.button <motion.button
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={handleImportFromLink} onClick={handleImportFromLink}
disabled={!importLink.trim()} disabled={!importLink.trim()}
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm font-medium will-change-transform" className="px-3 sm:px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm font-medium will-change-transform"
> >
</motion.button> </motion.button>
@ -683,7 +689,7 @@ export function TextilePlanOverview() {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3" className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-emerald-500 border-t-transparent rounded-full mb-3"
/> />
<p className="text-gray-400 text-sm">...</p> <p className="text-gray-400 text-sm">...</p>
</div> </div>
@ -747,45 +753,50 @@ export function TextilePlanOverview() {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: planIndex * 0.05, duration: 0.3 }} transition={{ delay: planIndex * 0.05, duration: 0.3 }}
className={`border rounded-lg overflow-hidden ${isRejected ? 'border-gray-200 bg-gray-100' : 'border-gray-200'}`} className={`border rounded-2xl overflow-hidden transition-all duration-300 ${isRejected ? 'border-gray-200 bg-gray-50' : 'border-gray-200 bg-white hover:border-emerald-200 hover:shadow-lg hover:shadow-gray-100/50'}`}
> >
<motion.div <motion.div
onClick={() => !isRejected && setExpandedPlan(expandedPlan === plan.id ? null : plan.id)} onClick={() => !isRejected && setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
whileTap={isRejected ? {} : { scale: 0.995 }} whileTap={isRejected ? {} : { scale: 0.995 }}
className={`p-3 sm:p-4 transition-colors will-change-transform ${isRejected ? 'cursor-default' : 'cursor-pointer hover:bg-gray-50'}`} className={`p-4 sm:p-5 transition-colors will-change-transform ${isRejected ? 'cursor-default' : 'cursor-pointer hover:bg-gray-50/50'}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1.5 sm:mb-2"> <div className="flex items-center gap-2 mb-2">
{isRejected ? ( {isRejected ? (
<span className="px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs rounded-full bg-gray-400 text-white"> <span className="px-2.5 py-1 text-xs rounded-full bg-gray-400 text-white font-medium">
</span> </span>
) : ( ) : (
<span className={`px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs rounded-full ${statusMap[plan.status]?.color || 'bg-gray-100'}`}> <span className={`px-2.5 py-1 text-xs rounded-full font-medium ${statusMap[plan.status]?.color || 'bg-gray-100'}`}>
{statusMap[plan.status]?.label || plan.status} {statusMap[plan.status]?.label || plan.status}
</span> </span>
)} )}
<span className="text-xs text-gray-400 font-mono">{plan.plan_code}</span>
</div> </div>
<p className={`text-xs sm:text-sm truncate ${isRejected ? 'text-gray-500' : 'text-gray-600'}`}>{plan.fabric_code}</p> <p className={`text-sm font-medium truncate ${isRejected ? 'text-gray-500' : 'text-gray-800'}`}>{plan.fabric_code}</p>
<p className="text-[10px] sm:text-xs text-gray-400">{plan.plan_code}</p>
</div> </div>
<div className={`text-right text-xs sm:text-sm flex-shrink-0 ${isRejected ? 'text-gray-500' : 'text-gray-600'}`}> <div className={`text-right text-sm flex-shrink-0 ${isRejected ? 'text-gray-500' : 'text-gray-700'}`}>
<p>: {plan.target_quantity}</p> <p className="font-medium">{plan.target_quantity.toLocaleString()}<span className="text-xs text-gray-500 ml-1"></span></p>
<p>: {plan.completed_quantity}</p> <p className="text-xs text-emerald-600"> {plan.completed_quantity.toLocaleString()}</p>
</div> </div>
</div> </div>
<div className="mt-2 sm:mt-3"> <div className="mt-4">
<div className="flex justify-between text-[10px] sm:text-xs text-gray-500 mb-1"> <div className="flex justify-between text-xs text-gray-500 mb-1.5">
<span></span> <span className="flex items-center gap-1">
<span>{progress}%</span> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</span>
<span className="font-medium text-emerald-600">{progress}%</span>
</div> </div>
<div className="h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden"> <div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<motion.div <motion.div
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${progress}%` }} animate={{ width: `${progress}%` }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.46, 0.45, 0.94] }} transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.46, 0.45, 0.94] }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full will-change-transform" className="h-full bg-gradient-to-r from-emerald-500 to-green-500 rounded-full will-change-transform"
/> />
</div> </div>
</div> </div>
@ -831,7 +842,7 @@ export function TextilePlanOverview() {
</div> </div>
<div className="truncate"> <div className="truncate">
<span className="text-gray-400">:</span> <span className="text-gray-400">:</span>
<span className="ml-1 text-blue-600 font-medium">{progress}%</span> <span className="ml-1 text-emerald-600 font-medium">{progress}%</span>
</div> </div>
<div className="truncate"> <div className="truncate">
<span className="text-gray-400">:</span> <span className="text-gray-400">:</span>
@ -882,8 +893,8 @@ export function TextilePlanOverview() {
<h5 className="text-sm font-medium text-gray-900 mb-2">线/</h5> <h5 className="text-sm font-medium text-gray-900 mb-2">线/</h5>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{plan.yarnRatios.map((yarn, index) => ( {plan.yarnRatios.map((yarn, index) => (
<span key={index} className="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-lg"> <span key={index} className="inline-flex items-center px-2 py-1 bg-emerald-50 text-emerald-700 text-xs rounded-lg">
{yarnTypeLabels[yarn.yarn_type || 'all']}/{yarn.yarn_name}:{yarn.ratio}% {yarnTypeLabels[yarn.yarn_type || 'all']}/{yarn.yarn_name}:{Number(yarn.ratio).toFixed(0)}%
{yarn.amount_per_meter && `(${yarn.amount_per_meter}g/m)`} {yarn.amount_per_meter && `(${yarn.amount_per_meter}g/m)`}
</span> </span>
))} ))}
@ -900,7 +911,7 @@ export function TextilePlanOverview() {
{plan.processSteps.length > 0 && ( {plan.processSteps.length > 0 && (
<div className="border-t border-gray-200 pt-3"> <div className="border-t border-gray-200 pt-3">
<h5 className="font-medium mb-3 sm:mb-4 flex items-center gap-2 text-sm"> <h5 className="font-medium mb-3 sm:mb-4 flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-blue-600" /> <Check className="w-4 h-4 text-emerald-600" />
</h5> </h5>
<ProcessFlow <ProcessFlow
@ -914,6 +925,9 @@ export function TextilePlanOverview() {
isPlanLocked={plan.status === 'completed'} isPlanLocked={plan.status === 'completed'}
lastInventoryTime={plan.inventoryRecords[0]?.time} lastInventoryTime={plan.inventoryRecords[0]?.time}
inventoryRecords={plan.inventoryRecords} inventoryRecords={plan.inventoryRecords}
yarnRatios={plan.yarnRatios}
companyId={auth.company?.id}
planId={plan.id}
/> />
</div> </div>
)} )}

View File

@ -1,17 +1,31 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ArrowLeft, Plus, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { YarnStock, Warehouse } from '../../types'; import type { YarnStock, Warehouse } from '../../types';
interface YarnStockRecord {
id: string;
yarn_stock_id: string | null;
plan_id: string | null;
record_type: 'inbound' | 'outbound';
quantity: number;
unit: string;
batch_no: string | null;
notes: string | null;
created_at: string;
yarn_name?: string;
}
export function YarnWarehouse() { export function YarnWarehouse() {
const navigate = useNavigate(); const navigate = useNavigate();
const { auth } = useAuth(); const { auth } = useAuth();
const [activeTab, setActiveTab] = useState<'stock' | 'record'>('stock'); const [activeTab, setActiveTab] = useState<'stock' | 'record'>('stock');
const [yarns, setYarns] = useState<YarnStock[]>([]); const [yarns, setYarns] = useState<YarnStock[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [records, setRecords] = useState<YarnStockRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [newYarn, setNewYarn] = useState({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' }); const [newYarn, setNewYarn] = useState({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
@ -21,6 +35,12 @@ export function YarnWarehouse() {
fetchData(); fetchData();
}, [auth.company]); }, [auth.company]);
useEffect(() => {
if (activeTab === 'record' && auth.company) {
fetchRecords();
}
}, [activeTab, auth.company]);
const fetchData = async () => { const fetchData = async () => {
const { data: yarnData } = await supabase const { data: yarnData } = await supabase
.from('yarn_stock') .from('yarn_stock')
@ -39,10 +59,43 @@ export function YarnWarehouse() {
setLoading(false); setLoading(false);
}; };
const fetchRecords = async () => {
setLoading(true);
const { data: recordsData } = await supabase
.from('yarn_stock_records')
.select('*')
.eq('company_id', auth.company!.id)
.order('created_at', { ascending: false });
// 获取纱线名称
const yarnIds = recordsData?.map(r => r.yarn_stock_id).filter((id): id is string => !!id) || [];
const { data: yarnsData } = await supabase
.from('yarn_stock')
.select('id, name')
.in('id', yarnIds.length > 0 ? yarnIds : ['00000000-0000-0000-0000-000000000000']);
const yarnMap = new Map(yarnsData?.map(y => [y.id, y.name]) || []);
const recordsWithNames: YarnStockRecord[] = recordsData?.map(r => ({
...r,
yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || '未知纱线' : '未知纱线',
record_type: r.record_type as 'inbound' | 'outbound'
})) || [];
setRecords(recordsWithNames);
setLoading(false);
};
const handleAddYarn = async () => { const handleAddYarn = async () => {
if (!newYarn.name) return; if (!newYarn.name) return;
const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库 const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库
const { error } = await supabase
// 获取当前用户ID
const { data: { user } } = await supabase.auth.getUser();
const operatorId = user?.id;
// 插入纱线库存
const { data: newYarnData, error } = await supabase
.from('yarn_stock') .from('yarn_stock')
.insert({ .insert({
company_id: auth.company!.id, company_id: auth.company!.id,
@ -52,11 +105,24 @@ export function YarnWarehouse() {
quantity: newYarn.quantity, quantity: newYarn.quantity,
min_stock: newYarn.minStock, min_stock: newYarn.minStock,
unit: newYarn.unit unit: newYarn.unit
}); })
.select()
.single();
if (error) { if (error) {
console.error('添加失败:', error); console.error('添加失败:', error);
} else { } else {
// 记录入库
await supabase.from('yarn_stock_records').insert({
company_id: auth.company!.id,
yarn_stock_id: newYarnData.id,
record_type: 'inbound',
quantity: newYarn.quantity,
unit: newYarn.unit,
notes: `纱线入库 - ${newYarn.name}`,
operator_id: operatorId
});
setShowAddModal(false); setShowAddModal(false);
setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' }); setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
await fetchData(); await fetchData();
@ -84,7 +150,7 @@ export function YarnWarehouse() {
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setActiveTab('stock')} onClick={() => setActiveTab('stock')}
className={`flex-1 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors will-change-transform ${ className={`flex-1 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors will-change-transform ${
activeTab === 'stock' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50' activeTab === 'stock' ? 'bg-emerald-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'
}`} }`}
> >
@ -93,10 +159,10 @@ export function YarnWarehouse() {
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => setActiveTab('record')} onClick={() => setActiveTab('record')}
className={`flex-1 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors will-change-transform ${ className={`flex-1 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors will-change-transform ${
activeTab === 'record' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50' activeTab === 'record' ? 'bg-emerald-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'
}`} }`}
> >
</motion.button> </motion.button>
</div> </div>
@ -105,7 +171,7 @@ export function YarnWarehouse() {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3" className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-emerald-500 border-t-transparent rounded-full mb-3"
/> />
<p className="text-gray-400 text-sm">...</p> <p className="text-gray-400 text-sm">...</p>
</div> </div>
@ -121,7 +187,7 @@ export function YarnWarehouse() {
<motion.button <motion.button
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={() => setShowAddModal(true)} onClick={() => setShowAddModal(true)}
className="flex items-center gap-1 text-xs sm:text-sm text-blue-600 will-change-transform" className="flex items-center gap-1 text-xs sm:text-sm text-emerald-600 will-change-transform"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</motion.button> </motion.button>
@ -169,9 +235,67 @@ export function YarnWarehouse() {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="bg-white rounded-lg shadow-sm p-4" className="bg-white rounded-lg shadow-sm"
> >
<p className="text-center text-gray-400 py-6 sm:py-8 text-sm"></p> <div className="p-3 sm:p-4 border-b">
<h2 className="font-medium text-sm sm:text-base"></h2>
</div>
{records.length === 0 ? (
<p className="text-center text-gray-400 py-6 sm:py-8 text-sm"></p>
) : (
<div className="divide-y">
{records.map((record, index) => (
<motion.div
key={record.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05, duration: 0.25 }}
className="p-3 sm:p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm sm:text-base">{record.yarn_name}</span>
<span className={`text-[10px] sm:text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
record.record_type === 'inbound'
? 'bg-emerald-100 text-emerald-600'
: 'bg-amber-100 text-amber-600'
}`}>
{record.record_type === 'inbound' ? (
<span className="flex items-center gap-1">
<ArrowDownLeft className="w-3 h-3" />
</span>
) : (
<span className="flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
</span>
)}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-500 mt-1">
{record.notes || '-'}
</p>
{record.plan_id && (
<p className="text-[10px] sm:text-xs text-gray-400 mt-0.5">
: {record.plan_id.slice(0, 8)}...
</p>
)}
</div>
<div className="text-right flex-shrink-0 ml-2">
<p className={`font-semibold text-sm sm:text-base ${
record.record_type === 'inbound' ? 'text-emerald-600' : 'text-amber-600'
}`}>
{record.record_type === 'inbound' ? '+' : '-'}{record.quantity} {record.unit}
</p>
<p className="text-[10px] sm:text-xs text-gray-400">
{new Date(record.created_at).toLocaleDateString()}
</p>
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div> </motion.div>
)} )}
</div> </div>
@ -245,7 +369,7 @@ export function YarnWarehouse() {
<motion.button <motion.button
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={handleAddYarn} onClick={handleAddYarn}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm will-change-transform" className="flex-1 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm will-change-transform"
> >
</motion.button> </motion.button>

View File

@ -93,19 +93,19 @@ export function CompletedFabric() {
: 0; : 0;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-green-50 to-emerald-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50/30 to-violet-50/30">
{/* Header */} {/* Header - 与 Dashboard 一致 */}
<header className="bg-gradient-to-r from-green-500 to-emerald-500 shadow-lg"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3"> <div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3">
<button <button
onClick={() => navigate('/washing')} onClick={() => navigate('/washing')}
className="p-2 hover:bg-white/20 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<ArrowLeft className="w-5 h-5 text-white" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</button> </button>
<div> <div>
<h1 className="text-lg font-bold text-white"></h1> <h1 className="text-lg font-bold text-gray-900"></h1>
<p className="text-xs text-white/70"></p> <p className="text-xs text-gray-500"></p>
</div> </div>
</div> </div>
</header> </header>
@ -117,9 +117,9 @@ export function CompletedFabric() {
<p className="text-xs text-gray-500 mb-1"></p> <p className="text-xs text-gray-500 mb-1"></p>
<p className="text-2xl font-bold text-gray-800">{items.length}</p> <p className="text-2xl font-bold text-gray-800">{items.length}</p>
</div> </div>
<div className="bg-blue-50 rounded-xl p-4 shadow-sm"> <div className="bg-purple-50 rounded-xl p-4 shadow-sm">
<p className="text-xs text-blue-600 mb-1"></p> <p className="text-xs text-purple-600 mb-1"></p>
<p className="text-2xl font-bold text-blue-700">{totalBefore.toFixed(2)}</p> <p className="text-2xl font-bold text-purple-700">{totalBefore.toFixed(2)}</p>
</div> </div>
<div className="bg-green-50 rounded-xl p-4 shadow-sm"> <div className="bg-green-50 rounded-xl p-4 shadow-sm">
<p className="text-xs text-green-600 mb-1"></p> <p className="text-xs text-green-600 mb-1"></p>

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, HelpCircle } from 'lucide-react'; import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera, FileText, Plus, TrendingUp, Building2 } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide'; import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter'; import { NotificationCenter } from '../../components/NotificationCenter';
@ -14,48 +14,34 @@ import { UserManual } from '../../components/UserManual';
import { CrashReporter } from '../../components/CrashReporter'; import { CrashReporter } from '../../components/CrashReporter';
import type { WashingPlan } from '../../types'; import type { WashingPlan } from '../../types';
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any }> = {
pending: { label: '待处理', color: 'text-amber-600', bgColor: 'bg-amber-50', icon: Clock },
processing: { label: '处理中', color: 'text-violet-500', bgColor: 'bg-violet-50', icon: TrendingUp },
completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', icon: Package }
};
const getQuickActions = (isMaster: boolean) => { const getQuickActions = (isMaster: boolean) => {
const actions = [ const actions = [
{ id: 'plans', icon: Import, label: '计划总览', path: '/washing/plans', gradient: 'from-purple-500 to-violet-500' }, { id: 'plans', icon: FileText, label: '计划总览', path: '/washing/plans', gradient: 'from-violet-400 to-fuchsia-400', description: '查看所有计划' },
{ id: 'pending', icon: Package, label: '待处理坯布', path: '/washing/pending', gradient: 'from-amber-500 to-orange-500' }, { id: 'pending', icon: Package, label: '待处理坯布', path: '/washing/pending', gradient: 'from-amber-500 to-orange-500', description: '处理待水洗坯布' },
{ id: 'completed', icon: CheckCircle, label: '已完成坯布', path: '/washing/completed', gradient: 'from-green-500 to-emerald-500' }, { id: 'completed', icon: CheckCircle, label: '已完成坯布', path: '/washing/completed', gradient: 'from-emerald-500 to-green-500', description: '查看已完成水洗' },
{ id: 'finished', icon: Warehouse, label: '成品仓库', path: '/washing/finished-warehouse', gradient: 'from-blue-500 to-cyan-500' }, { id: 'finished', icon: Warehouse, label: '成品仓库', path: '/washing/finished-warehouse', gradient: 'from-violet-400 to-fuchsia-400', description: '管理成品库存' },
{ id: 'payment', icon: CreditCard, label: '待结款', path: '/washing/payments', gradient: 'from-rose-500 to-pink-500' } { id: 'payment', icon: CreditCard, label: '待结款', path: '/washing/payments', gradient: 'from-rose-500 to-pink-500', description: '查看待结款项' }
]; ];
if (isMaster) { if (isMaster) {
actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-indigo-500 to-purple-500' }); actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-fuchsia-400 to-purple-400', description: '管理团队账号' });
} }
return actions; return actions;
}; };
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: '待处理', color: 'bg-amber-100 text-amber-700' },
processing: { label: '处理中', color: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', color: 'bg-green-100 text-green-700' }
};
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.05
}
}
}; };
const itemVariants = { const itemVariants = {
hidden: { opacity: 0, y: 15, scale: 0.98 }, hidden: { opacity: 0, y: 20 },
visible: { visible: { opacity: 1, y: 0, transition: { duration: 0.4 } }
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.35,
ease: [0.25, 0.46, 0.45, 0.94]
}
}
}; };
export function WashingDashboard() { export function WashingDashboard() {
@ -64,8 +50,6 @@ export function WashingDashboard() {
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const [plans, setPlans] = useState<WashingPlan[]>([]); const [plans, setPlans] = useState<WashingPlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pendingCount, setPendingCount] = useState(0);
const [completedCount, setCompletedCount] = useState(0);
// 帮助中心状态 // 帮助中心状态
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
@ -77,6 +61,12 @@ export function WashingDashboard() {
// 版本更新检查 // 版本更新检查
const { showUpdate, setShowUpdate } = useVersionCheck(); const { showUpdate, setShowUpdate } = useVersionCheck();
// 账户卡片弹窗状态
const [showAccountCard, setShowAccountCard] = useState(false);
// 最近计划轮播索引
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
// 检查是否显示新手引导 // 检查是否显示新手引导
useEffect(() => { useEffect(() => {
if (shouldShowOnboarding('washing')) { if (shouldShowOnboarding('washing')) {
@ -96,10 +86,7 @@ export function WashingDashboard() {
.eq('washing_factory_id', auth.company!.id) .eq('washing_factory_id', auth.company!.id)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
const plans = washingPlans || []; setPlans(washingPlans || []);
setPlans(plans);
setPendingCount(plans.filter(p => p.status === 'pending' || p.status === 'processing').length);
setCompletedCount(plans.filter(p => p.status === 'completed').length);
setLoading(false); setLoading(false);
}; };
@ -109,205 +96,240 @@ export function WashingDashboard() {
completed: plans.filter(p => p.status === 'completed').length completed: plans.filter(p => p.status === 'completed').length
}; };
const recentPlans = plans.slice(0, 5); // 获取进行中的计划用于轮播
const processingPlans = plans.filter(p => p.status === 'processing').slice(0, 5);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-violet-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-violet-50/40 to-fuchsia-50/30">
<header className="bg-gradient-to-r from-purple-600 to-violet-600 shadow-lg"> {/* Header - 与采购商一致 */}
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 md:py-5 flex justify-between items-center"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-white/20 rounded-xl flex items-center justify-center"> <motion.button
<Droplets className="w-4 h-4 sm:w-5 sm:h-5 text-white" /> whileHover={{ scale: 1.05 }}
</div> whileTap={{ scale: 0.95 }}
onClick={() => setShowAccountCard(true)}
className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg shadow-violet-400/20 cursor-pointer overflow-hidden"
>
{(auth.user as any)?.image_url ? (
<img
src={(auth.user as any).image_url}
alt="头像"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Droplets className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
)}
</motion.button>
<div> <div>
<h1 className="text-sm sm:text-lg font-bold text-white flex items-center gap-2 whitespace-nowrap"> <h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap">
{auth.company?.name || '水洗厂'} <span className="sm:hidden truncate max-w-[80px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</span>
<span className="text-[10px] sm:text-xs bg-white/20 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span> <span className="hidden sm:inline">{auth.company?.name || '水洗厂'}</span>
</h1> <span className="text-[10px] sm:text-xs bg-gradient-to-r from-violet-400 to-fuchsia-400 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span>
<p className="text-xs text-white/70 hidden sm:block">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p> </h1>
<p className="text-xs text-gray-500 hidden sm:block truncate max-w-[150px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpButton onClick={() => setShowHelp(true)} /> <HelpButton onClick={() => setShowHelp(true)} />
<NotificationCenter theme="purple" /> <NotificationCenter theme="violet" />
<button <button
onClick={async () => { await logout(); navigate('/login'); }} onClick={async () => { await logout(); navigate('/login'); }}
className="p-2 sm:px-3 sm:py-1 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors" className="p-2 sm:px-3 sm:py-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
title="退出登录" title="退出登录"
> >
<LogOut className="w-5 h-5 sm:hidden" /> <LogOut className="w-5 h-5 sm:hidden" />
<span className="hidden sm:inline text-xs md:text-sm">退</span> <span className="hidden sm:inline text-xs sm:text-sm">退</span>
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<div className="p-4 md:p-8 max-w-7xl mx-auto"> <div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
{loading ? ( {loading ? (
<div className="text-center py-12 text-gray-400">...</div> <div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-violet-400 border-t-transparent rounded-full animate-spin" />
</div>
) : ( ) : (
<motion.div variants={containerVariants} initial="hidden" animate="visible"> <motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-3 gap-3 md:gap-6 mb-6 md:mb-8"> {/* 统计卡片 - 与采购商一致 */}
<motion.div variants={itemVariants} className="bg-gradient-to-br from-purple-500 to-violet-500 rounded-xl md:rounded-2xl shadow-lg shadow-purple-500/20 p-4 md:p-6 text-white"> <div className="grid grid-cols-3 gap-2 sm:gap-4">
<p className="text-xs md:text-sm text-purple-100 mb-1 md:mb-2"></p> <motion.div
<p className="text-2xl md:text-4xl font-bold">{stats.all}</p> variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-violet-50 rounded-lg flex items-center justify-center">
<FileText className="w-3 h-3 sm:w-4 sm:h-4 text-violet-500" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.all}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
<motion.div variants={itemVariants} className="bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl md:rounded-2xl shadow-lg shadow-blue-500/20 p-4 md:p-6 text-white">
<p className="text-xs md:text-sm text-blue-100 mb-1 md:mb-2"></p> <motion.div
<p className="text-2xl md:text-4xl font-bold">{stats.producing}</p> variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans?filter=processing')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-violet-50 rounded-lg flex items-center justify-center">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-violet-500" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.producing}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
<motion.div variants={itemVariants} className="bg-gradient-to-br from-green-500 to-emerald-500 rounded-xl md:rounded-2xl shadow-lg shadow-green-500/20 p-4 md:p-6 text-white">
<p className="text-xs md:text-sm text-green-100 mb-1 md:mb-2"></p> <motion.div
<p className="text-2xl md:text-4xl font-bold">{stats.completed}</p> variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans?filter=completed')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-emerald-50 rounded-lg flex items-center justify-center">
<Package className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-600" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.completed}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div> </motion.div>
</div> </div>
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-4 md:p-6 mb-6 md:mb-8 border border-white/50"> {/* 最近计划 - 左右滑动展示 */}
<h2 className="text-base md:text-lg font-semibold text-gray-800 mb-4 md:mb-6"></h2> <motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3 md:gap-4"> <div className="flex justify-between items-center mb-3 sm:mb-4">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/washing/plans')}
className="text-xs sm:text-sm text-violet-500 hover:text-violet-600 font-medium flex items-center gap-1 whitespace-nowrap"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
</button>
</div>
{processingPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-gray-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"></p>
<button
onClick={() => navigate('/washing/plans')}
className="mt-3 px-4 py-2 bg-violet-400 text-white rounded-lg text-sm hover:bg-violet-500 transition-colors"
>
</button>
</div>
) : (
<div className="relative">
{/* 滑动容器 */}
<div className="overflow-hidden">
<motion.div
className="flex"
animate={{ x: -currentPlanIndex * 100 + '%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{processingPlans.map((plan, i) => {
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<div
key={plan.id}
className="w-full flex-shrink-0 px-1"
onClick={() => navigate(`/washing/plans?id=${plan.id}`)}
>
<div className="group p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 cursor-pointer transition-all">
{/* 计划标题行 */}
<div className="flex items-center gap-2 sm:gap-3">
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center ${status.bgColor}`}>
<StatusIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<h3 className="font-medium text-gray-900 text-sm sm:text-base truncate">{plan.plan_code}</h3>
<span className={`px-1.5 py-0.5 text-xs rounded-full whitespace-nowrap ${status.bgColor} ${status.color}`}>
{status.label}
</span>
</div>
<p className="text-xs text-gray-500">{plan.planned_meters}</p>
</div>
</div>
</div>
</div>
);
})}
</motion.div>
</div>
{/* 左右滑动按钮 */}
{processingPlans.length > 1 && (
<>
<button
onClick={() => setCurrentPlanIndex(prev => Math.max(0, prev - 1))}
disabled={currentPlanIndex === 0}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => setCurrentPlanIndex(prev => Math.min(processingPlans.length - 1, prev + 1))}
disabled={currentPlanIndex === processingPlans.length - 1}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600 rotate-180" />
</button>
</>
)}
{/* 指示器 */}
{processingPlans.length > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-4">
{processingPlans.map((_, i) => (
<button
key={i}
onClick={() => setCurrentPlanIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === currentPlanIndex ? 'bg-purple-500 w-4' : 'bg-gray-300 hover:bg-gray-400'
}`}
/>
))}
</div>
)}
</div>
)}
</motion.div>
{/* 快捷操作 - 与采购商一致 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4 whitespace-nowrap"></h2>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
{getQuickActions(auth.user?.is_master || false).map((action, index) => ( {getQuickActions(auth.user?.is_master || false).map((action, index) => (
<motion.button <motion.button
key={action.id} key={action.id}
initial={{ opacity: 0, y: 10 }} whileHover={isMobile ? {} : { scale: 1.03, y: -2 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
whileHover={isMobile ? {} : { scale: 1.03, y: -3 }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={() => navigate(action.path)} onClick={() => navigate(action.path)}
className="flex flex-col items-center justify-center p-3 md:p-4 rounded-xl border border-gray-200 hover:border-transparent hover:shadow-md transition-all duration-200 group will-change-transform" className="group flex flex-col items-center p-2 sm:p-3 rounded-lg sm:rounded-xl border border-gray-100 hover:border-transparent hover:shadow-md transition-all bg-gray-50/50 hover:bg-white"
> >
<div className={`w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center mb-2 md:mb-3 bg-gradient-to-br ${action.gradient} shadow-lg transition-shadow duration-200 group-hover:shadow-xl`}> <div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl flex items-center justify-center mb-1 sm:mb-2 bg-gradient-to-br ${action.gradient} shadow-sm group-hover:shadow-md transition-shadow`}>
<action.icon className="w-5 h-5 md:w-6 md:h-6 text-white" /> <action.icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div> </div>
<span className="text-xs md:text-sm text-gray-700 group-hover:text-gray-900 transition-colors text-center">{action.label}</span> <span className="text-[10px] sm:text-xs font-medium text-gray-700 group-hover:text-gray-900 text-center whitespace-nowrap">{action.label}</span>
</motion.button> </motion.button>
))} ))}
</div> </div>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-4 md:p-6 border border-white/50">
<div className="flex justify-between items-center mb-4 md:mb-6">
<h2 className="text-base md:text-lg font-semibold text-gray-800"></h2>
<button onClick={() => navigate('/washing/pending')} className="text-xs md:text-sm text-amber-600 hover:text-amber-700 font-medium"></button>
</div>
{pendingCount === 0 ? (
<p className="text-center text-gray-400 py-6 md:py-8"></p>
) : (
<div className="space-y-3 md:space-y-4">
{plans
.filter(p => p.status === 'pending' || p.status === 'processing')
.slice(0, 3)
.map((plan, i) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
whileHover={isMobile ? {} : { x: 4, backgroundColor: 'rgba(254, 243, 199, 0.5)' }}
whileTap={{ scale: 0.99 }}
onClick={() => navigate(`/washing/plans?id=${plan.id}`)}
className="border border-gray-200 rounded-xl p-3 md:p-4 hover:border-amber-300 cursor-pointer transition-all duration-200 will-change-transform"
>
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-xs text-gray-500">{plan.plan_code}</p>
<p className="text-sm font-medium text-gray-800 mt-1">{plan.planned_meters}</p>
</div>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${statusMap[plan.status]?.color || 'bg-gray-100'}`}>
{statusMap[plan.status]?.label || plan.status}
</span>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-4 md:p-6 border border-white/50">
<div className="flex justify-between items-center mb-4 md:mb-6">
<h2 className="text-base md:text-lg font-semibold text-gray-800"></h2>
<button onClick={() => navigate('/washing/completed')} className="text-xs md:text-sm text-green-600 hover:text-green-700 font-medium"></button>
</div>
{completedCount === 0 ? (
<p className="text-center text-gray-400 py-6 md:py-8"></p>
) : (
<div className="space-y-3 md:space-y-4">
{plans
.filter(p => p.status === 'completed')
.slice(0, 3)
.map((plan, i) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
whileHover={isMobile ? {} : { x: 4, backgroundColor: 'rgba(209, 250, 229, 0.5)' }}
whileTap={{ scale: 0.99 }}
onClick={() => navigate(`/washing/plans?id=${plan.id}`)}
className="border border-gray-200 rounded-xl p-3 md:p-4 hover:border-green-300 cursor-pointer transition-all duration-200 will-change-transform"
>
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-xs text-gray-500">{plan.plan_code}</p>
<p className="text-sm font-medium text-gray-800 mt-1">
: {plan.planned_meters} : {plan.actual_washed_meters || plan.estimated_washed_meters}
</p>
</div>
<span className="px-2 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700">
</span>
</div>
<p className="text-xs text-gray-500">
: {plan.actual_shrinkage_rate || plan.estimated_shrinkage_rate}%
</p>
</motion.div>
))}
</div>
)}
</motion.div>
</div>
<motion.div variants={itemVariants} className="bg-white/80 backdrop-blur rounded-2xl shadow-sm p-4 md:p-6 mt-4 md:mt-6 border border-white/50">
<div className="flex justify-between items-center mb-4 md:mb-6">
<h2 className="text-base md:text-lg font-semibold text-gray-800"></h2>
<button onClick={() => navigate('/washing/plans')} className="text-xs md:text-sm text-purple-600 hover:text-purple-700 font-medium"></button>
</div>
{recentPlans.length === 0 ? (
<p className="text-center text-gray-400 py-6 md:py-8"></p>
) : (
<div className="space-y-3 md:space-y-4">
{recentPlans.map((plan, i) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.08, duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
whileHover={isMobile ? {} : { x: 4, backgroundColor: 'rgba(243, 232, 255, 0.8)' }}
whileTap={{ scale: 0.99 }}
onClick={() => navigate(`/washing/plans?id=${plan.id}`)}
className="border border-gray-200 rounded-xl p-4 md:p-5 hover:border-purple-300 cursor-pointer transition-all duration-200 will-change-transform"
>
<div className="flex justify-between items-start mb-2 md:mb-3">
<div>
<p className="text-sm text-gray-600">{plan.plan_code}</p>
<p className="text-xs text-gray-400 mt-1">: {plan.planned_meters}</p>
</div>
<span className={`px-2 py-1 md:px-3 md:py-1.5 rounded-lg text-xs md:text-sm font-medium ${statusMap[plan.status]?.color || 'bg-gray-100'}`}>
{statusMap[plan.status]?.label || plan.status}
</span>
</div>
{plan.status === 'completed' && (
<div className="flex items-center gap-2 md:gap-4 text-xs md:text-sm text-gray-600">
<span>: {plan.actual_washed_meters}</span>
<span>: {plan.actual_shrinkage_rate}%</span>
</div>
)}
</motion.div>
))}
</div>
)}
</motion.div>
</motion.div> </motion.div>
)} )}
</div> </div>
@ -339,6 +361,160 @@ export function WashingDashboard() {
{/* 崩溃报告 */} {/* 崩溃报告 */}
<CrashReporter /> <CrashReporter />
{/* 账户卡片弹窗 */}
{showAccountCard && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="bg-gradient-to-r from-violet-400 to-fuchsia-400 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"></h2>
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* 账户详情 */}
<div className="p-6 space-y-4">
{/* 用户信息 */}
<div className="flex items-center gap-4">
<label className="w-16 h-16 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
{(auth.user as any)?.image_url ? (
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
) : (
<Droplets className="w-8 h-8 text-white relative z-10" />
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const arrayBuffer = await file.arrayBuffer();
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, arrayBuffer, {
contentType: file.type,
upsert: true
});
if (uploadError) {
console.error('头像上传失败:', uploadError);
alert('头像上传失败: ' + uploadError.message);
return;
}
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
const { error: updateError } = await supabase
.from('profiles')
.update({ image_url: publicUrl } as any)
.eq('id', user.id);
if (updateError) {
console.error('头像更新失败:', updateError);
alert('头像更新失败: ' + updateError.message);
} else {
window.location.reload();
}
} catch (err) {
console.error('头像上传出错:', err);
alert('头像上传出错,请重试');
}
}}
/>
</label>
<div>
<p className="font-semibold text-gray-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
<p className="text-sm text-gray-500">{auth.company?.name || '水洗厂'}</p>
<span className="inline-flex items-center gap-1 text-xs bg-violet-100 text-violet-600 px-2 py-0.5 rounded-full mt-1">
{auth.user?.is_master ? '主账号' : '子账号'}
</span>
</div>
</div>
{/* 功能菜单 */}
<div className="space-y-2 pt-4 border-t border-gray-100">
<button
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<BookOpen className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500">使</p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Info className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
{/* 退出登录 */}
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium"
>
<LogOut className="w-5 h-5" />
退
</button>
</div>
</motion.div>
</div>
)}
</div> </div>
); );
} }

View File

@ -101,19 +101,19 @@ export function FinishedWarehouse() {
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-violet-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50/30 to-violet-50/30">
{/* Header */} {/* Header - 与 Dashboard 一致 */}
<header className="bg-gradient-to-r from-purple-600 to-violet-600 shadow-lg"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3"> <div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3">
<button <button
onClick={() => navigate('/washing')} onClick={() => navigate('/washing')}
className="p-2 hover:bg-white/20 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<ArrowLeft className="w-5 h-5 text-white" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</button> </button>
<div> <div>
<h1 className="text-lg font-bold text-white"></h1> <h1 className="text-lg font-bold text-gray-900"></h1>
<p className="text-xs text-white/70"></p> <p className="text-xs text-gray-500"></p>
</div> </div>
</div> </div>
</header> </header>
@ -131,7 +131,7 @@ export function FinishedWarehouse() {
</div> </div>
<div className="bg-white rounded-xl p-4 shadow-sm"> <div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-1">/</p> <p className="text-xs text-gray-500 mb-1">/</p>
<p className="text-2xl font-bold text-blue-600">{stats.totalStockRolls}</p> <p className="text-2xl font-bold text-purple-600">{stats.totalStockRolls}</p>
</div> </div>
<div className="bg-white rounded-xl p-4 shadow-sm"> <div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-1"></p> <p className="text-xs text-gray-500 mb-1"></p>

View File

@ -21,7 +21,7 @@ interface PendingFabricItem {
const statusMap: Record<string, { label: string; color: string; bgColor: string }> = { const statusMap: Record<string, { label: string; color: string; bgColor: string }> = {
pending: { label: '待确认', color: 'text-amber-600', bgColor: 'bg-amber-50' }, pending: { label: '待确认', color: 'text-amber-600', bgColor: 'bg-amber-50' },
processing: { label: '处理中', color: 'text-blue-600', bgColor: 'bg-blue-50' } processing: { label: '处理中', color: 'text-purple-600', bgColor: 'bg-purple-50' }
}; };
export function PendingFabric() { export function PendingFabric() {
@ -72,19 +72,19 @@ export function PendingFabric() {
const totalMeters = items.reduce((sum, item) => sum + item.planned_meters, 0); const totalMeters = items.reduce((sum, item) => sum + item.planned_meters, 0);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-amber-50 to-orange-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50/30 to-violet-50/30">
{/* Header */} {/* Header - 与 Dashboard 一致 */}
<header className="bg-gradient-to-r from-amber-500 to-orange-500 shadow-lg"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3"> <div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3">
<button <button
onClick={() => navigate('/washing')} onClick={() => navigate('/washing')}
className="p-2 hover:bg-white/20 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<ArrowLeft className="w-5 h-5 text-white" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</button> </button>
<div> <div>
<h1 className="text-lg font-bold text-white"></h1> <h1 className="text-lg font-bold text-gray-900"></h1>
<p className="text-xs text-white/70"></p> <p className="text-xs text-gray-500"></p>
</div> </div>
</div> </div>
</header> </header>

View File

@ -23,7 +23,7 @@ interface PlanGroup {
const statusMap: Record<string, { label: string; color: string }> = { const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: '待处理', color: 'bg-amber-100 text-amber-700' }, pending: { label: '待处理', color: 'bg-amber-100 text-amber-700' },
processing: { label: '处理中', color: 'bg-blue-100 text-blue-700' }, processing: { label: '处理中', color: 'bg-purple-100 text-purple-700' },
completed: { label: '已完成', color: 'bg-green-100 text-green-700' } completed: { label: '已完成', color: 'bg-green-100 text-green-700' }
}; };
@ -171,24 +171,27 @@ export function WashingPlanOverview() {
return { return {
token: url.searchParams.get('token'), token: url.searchParams.get('token'),
plan: url.searchParams.get('plan'), plan: url.searchParams.get('plan'),
type: url.searchParams.get('type') type: url.searchParams.get('type'),
shortCode: url.searchParams.get('s')
}; };
} catch { } catch {
return { token: null, plan: null, type: null }; return { token: null, plan: null, type: null, shortCode: null };
} }
}; };
const handleImportFromLink = () => { const handleImportFromLink = () => {
if (!importLink.trim()) return; if (!importLink.trim()) return;
const { token, plan, type } = extractParamsFromLink(importLink); const { token, plan, type, shortCode } = extractParamsFromLink(importLink);
if (token) { if (shortCode) {
navigate(`/import?s=${shortCode}`);
} else if (token) {
navigate(`/import?token=${token}`); navigate(`/import?token=${token}`);
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
} else { } else {
alert('无效的链接格式,请确保链接包含 token 或 plan 参数'); alert('无效的链接格式,请确保链接包含 token、s 或 plan 参数');
} }
}; };
@ -197,9 +200,11 @@ export function WashingPlanOverview() {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (text.includes('/import?') || text.includes('#/import?')) { if (text.includes('/import?') || text.includes('#/import?')) {
setImportLink(text); setImportLink(text);
const { token, plan, type } = extractParamsFromLink(text); const { token, plan, type, shortCode } = extractParamsFromLink(text);
if (token) { if (shortCode) {
navigate(`/import?s=${shortCode}`);
} else if (token) {
navigate(`/import?token=${token}`); navigate(`/import?token=${token}`);
} else if (plan && type) { } else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`); navigate(`/import?plan=${plan}&type=${type}`);
@ -326,14 +331,14 @@ export function WashingPlanOverview() {
return ( return (
<div key={stepType} className="flex items-start gap-3"> <div key={stepType} className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
isCompleted ? 'bg-green-500 text-white' : isCompleted ? 'bg-green-500 text-white' :
isActive ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-400' isActive ? 'bg-purple-500 text-white' : 'bg-gray-200 text-gray-400'
}`}> }`}>
{isCompleted ? <Check className="w-4 h-4" /> : index + 1} {isCompleted ? <Check className="w-4 h-4" /> : index + 1}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className={`font-medium ${isCompleted ? 'text-green-700' : isActive ? 'text-blue-700' : 'text-gray-500'}`}> <p className={`font-medium ${isCompleted ? 'text-green-700' : isActive ? 'text-purple-700' : 'text-gray-500'}`}>
{stepNames[stepType]} {stepNames[stepType]}
</p> </p>
{step?.timestamp && ( {step?.timestamp && (
@ -351,7 +356,7 @@ export function WashingPlanOverview() {
<button <button
onClick={() => handleConfirmStep(step.id, stepType)} onClick={() => handleConfirmStep(step.id, stepType)}
disabled={confirming === step.id} disabled={confirming === step.id}
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded-lg hover:bg-blue-600 disabled:opacity-50" className="mt-2 px-3 py-1 bg-purple-500 text-white text-xs rounded-lg hover:bg-purple-600 disabled:opacity-50"
> >
{confirming === step.id ? '确认中...' : '确认'} {confirming === step.id ? '确认中...' : '确认'}
</button> </button>
@ -373,18 +378,24 @@ export function WashingPlanOverview() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 p-3 sm:p-4 md:p-6"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50/30 to-violet-50/30">
<div className="max-w-6xl mx-auto"> {/* Header - 与 Dashboard 一致 */}
<div className="flex items-center gap-2 sm:gap-3 md:gap-4 mb-4 sm:mb-5 md:mb-6"> <header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<motion.button <div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3">
whileTap={{ scale: 0.95 }} <button
onClick={() => navigate('/washing')} onClick={() => navigate('/washing')}
className="p-2 hover:bg-gray-200 rounded-full transition-colors will-change-transform" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6 text-gray-600" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</motion.button> </button>
<h1 className="text-lg sm:text-xl md:text-2xl font-bold text-gray-800"></h1> <div>
<h1 className="text-lg font-bold text-gray-900"></h1>
<p className="text-xs text-gray-500"></p>
</div>
</div> </div>
</header>
<div className="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto">
{/* 导入链接区域 */} {/* 导入链接区域 */}
<motion.div <motion.div

View File

@ -1157,6 +1157,7 @@ export type Database = {
created_at: string created_at: string
display_name: string | null display_name: string | null
id: string id: string
image_url: string | null
is_master: boolean is_master: boolean
master_id: string | null master_id: string | null
phone: string | null phone: string | null
@ -1167,6 +1168,7 @@ export type Database = {
created_at?: string created_at?: string
display_name?: string | null display_name?: string | null
id: string id: string
image_url?: string | null
is_master?: boolean is_master?: boolean
master_id?: string | null master_id?: string | null
phone?: string | null phone?: string | null
@ -1177,6 +1179,7 @@ export type Database = {
created_at?: string created_at?: string
display_name?: string | null display_name?: string | null
id?: string id?: string
image_url?: string | null
is_master?: boolean is_master?: boolean
master_id?: string | null master_id?: string | null
phone?: string | null phone?: string | null
@ -1191,6 +1194,39 @@ export type Database = {
}, },
] ]
} }
share_links: {
Row: {
click_count: number | null
created_at: string
created_by: string
expires_at: string | null
factory_type: string
id: string
plan_id: string
short_code: string
}
Insert: {
click_count?: number | null
created_at?: string
created_by: string
expires_at?: string | null
factory_type: string
id?: string
plan_id: string
short_code: string
}
Update: {
click_count?: number | null
created_at?: string
created_by?: string
expires_at?: string | null
factory_type?: string
id?: string
plan_id?: string
short_code?: string
}
Relationships: []
}
user_feedback: { user_feedback: {
Row: { Row: {
contact: string | null contact: string | null
@ -1596,6 +1632,67 @@ export type Database = {
}, },
] ]
} }
yarn_stock_records: {
Row: {
batch_no: string | null
company_id: string
created_at: string
id: string
notes: string | null
operator_id: string | null
plan_id: string | null
quantity: number
record_type: string
unit: string
yarn_stock_id: string | null
}
Insert: {
batch_no?: string | null
company_id: string
created_at?: string
id?: string
notes?: string | null
operator_id?: string | null
plan_id?: string | null
quantity?: number
record_type: string
unit?: string
yarn_stock_id?: string | null
}
Update: {
batch_no?: string | null
company_id?: string
created_at?: string
id?: string
notes?: string | null
operator_id?: string | null
plan_id?: string | null
quantity?: number
record_type?: string
unit?: string
yarn_stock_id?: string | null
}
Relationships: [
{
foreignKeyName: "yarn_stock_records_company_id_fkey"
columns: ["company_id"]
referencedRelation: "companies"
referencedColumns: ["id"]
},
{
foreignKeyName: "yarn_stock_records_plan_id_fkey"
columns: ["plan_id"]
referencedRelation: "production_plans"
referencedColumns: ["id"]
},
{
foreignKeyName: "yarn_stock_records_yarn_stock_id_fkey"
columns: ["yarn_stock_id"]
referencedRelation: "yarn_stock"
referencedColumns: ["id"]
},
]
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
@ -1649,6 +1746,10 @@ export type Database = {
Args: { "": unknown } Args: { "": unknown }
Returns: unknown Returns: unknown
} }
increment_share_link_clicks: {
Args: { code: string }
Returns: undefined
}
ivfflat_bit_support: { ivfflat_bit_support: {
Args: { "": unknown } Args: { "": unknown }
Returns: unknown Returns: unknown

View File

@ -37,26 +37,27 @@ export const stepOrder = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start',
// 动画配置 // 动画配置
export const animationConfig = { export const animationConfig = {
// 标准过渡 // 标准过渡 - 用于展开/收起,使用更平滑的缓动函数避免抖动
transition: { transition: {
duration: 0.3, duration: 0.25,
ease: [0.25, 0.46, 0.45, 0.94] ease: [0.4, 0, 0.2, 1] // 使用 ease-out 缓动,更平滑
}, },
// 快速过渡 // 快速过渡
fastTransition: { fastTransition: {
duration: 0.2, duration: 0.2,
ease: [0.25, 0.46, 0.45, 0.94] ease: [0.4, 0, 0.2, 1]
}, },
// 慢速过渡 // 慢速过渡
slowTransition: { slowTransition: {
duration: 0.5, duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94] ease: [0.4, 0, 0.2, 1]
}, },
// 弹簧动画 // 弹簧动画 - 用于交互反馈,降低弹性避免抖动
spring: { spring: {
type: 'spring', type: 'spring',
stiffness: 300, stiffness: 200,
damping: 30 damping: 25,
mass: 0.8
} }
}; };