Compare commits
10 Commits
ee8fc85516
...
986e7d52f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986e7d52f1 | ||
|
|
6b835b933c | ||
|
|
d085054437 | ||
|
|
f99322df5f | ||
|
|
c8b42edaa7 | ||
|
|
b24de9ac11 | ||
|
|
29451277d1 | ||
|
|
284499745a | ||
|
|
1b506edfbf | ||
|
|
01026a17d8 |
41
migrations/20260707_seed_washing_plans.sql
Normal file
41
migrations/20260707_seed_washing_plans.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 水洗计划示例数据 - Seed washing plans and related data
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 先创建示例产品(水洗计划需要引用 product_id)
|
||||||
|
INSERT INTO public.products (id, company_id, product_name, weight, color, fabric_code, color_code, production_price, product_type, yarn_usage_per_meter, created_at) VALUES
|
||||||
|
('44444444-4444-4444-4444-444444444401', '11111111-1111-1111-1111-111111111101', '水洗棉花糖', 180, '白色', 'SX', '01', 10, 'textile', 100, '2026-01-10T00:00:00Z'),
|
||||||
|
('44444444-4444-4444-4444-444444444402', '11111111-1111-1111-1111-111111111101', '纯棉汗布', 200, '黑色', 'CM', '02', 12, 'textile', 120, '2026-01-10T00:00:00Z'),
|
||||||
|
('44444444-4444-4444-4444-444444444403', '11111111-1111-1111-1111-111111111101', '磨毛布', 160, '灰色', 'MM', '03', 8, 'textile', 80, '2026-01-10T00:00:00Z')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 创建示例水洗计划
|
||||||
|
INSERT INTO public.washing_plans (id, plan_code, product_id, company_id, washing_factory_id, planned_meters, washing_price, estimated_shrinkage_rate, estimated_washed_meters, estimated_total_cost, status, notes, created_by, created_at) VALUES
|
||||||
|
('55555555-5555-5555-5555-555555555501', 'WS20260201-A1B2', '44444444-4444-4444-4444-444444444401', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 3000, 2.5, 5, 2850, 7500, 'pending', '第一批水洗', 'a0000001-0000-0000-0000-000000000001', '2026-02-01T00:00:00Z'),
|
||||||
|
('55555555-5555-5555-5555-555555555502', 'WS20260210-C3D4', '44444444-4444-4444-4444-444444444402', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 5000, 3.0, 6, 4700, 15000, 'in_progress', '加急处理', 'a0000001-0000-0000-0000-000000000001', '2026-02-10T00:00:00Z'),
|
||||||
|
('55555555-5555-5555-5555-555555555503', 'WS20260115-E5F6', '44444444-4444-4444-4444-444444444403', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 2000, 2.0, 4, 1920, 4000, 'completed', '常规水洗', 'a0000001-0000-0000-0000-000000000001', '2026-01-15T00:00:00Z')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 更新已完成计划的实际数据
|
||||||
|
UPDATE public.washing_plans
|
||||||
|
SET actual_washed_meters = 1880,
|
||||||
|
actual_shrinkage_rate = 6,
|
||||||
|
actual_total_cost = 4000,
|
||||||
|
washing_date = '2026-01-20'
|
||||||
|
WHERE id = '55555555-5555-5555-5555-555555555503';
|
||||||
|
|
||||||
|
-- 创建水洗流程步骤
|
||||||
|
INSERT INTO public.washing_process_steps (id, washing_plan_id, company_id, step_type, status, notes, operator_id, created_at) VALUES
|
||||||
|
-- 待处理计划的步骤
|
||||||
|
('66666666-6666-6666-6666-666666666601', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'confirm', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666602', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'start_washing', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666603', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'complete', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
|
||||||
|
-- 处理中计划的步骤
|
||||||
|
('66666666-6666-6666-6666-666666666604', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'confirm', 'completed', '已确认', 'a0000003-0000-0000-0000-000000000003', '2026-02-10T09:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666605', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'start_washing', 'completed', '已开始水洗', 'a0000003-0000-0000-0000-000000000003', '2026-02-11T08:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666606', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'complete', 'pending', NULL, NULL, '2026-02-10T00:00:00Z'),
|
||||||
|
-- 已完成计划的步骤
|
||||||
|
('66666666-6666-6666-6666-666666666607', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'confirm', 'completed', '已确认', 'a0000003-0000-0000-0000-000000000003', '2026-01-15T10:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666608', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'start_washing', 'completed', '已开始水洗', 'a0000003-0000-0000-0000-000000000003', '2026-01-16T08:00:00Z'),
|
||||||
|
('66666666-6666-6666-6666-666666666609', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'complete', 'completed', '水洗完成', 'a0000003-0000-0000-0000-000000000003', '2026-01-20T14:00:00Z')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { http } from './client';
|
import { http, getAccessToken } from './client';
|
||||||
import type { Company, CompanyMember, Profile } from '../types';
|
import type { Company, CompanyMember, Profile } from '../types';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@ -38,7 +38,7 @@ export const commonApi = {
|
|||||||
markNotificationRead: (id: string) =>
|
markNotificationRead: (id: string) =>
|
||||||
http.patch(`/common/notifications/${id}`, { is_read: true }),
|
http.patch(`/common/notifications/${id}`, { is_read: true }),
|
||||||
|
|
||||||
createShareLink: (data: { type: string; target_id: string }) =>
|
createShareLink: (data: { resource_type: string; resource_id: string }) =>
|
||||||
http.post<{ code: string }>('/common/share-links', data).then(r => r.data),
|
http.post<{ code: string }>('/common/share-links', data).then(r => r.data),
|
||||||
|
|
||||||
getShareLink: (code: string) =>
|
getShareLink: (code: string) =>
|
||||||
@ -46,4 +46,52 @@ export const commonApi = {
|
|||||||
|
|
||||||
submitFeedback: (data: { content: string }) =>
|
submitFeedback: (data: { content: string }) =>
|
||||||
http.post('/common/feedback', data),
|
http.post('/common/feedback', data),
|
||||||
|
|
||||||
|
uploadFile: async (file: File, bucket: string, pathPrefix: string): Promise<string> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('bucket', bucket);
|
||||||
|
formData.append('path_prefix', pathPrefix);
|
||||||
|
|
||||||
|
if (!getAccessToken()) {
|
||||||
|
const { authApi } = await import('./auth');
|
||||||
|
try { await authApi.refresh(); } catch { /* will fail on upload */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doUpload = async () => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
return fetch('/api/v1/common/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = await doUpload();
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const { authApi } = await import('./auth');
|
||||||
|
try {
|
||||||
|
await authApi.refresh();
|
||||||
|
} catch {
|
||||||
|
throw new Error('auth expired');
|
||||||
|
}
|
||||||
|
res = await doUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
throw new Error(json.message || 'upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data?.url || '';
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { http } from '../api';
|
import { http } from '../api';
|
||||||
|
import { commonApi } from '../api/common';
|
||||||
|
|
||||||
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
||||||
|
|
||||||
@ -82,23 +83,7 @@ export function AccountProfileCard({
|
|||||||
if (!auth.user) return;
|
if (!auth.user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 头像上传接口待后端实现
|
const publicUrl = await commonApi.uploadFile(file, 'avatars', 'avatars');
|
||||||
const formDataObj = new FormData();
|
|
||||||
formDataObj.append('file', file);
|
|
||||||
formDataObj.append('bucket', 'avatars');
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/common/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formDataObj,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
alert('头像上传失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const publicUrl = json.data?.url || '';
|
|
||||||
|
|
||||||
await http.patch('/auth/me', { image_url: publicUrl });
|
await http.patch('/auth/me', { image_url: publicUrl });
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
||||||
import { http } from '../api';
|
import { commonApi } from '../api/common';
|
||||||
|
|
||||||
export type UploadBucket = 'avatars' | 'product_images';
|
export type UploadBucket = 'avatars' | 'product_images';
|
||||||
|
|
||||||
@ -68,25 +68,7 @@ export function ImageUploader({
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formDataObj = new FormData();
|
const publicUrl = await commonApi.uploadFile(file, bucket, pathPrefix);
|
||||||
formDataObj.append('file', file);
|
|
||||||
formDataObj.append('bucket', bucket);
|
|
||||||
formDataObj.append('path_prefix', pathPrefix);
|
|
||||||
|
|
||||||
// TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
|
|
||||||
const res = await fetch('/api/v1/common/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formDataObj,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
alert('图片上传失败');
|
|
||||||
setUploading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const publicUrl = json.data?.url || '';
|
|
||||||
setPreviewUrl(publicUrl);
|
setPreviewUrl(publicUrl);
|
||||||
onChange(publicUrl);
|
onChange(publicUrl);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|||||||
@ -146,8 +146,8 @@ export function ShareModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await commonApi.createShareLink({
|
const res = await commonApi.createShareLink({
|
||||||
type: factoryType,
|
resource_type: factoryType,
|
||||||
target_id: planId,
|
resource_id: planId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code) {
|
if (res.code) {
|
||||||
|
|||||||
176
src/components/plans/WashingPlanCard.tsx
Normal file
176
src/components/plans/WashingPlanCard.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronDown, Droplets, DollarSign } from 'lucide-react';
|
||||||
|
import { washingStatusMap } from '../../utils/constants';
|
||||||
|
import type { WashingPlanWithProduct } from '../../hooks/useWashingPlanData';
|
||||||
|
|
||||||
|
interface WashingPlanCardProps {
|
||||||
|
plan: WashingPlanWithProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WashingPlanCard({ plan }: WashingPlanCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const status = washingStatusMap[plan.status] || washingStatusMap.pending;
|
||||||
|
|
||||||
|
const progress = plan.status === 'completed' ? 100
|
||||||
|
: plan.status === 'in_progress' ? 50
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-gray-300 transition-colors">
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(prev => !prev)}
|
||||||
|
className="p-3 sm:p-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 sm:gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-gray-900 text-sm sm:text-base truncate">
|
||||||
|
{plan.product_name || '未知产品'}-{plan.product_color || ''}
|
||||||
|
</h4>
|
||||||
|
<span className={`px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full flex-shrink-0 ${status.bgColor} ${status.color} border ${status.borderColor}`}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{plan.fabric_code || ''} | 计划: {plan.planned_meters}米
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{plan.plan_code}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="text-right text-xs sm:text-sm">
|
||||||
|
<p className="text-gray-600 font-medium">
|
||||||
|
¥{plan.washing_price}/米
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: expanded ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-2 sm:mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||||
|
<span>水洗进度</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{plan.status === 'completed' ? '已完成' : plan.status === 'in_progress' ? '处理中' : '待处理'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
plan.status === 'completed' ? 'bg-emerald-500' :
|
||||||
|
plan.status === 'in_progress' ? 'bg-purple-500' :
|
||||||
|
'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="p-3 sm:p-4 bg-gray-50 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InfoItem
|
||||||
|
icon={<Droplets className="w-3.5 h-3.5 text-blue-500" />}
|
||||||
|
label="计划水洗"
|
||||||
|
value={`${plan.planned_meters}米`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<DollarSign className="w-3.5 h-3.5 text-green-500" />}
|
||||||
|
label="水洗单价"
|
||||||
|
value={`¥${plan.washing_price}/米`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label="预计缩水率"
|
||||||
|
value={plan.estimated_shrinkage_rate ? `${plan.estimated_shrinkage_rate}%` : '-'}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label="预计洗后"
|
||||||
|
value={plan.estimated_washed_meters ? `${plan.estimated_washed_meters}米` : '-'}
|
||||||
|
/>
|
||||||
|
{plan.estimated_total_cost != null && (
|
||||||
|
<InfoItem
|
||||||
|
label="预计总费用"
|
||||||
|
value={`¥${plan.estimated_total_cost.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.status === 'completed' && (
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<p className="text-xs font-medium text-gray-600 mb-2">实际结果</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InfoItem
|
||||||
|
label="实际洗后"
|
||||||
|
value={plan.actual_washed_meters ? `${plan.actual_washed_meters}米` : '-'}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label="实际缩水率"
|
||||||
|
value={plan.actual_shrinkage_rate ? `${plan.actual_shrinkage_rate}%` : '-'}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
{plan.actual_total_cost != null && (
|
||||||
|
<InfoItem
|
||||||
|
label="实际总费用"
|
||||||
|
value={`¥${plan.actual_total_cost.toFixed(2)}`}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{plan.washing_date && (
|
||||||
|
<InfoItem
|
||||||
|
label="水洗日期"
|
||||||
|
value={new Date(plan.washing_date).toLocaleDateString('zh-CN')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.notes && (
|
||||||
|
<div className="pt-2 border-t border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<span className="font-medium">备注:</span> {plan.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ icon, label, value, highlight }: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
{icon && <span className="mt-0.5">{icon}</span>}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
<p className={`text-sm font-medium ${highlight ? 'text-emerald-700' : 'text-gray-900'}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
|
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { http } from '../../api';
|
import { http } from '../../api';
|
||||||
|
import { commonApi } from '../../api/common';
|
||||||
import type { Product, ProductYarnRatio } from '../../types';
|
import type { Product, ProductYarnRatio } from '../../types';
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
@ -17,6 +18,7 @@ interface ProductFormData {
|
|||||||
production_price: string;
|
production_price: string;
|
||||||
yarn_ratios: { yarn_name: string; ratio: number; yarn_type: 'warp' | 'weft' | 'all' }[];
|
yarn_ratios: { yarn_name: string; ratio: number; yarn_type: 'warp' | 'weft' | 'all' }[];
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
|
product_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
@ -62,7 +64,8 @@ const defaultInitialForm: ProductFormData = {
|
|||||||
color_code: '01',
|
color_code: '01',
|
||||||
production_price: '',
|
production_price: '',
|
||||||
yarn_ratios: [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
|
yarn_ratios: [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
|
||||||
image_url: undefined
|
image_url: undefined,
|
||||||
|
product_type: 'raw_fabric'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductForm({
|
export function ProductForm({
|
||||||
@ -103,7 +106,8 @@ export function ProductForm({
|
|||||||
yarn_ratios: ratios.length > 0
|
yarn_ratios: ratios.length > 0
|
||||||
? ratios.map(r => ({ yarn_name: r.yarn_name, ratio: r.ratio, yarn_type: (r.yarn_type as 'warp' | 'weft' | 'all') || 'all' }))
|
? ratios.map(r => ({ yarn_name: r.yarn_name, ratio: r.ratio, yarn_type: (r.yarn_type as 'warp' | 'weft' | 'all') || 'all' }))
|
||||||
: [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
|
: [{ yarn_name: '', ratio: 100, yarn_type: 'all' }],
|
||||||
image_url: editingProduct.image_url || undefined
|
image_url: editingProduct.image_url || undefined,
|
||||||
|
product_type: (editingProduct as any).product_type || 'raw_fabric'
|
||||||
});
|
});
|
||||||
setPreviewUrl(editingProduct.image_url || null);
|
setPreviewUrl(editingProduct.image_url || null);
|
||||||
} else if (initialFormData) {
|
} else if (initialFormData) {
|
||||||
@ -379,25 +383,7 @@ export function ProductForm({
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 文件上传接口待后端实现
|
const publicUrl = await commonApi.uploadFile(file, 'product_images', 'products');
|
||||||
const formDataObj = new FormData();
|
|
||||||
formDataObj.append('file', file);
|
|
||||||
formDataObj.append('bucket', 'product_images');
|
|
||||||
formDataObj.append('path_prefix', 'products');
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/common/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formDataObj,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
alert('图片上传失败');
|
|
||||||
setUploading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const publicUrl = json.data?.url || '';
|
|
||||||
setPreviewUrl(publicUrl);
|
setPreviewUrl(publicUrl);
|
||||||
setFormData(prev => ({ ...prev, image_url: publicUrl }));
|
setFormData(prev => ({ ...prev, image_url: publicUrl }));
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@ -542,6 +528,23 @@ export function ProductForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 产品类型选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Package className="w-4 h-4 text-gray-400" />
|
||||||
|
产品类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.product_type || 'raw_fabric'}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, product_type: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all bg-white"
|
||||||
|
>
|
||||||
|
<option value="raw_fabric">原坯布</option>
|
||||||
|
<option value="textile">纺织成品</option>
|
||||||
|
<option value="washed">水洗成品</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 三级结构:成品名称-颜色 */}
|
{/* 三级结构:成品名称-颜色 */}
|
||||||
<div className={`grid ${isMobile ? 'grid-cols-1 gap-4' : 'grid-cols-2 gap-4'}`}>
|
<div className={`grid ${isMobile ? 'grid-cols-1 gap-4' : 'grid-cols-2 gap-4'}`}>
|
||||||
{/* 成品名称 - 带搜索建议 */}
|
{/* 成品名称 - 带搜索建议 */}
|
||||||
|
|||||||
@ -15,6 +15,12 @@ interface ProductListProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const productTypeLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
raw_fabric: { label: '原坯布', color: 'bg-gray-100 text-gray-600' },
|
||||||
|
textile: { label: '纺织成品', color: 'bg-blue-100 text-blue-600' },
|
||||||
|
washed: { label: '水洗成品', color: 'bg-green-100 text-green-600' },
|
||||||
|
};
|
||||||
|
|
||||||
export function ProductList({
|
export function ProductList({
|
||||||
products,
|
products,
|
||||||
productRatios,
|
productRatios,
|
||||||
@ -402,7 +408,14 @@ function ProductRow({ product, ratios, onViewRecords, onViewPriceHistory, onView
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 text-sm">{product.color}</p>
|
<p className="font-medium text-gray-900 text-sm">{product.color}</p>
|
||||||
<p className="text-xs text-gray-500">色号: {product.color_code}</p>
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<p className="text-xs text-gray-500">色号: {product.color_code}</p>
|
||||||
|
{(() => {
|
||||||
|
const pt = (product as any).product_type || 'raw_fabric';
|
||||||
|
const info = productTypeLabels[pt] || productTypeLabels.raw_fabric;
|
||||||
|
return <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${info.color}`}>{info.label}</span>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -469,9 +482,16 @@ function ProductCardMobile({ product, ratios, onViewRecords, onViewPriceHistory,
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 text-sm">{product.color}</p>
|
<p className="font-medium text-gray-900 text-sm">{product.color}</p>
|
||||||
<span className="px-1.5 py-0.5 bg-gray-100 rounded text-xs font-mono text-gray-600">
|
<div className="flex items-center gap-1.5">
|
||||||
{product.fabric_code}
|
<span className="px-1.5 py-0.5 bg-gray-100 rounded text-xs font-mono text-gray-600">
|
||||||
</span>
|
{product.fabric_code}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const pt = (product as any).product_type || 'raw_fabric';
|
||||||
|
const info = productTypeLabels[pt] || productTypeLabels.raw_fabric;
|
||||||
|
return <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${info.color}`}>{info.label}</span>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
@ -63,17 +63,17 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const productsRes = await purchaserApi.listProducts();
|
const productsRes = await purchaserApi.listProducts();
|
||||||
const products: Product[] = productsRes.data;
|
const products: Product[] = productsRes.data || [];
|
||||||
|
|
||||||
const recordResults = await Promise.all(
|
const recordResults = await Promise.all(
|
||||||
products.map(p =>
|
products.map(p =>
|
||||||
purchaserApi.getRecords(p.id).catch(() => [] as ProductInRecord[])
|
purchaserApi.getRecords(p.id).then(r => r || []).catch(() => [] as ProductInRecord[])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const allBatches: FabricBatch[] = [];
|
const allBatches: FabricBatch[] = [];
|
||||||
const updatedProducts: ProductWithInventory[] = products.map((p, i) => {
|
const updatedProducts: ProductWithInventory[] = products.map((p, i) => {
|
||||||
const records = recordResults[i];
|
const records = recordResults[i] || [];
|
||||||
const inRecords = records.filter(r => r.record_type !== 'out');
|
const inRecords = records.filter(r => r.record_type !== 'out');
|
||||||
const outRecords = records.filter(r => r.record_type === 'out');
|
const outRecords = records.filter(r => r.record_type === 'out');
|
||||||
|
|
||||||
|
|||||||
113
src/hooks/useWashingPlanData.ts
Normal file
113
src/hooks/useWashingPlanData.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { purchaserApi } from '../api';
|
||||||
|
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
|
||||||
|
import type { WashingPlan, Company } from '../types';
|
||||||
|
|
||||||
|
export interface WashingPlanWithProduct extends WashingPlan {
|
||||||
|
product_name?: string;
|
||||||
|
product_color?: string;
|
||||||
|
fabric_code?: string;
|
||||||
|
washing_factory_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWashingPlanDataOptions {
|
||||||
|
companyId: string | null;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WashingPlanDataState {
|
||||||
|
plans: WashingPlanWithProduct[];
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
currentPage: number;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWashingPlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UseWashingPlanDataOptions) {
|
||||||
|
const [state, setState] = useState<WashingPlanDataState>({
|
||||||
|
plans: [],
|
||||||
|
loading: true,
|
||||||
|
loadingMore: false,
|
||||||
|
hasMore: true,
|
||||||
|
currentPage: 1,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheRef = useRef<{
|
||||||
|
data: WashingPlanDataState | null;
|
||||||
|
timestamp: number;
|
||||||
|
}>({ data: null, timestamp: 0 });
|
||||||
|
|
||||||
|
const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (page: number = 1, append: boolean = false) => {
|
||||||
|
if (!companyId) {
|
||||||
|
setState(prev => ({ ...prev, loading: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirstPage = page === 1;
|
||||||
|
|
||||||
|
if (isFirstPage) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
|
||||||
|
setState(cacheRef.current.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
} else {
|
||||||
|
setState(prev => ({ ...prev, loadingMore: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await purchaserApi.listWashingPlans({ page, page_size: pageSize });
|
||||||
|
const rawData = res.data;
|
||||||
|
const washingPlans: WashingPlanWithProduct[] = Array.isArray(rawData) ? rawData : [];
|
||||||
|
|
||||||
|
setState(prev => {
|
||||||
|
const newState: WashingPlanDataState = {
|
||||||
|
plans: append ? [...prev.plans, ...washingPlans] : washingPlans,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
hasMore: res.meta?.has_more ?? washingPlans.length === pageSize,
|
||||||
|
currentPage: page,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFirstPage) {
|
||||||
|
cacheRef.current = { data: newState, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: err instanceof Error ? err : new Error('Unknown error'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [companyId, pageSize]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (state.loadingMore || !state.hasMore) return;
|
||||||
|
fetchData(state.currentPage + 1, true);
|
||||||
|
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
cacheRef.current.timestamp = 0;
|
||||||
|
fetchData(1, false);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(1, false);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loadMore,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { purchaserApi } from '../../api';
|
import { purchaserApi } from '../../api';
|
||||||
import { http } from '../../api';
|
|
||||||
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
|
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||||
@ -29,6 +28,7 @@ interface Product {
|
|||||||
production_price: number;
|
production_price: number;
|
||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
yarn_usage_per_meter: number;
|
yarn_usage_per_meter: number;
|
||||||
|
product_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 纱线配比类型
|
// 纱线配比类型
|
||||||
@ -88,47 +88,57 @@ export function NewPlan() {
|
|||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await purchaserApi.listProducts();
|
try {
|
||||||
const prodData = res.data;
|
const res = await purchaserApi.listProducts();
|
||||||
|
const prodData = res.data || [];
|
||||||
|
|
||||||
if (prodData) {
|
|
||||||
setProducts(prodData as Product[]);
|
setProducts(prodData as Product[]);
|
||||||
|
|
||||||
const productIds = prodData.map(p => p.id);
|
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
||||||
if (productIds.length > 0) {
|
prodData.forEach((p: any) => {
|
||||||
const ratiosRes = await http.get<ProductYarnRatio[]>('/purchaser/products/yarn-ratios', {
|
if (p.yarn_ratios && Array.isArray(p.yarn_ratios)) {
|
||||||
product_ids: productIds.join(',')
|
ratiosMap[p.id] = p.yarn_ratios.map((r: any, i: number) => ({
|
||||||
});
|
id: `${p.id}-${i}`,
|
||||||
|
product_id: p.id,
|
||||||
if (ratiosRes.data) {
|
yarn_name: r.yarn_name,
|
||||||
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
ratio: r.ratio,
|
||||||
ratiosRes.data.forEach((r: any) => {
|
yarn_type: r.yarn_type,
|
||||||
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
}));
|
||||||
ratiosMap[r.product_id].push(r as ProductYarnRatio);
|
|
||||||
});
|
|
||||||
setProductRatios(ratiosMap);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
setProductRatios(ratiosMap);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, { userMessage: '加载产品列表失败' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchFactories = async () => {
|
const fetchFactories = async () => {
|
||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
const data = await purchaserApi.listFactories();
|
const data = await purchaserApi.listFactories();
|
||||||
setFactories(data || []);
|
const textileFactories = (data || [])
|
||||||
|
.filter((r: any) => r.factory_type === 'textile')
|
||||||
|
.map((r: any) => ({
|
||||||
|
id: r.factory_company_id,
|
||||||
|
name: r.factory_name || r.factory_company_id,
|
||||||
|
})) as Company[];
|
||||||
|
setFactories(textileFactories);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据搜索词筛选产品
|
// 根据搜索词筛选产品(仅原坯布类型)
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return [];
|
let list = products.filter(p => !p.product_type || p.product_type === 'raw_fabric');
|
||||||
const query = searchQuery.toLowerCase();
|
if (searchQuery.trim()) {
|
||||||
return products.filter(product =>
|
const query = searchQuery.toLowerCase();
|
||||||
product.product_name?.toLowerCase().includes(query) ||
|
list = list.filter(product =>
|
||||||
product.fabric_code?.toLowerCase().includes(query) ||
|
product.product_name?.toLowerCase().includes(query) ||
|
||||||
product.color?.toLowerCase().includes(query) ||
|
product.fabric_code?.toLowerCase().includes(query) ||
|
||||||
product.color_code?.toLowerCase().includes(query)
|
product.color?.toLowerCase().includes(query) ||
|
||||||
).slice(0, 10); // 最多显示10条
|
product.color_code?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list.slice(0, 20);
|
||||||
}, [searchQuery, products]);
|
}, [searchQuery, products]);
|
||||||
|
|
||||||
// 选择产品后自动填充表单
|
// 选择产品后自动填充表单
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { purchaserApi, http } from '../../api';
|
import { purchaserApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
|
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -43,16 +43,18 @@ export function NewWashingPlan() {
|
|||||||
const res = await purchaserApi.listProducts();
|
const res = await purchaserApi.listProducts();
|
||||||
const productsData = res.data || [];
|
const productsData = res.data || [];
|
||||||
|
|
||||||
const productsWithInventory: ProductWithInventory[] = productsData.map(product => ({
|
const productsWithInventory: ProductWithInventory[] = productsData
|
||||||
...product,
|
.filter((p: any) => p.product_type === 'textile')
|
||||||
raw_fabric_meters: product.raw_fabric_meters || 0,
|
.map(product => ({
|
||||||
raw_fabric_rolls: product.raw_fabric_rolls || 0,
|
...product,
|
||||||
total_stock: product.total_stock || 0,
|
raw_fabric_meters: product.raw_fabric_meters || 0,
|
||||||
total_in_meters: 0,
|
raw_fabric_rolls: product.raw_fabric_rolls || 0,
|
||||||
total_in_rolls: 0,
|
total_stock: product.total_stock || 0,
|
||||||
total_out_meters: 0,
|
total_in_meters: 0,
|
||||||
total_out_rolls: 0
|
total_in_rolls: 0,
|
||||||
}));
|
total_out_meters: 0,
|
||||||
|
total_out_rolls: 0
|
||||||
|
}));
|
||||||
|
|
||||||
setProducts(productsWithInventory);
|
setProducts(productsWithInventory);
|
||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
@ -60,8 +62,14 @@ export function NewWashingPlan() {
|
|||||||
// 获取水洗厂列表
|
// 获取水洗厂列表
|
||||||
const fetchWashingFactories = useCallback(async () => {
|
const fetchWashingFactories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await http.get<Company[]>('/purchaser/factories', { role: 'washing' });
|
const res = await purchaserApi.listFactories();
|
||||||
setWashingFactories(res.data || []);
|
const factories = (res || [])
|
||||||
|
.filter((r: any) => r.factory_type === 'washing')
|
||||||
|
.map((r: any) => ({
|
||||||
|
id: r.factory_company_id,
|
||||||
|
name: r.factory_name || r.factory_company_id,
|
||||||
|
})) as Company[];
|
||||||
|
setWashingFactories(factories);
|
||||||
} catch {
|
} catch {
|
||||||
setWashingFactories([]);
|
setWashingFactories([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,17 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { usePlanData } from '../../hooks/usePlanData';
|
import { usePlanData } from '../../hooks/usePlanData';
|
||||||
|
import { useWashingPlanData } from '../../hooks/useWashingPlanData';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, Search, Factory, Droplets, Share2 } from 'lucide-react';
|
import { ArrowLeft, Search, Factory, Droplets, Share2 } from 'lucide-react';
|
||||||
import { PlanGroup } from '../../components/plans/PlanGroup';
|
import { PlanGroup } from '../../components/plans/PlanGroup';
|
||||||
|
import { WashingPlanCard } from '../../components/plans/WashingPlanCard';
|
||||||
import { ShareModal } from '../../components/plans/ShareModal';
|
import { ShareModal } from '../../components/plans/ShareModal';
|
||||||
import { statusMap } from '../../utils/constants';
|
import { statusMap, washingStatusMap } from '../../utils/constants';
|
||||||
import { calculateProgress } from '../../utils/helpers';
|
import { calculateProgress } from '../../utils/helpers';
|
||||||
import type { PlanWithSteps } from '../../types';
|
import type { PlanWithSteps } from '../../types';
|
||||||
|
import type { WashingPlanWithProduct } from '../../hooks/useWashingPlanData';
|
||||||
|
|
||||||
export function PlanOverview() {
|
export function PlanOverview() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -44,37 +47,65 @@ export function PlanOverview() {
|
|||||||
// 分享弹窗
|
// 分享弹窗
|
||||||
const [shareModal, setShareModal] = useState<{ planId: string; type: 'textile' | 'washing'; factoryName?: string; factoryId?: string; productName?: string; color?: string; colorCode?: string } | null>(null);
|
const [shareModal, setShareModal] = useState<{ planId: string; type: 'textile' | 'washing'; factoryName?: string; factoryId?: string; productName?: string; color?: string; colorCode?: string } | null>(null);
|
||||||
|
|
||||||
// 使用优化的数据获取 Hook
|
// 使用优化的数据获取 Hook(织布厂)
|
||||||
const {
|
const {
|
||||||
plans,
|
plans,
|
||||||
factories,
|
factories,
|
||||||
planFactories,
|
planFactories,
|
||||||
operatorNames,
|
operatorNames,
|
||||||
loading,
|
loading: textileLoading,
|
||||||
loadingMore,
|
loadingMore: textileLoadingMore,
|
||||||
hasMore,
|
hasMore: textileHasMore,
|
||||||
loadMore,
|
loadMore: textileLoadMore,
|
||||||
refetch
|
refetch: textileRefetch
|
||||||
} = usePlanData({
|
} = usePlanData({
|
||||||
companyId: auth.company?.id || null,
|
companyId: auth.company?.id || null,
|
||||||
pageSize: 20
|
pageSize: 20
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据状态过滤计划 - 必须在 getPlansForFactory 之前声明
|
// 水洗厂数据获取 Hook
|
||||||
|
const {
|
||||||
|
plans: washingPlans,
|
||||||
|
loading: washingLoading,
|
||||||
|
loadingMore: washingLoadingMore,
|
||||||
|
hasMore: washingHasMore,
|
||||||
|
loadMore: washingLoadMore,
|
||||||
|
refetch: washingRefetch
|
||||||
|
} = useWashingPlanData({
|
||||||
|
companyId: auth.company?.id || null,
|
||||||
|
pageSize: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前 tab 的加载状态
|
||||||
|
const loading = activeTab === 'textile' ? textileLoading : washingLoading;
|
||||||
|
const loadingMore = activeTab === 'textile' ? textileLoadingMore : washingLoadingMore;
|
||||||
|
const hasMore = activeTab === 'textile' ? textileHasMore : washingHasMore;
|
||||||
|
const loadMore = activeTab === 'textile' ? textileLoadMore : washingLoadMore;
|
||||||
|
|
||||||
|
// 根据状态过滤计划 - 织布厂
|
||||||
const filteredPlansByStatus = useMemo(() => {
|
const filteredPlansByStatus = useMemo(() => {
|
||||||
if (!statusFilter || statusFilter === 'all') return plans;
|
if (!statusFilter || statusFilter === 'all') return plans;
|
||||||
return plans.filter(p => p.status === statusFilter);
|
return plans.filter(p => p.status === statusFilter);
|
||||||
}, [plans, statusFilter]);
|
}, [plans, statusFilter]);
|
||||||
|
|
||||||
// 筛选工厂
|
// 根据状态过滤 - 水洗厂
|
||||||
|
const filteredWashingPlansByStatus = useMemo(() => {
|
||||||
|
if (!statusFilter || statusFilter === 'all') return washingPlans;
|
||||||
|
const statusMapping: Record<string, string> = {
|
||||||
|
producing: 'in_progress',
|
||||||
|
completed: 'completed',
|
||||||
|
};
|
||||||
|
const mappedStatus = statusMapping[statusFilter] || statusFilter;
|
||||||
|
return washingPlans.filter(p => p.status === mappedStatus);
|
||||||
|
}, [washingPlans, statusFilter]);
|
||||||
|
|
||||||
|
// 筛选工厂(仅织布厂 tab 使用)
|
||||||
const filteredFactories = useMemo(() =>
|
const filteredFactories = useMemo(() =>
|
||||||
factories.filter(f =>
|
factories.filter(f => f.role === 'textile'),
|
||||||
activeTab === 'textile' ? f.role === 'textile' : f.role === 'washing'
|
[factories]
|
||||||
),
|
|
||||||
[factories, activeTab]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取工厂的计划
|
// 获取工厂的计划(仅织布厂 tab 使用)
|
||||||
const getPlansForFactory = useCallback((factoryId: string) => {
|
const getPlansForFactory = useCallback((factoryId: string) => {
|
||||||
const planIds = planFactories
|
const planIds = planFactories
|
||||||
.filter(pf => pf.factory_id === factoryId)
|
.filter(pf => pf.factory_id === factoryId)
|
||||||
@ -82,7 +113,7 @@ export function PlanOverview() {
|
|||||||
return filteredPlansByStatus.filter(p => planIds.includes(p.id));
|
return filteredPlansByStatus.filter(p => planIds.includes(p.id));
|
||||||
}, [planFactories, filteredPlansByStatus]);
|
}, [planFactories, filteredPlansByStatus]);
|
||||||
|
|
||||||
// 搜索过滤
|
// 搜索过滤 - 织布厂
|
||||||
const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => {
|
const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => {
|
||||||
if (!searchQuery) return plans;
|
if (!searchQuery) return plans;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
@ -93,14 +124,47 @@ export function PlanOverview() {
|
|||||||
);
|
);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// 未关联的计划
|
// 搜索过滤 - 水洗厂
|
||||||
|
const filteredWashingPlans = useMemo(() => {
|
||||||
|
if (!searchQuery) return filteredWashingPlansByStatus;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return filteredWashingPlansByStatus.filter(p =>
|
||||||
|
(p.product_name || '').toLowerCase().includes(query) ||
|
||||||
|
(p.fabric_code || '').toLowerCase().includes(query) ||
|
||||||
|
p.plan_code.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [filteredWashingPlansByStatus, searchQuery]);
|
||||||
|
|
||||||
|
// 水洗计划按工厂分组
|
||||||
|
const washingPlansByFactory = useMemo(() => {
|
||||||
|
const linked: Record<string, { factoryName: string; plans: WashingPlanWithProduct[] }> = {};
|
||||||
|
const unlinked: WashingPlanWithProduct[] = [];
|
||||||
|
|
||||||
|
filteredWashingPlans.forEach(plan => {
|
||||||
|
if (plan.washing_factory_id) {
|
||||||
|
if (!linked[plan.washing_factory_id]) {
|
||||||
|
linked[plan.washing_factory_id] = {
|
||||||
|
factoryName: plan.washing_factory_name || '水洗厂',
|
||||||
|
plans: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
linked[plan.washing_factory_id].plans.push(plan);
|
||||||
|
} else {
|
||||||
|
unlinked.push(plan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { linked, unlinked };
|
||||||
|
}, [filteredWashingPlans]);
|
||||||
|
|
||||||
|
// 未关联的计划(仅织布厂 tab)
|
||||||
const unlinkedPlans = useMemo(() =>
|
const unlinkedPlans = useMemo(() =>
|
||||||
filteredPlansByStatus.filter(p =>
|
filteredPlansByStatus.filter(p =>
|
||||||
!planFactories.some(pf =>
|
!planFactories.some(pf =>
|
||||||
pf.plan_id === p.id && pf.factory_type === activeTab
|
pf.plan_id === p.id && pf.factory_type === 'textile'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
[filteredPlansByStatus, planFactories, activeTab]
|
[filteredPlansByStatus, planFactories]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 切换分组展开
|
// 切换分组展开
|
||||||
@ -128,6 +192,9 @@ export function PlanOverview() {
|
|||||||
setShareModal(null);
|
setShareModal(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 总计划数(用于空状态判断)
|
||||||
|
const totalPlans = activeTab === 'textile' ? plans.length : washingPlans.length;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
@ -201,44 +268,117 @@ export function PlanOverview() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="搜索产品名称、坯布码、计划编号..."
|
placeholder={activeTab === 'textile'
|
||||||
|
? "搜索产品名称、坯布码、计划编号..."
|
||||||
|
: "搜索产品名称、坯布码、计划编号..."
|
||||||
|
}
|
||||||
className="w-full pl-9 sm:pl-10 pr-4 py-2.5 sm:py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all text-sm sm:text-base"
|
className="w-full pl-9 sm:pl-10 pr-4 py-2.5 sm:py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all text-sm sm:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 已关联工厂的计划 */}
|
{/* ===== 织布厂 Tab 内容 ===== */}
|
||||||
{filteredFactories.length > 0 && (
|
{activeTab === 'textile' && (
|
||||||
<div className="space-y-4 mb-6">
|
<>
|
||||||
{filteredFactories.map(factory => {
|
{/* 已关联工厂的计划 */}
|
||||||
const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
|
{filteredFactories.length > 0 && (
|
||||||
if (factoryPlans.length === 0 && searchQuery) return null;
|
<div className="space-y-4 mb-6">
|
||||||
|
{filteredFactories.map(factory => {
|
||||||
|
const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
|
||||||
|
if (factoryPlans.length === 0 && searchQuery) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlanGroup
|
<PlanGroup
|
||||||
key={factory.id}
|
key={factory.id}
|
||||||
factory={factory}
|
factory={factory}
|
||||||
plans={factoryPlans}
|
plans={factoryPlans}
|
||||||
isExpanded={expandedGroups.has(factory.id)}
|
isExpanded={expandedGroups.has(factory.id)}
|
||||||
expandedPlan={expandedPlan}
|
expandedPlan={expandedPlan}
|
||||||
onToggleGroup={() => toggleGroup(factory.id)}
|
onToggleGroup={() => toggleGroup(factory.id)}
|
||||||
onTogglePlan={togglePlan}
|
onTogglePlan={togglePlan}
|
||||||
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
|
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
|
||||||
onPlanUpdated={refetch}
|
onPlanUpdated={textileRefetch}
|
||||||
operatorNames={operatorNames}
|
operatorNames={operatorNames}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 未关联工厂的计划 */}
|
||||||
|
{unlinkedPlans.length > 0 && (
|
||||||
|
<UnlinkedPlansSection
|
||||||
|
plans={filterPlansBySearch(unlinkedPlans)}
|
||||||
|
activeTab="textile"
|
||||||
|
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 未关联工厂的计划 */}
|
{/* ===== 水洗厂 Tab 内容 ===== */}
|
||||||
{unlinkedPlans.length > 0 && (
|
{activeTab === 'washing' && (
|
||||||
<UnlinkedPlansSection
|
<>
|
||||||
plans={filterPlansBySearch(unlinkedPlans)}
|
{/* 按工厂分组的水洗计划 */}
|
||||||
activeTab={activeTab}
|
{Object.entries(washingPlansByFactory.linked).map(([factoryId, group]) => (
|
||||||
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
|
<motion.div
|
||||||
/>
|
key={factoryId}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl border border-gray-200 overflow-hidden mb-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleGroup(factoryId)}
|
||||||
|
className="p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||||
|
<Droplets className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">{group.factoryName}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{group.plans.length} 个计划</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-100 p-3 sm:p-4 space-y-3">
|
||||||
|
{group.plans.map(plan => (
|
||||||
|
<WashingPlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 未关联工厂的水洗计划 */}
|
||||||
|
{washingPlansByFactory.unlinked.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-gradient-to-br from-purple-50 to-violet-50 rounded-2xl border border-purple-200 overflow-hidden mb-4 sm:mb-6"
|
||||||
|
>
|
||||||
|
<div className="p-3 sm:p-4 bg-white/50 border-b border-purple-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 sm:w-8 sm:h-8 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Droplets className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-purple-900 text-sm sm:text-base">
|
||||||
|
待关联水洗厂的计划
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-purple-600">
|
||||||
|
这些计划尚未关联水洗厂
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 sm:p-4 space-y-3">
|
||||||
|
{washingPlansByFactory.unlinked.map(plan => (
|
||||||
|
<WashingPlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载更多 */}
|
{/* 加载更多 */}
|
||||||
@ -255,18 +395,26 @@ export function PlanOverview() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
{plans.length === 0 && (
|
{totalPlans === 0 && (
|
||||||
<div className="text-center py-12 sm:py-16">
|
<div className="text-center py-12 sm:py-16">
|
||||||
<div className="w-14 h-14 sm:w-16 sm:h-16 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<div className="w-14 h-14 sm:w-16 sm:h-16 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
<Factory className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
|
{activeTab === 'textile' ? (
|
||||||
|
<Factory className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<Droplets className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-2">暂无计划</h3>
|
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-2">
|
||||||
<p className="text-sm text-gray-500 mb-4">请先创建计划</p>
|
{activeTab === 'textile' ? '暂无纺织计划' : '暂无水洗计划'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
{activeTab === 'textile' ? '请先创建纺织计划' : '请先创建水洗计划'}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/purchaser/plans/new')}
|
onClick={() => navigate(activeTab === 'textile' ? '/purchaser/plans/new' : '/purchaser/washing-plans/new')}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
创建计划
|
{activeTab === 'textile' ? '创建纺织计划' : '创建水洗计划'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -284,14 +432,14 @@ export function PlanOverview() {
|
|||||||
planProductName={shareModal.productName}
|
planProductName={shareModal.productName}
|
||||||
planColor={shareModal.color}
|
planColor={shareModal.color}
|
||||||
planColorCode={shareModal.colorCode}
|
planColorCode={shareModal.colorCode}
|
||||||
onDirectPush={() => refetch()}
|
onDirectPush={() => textileRefetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未关联计划区域组件
|
// 未关联计划区域组件(仅用于织布厂)
|
||||||
interface UnlinkedPlansSectionProps {
|
interface UnlinkedPlansSectionProps {
|
||||||
plans: PlanWithSteps[];
|
plans: PlanWithSteps[];
|
||||||
activeTab: 'textile' | 'washing';
|
activeTab: 'textile' | 'washing';
|
||||||
@ -300,7 +448,7 @@ interface UnlinkedPlansSectionProps {
|
|||||||
|
|
||||||
function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) {
|
function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) {
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
const factoryLabel = activeTab === 'textile' ? '织布厂' : '水洗厂';
|
const factoryLabel = '织布厂';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -38,6 +38,7 @@ interface ProductFormData {
|
|||||||
production_price: string;
|
production_price: string;
|
||||||
yarn_ratios: { yarn_name: string; ratio: number; yarn_type: 'warp' | 'weft' | 'all' }[];
|
yarn_ratios: { yarn_name: string; ratio: number; yarn_type: 'warp' | 'weft' | 'all' }[];
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
|
product_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboundFormData {
|
interface InboundFormData {
|
||||||
@ -103,6 +104,7 @@ export function WarehouseManage() {
|
|||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [productTypeFilter, setProductTypeFilter] = useState<string>('all');
|
||||||
|
|
||||||
// 展开状态
|
// 展开状态
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||||
@ -145,13 +147,19 @@ export function WarehouseManage() {
|
|||||||
|
|
||||||
// 筛选产品
|
// 筛选产品
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
if (!searchQuery) return products;
|
let filtered = products;
|
||||||
return products.filter(p =>
|
if (productTypeFilter !== 'all') {
|
||||||
p.product_name.includes(searchQuery) ||
|
filtered = filtered.filter(p => (p as any).product_type === productTypeFilter || (!((p as any).product_type) && productTypeFilter === 'raw_fabric'));
|
||||||
p.fabric_code.includes(searchQuery) ||
|
}
|
||||||
p.color.includes(searchQuery)
|
if (searchQuery) {
|
||||||
);
|
filtered = filtered.filter(p =>
|
||||||
}, [products, searchQuery]);
|
p.product_name.includes(searchQuery) ||
|
||||||
|
p.fabric_code.includes(searchQuery) ||
|
||||||
|
p.color.includes(searchQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [products, searchQuery, productTypeFilter]);
|
||||||
|
|
||||||
// 保存产品
|
// 保存产品
|
||||||
const handleSaveProduct = useCallback(async (formData: ProductFormData) => {
|
const handleSaveProduct = useCallback(async (formData: ProductFormData) => {
|
||||||
@ -167,7 +175,8 @@ export function WarehouseManage() {
|
|||||||
fabric_code: formData.fabric_code.toUpperCase(),
|
fabric_code: formData.fabric_code.toUpperCase(),
|
||||||
color_code: formData.color_code,
|
color_code: formData.color_code,
|
||||||
production_price: parseFloat(formData.production_price) || 0,
|
production_price: parseFloat(formData.production_price) || 0,
|
||||||
image_url: formData.image_url || null
|
image_url: formData.image_url || null,
|
||||||
|
product_type: formData.product_type || 'raw_fabric'
|
||||||
};
|
};
|
||||||
|
|
||||||
const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
|
const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
|
||||||
@ -393,6 +402,28 @@ export function WarehouseManage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 产品类型筛选 */}
|
||||||
|
<div className="flex gap-2 mb-3 overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'raw_fabric', label: '原坯布' },
|
||||||
|
{ key: 'textile', label: '纺织成品' },
|
||||||
|
{ key: 'washed', label: '水洗成品' },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setProductTypeFilter(tab.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
||||||
|
productTypeFilter === tab.key
|
||||||
|
? 'bg-blue-500 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 产品列表 */}
|
{/* 产品列表 */}
|
||||||
{filteredProducts.length === 0 ? (
|
{filteredProducts.length === 0 ? (
|
||||||
<div className="text-center py-10 sm:py-12 bg-white rounded-2xl border border-dashed border-gray-300">
|
<div className="text-center py-10 sm:py-12 bg-white rounded-2xl border border-dashed border-gray-300">
|
||||||
|
|||||||
@ -1221,6 +1221,7 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
product_name: string
|
product_name: string
|
||||||
|
product_type: string
|
||||||
production_price: number | null
|
production_price: number | null
|
||||||
raw_fabric_meters: number
|
raw_fabric_meters: number
|
||||||
raw_fabric_rolls: number
|
raw_fabric_rolls: number
|
||||||
@ -1243,6 +1244,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
image_url?: string | null
|
image_url?: string | null
|
||||||
product_name: string
|
product_name: string
|
||||||
|
product_type?: string
|
||||||
production_price?: number | null
|
production_price?: number | null
|
||||||
raw_fabric_meters?: number
|
raw_fabric_meters?: number
|
||||||
raw_fabric_rolls?: number
|
raw_fabric_rolls?: number
|
||||||
@ -1265,6 +1267,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
image_url?: string | null
|
image_url?: string | null
|
||||||
product_name?: string
|
product_name?: string
|
||||||
|
product_type?: string
|
||||||
production_price?: number | null
|
production_price?: number | null
|
||||||
raw_fabric_meters?: number
|
raw_fabric_meters?: number
|
||||||
raw_fabric_rolls?: number
|
raw_fabric_rolls?: number
|
||||||
|
|||||||
@ -78,6 +78,13 @@ export const OUTBOUND_TYPE = {
|
|||||||
|
|
||||||
// ==================== UI 状态映射 ====================
|
// ==================== UI 状态映射 ====================
|
||||||
|
|
||||||
|
// 水洗计划状态映射
|
||||||
|
export const washingStatusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||||
|
[WASHING_PLAN_STATUS.PENDING]: { label: '待处理', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
|
||||||
|
[WASHING_PLAN_STATUS.IN_PROGRESS]: { label: '处理中', color: 'text-purple-700', bgColor: 'bg-purple-50', borderColor: 'border-purple-200' },
|
||||||
|
[WASHING_PLAN_STATUS.COMPLETED]: { label: '已完成', color: 'text-emerald-700', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200' },
|
||||||
|
};
|
||||||
|
|
||||||
// 状态映射配置
|
// 状态映射配置
|
||||||
export const statusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
export const statusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||||
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
|
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user