diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..342d8d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY iloom-flatten/package.json iloom-flatten/package-lock.json ./ +RUN npm ci --legacy-peer-deps +COPY iloom-flatten/ . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY iloom-flatten/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3015 +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..4a5e4ad --- /dev/null +++ b/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 3015; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + gzip_min_length 256; + + # API 反向代理 + location /api/ { + proxy_pass http://gateway:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket 反向代理 + location /ws/ { + proxy_pass http://gateway:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/package.json b/package.json index 41ae493..d8cc369 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,6 @@ "date-fns": "^2.30.0", "framer-motion": "^11.16.1", "lucide-react": "^0.294.0", - "@supabase/supabase-js": "^2.98.0", - "base64-arraybuffer": "^1.0.2", "zustand": "^4.4.7", "immer": "^10.0.3" }, diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..eef2a3e --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,52 @@ +import { http, setAccessToken } from './client'; +import type { Profile, Company, UserRole } from '../types'; + +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + access_token: string; + refresh_token: string; + user: Profile; + company: Company | null; +} + +export interface RegisterRequest { + username: string; + password: string; + display_name: string; + company_name: string; + phone: string; + role: UserRole; +} + +export interface MeResponse { + user: Profile; + company: Company | null; +} + +export const authApi = { + login: async (data: LoginRequest) => { + const res = await http.post('/auth/login', data); + setAccessToken(res.data.access_token); + return res.data; + }, + + register: (data: RegisterRequest) => + http.post('/auth/register', data).then(r => r.data), + + logout: async () => { + await http.post('/auth/logout'); + setAccessToken(null); + }, + + refresh: async () => { + const res = await http.post<{ access_token: string }>('/auth/refresh'); + setAccessToken(res.data.access_token); + return res.data.access_token; + }, + + me: () => http.get('/auth/me').then(r => r.data), +}; diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..ac87954 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,119 @@ +export interface ApiResponse { + code: number; + message: string; + data: T; + meta?: { page: number; page_size: number; total: number; has_more: boolean }; +} + +export interface PaginationParams { + page?: number; + page_size?: number; +} + +const API_BASE = '/api/v1'; + +let accessToken: string | null = null; +let refreshPromise: Promise | null = null; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export function getAccessToken(): string | null { + return accessToken; +} + +async function refreshToken(): Promise { + try { + const res = await fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return false; + const body: ApiResponse<{ access_token: string }> = await res.json(); + if (body.code === 0 && body.data.access_token) { + accessToken = body.data.access_token; + return true; + } + return false; + } catch { + return false; + } +} + +async function request( + method: string, + path: string, + body?: unknown, + params?: Record, +): Promise> { + let url = `${API_BASE}${path}`; + + if (params) { + const qs = Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&'); + if (qs) url += `?${qs}`; + } + + const headers: Record = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + + let res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'include', + }); + + if (res.status === 401 && accessToken) { + if (!refreshPromise) refreshPromise = refreshToken(); + const ok = await refreshPromise; + refreshPromise = null; + + if (ok) { + headers['Authorization'] = `Bearer ${accessToken}`; + res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'include', + }); + } else { + accessToken = null; + window.dispatchEvent(new CustomEvent('auth:expired')); + } + } + + const json: ApiResponse = await res.json(); + if (json.code !== 0) { + throw new ApiError(json.code, json.message); + } + return json; +} + +export class ApiError extends Error { + constructor(public code: number, message: string) { + super(message); + this.name = 'ApiError'; + } +} + +export const http = { + get: (path: string, params?: Record) => + request('GET', path, undefined, params), + + post: (path: string, body?: unknown) => + request('POST', path, body), + + put: (path: string, body?: unknown) => + request('PUT', path, body), + + patch: (path: string, body?: unknown) => + request('PATCH', path, body), + + delete: (path: string) => + request('DELETE', path), +}; diff --git a/src/api/common.ts b/src/api/common.ts new file mode 100644 index 0000000..e604829 --- /dev/null +++ b/src/api/common.ts @@ -0,0 +1,49 @@ +import { http } from './client'; +import type { Company, CompanyMember, Profile } from '../types'; + +interface Notification { + id: string; + type: string; + title: string; + content: string; + is_read: boolean; + created_at: string; +} + +interface CompanyRelationship { + id: string; + company: Company; + role: string; +} + +export const commonApi = { + getCompany: (id: string) => + http.get(`/common/companies/${id}`).then(r => r.data), + + listRelationships: () => + http.get('/common/companies').then(r => r.data), + + listMembers: () => + http.get<(CompanyMember & { profile: Profile })[]>('/common/members').then(r => r.data), + + createMember: (data: { username: string; password: string; display_name: string; phone?: string }) => + http.post('/common/members', data).then(r => r.data), + + deleteMember: (id: string) => + http.delete(`/common/members/${id}`), + + listNotifications: () => + http.get('/common/notifications').then(r => r.data), + + markNotificationRead: (id: string) => + http.patch(`/common/notifications/${id}`, { is_read: true }), + + createShareLink: (data: { type: string; target_id: string }) => + http.post<{ code: string }>('/common/share-links', data).then(r => r.data), + + getShareLink: (code: string) => + http.get(`/common/share-links/${code}`).then(r => r.data), + + submitFeedback: (data: { content: string }) => + http.post('/common/feedback', data), +}; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..40f1948 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,8 @@ +export { http, setAccessToken, getAccessToken, ApiError } from './client'; +export type { ApiResponse, PaginationParams } from './client'; +export { authApi } from './auth'; +export { commonApi } from './common'; +export { purchaserApi } from './purchaser'; +export { textileApi } from './textile'; +export { washingApi } from './washing'; +export { wsClient } from './websocket'; diff --git a/src/api/purchaser.ts b/src/api/purchaser.ts new file mode 100644 index 0000000..00f69cd --- /dev/null +++ b/src/api/purchaser.ts @@ -0,0 +1,155 @@ +import { http } from './client'; +import type { PaginationParams, ApiResponse } from './client'; +import type { + ProductionPlan, + PlanFactory, + YarnRatio, + PlanProcessStep, + Product, + WashingPlan, + Company, +} from '../types'; +import type { ProductPriceHistory, ProductInRecord } from '../types'; + +// --- Plans --- + +export interface PlanDetail extends ProductionPlan { + process_steps: PlanProcessStep[]; + inventory_records: { time: string; quantity: number; rolls: number | null }[]; + price_history?: ProductPriceHistory[]; + yarn_ratios?: YarnRatio[]; + image_url?: string | null; + warp_weight?: number | null; + weft_weight?: number | null; + total_weight?: number | null; +} + +export interface PlanListResponse { + plans: PlanDetail[]; + factories: Company[]; + plan_factories: PlanFactory[]; + operator_names: Record; +} + +export interface CreatePlanRequest { + product_name: string; + fabric_code: string; + color_code: string; + color: string; + target_quantity: number; + unit_price?: number; + notes?: string; + yarn_ratios?: { yarn_name: string; ratio: number; amount_per_meter?: number; yarn_type?: string }[]; +} + +// --- Products --- + +export interface InboundRequest { + batch_no: string; + rolls: number; + meters: number; + notes?: string; + warehouse_id?: string; + plan_code?: string; + factory_id?: string; + source?: 'self' | 'textile'; + price_per_meter?: number; +} + +export interface OutboundRequest { + batch_no: string; + rolls: number; + meters: number; + outbound_type: string; + notes?: string; + washing_plan_id?: string; + recipient_company_id?: string; +} + +// --- Dashboard --- + +export interface DashboardStats { + total_plans: number; + producing_plans: number; + completed_plans: number; + total_products: number; + total_stock_meters: number; + pending_payable: number; +} + +export const purchaserApi = { + // Dashboard + dashboardStats: () => + http.get('/purchaser/dashboard/stats').then(r => r.data), + + // Plans + listPlans: (params?: PaginationParams) => + http.get('/purchaser/plans', params as Record), + + createPlan: (data: CreatePlanRequest) => + http.post('/purchaser/plans', data).then(r => r.data), + + getPlan: (id: string) => + http.get(`/purchaser/plans/${id}`).then(r => r.data), + + updatePlan: (id: string, data: Partial) => + http.put(`/purchaser/plans/${id}`, data).then(r => r.data), + + deletePlan: (id: string) => + http.delete(`/purchaser/plans/${id}`), + + assignFactory: (planId: string, data: { factory_id: string; factory_type: string }) => + http.post(`/purchaser/plans/${planId}/factories`, data).then(r => r.data), + + getPlanSteps: (planId: string) => + http.get(`/purchaser/plans/${planId}/steps`).then(r => r.data), + + getPlanInventory: (planId: string) => + http.get(`/purchaser/plans/${planId}/inventory`).then(r => r.data), + + // Products + listProducts: (params?: PaginationParams) => + http.get('/purchaser/products', params as Record), + + createProduct: (data: Record) => + http.post('/purchaser/products', data).then(r => r.data), + + updateProduct: (id: string, data: Record) => + http.put(`/purchaser/products/${id}`, data).then(r => r.data), + + deleteProduct: (id: string) => + http.delete(`/purchaser/products/${id}`), + + inbound: (productId: string, data: InboundRequest) => + http.post(`/purchaser/products/${productId}/inbound`, data).then(r => r.data), + + outbound: (productId: string, data: OutboundRequest) => + http.post(`/purchaser/products/${productId}/outbound`, data).then(r => r.data), + + getRecords: (productId: string) => + http.get(`/purchaser/products/${productId}/records`).then(r => r.data), + + updatePrice: (productId: string, data: { price: number; reason?: string }) => + http.put(`/purchaser/products/${productId}/price`, data), + + priceHistory: (productId: string) => + http.get(`/purchaser/products/${productId}/price-history`).then(r => r.data), + + // Factories + listFactories: () => + http.get('/purchaser/factories').then(r => r.data), + + // Accounts Payable + listAccountsPayable: (params?: PaginationParams) => + http.get('/purchaser/accounts-payable', params as Record), + + payAccountsPayable: (id: string, data: { amount: number; notes?: string }) => + http.post(`/purchaser/accounts-payable/${id}/pay`, data), + + // Washing Plans + createWashingPlan: (data: Partial) => + http.post('/purchaser/washing-plans', data).then(r => r.data), + + listWashingPlans: (params?: PaginationParams) => + http.get('/purchaser/washing-plans', params as Record), +}; diff --git a/src/api/textile.ts b/src/api/textile.ts new file mode 100644 index 0000000..9e500cf --- /dev/null +++ b/src/api/textile.ts @@ -0,0 +1,74 @@ +import { http } from './client'; +import type { PaginationParams } from './client'; +import type { + ProductionPlan, + PlanProcessStep, + YarnStock, + InventoryRecord, + Payment, +} from '../types'; + +// --- Plans --- + +export interface TextilePlanDetail extends ProductionPlan { + process_steps: PlanProcessStep[]; + purchaser_name: string; +} + +// --- Dashboard --- + +export interface TextileDashboardStats { + total_plans: number; + producing_plans: number; + completed_plans: number; + total_yarn_types: number; + total_inventory_meters: number; + pending_receivable: number; +} + +export const textileApi = { + // Dashboard + dashboardStats: () => + http.get('/textile/dashboard/stats').then(r => r.data), + + // Plans (read-only from textile perspective) + listPlans: (params?: PaginationParams) => + http.get('/textile/plans', params as Record), + + getPlan: (id: string) => + http.get(`/textile/plans/${id}`).then(r => r.data), + + // Process Steps + completeStep: (planId: string, stepId: string, data?: { notes?: string; operator_name?: string }) => + http.post(`/textile/plans/${planId}/steps/${stepId}/complete`, data).then(r => r.data), + + rejectStep: (planId: string, stepId: string, data: { reason: string }) => + http.post(`/textile/plans/${planId}/steps/${stepId}/reject`, data).then(r => r.data), + + // Inventory + inbound: (planId: string, data: { quantity: number; rolls?: number; notes?: string }) => + http.post(`/textile/plans/${planId}/inventory`, data).then(r => r.data), + + listInventory: (planId: string) => + http.get(`/textile/plans/${planId}/inventory`).then(r => r.data), + + // Yarn Stock + listYarnStock: (params?: PaginationParams) => + http.get('/textile/yarn-stock', params as Record), + + createYarnStock: (data: Partial) => + http.post('/textile/yarn-stock', data).then(r => r.data), + + updateYarnStock: (id: string, data: Partial) => + http.put(`/textile/yarn-stock/${id}`, data).then(r => r.data), + + addYarnRecord: (id: string, data: { change_type: string; quantity: number; notes?: string }) => + http.post(`/textile/yarn-stock/${id}/records`, data), + + // Payments + listPayments: (params?: PaginationParams) => + http.get('/textile/payments', params as Record), + + confirmPayment: (id: string) => + http.post(`/textile/payments/${id}/confirm`).then(r => r.data), +}; diff --git a/src/api/washing.ts b/src/api/washing.ts new file mode 100644 index 0000000..b1e9844 --- /dev/null +++ b/src/api/washing.ts @@ -0,0 +1,63 @@ +import { http } from './client'; +import type { PaginationParams } from './client'; +import type { + WashingPlan, + FinishedProduct, + FinishedProductInventoryRecord, + Payment, +} from '../types'; +import type { WashingCompletionFormData } from '../types'; + +// --- Dashboard --- + +export interface WashingDashboardStats { + total_plans: number; + processing_plans: number; + completed_plans: number; + total_finished_products: number; + total_stock_meters: number; + pending_receivable: number; +} + +export const washingApi = { + // Dashboard + dashboardStats: () => + http.get('/washing/dashboard/stats').then(r => r.data), + + // Plans + listPlans: (params?: PaginationParams) => + http.get('/washing/plans', params as Record), + + listPendingPlans: () => + http.get('/washing/plans/pending').then(r => r.data), + + listCompletedPlans: () => + http.get('/washing/plans/completed').then(r => r.data), + + getPlan: (id: string) => + http.get(`/washing/plans/${id}`).then(r => r.data), + + updatePlanStatus: (id: string, data: { status: string }) => + http.patch(`/washing/plans/${id}/status`, data), + + // Completions + completePlan: (planId: string, data: WashingCompletionFormData) => + http.post(`/washing/plans/${planId}/complete`, data), + + // Finished Products + listFinishedProducts: (params?: PaginationParams) => + http.get('/washing/finished-products', params as Record), + + createFinishedProduct: (data: Partial) => + http.post('/washing/finished-products', data).then(r => r.data), + + outboundFinishedProduct: (id: string, data: { rolls: number; meters: number; notes?: string }) => + http.post(`/washing/finished-products/${id}/outbound`, data).then(r => r.data), + + // Payments + listPayments: (params?: PaginationParams) => + http.get('/washing/payments', params as Record), + + confirmPayment: (id: string) => + http.post(`/washing/payments/${id}/confirm`).then(r => r.data), +}; diff --git a/src/api/websocket.ts b/src/api/websocket.ts new file mode 100644 index 0000000..ed4da65 --- /dev/null +++ b/src/api/websocket.ts @@ -0,0 +1,89 @@ +type ChangeCallback = () => void; + +interface WSEvent { + table: string; + action: 'INSERT' | 'UPDATE' | 'DELETE'; + company_id: string; +} + +type WSRole = 'purchaser' | 'textile' | 'washing'; + +class WebSocketClient { + private ws: WebSocket | null = null; + private listeners = new Map>(); + private reconnectTimer: ReturnType | null = null; + private role: WSRole | null = null; + private connected = false; + + connect(role: WSRole) { + this.role = role; + this.doConnect(); + } + + disconnect() { + this.connected = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + subscribe(table: string, callback: ChangeCallback): () => void { + if (!this.listeners.has(table)) { + this.listeners.set(table, new Set()); + } + this.listeners.get(table)!.add(callback); + return () => { + this.listeners.get(table)?.delete(callback); + if (this.listeners.get(table)?.size === 0) { + this.listeners.delete(table); + } + }; + } + + private doConnect() { + if (!this.role) return; + this.connected = true; + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${location.host}/ws/v1/${this.role}`; + + try { + this.ws = new WebSocket(url); + + this.ws.onmessage = (e) => { + try { + const event: WSEvent = JSON.parse(e.data); + this.listeners.get(event.table)?.forEach(cb => cb()); + this.listeners.get('*')?.forEach(cb => cb()); + } catch { + // ignore malformed messages + } + }; + + this.ws.onclose = () => { + if (this.connected) this.scheduleReconnect(); + }; + + this.ws.onerror = () => { + this.ws?.close(); + }; + } catch { + this.scheduleReconnect(); + } + } + + private scheduleReconnect() { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.connected) this.doConnect(); + }, 3000); + } +} + +export const wsClient = new WebSocketClient(); diff --git a/src/components/AccountProfileCard.tsx b/src/components/AccountProfileCard.tsx index 4e83e9f..ef007a2 100644 --- a/src/components/AccountProfileCard.tsx +++ b/src/components/AccountProfileCard.tsx @@ -14,7 +14,7 @@ import { Droplets } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../supabase/client'; +import { http } from '../api'; export type AccountRole = 'purchaser' | 'textile' | 'washing'; @@ -79,34 +79,30 @@ export function AccountProfileCard({ const file = e.target.files?.[0]; if (!file) return; - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; + if (!auth.user) return; try { - const fileExt = file.name.split('.').pop(); - const fileName = `${user.id}-${Date.now()}.${fileExt}`; - const arrayBuffer = await file.arrayBuffer(); + // TODO: 头像上传接口待后端实现 + const formDataObj = new FormData(); + formDataObj.append('file', file); + formDataObj.append('bucket', 'avatars'); - const { error: uploadError } = await supabase.storage - .from('avatars') - .upload(fileName, arrayBuffer, { contentType: file.type, upsert: true }); + const res = await fetch('/api/v1/common/upload', { + method: 'POST', + body: formDataObj, + }); - if (uploadError) { - alert('头像上传失败: ' + uploadError.message); + if (!res.ok) { + alert('头像上传失败'); 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); + const json = await res.json(); + const publicUrl = json.data?.url || ''; - if (updateError) { - alert('头像更新失败: ' + updateError.message); - } else { - window.location.reload(); - } + await http.patch('/auth/me', { image_url: publicUrl }); + + window.location.reload(); } catch (err) { alert('头像上传出错,请重试'); } diff --git a/src/components/CrashReporter.tsx b/src/components/CrashReporter.tsx index 1c74a44..2d77d6a 100644 --- a/src/components/CrashReporter.tsx +++ b/src/components/CrashReporter.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react'; -import { supabase } from '../supabase/client'; +import { http } from '../api'; interface CrashReport { error: Error; @@ -40,19 +40,13 @@ export function CrashReporter() { setIsSubmitting(true); try { - // 获取当前用户信息 - const { data: { user } } = await supabase.auth.getUser(); - - // 提交崩溃报告 - await supabase.from('crash_reports').insert({ + await http.post('/common/crash-reports', { error_message: crash.error.message, error_stack: crash.error.stack, component_stack: crash.errorInfo.componentStack, user_description: userDescription.trim() || null, user_agent: crash.userAgent, url: crash.url, - user_id: user?.id || null, - status: 'pending', }); setIsSubmitted(true); diff --git a/src/components/FeedbackModal.tsx b/src/components/FeedbackModal.tsx index 75feb15..aa52337 100644 --- a/src/components/FeedbackModal.tsx +++ b/src/components/FeedbackModal.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react'; -import { supabase } from '../supabase/client'; +import { commonApi } from '../api'; interface FeedbackModalProps { isOpen: boolean; @@ -29,16 +29,8 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { setIsSubmitting(true); try { - // 获取当前用户信息 - const { data: { user } } = await supabase.auth.getUser(); - - // 提交反馈到数据库 - await supabase.from('user_feedback').insert({ - type, - content: content.trim(), - contact: contact.trim() || null, - user_id: user?.id || null, - status: 'pending', + await commonApi.submitFeedback({ + content: `[${type}] ${content.trim()}${contact.trim() ? ` (联系方式: ${contact.trim()})` : ''}`, }); setIsSuccess(true); diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index 5d725b3..ea7f68f 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -1,8 +1,7 @@ import React, { useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { Camera, X, Upload, ImageIcon } from 'lucide-react'; -import { supabase } from '../supabase/client'; -import { decode } from 'base64-arraybuffer'; +import { http } from '../api'; export type UploadBucket = 'avatars' | 'product_images'; @@ -69,44 +68,29 @@ export function ImageUploader({ setUploading(true); try { - // 读取文件为 base64 - const reader = new FileReader(); - reader.onload = async (event) => { - const base64 = (event.target?.result as string).split(',')[1]; + const formDataObj = new FormData(); + formDataObj.append('file', file); + formDataObj.append('bucket', bucket); + formDataObj.append('path_prefix', pathPrefix); - // 生成唯一文件名 - const fileExt = file.name.split('.').pop(); - const fileName = bucket === 'avatars' - ? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}` - : `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`; + // TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData + const res = await fetch('/api/v1/common/upload', { + method: 'POST', + body: formDataObj, + }); - // 上传图片到 Supabase Storage - const { error } = await supabase.storage - .from(bucket) - .upload(fileName, decode(base64), { - contentType: file.type, - upsert: true - }); - - if (error) { - console.error('上传失败:', error); - alert('图片上传失败: ' + error.message); - setUploading(false); - return; - } - - // 获取图片的公开 URL - const { data: { publicUrl } } = supabase.storage - .from(bucket) - .getPublicUrl(fileName); - - setPreviewUrl(publicUrl); - onChange(publicUrl); + if (!res.ok) { + alert('图片上传失败'); setUploading(false); - }; - reader.readAsDataURL(file); + return; + } + + const json = await res.json(); + const publicUrl = json.data?.url || ''; + setPreviewUrl(publicUrl); + onChange(publicUrl); + setUploading(false); } catch (error) { - console.error('上传错误:', error); alert('图片上传失败'); setUploading(false); } diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index 5891798..245761f 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { supabase } from '../supabase/client'; +import { commonApi } from '../api'; import { useAuth } from '../contexts/AuthContext'; interface Notification { @@ -52,23 +52,15 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps useEffect(() => { if (!auth.user) return; fetchNotifications(); - const channel = subscribeToNotifications(); - return () => { - channel.unsubscribe(); - }; + const interval = setInterval(fetchNotifications, 30000); + return () => clearInterval(interval); }, [auth.user]); const fetchNotifications = async () => { - const { data } = await supabase - .from('notifications') - .select('*, sender_company_id(name)') - .eq('recipient_id', auth.user!.id) - .order('created_at', { ascending: false }) - .limit(20); - - if (data) { - const formatted = data.map(n => ({ + try { + const data = await commonApi.listNotifications(); + const formatted = (data as any[]).map((n: any) => ({ id: n.id, type: n.type as Notification['type'], title: n.title, @@ -76,52 +68,27 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps is_read: n.is_read || false, created_at: n.created_at || '', plan_id: n.plan_id, - sender_company_name: n.sender_company_id?.name + sender_company_name: n.sender_company_name })); setNotifications(formatted); setUnreadCount(formatted.filter(n => !n.is_read).length); + } catch { + // ignore } }; - const subscribeToNotifications = () => { - const channel = supabase.channel('notifications'); - - channel - .on('postgres_changes', { - event: 'INSERT', - schema: 'public', - table: 'notifications', - filter: `recipient_id=eq.${auth.user!.id}` - }, () => { - fetchNotifications(); - }) - .subscribe((status) => { - if (status === 'SUBSCRIBED') { - console.log('Successfully subscribed to notifications'); - } - }); - - return channel; - }; - const markAsRead = async (id: string) => { - await supabase - .from('notifications') - .update({ is_read: true, read_at: new Date().toISOString() }) - .eq('id', id); + await commonApi.markNotificationRead(id); - setNotifications(prev => prev.map(n => + setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n )); setUnreadCount(prev => Math.max(0, prev - 1)); }; const markAllAsRead = async () => { - await supabase - .from('notifications') - .update({ is_read: true, read_at: new Date().toISOString() }) - .eq('recipient_id', auth.user!.id) - .eq('is_read', false); + const unread = notifications.filter(n => !n.is_read); + await Promise.all(unread.map(n => commonApi.markNotificationRead(n.id))); setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); setUnreadCount(0); diff --git a/src/components/YarnAllocationModal.tsx b/src/components/YarnAllocationModal.tsx index 85a5a21..fb82c31 100644 --- a/src/components/YarnAllocationModal.tsx +++ b/src/components/YarnAllocationModal.tsx @@ -1,7 +1,7 @@ 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 { http } from '../api'; import type { YarnRatio, YarnStock } from '../types'; interface YarnAllocation { @@ -54,13 +54,12 @@ export function YarnAllocationModal({ const fetchYarnStock = async () => { setLoading(true); - const { data } = await supabase - .from('yarn_stock') - .select('*') - .eq('company_id', companyId) - .order('name'); - - setYarnStock(data || []); + try { + const res = await http.get('/textile/yarn-stock', { company_id: companyId }); + setYarnStock(res.data || []); + } catch { + setYarnStock([]); + } setLoading(false); }; @@ -186,40 +185,24 @@ export function YarnAllocationModal({ 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 http.put(`/textile/yarn-stock/${existingYarn.id}`, { quantity: newQuantity }); - // 记录出库 - await supabase.from('yarn_stock_records').insert({ - company_id: companyId, - yarn_stock_id: existingYarn.id, - plan_id: planId || null, - record_type: 'outbound', + await http.post(`/textile/yarn-stock/${existingYarn.id}/records`, { + change_type: 'outbound', quantity: manualQty, - unit: 'kg', notes: `采纱出库 - 计划: ${planId || '未知'}`, - operator_id: operatorId }); } else { alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg,库存 ${existingYarn.quantity}kg`); @@ -227,54 +210,34 @@ export function YarnAllocationModal({ 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(); + const res = await http.post('/textile/yarn-stock', { + company_id: companyId, + name: manualName, + quantity: -manualQty, + unit: 'kg', + min_stock: 0 + }); + const newYarn = res.data; - // 记录出库 if (newYarn) { - await supabase.from('yarn_stock_records').insert({ - company_id: companyId, - yarn_stock_id: newYarn.id, - plan_id: planId || null, - record_type: 'outbound', + await http.post(`/textile/yarn-stock/${newYarn.id}/records`, { + change_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 http.put(`/textile/yarn-stock/${alloc.allocatedYarn.id}`, { quantity: newQuantity }); - // 记录出库 - await supabase.from('yarn_stock_records').insert({ - company_id: companyId, - yarn_stock_id: alloc.allocatedYarn.id, - plan_id: planId || null, - record_type: 'outbound', + await http.post(`/textile/yarn-stock/${alloc.allocatedYarn.id}/records`, { + change_type: 'outbound', quantity: alloc.requiredQuantity, - unit: 'kg', notes: `采纱出库 - 计划: ${planId || '未知'}`, - operator_id: operatorId }); } } diff --git a/src/components/plans/PlanEditModal.tsx b/src/components/plans/PlanEditModal.tsx index 75abb07..a3f6a9b 100644 --- a/src/components/plans/PlanEditModal.tsx +++ b/src/components/plans/PlanEditModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; -import { supabase } from '../../supabase/client'; +import { purchaserApi } from '../../api'; import type { PlanWithSteps } from '../../types'; interface PlanEditModalProps { @@ -39,24 +39,20 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit setIsSubmitting(true); setError(''); - const { error: updateError } = await supabase - .from('production_plans') - .update({ + try { + await purchaserApi.updatePlan(plan.id, { product_name: editForm.product_name, - color: editForm.color, + color_code: editForm.color, target_quantity: editForm.target_quantity, - remark: editForm.remark || null, - updated_at: new Date().toISOString() - }) - .eq('id', plan.id); + notes: editForm.remark || undefined, + }); - setIsSubmitting(false); - - if (updateError) { - setError('更新失败: ' + updateError.message); - } else { + setIsSubmitting(false); onPlanUpdated?.(); onClose(); + } catch (err: any) { + setIsSubmitting(false); + setError('更新失败: ' + (err.message || '未知错误')); } }; @@ -69,25 +65,15 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit setIsSubmitting(true); setError(''); - // 先删除关联的 process_steps - await supabase.from('plan_process_steps').delete().eq('plan_id', plan.id); - - // 再删除 plan_factories 关联 - await supabase.from('plan_factories').delete().eq('plan_id', plan.id); - - // 最后删除计划 - const { error: deleteError } = await supabase - .from('production_plans') - .delete() - .eq('id', plan.id); + try { + await purchaserApi.deletePlan(plan.id); - setIsSubmitting(false); - - if (deleteError) { - setError('删除失败: ' + deleteError.message); - } else { + setIsSubmitting(false); onPlanUpdated?.(); onClose(); + } catch (err: any) { + setIsSubmitting(false); + setError('删除失败: ' + (err.message || '未知错误')); } }; diff --git a/src/components/plans/ShareModal.tsx b/src/components/plans/ShareModal.tsx index f9c1718..00ac76f 100644 --- a/src/components/plans/ShareModal.tsx +++ b/src/components/plans/ShareModal.tsx @@ -3,7 +3,8 @@ import { motion } from 'framer-motion'; import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import { encryptShareParams } from '../../utils/shareCrypto'; -import { supabase } from '../../supabase/client'; +import { http, commonApi } from '../../api'; +import { useAuth } from '../../contexts/AuthContext'; interface ShareModalProps { isOpen: boolean; @@ -53,6 +54,7 @@ export function ShareModal({ onDirectPush }: ShareModalProps) { const { isMobile } = useResponsive(); + const { auth } = useAuth(); const [copied, setCopied] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [recipientCompany, setRecipientCompany] = useState(''); @@ -74,74 +76,51 @@ export function ShareModal({ useEffect(() => { const fetchData = async () => { if (!isOpen || !factoryId) return; - + setIsCheckingRelationship(true); - - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { + + if (!auth.user) { setIsCheckingRelationship(false); return; } - // 获取当前用户的公司信息 - const { data: profile } = await supabase - .from('profiles') - .select('company_id') - .eq('id', user.id) - .single(); + try { + if (auth.company) { + setRecipientCompany(auth.company.name); - if (profile?.company_id) { - // 获取公司名称 - const { data: company } = await supabase - .from('companies') - .select('name') - .eq('id', profile.company_id) - .single(); - - if (company) { - setRecipientCompany(company.name); - } + const res = await http.get<{ has_relationship: boolean }>('/common/companies/check-relationship', { + factory_id: factoryId, + }); + setHasRelationship(res.data.has_relationship); - // 检查是否已有合作关系 - const { data: relationship } = await supabase - .from('company_relationships') - .select('*') - .eq('purchaser_company_id', profile.company_id) - .eq('factory_company_id', factoryId) - .eq('status', 'active') - .maybeSingle(); - - setHasRelationship(!!relationship); - - // 如果没有合作关系,检查是否已有待处理的分享链接 - if (!relationship) { - const { data: link } = await supabase - .from('share_links') - .select('*') - .eq('plan_id', planId) - .eq('factory_type', factoryType) - .in('status', ['pending', 'clicked']) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); - - if (link) { - // 检查是否过期 - const expiresAt = link.expires_at ? new Date(link.expires_at) : null; - if (expiresAt && expiresAt > new Date()) { - setExistingLink(link as ShareLinkStatus); - setShortCode(link.short_code); + if (!res.data.has_relationship) { + try { + const linkRes = await http.get('/common/share-links/active', { + plan_id: planId, + factory_type: factoryType, + }); + if (linkRes.data) { + const link = linkRes.data; + const expiresAt = link.expires_at ? new Date(link.expires_at) : null; + if (expiresAt && expiresAt > new Date()) { + setExistingLink(link); + setShortCode(link.short_code); + } + } + } catch { + // no existing link } } } + } catch { + setHasRelationship(false); } - + setIsCheckingRelationship(false); }; fetchData(); - // 清理 return () => { shortLinkGenerated.current = false; setShortCode(''); @@ -154,70 +133,47 @@ export function ShareModal({ // 生成短链(30分钟有效) const generateShortLink = useCallback(async () => { if (!factoryId) return; - + setIsGeneratingLink(true); - - const baseUrl = window.location.origin + window.location.pathname; - 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) { - // 取消该计划之前未处理的链接 - await supabase - .from('share_links') - .update({ status: 'cancelled' }) - .eq('plan_id', planId) - .eq('status', 'pending'); + try { + const res = await commonApi.createShareLink({ + type: factoryType, + target_id: planId, + }); - // 创建新链接(30分钟过期) - const { data, error } = await supabase.from('share_links').insert({ - short_code: code, - plan_id: planId, - factory_type: factoryType, - hide_sensitive: hideSensitive, - created_by: user.id, - status: 'pending', - expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟 - }).select().single(); - - if (!error && data) { - setShortCode(code); - setExistingLink(data as ShareLinkStatus); + if (res.code) { + setShortCode(res.code); } else { setError('生成链接失败,请重试'); } + } catch { + setError('生成链接失败,请重试'); } - + setIsGeneratingLink(false); }, [planId, factoryType, hideSensitive, factoryId]); - // 直接推送计划(已有合作关系) const handleDirectPush = async () => { if (!factoryId || !onDirectPush) return; - - setIsGeneratingLink(true); - - // 直接创建 plan_factories 关联 - const { error } = await supabase.from('plan_factories').insert({ - plan_id: planId, - factory_id: factoryId, - factory_type: factoryType - }); - if (!error) { + setIsGeneratingLink(true); + + try { + await http.post(`/purchaser/plans/${planId}/factories`, { + factory_id: factoryId, + factory_type: factoryType, + }); + setPushSuccess(true); setTimeout(() => { onDirectPush(); onClose(); }, 1500); - } else { + } catch { setError('推送失败,请重试'); } - + setIsGeneratingLink(false); }; @@ -265,15 +221,14 @@ export function ShareModal({ // 检查链接状态 const checkLinkStatus = async () => { if (!existingLink) return; - - const { data } = await supabase - .from('share_links') - .select('*') - .eq('id', existingLink.id) - .single(); - - if (data) { - setExistingLink(data as ShareLinkStatus); + + try { + const data = await commonApi.getShareLink(existingLink.short_code || existingLink.id); + if (data) { + setExistingLink(data as ShareLinkStatus); + } + } catch { + // ignore } }; diff --git a/src/components/warehouse/OutboundForm.tsx b/src/components/warehouse/OutboundForm.tsx index e755283..2a6444c 100644 --- a/src/components/warehouse/OutboundForm.tsx +++ b/src/components/warehouse/OutboundForm.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { X, Package, AlertCircle, Building2, Tag } from 'lucide-react'; -import { supabase } from '../../supabase/client'; +import { http, commonApi } from '../../api'; +import { purchaserApi } from '../../api'; import type { ProductWithInventory, ProductInRecord, Company } from '../../types'; interface OutboundFormProps { @@ -49,26 +50,21 @@ export function OutboundForm({ const fetchInboundBatches = async () => { if (!formData.product_id) return; setLoadingBatches(true); - const { data } = await supabase - .from('product_inventory_records') - .select('*') - .eq('product_id', formData.product_id) - .order('created_at', { ascending: false }); - - if (data) { - setInboundBatches(data); + try { + const data = await purchaserApi.getRecords(formData.product_id); + setInboundBatches(data || []); + } catch { + setInboundBatches([]); } setLoadingBatches(false); }; const fetchCompanies = async () => { - const { data } = await supabase - .from('companies') - .select('*') - .order('name', { ascending: true }); - - if (data) { - setCompanies(data); + try { + const data = await purchaserApi.listFactories(); + setCompanies(data || []); + } catch { + setCompanies([]); } }; diff --git a/src/components/warehouse/ProductForm.tsx b/src/components/warehouse/ProductForm.tsx index c5cf5fa..048dc37 100644 --- a/src/components/warehouse/ProductForm.tsx +++ b/src/components/warehouse/ProductForm.tsx @@ -2,8 +2,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; 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 { useResponsive } from '../../hooks/useResponsive'; -import { supabase } from '../../supabase/client'; -import { decode } from 'base64-arraybuffer'; +import { http } from '../../api'; import type { Product, ProductYarnRatio } from '../../types'; interface ProductFormData { @@ -138,22 +137,15 @@ export function ProductForm({ } setIsSearching(true); - setShowProductSuggestions(true); // 立即显示建议框(带加载状态) + setShowProductSuggestions(true); try { - const { data, error } = await supabase - .from('products') - .select('id, product_name, total_weight, warp_weight, weft_weight, color, fabric_code, color_code, production_price, image_url') - .eq('company_id', companyId) - .ilike('product_name', `%${query}%`) - .limit(10); + const res = await http.get('/purchaser/products', { + search: query, + page_size: 10, + }); - if (!error && data) { - setProductSuggestions(data as ProductSuggestion[]); - } else { - setProductSuggestions([]); - } - } catch (err) { - console.error('搜索产品失败:', err); + setProductSuggestions(res.data || []); + } catch { setProductSuggestions([]); } finally { setIsSearching(false); @@ -168,22 +160,18 @@ export function ProductForm({ } try { - const { data, error } = await supabase - .from('products') - .select('total_weight') - .eq('company_id', companyId) - .eq('product_name', productName) - .not('total_weight', 'is', null) - .limit(20); + const res = await http.get<{ total_weight: number | null }[]>('/purchaser/products', { + search: productName, + page_size: 20, + }); - if (!error && data) { - // 提取唯一的克重值,过滤掉 null 值 - const weights = data.map(p => p.total_weight).filter((w): w is number => w !== null && w > 0); - const uniqueWeights = Array.from(new Set(weights)); - setWeightSuggestions(uniqueWeights.sort((a, b) => a - b)); - } - } catch (err) { - console.error('搜索克重失败:', err); + const weights = (res.data || []) + .map(p => p.total_weight) + .filter((w): w is number => w !== null && w > 0); + const uniqueWeights = Array.from(new Set(weights)); + setWeightSuggestions(uniqueWeights.sort((a, b) => a - b)); + } catch { + setWeightSuggestions([]); } }, [companyId]); @@ -214,19 +202,16 @@ export function ProductForm({ if (!prefix) return ''; try { - // 查询该公司所有以该前缀开头的产品码 - const { data, error } = await supabase - .from('products') - .select('fabric_code') - .eq('company_id', companyId) - .ilike('fabric_code', `${prefix}%`); + const res = await http.get<{ fabric_code: string }[]>('/purchaser/products', { + search: prefix, + page_size: 100, + }); + const data = res.data || []; - if (error || !data || data.length === 0) { - // 如果没有匹配的产品,从1开始 + if (data.length === 0) { return `${prefix}1`; } - // 提取所有产品码中的数字,找出最大值 let maxNum = 0; data.forEach(item => { const code = item.fabric_code; @@ -239,10 +224,8 @@ export function ProductForm({ } }); - // 返回前缀 + (最大数字 + 1) return `${prefix}${maxNum + 1}`; - } catch (err) { - console.error('生成产品码失败:', err); + } catch { return `${prefix}1`; } }; @@ -254,20 +237,16 @@ export function ProductForm({ totalWeight: number | null ): Promise => { try { - // 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增) - const { data, error } = await supabase - .from('products') - .select('color_code') - .eq('company_id', companyId) - .eq('product_name', productName) - .eq('total_weight', totalWeight || 0); + const res = await http.get<{ color_code: string }[]>('/purchaser/products', { + search: productName, + page_size: 100, + }); + const data = res.data || []; - if (error || !data || data.length === 0) { - // 如果没有匹配的产品,从01开始 + if (data.length === 0) { return '01'; } - // 提取所有色号中的数字,找出最大值 let maxNum = 0; data.forEach(item => { const code = item.color_code; @@ -277,11 +256,9 @@ export function ProductForm({ } }); - // 返回最大数字 + 1,保持两位数格式 const nextNum = maxNum + 1; return nextNum < 10 ? `0${nextNum}` : String(nextNum); - } catch (err) { - console.error('生成色号失败:', err); + } catch { return '01'; } }; @@ -402,42 +379,29 @@ export function ProductForm({ setUploading(true); try { - // 读取文件为 base64 - const reader = new FileReader(); - reader.onload = async (event) => { - const base64 = (event.target?.result as string).split(',')[1]; + // TODO: 文件上传接口待后端实现 + const formDataObj = new FormData(); + formDataObj.append('file', file); + formDataObj.append('bucket', 'product_images'); + formDataObj.append('path_prefix', 'products'); - // 生成唯一文件名 - const fileExt = file.name.split('.').pop(); - const fileName = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`; - const filePath = `products/${fileName}`; + const res = await fetch('/api/v1/common/upload', { + method: 'POST', + body: formDataObj, + }); - // 上传图片到 Supabase Storage - const { data, error } = await supabase.storage - .from('product_images') - .upload(filePath, decode(base64), { - contentType: file.type, - }); - - if (error) { - console.error('上传失败:', error); - alert('图片上传失败: ' + error.message); - setUploading(false); - return; - } - - // 获取图片的公开 URL - const { data: { publicUrl } } = supabase.storage - .from('product_images') - .getPublicUrl(filePath); - - setPreviewUrl(publicUrl); - setFormData(prev => ({ ...prev, image_url: publicUrl })); + if (!res.ok) { + alert('图片上传失败'); setUploading(false); - }; - reader.readAsDataURL(file); + return; + } + + const json = await res.json(); + const publicUrl = json.data?.url || ''; + setPreviewUrl(publicUrl); + setFormData(prev => ({ ...prev, image_url: publicUrl })); + setUploading(false); } catch (error) { - console.error('上传错误:', error); alert('图片上传失败'); setUploading(false); } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 653c042..af5ae0d 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,7 +1,8 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import { supabase } from '../supabase/client'; +import { authApi } from '../api'; +import { setAccessToken } from '../api/client'; +import { wsClient } from '../api/websocket'; import type { Profile, Company, UserRole, AuthState } from '../types'; -import { encryptPhone, decryptPhone } from '../utils/crypto'; interface RegisterData { username: string; @@ -32,195 +33,92 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); const [loading, setLoading] = useState(true); - // 初始化:检查已有 session useEffect(() => { const initAuth = async () => { - const { data: { session } } = await supabase.auth.getSession(); - if (session?.user) { - const { data: profile } = await supabase - .from('profiles') - .select('*') - .eq('id', session.user.id) - .maybeSingle(); - - if (profile) { - let company: Company | null = null; - if (profile.company_id) { - const { data: comp } = await supabase - .from('companies') - .select('*') - .eq('id', profile.company_id) - .maybeSingle(); - company = comp; - } - setAuth({ - isLoggedIn: true, - user: profile, - company, - currentRole: company?.role || null - }); + try { + await authApi.refresh(); + const { user, company } = await authApi.me(); + setAuth({ + isLoggedIn: true, + user, + company, + currentRole: company?.role || null + }); + if (company?.role) { + wsClient.connect(company.role); } + } catch { + // not logged in } setLoading(false); }; initAuth(); - // 监听 auth 状态变化 - const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { - if (event === 'SIGNED_IN' && session?.user) { - setTimeout(async () => { - const { data: profile } = await supabase - .from('profiles') - .select('*') - .eq('id', session.user.id) - .maybeSingle(); - - if (profile) { - let company: Company | null = null; - if (profile.company_id) { - const { data: comp } = await supabase - .from('companies') - .select('*') - .eq('id', profile.company_id) - .maybeSingle(); - company = comp; - } - setAuth({ - isLoggedIn: true, - user: profile, - company, - currentRole: company?.role || null - }); - } - }, 0); - } else if (event === 'SIGNED_OUT') { - setAuth({ - isLoggedIn: false, - user: null, - company: null, - currentRole: null - }); - } - }); - - return () => subscription.unsubscribe(); + const handleExpired = () => { + wsClient.disconnect(); + setAuth({ + isLoggedIn: false, + user: null, + company: null, + currentRole: null + }); + }; + window.addEventListener('auth:expired', handleExpired); + return () => window.removeEventListener('auth:expired', handleExpired); }, []); const login = useCallback(async (username: string, password: string): Promise => { - const { error } = await supabase.auth.signInWithPassword({ - email: `${username}@meoo.local`, - password, - }); - if (error) { - console.error('登录失败:', error.message); + try { + const { user, company } = await authApi.login({ username, password }); + setAuth({ + isLoggedIn: true, + user, + company, + currentRole: company?.role || null + }); + if (company?.role) { + wsClient.connect(company.role); + } + return true; + } catch { return false; } - return true; }, []); const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => { try { - // 1. 创建公司 - const { data: companyData, error: companyError } = await supabase - .from('companies') - .insert({ - name: data.companyName, - role: data.role, - contact_phone: data.phone - }) - .select() - .single(); - - if (companyError || !companyData) { - console.error('创建公司失败:', companyError?.message); - return { success: false, error: '创建公司失败,请稍后重试' }; - } - - // 2. 注册用户 - const { data: authData, error: authError } = await supabase.auth.signUp({ - email: `${data.username}@meoo.local`, + const res = await authApi.register({ + username: data.username, password: data.password, + display_name: data.displayName, + company_name: data.companyName, + phone: data.phone, + role: data.role, }); - - if (authError || !authData.user) { - console.error('注册失败:', authError?.message); - // 回滚:删除创建的公司 - await supabase.from('companies').delete().eq('id', companyData.id); - - // 根据错误类型返回具体错误信息 - if (authError?.message?.includes('already registered') || authError?.message?.includes('already exists')) { - return { success: false, error: '用户名已被注册,请更换用户名' }; - } - if (authError?.message?.includes('password')) { - return { success: false, error: '密码不符合要求,请使用至少6位字符' }; - } - return { success: false, error: '注册失败:' + (authError?.message || '请检查信息后重试') }; + setAccessToken(res.access_token); + setAuth({ + isLoggedIn: true, + user: res.user, + company: res.company, + currentRole: res.company?.role || null + }); + if (res.company?.role) { + wsClient.connect(res.company.role); } - - // 3. 创建用户资料(手机号加密存储) - const { error: profileError } = await supabase - .from('profiles') - .insert({ - id: authData.user.id, - username: data.username, - display_name: data.displayName, - phone: encryptPhone(data.phone), - company_id: companyData.id, - is_master: true - }); - - if (profileError) { - console.error('创建用户资料失败:', profileError.message); - // 回滚:删除创建的公司 - await supabase.from('companies').delete().eq('id', companyData.id); - - if (profileError.message?.includes('unique constraint') || profileError.message?.includes('duplicate')) { - return { success: false, error: '用户名已被使用,请更换用户名' }; - } - return { success: false, error: '创建用户资料失败,请稍后重试' }; - } - return { success: true }; - } catch (err: any) { - console.error('注册过程出错:', err); - return { success: false, error: '注册过程出错:' + (err?.message || '请稍后重试') }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '注册失败,请稍后重试'; + return { success: false, error: message }; } }, []); const logout = useCallback(async () => { - // 清除所有 cookie - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i]; - const eqPos = cookie.indexOf('='); - const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); - // 设置过期时间为过去,删除 cookie - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`; - } - - // 清除 localStorage 和 sessionStorage 中的 Supabase 相关数据 - const storageKeys = Object.keys(localStorage); - for (const key of storageKeys) { - if (key.startsWith('sb-') || key.includes('supabase')) { - localStorage.removeItem(key); - } - } - - const sessionKeys = Object.keys(sessionStorage); - for (const key of sessionKeys) { - if (key.startsWith('sb-') || key.includes('supabase')) { - sessionStorage.removeItem(key); - } - } - - // 清除免密登录和记住密码相关存储 + try { await authApi.logout(); } catch { /* ignore */ } + wsClient.disconnect(); localStorage.removeItem('saved_username'); localStorage.removeItem('saved_password'); localStorage.removeItem('auto_login'); localStorage.removeItem('auto_login_expiry'); - - await supabase.auth.signOut(); setAuth({ isLoggedIn: false, user: null, @@ -244,4 +142,4 @@ export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within AuthProvider'); return context; -} \ No newline at end of file +} diff --git a/src/hooks/useDashboardData.ts b/src/hooks/useDashboardData.ts index 5f0d6b7..bd5e989 100644 --- a/src/hooks/useDashboardData.ts +++ b/src/hooks/useDashboardData.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { supabase } from '../supabase/client'; +import { purchaserApi, textileApi, washingApi } from '../api'; import type { ProductionPlan, WashingPlan } from '../types'; export type DashboardRole = 'purchaser' | 'textile' | 'washing'; @@ -23,10 +23,6 @@ interface DashboardDataState { rejectedPlanIds: Set; } -/** - * 统一的 Dashboard 数据获取 Hook - * 根据角色自动选择正确的数据源和过滤逻辑 - */ export function useDashboardData({ role, companyId @@ -39,134 +35,61 @@ export function useDashboardData({ rejectedPlanIds: new Set() }); - // 缓存引用,避免重复请求 const cacheRef = useRef<{ data: DashboardDataState | null; timestamp: number; }>({ data: null, timestamp: 0 }); - const CACHE_DURATION = 5000; // 5秒缓存 + const CACHE_DURATION = 5000; - // 计算统计数据 - const calculateStats = useCallback((plans: T[], roleType: DashboardRole): DashboardStats => { - const all = plans.length; - let producing = 0; - let completed = 0; - - if (roleType === 'washing') { - // 水洗厂使用 processing 状态 - producing = (plans as WashingPlan[]).filter(p => p.status === 'processing').length; - completed = (plans as WashingPlan[]).filter(p => p.status === 'completed').length; - } else { - // 采购商和纺织厂使用 producing 状态 - producing = (plans as ProductionPlan[]).filter(p => p.status === 'producing').length; - completed = (plans as ProductionPlan[]).filter(p => p.status === 'completed').length; - } - - return { all, producing, completed }; + const fetchPurchaserData = useCallback(async () => { + const [statsData, plansRes] = await Promise.all([ + purchaserApi.dashboardStats(), + purchaserApi.listPlans(), + ]); + const plans = plansRes.data.plans as unknown as T[]; + const stats: DashboardStats = { + all: statsData.total_plans, + producing: statsData.producing_plans, + completed: statsData.completed_plans, + }; + return { plans, stats, rejectedPlanIds: new Set() }; }, []); - // 获取采购商数据 - const fetchPurchaserData = useCallback(async () => { - if (!companyId) return; - - const { data: planData, error } = await supabase - .from('production_plans') - .select('*') - .eq('purchaser_id', companyId) - .order('created_at', { ascending: false }); - - if (error) throw error; - - // 获取流程步骤,检测 rejected 状态 - const planIds = (planData || []).map(p => p.id); - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('plan_id, status') - .in('plan_id', planIds); - - const rejectedIds = new Set(); - (stepsData || []).forEach(step => { - if (step.status === 'rejected') { - rejectedIds.add(step.plan_id); - } - }); - - const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[]; - const stats = calculateStats(filteredPlans, 'purchaser'); - - return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds }; - }, [companyId, calculateStats]); - - // 获取纺织厂数据 const fetchTextileData = useCallback(async () => { - if (!companyId) return; - - // 先获取关联的计划ID - const { data: planFactories } = await supabase - .from('plan_factories') - .select('plan_id') - .eq('factory_id', companyId) - .eq('factory_type', 'textile'); - - if (!planFactories || planFactories.length === 0) { - return { plans: [] as T[], stats: { all: 0, producing: 0, completed: 0 }, rejectedPlanIds: new Set() }; - } - - const planIds = planFactories.map(pf => pf.plan_id); - const { data: planData, error } = await supabase - .from('production_plans') - .select('*') - .in('id', planIds) - .order('created_at', { ascending: false }); - - if (error) throw error; - - // 获取流程步骤,检测 rejected 状态 - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('plan_id, status') - .in('plan_id', planIds); - - const rejectedIds = new Set(); - (stepsData || []).forEach(step => { - if (step.status === 'rejected') { - rejectedIds.add(step.plan_id); - } - }); - - const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[]; - const stats = calculateStats(filteredPlans, 'textile'); - - return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds }; - }, [companyId, calculateStats]); - - // 获取水洗厂数据 - const fetchWashingData = useCallback(async () => { - if (!companyId) return; - - const { data: washingPlans, error } = await supabase - .from('washing_plans') - .select('*') - .eq('washing_factory_id', companyId) - .order('created_at', { ascending: false }); - - if (error) throw error; - - const plans = (washingPlans || []) as T[]; - const stats = calculateStats(plans, 'washing'); - + const [statsData, plansRes] = await Promise.all([ + textileApi.dashboardStats(), + textileApi.listPlans(), + ]); + const plans = plansRes.data as unknown as T[]; + const stats: DashboardStats = { + all: statsData.total_plans, + producing: statsData.producing_plans, + completed: statsData.completed_plans, + }; return { plans, stats, rejectedPlanIds: new Set() }; - }, [companyId, calculateStats]); + }, []); + + const fetchWashingData = useCallback(async () => { + const [statsData, plansRes] = await Promise.all([ + washingApi.dashboardStats(), + washingApi.listPlans(), + ]); + const plans = plansRes.data as unknown as T[]; + const stats: DashboardStats = { + all: statsData.total_plans, + producing: statsData.processing_plans, + completed: statsData.completed_plans, + }; + return { plans, stats, rejectedPlanIds: new Set() }; + }, []); - // 主数据获取函数 const fetchData = useCallback(async () => { if (!companyId) { setState(prev => ({ ...prev, loading: false })); return; } - // 检查缓存 const now = Date.now(); if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) { setState(cacheRef.current.data); @@ -174,22 +97,22 @@ export function useDashboardData({ } try { - let result: DashboardDataState | undefined; + let result: { plans: T[]; stats: DashboardStats; rejectedPlanIds: Set } | undefined; switch (role) { case 'purchaser': - result = await fetchPurchaserData() as DashboardDataState | undefined; + result = await fetchPurchaserData(); break; case 'textile': - result = await fetchTextileData() as DashboardDataState | undefined; + result = await fetchTextileData(); break; case 'washing': - result = await fetchWashingData() as DashboardDataState | undefined; + result = await fetchWashingData(); break; } if (result) { - const newState = { + const newState: DashboardDataState = { plans: result.plans, stats: result.stats, loading: false, @@ -208,12 +131,10 @@ export function useDashboardData({ } }, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]); - // 初始加载和 companyId 变化时重新获取 useEffect(() => { fetchData(); }, [fetchData]); - // 提供手动刷新方法 const refresh = useCallback(() => { cacheRef.current = { data: null, timestamp: 0 }; fetchData(); diff --git a/src/hooks/useInventoryData.ts b/src/hooks/useInventoryData.ts index 92f0f48..253fdb2 100644 --- a/src/hooks/useInventoryData.ts +++ b/src/hooks/useInventoryData.ts @@ -1,14 +1,14 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { supabase } from '../supabase/client'; +import { purchaserApi } from '../api'; import { CACHE_CONFIG } from '../config/app'; import { useInventoryRealtime } from './useRealtime'; -import type { Tables } from '../supabase/types'; import type { Product, ProductWithInventory, ProductYarnRatio, FabricBatch, - ProductInRecord + ProductInRecord, + FactoryInventory, } from '../types'; interface UseInventoryDataOptions { @@ -24,32 +24,6 @@ interface InventoryDataState { error: Error | null; } -// Supabase Row 类型别名 -type InventoryRecord = Tables<'inventory_records'>; -type ProductInventoryRecord = Tables<'product_inventory_records'>; -type ProductOutboundRecord = Tables<'product_outbound_records'>; - -// 部分字段查询的局部类型(避免使用 any) -interface PlanSummary { - id: string; - fabric_code: string; - completed_quantity: number; -} - -interface PlanFactoryRef { - plan_id: string; - factory_id: string; -} - -interface CompanyRef { - id: string; - name: string; -} - -/** - * 优化的库存数据获取 Hook - * 使用缓存和防抖来减少不必要的请求 - */ export function useInventoryData({ companyId, activeType }: UseInventoryDataOptions) { const [state, setState] = useState({ products: [], @@ -76,7 +50,6 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti const cacheKey = `${companyId}-${activeType}`; const now = Date.now(); - // 检查缓存 if ( cacheRef.current.data && cacheRef.current.key === cacheKey && @@ -89,135 +62,86 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti setState(prev => ({ ...prev, loading: true, error: null })); try { - // 并行获取数据 - const [productsResult, batchesResult, outboundResult] = await Promise.all([ - // 获取产品列表 - supabase - .from('products') - .select('*') - .eq('company_id', companyId) - .order('created_at', { ascending: false }), - // 获取入库记录 - supabase - .from('product_inventory_records') - .select('*') - .eq('company_id', companyId) - .order('created_at', { ascending: false }), - // 获取出库记录 - supabase - .from('product_outbound_records') - .select('*') - .eq('company_id', companyId) - .order('created_at', { ascending: false }) - ]); + const productsRes = await purchaserApi.listProducts(); + const products: Product[] = productsRes.data; - if (productsResult.error) throw productsResult.error; - if (batchesResult.error) throw batchesResult.error; - if (outboundResult.error) throw outboundResult.error; + const recordResults = await Promise.all( + products.map(p => + purchaserApi.getRecords(p.id).catch(() => [] as ProductInRecord[]) + ) + ); - const productsData = productsResult.data || []; - const batchesData: ProductInventoryRecord[] = batchesResult.data || []; - const outboundData: ProductOutboundRecord[] = outboundResult.data || []; + const allBatches: FabricBatch[] = []; + const updatedProducts: ProductWithInventory[] = products.map((p, i) => { + const records = recordResults[i]; + const inRecords = records.filter(r => r.record_type !== 'out'); + const outRecords = records.filter(r => r.record_type === 'out'); - // 获取纱线配比 - const productIds = productsData.map(p => p.id); - let ratiosMap: Record = {}; + const totalInRolls = inRecords.reduce((sum, r) => sum + (r.rolls || 0), 0); + const totalInMeters = inRecords.reduce((sum, r) => sum + (r.meters || 0), 0); + const totalOutRolls = outRecords.reduce((sum, r) => sum + (r.rolls || 0), 0); + const totalOutMeters = outRecords.reduce((sum, r) => sum + (r.meters || 0), 0); - if (productIds.length > 0) { - const { data: ratiosData, error: ratiosError } = await supabase - .from('product_yarn_ratios') - .select('*') - .in('product_id', productIds); + const factoryInventories: Record = {}; + inRecords + .filter(r => r.source === 'textile' && r.factory_name) + .forEach(r => { + const key = r.factory_name!; + if (!factoryInventories[key]) { + factoryInventories[key] = { + factory_id: r.factory_id || 'unknown', + factory_name: key, + rolls: 0, + meters: 0, + records: [], + }; + } + factoryInventories[key].rolls += r.rolls || 0; + factoryInventories[key].meters += r.meters || 0; + factoryInventories[key].records.push(r); + }); - if (ratiosError) throw ratiosError; - - ratiosData?.forEach((r: ProductYarnRatio) => { - if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = []; - ratiosMap[r.product_id].push(r); + inRecords.forEach(r => { + allBatches.push({ + id: r.id, + product_id: r.product_id, + company_id: r.company_id || '', + batch_no: r.batch_no, + rolls: r.rolls, + meters: r.meters, + notes: r.notes, + created_at: r.created_at, + product: p, + source: r.source, + factory_name: r.factory_name, + factory_id: r.factory_id, + } as FabricBatch); }); - } - // 获取纺织厂入库数据 - const fabricCodes = productsData.map(p => p.fabric_code); - const { data: plansData } = await supabase - .from('production_plans') - .select('id, fabric_code, completed_quantity') - .eq('purchaser_id', companyId) - .in('fabric_code', fabricCodes); - - const typedPlansData: PlanSummary[] = plansData || []; - const planIds = typedPlansData.map(p => p.id).filter(Boolean); - - // 获取工厂信息 - const { data: planFactoriesData } = await supabase - .from('plan_factories') - .select('plan_id, factory_id') - .in('plan_id', planIds) - .eq('factory_type', 'textile'); - - const typedPlanFactories: PlanFactoryRef[] = planFactoriesData || []; - const factoryIds = [...new Set(typedPlanFactories.map(pf => pf.factory_id).filter(Boolean))]; - const { data: factoriesData } = await supabase - .from('companies') - .select('id, name') - .in('id', factoryIds); - - const typedFactories: CompanyRef[] = factoriesData || []; - - // 建立映射 - const planToFactoryName: Record = {}; - typedPlanFactories.forEach((pf: PlanFactoryRef) => { - const factory = typedFactories.find(f => f.id === pf.factory_id); - if (factory) { - planToFactoryName[pf.plan_id] = factory.name; - } - }); - - // 获取入库记录 - let textileInventoryRecords: InventoryRecord[] = []; - if (planIds.length > 0) { - const { data: records } = await supabase - .from('inventory_records') - .select('plan_id, quantity, rolls, warehouse_location') - .in('plan_id', planIds); - textileInventoryRecords = (records || []) as InventoryRecord[]; - } - - // 处理数据 - const updatedProducts = processInventoryData({ - products: productsData, - plansData: typedPlansData, - textileInventoryRecords, - planToFactoryName, - batchesData, - outboundData - }); - - // 为 batches 数据注入产品信息 - const batchesWithProduct: FabricBatch[] = batchesData.map((batch: ProductInventoryRecord) => { - const product = productsData.find((p: Product) => p.id === batch.product_id); return { - id: batch.id, - product_id: batch.product_id, - company_id: batch.company_id || '', - batch_no: batch.batch_no, - rolls: batch.rolls, - meters: batch.meters, - notes: batch.notes, - created_at: batch.created_at, - product: product || undefined - } as FabricBatch; + ...p, + production_price: p.production_price || 0, + raw_fabric_rolls: Math.max(0, totalInRolls - totalOutRolls), + raw_fabric_meters: Math.max(0, totalInMeters - totalOutMeters), + total_stock: Math.max(0, totalInMeters - totalOutMeters), + factory_inventories: factoryInventories, + in_records: inRecords, + out_records: outRecords, + total_in_rolls: totalInRolls, + total_in_meters: totalInMeters, + total_out_rolls: totalOutRolls, + total_out_meters: totalOutMeters, + } as ProductWithInventory; }); const newState: InventoryDataState = { products: updatedProducts, - productRatios: ratiosMap, - batches: batchesWithProduct, + productRatios: {}, + batches: allBatches, loading: false, error: null }; - // 更新缓存 cacheRef.current = { data: newState, timestamp: now, @@ -238,9 +162,7 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti fetchData(); }, [fetchData]); - // 使用统一的实时订阅管理器 useInventoryRealtime(companyId, () => { - // 清除缓存并刷新 cacheRef.current.timestamp = 0; fetchData(); }); @@ -250,189 +172,3 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti refetch: fetchData }; } - -// 处理库存数据的辅助函数 -interface ProcessInventoryDataParams { - products: Product[]; - plansData: PlanSummary[]; - textileInventoryRecords: InventoryRecord[]; - planToFactoryName: Record; - batchesData: ProductInventoryRecord[]; - outboundData: ProductOutboundRecord[]; -} - -function processInventoryData({ - products, - plansData, - textileInventoryRecords, - planToFactoryName, - batchesData, - outboundData -}: ProcessInventoryDataParams): ProductWithInventory[] { - // 建立 plan_id 到 fabric_code 的映射 - const planToFabricCode: Record = {}; - const planCompletedMap: Record = {}; - plansData.forEach((plan: PlanSummary) => { - planToFabricCode[plan.id] = plan.fabric_code; - planCompletedMap[plan.id] = plan.completed_quantity || 0; - }); - - // 按 plan_id 汇总库存 - const planInventoryMap: Record = {}; - - textileInventoryRecords.forEach((r: InventoryRecord) => { - const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂'; - const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`; - if (!planInventoryMap[r.plan_id]) { - planInventoryMap[r.plan_id] = { meters: 0, rolls: 0, factory_id: factoryId, factory_name: factoryName }; - } - planInventoryMap[r.plan_id].meters += (Number(r.quantity) || 0); - planInventoryMap[r.plan_id].rolls += (Number(r.rolls) || 0); - }); - - // 汇总每个坯布码的库存 - const fabricCodeInventory: Record = {}; - const fabricCodeFactoryInventory: Record> = {}; - - Object.entries(planInventoryMap).forEach(([planId, inventory]) => { - const fabricCode = planToFabricCode[planId]; - if (fabricCode) { - if (!fabricCodeInventory[fabricCode]) { - fabricCodeInventory[fabricCode] = { meters: 0, rolls: 0 }; - } - fabricCodeInventory[fabricCode].meters += inventory.meters || 0; - fabricCodeInventory[fabricCode].rolls += inventory.rolls || 0; - - if (!fabricCodeFactoryInventory[fabricCode]) { - fabricCodeFactoryInventory[fabricCode] = {}; - } - const factoryKey = inventory.factory_name || 'unknown'; - if (!fabricCodeFactoryInventory[fabricCode][factoryKey]) { - fabricCodeFactoryInventory[fabricCode][factoryKey] = { - factory_id: inventory.factory_id || 'unknown', - factory_name: inventory.factory_name || '纺织厂', - rolls: 0, - meters: 0, - records: [] - }; - } - fabricCodeFactoryInventory[fabricCode][factoryKey].rolls += inventory.rolls || 0; - fabricCodeFactoryInventory[fabricCode][factoryKey].meters += inventory.meters || 0; - } - }); - - // 处理入库记录 - 合并自录入和纺织厂入库 - const inRecordsMap: Record = {}; - - // 1. 先添加自录入的入库记录 - batchesData.forEach((b: ProductInventoryRecord) => { - if (!inRecordsMap[b.product_id]) { - inRecordsMap[b.product_id] = []; - } - inRecordsMap[b.product_id].push({ - id: b.id, - product_id: b.product_id, - company_id: b.company_id, - batch_no: b.batch_no, - rolls: b.rolls || 0, - meters: b.meters || 0, - notes: b.notes, - operator_id: b.operator_id, - warehouse_id: b.warehouse_id, - created_at: b.created_at, - time: b.created_at, - source: 'self' - }); - }); - - // 2. 添加纺织厂的入库记录 - // 建立 fabric_code 到 product_id 的映射 - const fabricCodeToProductId: Record = {}; - products.forEach((p: Product) => { - fabricCodeToProductId[p.fabric_code] = p.id; - }); - - // 将纺织厂入库记录按 fabric_code 关联到对应产品 - textileInventoryRecords.forEach((r: InventoryRecord) => { - const fabricCode = planToFabricCode[r.plan_id]; - const productId = fabricCodeToProductId[fabricCode]; - if (productId) { - if (!inRecordsMap[productId]) { - inRecordsMap[productId] = []; - } - const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂'; - inRecordsMap[productId].push({ - id: r.id || `${r.plan_id}-${r.created_at}`, - product_id: productId, - company_id: r.company_id, - batch_no: '-', - rolls: Number(r.rolls) || 0, - meters: Number(r.quantity) || 0, - notes: `来自${factoryName}`, - operator_id: r.operator_id, - warehouse_id: r.warehouse_id, - created_at: r.created_at, - time: r.created_at, - factory_name: factoryName, - factory_id: r.plan_id, - source: 'textile' - }); - } - }); - - // 按时间排序 - Object.keys(inRecordsMap).forEach(productId => { - inRecordsMap[productId].sort((a, b) => new Date(b.time || b.created_at).getTime() - new Date(a.time || a.created_at).getTime()); - }); - - // 处理出库记录 - 按 product_id 汇总 - const outboundMap: Record = {}; - outboundData.forEach((r: ProductOutboundRecord) => { - if (!outboundMap[r.product_id]) { - outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] }; - } - outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0; - outboundMap[r.product_id].totalMeters += Number(r.meters) || 0; - outboundMap[r.product_id].records.push(r); - }); - - // 计算每个产品的自录入库总量(从 batchesData) - const selfInventoryMap: Record = {}; - batchesData.forEach((b: ProductInventoryRecord) => { - if (!selfInventoryMap[b.product_id]) { - selfInventoryMap[b.product_id] = { rolls: 0, meters: 0 }; - } - selfInventoryMap[b.product_id].rolls += Number(b.rolls) || 0; - selfInventoryMap[b.product_id].meters += Number(b.meters) || 0; - }); - - // 更新产品数据 - 扣除出库数量 - return products.map((p: Product) => { - const textileInventory = fabricCodeInventory[p.fabric_code] || { meters: 0, rolls: 0 }; - const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {}; - // 使用实时计算的入库记录,而不是 products 表中的字段 - const selfInventory = selfInventoryMap[p.id] || { rolls: 0, meters: 0 }; - const outboundInfo = outboundMap[p.id] || { totalRolls: 0, totalMeters: 0, records: [] }; - - // 计算实际可用库存(入库 - 出库) - const totalInRolls = selfInventory.rolls + textileInventory.rolls; - const totalInMeters = selfInventory.meters + textileInventory.meters; - const availableRolls = Math.max(0, totalInRolls - outboundInfo.totalRolls); - const availableMeters = Math.max(0, totalInMeters - outboundInfo.totalMeters); - - return { - ...p, - production_price: p.production_price || 0, - raw_fabric_rolls: availableRolls, - raw_fabric_meters: availableMeters, - total_stock: availableMeters, - factory_inventories: factoryInventories, - in_records: inRecordsMap[p.id] || [], - out_records: outboundInfo.records, - total_in_rolls: totalInRolls, - total_in_meters: totalInMeters, - total_out_rolls: outboundInfo.totalRolls, - total_out_meters: outboundInfo.totalMeters - }; - }) as ProductWithInventory[]; -} diff --git a/src/hooks/usePlanData.ts b/src/hooks/usePlanData.ts index 4e81b12..db53674 100644 --- a/src/hooks/usePlanData.ts +++ b/src/hooks/usePlanData.ts @@ -1,16 +1,13 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { supabase } from '../supabase/client'; -import { statusMap } from '../utils/constants'; +import { purchaserApi } from '../api'; +import type { PlanDetail } from '../api/purchaser'; import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app'; import { usePlanRealtime } from './useRealtime'; import type { - ProductionPlan, PlanWithSteps, - PlanProcessStep, Company, PlanFactory, YarnRatio, - Product } from '../types'; interface UsePlanDataOptions { @@ -31,10 +28,15 @@ interface PlanDataState { error: Error | null; } -/** - * 优化的计划数据获取 Hook - * 支持分页加载和缓存 - */ +function mapPlanDetail(plan: PlanDetail): PlanWithSteps { + return { + ...plan, + processSteps: plan.process_steps, + inventoryRecords: plan.inventory_records, + priceHistory: plan.price_history, + }; +} + export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) { const [state, setState] = useState({ plans: [], @@ -49,7 +51,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag error: null }); - // 使用 ref 存储缓存,避免不必要的重渲染 const cacheRef = useRef<{ data: PlanDataState | null; timestamp: number; @@ -57,83 +58,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag const CACHE_DURATION = CACHE_CONFIG.planDataDuration; - // 获取操作人名称 - const fetchOperatorNames = useCallback(async (operatorIds: string[]) => { - if (operatorIds.length === 0) return {}; - - const { data: profilesData } = await supabase - .from('profiles') - .select('id, username') - .in('id', operatorIds); - - const names: Record = {}; - profilesData?.forEach(p => { - names[p.id] = p.username; - }); - return names; - }, []); - - // 处理计划数据 - const processPlansData = useCallback(( - planData: ProductionPlan[], - stepsData: PlanProcessStep[] | null, - inventoryData: any[] | null, - priceHistoryData: any[] | null, - yarnRatiosData: any[] | null, - productsData: Product[] | null - ): PlanWithSteps[] => { - return planData.map(plan => { - const inventoryRecords = (inventoryData || []) - .filter(r => r.plan_id === plan.id) - .map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls })); - - const priceHistory = (priceHistoryData || []) - .filter(h => h.plan_id === plan.id) - .map(h => ({ - id: h.id, - old_price: h.old_price, - new_price: h.new_price, - change_amount: h.change_amount, - operator_name: h.operator_name, - change_reason: h.change_reason, - created_at: h.created_at - })); - - const yarnRatios = (yarnRatiosData || []) - .filter(y => y.plan_id === plan.id) - .map(y => ({ - id: y.id, - plan_id: y.plan_id, - yarn_name: y.yarn_name, - ratio: y.ratio, - amount_per_meter: y.amount_per_meter, - total_amount: y.total_amount, - yarn_type: y.yarn_type, - created_at: y.created_at - })); - - // 查找关联的产品信息(通过fabric_code匹配) - const product = (productsData || []).find(p => - p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id - ); - - return { - ...plan, - processSteps: (stepsData || []).filter(s => s.plan_id === plan.id), - inventoryRecords, - priceHistory, - yarnRatios, - // 从产品表获取的字段 - image_url: product?.image_url || null, - warp_weight: product?.warp_weight || null, - weft_weight: product?.weft_weight || null, - total_weight: product?.total_weight || null, - weight: product?.weight || null - }; - }); - }, []); - - // 获取数据 const fetchData = useCallback(async (page: number = 1, append: boolean = false) => { if (!companyId) { setState(prev => ({ ...prev, loading: false })); @@ -141,9 +65,8 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag } const isFirstPage = page === 1; - + if (isFirstPage) { - // 检查缓存 const now = Date.now(); if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) { setState(cacheRef.current.data); @@ -155,114 +78,32 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag } try { - const offset = (page - 1) * pageSize; + const res = await purchaserApi.listPlans({ page, page_size: pageSize }); + const { plans: planDetails, factories, plan_factories, operator_names } = res.data; - // 获取计划 - const { data: planData, error: planError } = await supabase - .from('production_plans') - .select('*') - .eq('purchaser_id', companyId) - .order('created_at', { ascending: false }) - .range(offset, offset + pageSize - 1); + const plansWithSteps = planDetails.map(mapPlanDetail); - if (planError) throw planError; - - if (!planData || planData.length === 0) { - setState(prev => ({ - ...prev, - loading: false, - loadingMore: false, - hasMore: false, - plans: isFirstPage ? [] : prev.plans - })); - return; - } - - const planIds = planData.map(p => p.id); - - // 获取计划对应的采购商ID列表 - const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))]; - - // 并行获取关联数据 - const [ - stepsResult, - inventoryResult, - priceHistoryResult, - pfResult, - yarnRatiosResult, - productsResult - ] = await Promise.all([ - supabase.from('plan_process_steps').select('*').in('plan_id', planIds), - supabase - .from('inventory_records') - .select('plan_id, quantity, rolls, created_at') - .in('plan_id', planIds) - .order('created_at', { ascending: false }) - .limit(100), - supabase - .from('production_price_history') - .select('*') - .in('plan_id', planIds) - .order('created_at', { ascending: false }) - .limit(50), - supabase.from('plan_factories').select('*').in('plan_id', planIds), - supabase.from('yarn_ratios').select('*').in('plan_id', planIds), - // 获取采购商的产品信息(用于获取克重、图片等) - supabase - .from('products') - .select('*') - .in('company_id', purchaserIds) - ]); - - // 获取操作人信息 - const operatorIds = [...new Set( - (stepsResult.data || []) - .filter(s => s.operator_id) - .map(s => s.operator_id) - .filter(Boolean) - )] as string[]; - - const operatorNames = await fetchOperatorNames(operatorIds); - - // 获取工厂信息(包括所有纺织厂和水洗厂,不仅限于有关联的) - const { data: allFactories } = await supabase - .from('companies') - .select('*') - .in('role', ['textile', 'washing']); - const factories = allFactories || []; - - // 处理计划数据 - const plansWithSteps = processPlansData( - planData, - stepsResult.data, - inventoryResult.data, - priceHistoryResult.data, - yarnRatiosResult.data, - productsResult.data - ); - - // 构建纱线配比映射 const yarnRatiosMap: Record = {}; - (yarnRatiosResult.data || []).forEach((y: any) => { - if (!yarnRatiosMap[y.plan_id]) yarnRatiosMap[y.plan_id] = []; - yarnRatiosMap[y.plan_id].push(y as YarnRatio); + planDetails.forEach(p => { + if (p.yarn_ratios && p.yarn_ratios.length > 0) { + yarnRatiosMap[p.id] = p.yarn_ratios; + } }); setState(prev => { const newState: PlanDataState = { plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps, factories: isFirstPage ? factories : prev.factories, - planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories, - operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames }, + planFactories: isFirstPage ? plan_factories : prev.planFactories, + operatorNames: isFirstPage ? operator_names : { ...prev.operatorNames, ...operator_names }, yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap }, loading: false, loadingMore: false, - hasMore: planData.length === pageSize, + hasMore: res.meta?.has_more ?? planDetails.length === pageSize, currentPage: page, error: null }; - // 更新缓存 if (isFirstPage) { cacheRef.current = { data: newState, timestamp: Date.now() }; } @@ -277,26 +118,22 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag error: err instanceof Error ? err : new Error('Unknown error') })); } - }, [companyId, pageSize, fetchOperatorNames, processPlansData]); + }, [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]); - // 使用统一的实时订阅管理器 usePlanRealtime(companyId, () => { cacheRef.current.timestamp = 0; fetchData(1, false); diff --git a/src/hooks/usePlanStatusSync.ts b/src/hooks/usePlanStatusSync.ts index ea3737d..d137ee3 100644 --- a/src/hooks/usePlanStatusSync.ts +++ b/src/hooks/usePlanStatusSync.ts @@ -1,120 +1,20 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { supabase } from '../supabase/client'; -import { SYNC_CONFIG } from '../config/app'; -import { PLAN_STATUS, STEP_STATUS } from '../utils/constants'; +import { useEffect, useCallback } from 'react'; +import { wsClient } from '../api/websocket'; -interface PlanStatusCheckResult { - planId: string; - planCode: string; - stepStatus: string; - planStatus: string; - needsFix: boolean; -} - -/** - * 检查计划状态是否一致 - * 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复 - */ export function usePlanStatusSync(companyId: string | undefined) { - const intervalRef = useRef | null>(null); - const isCheckingRef = useRef(false); - const checkAndFixPlanStatus = useCallback(async () => { - if (!companyId || isCheckingRef.current) return; - - isCheckingRef.current = true; - - try { - // 获取当前纺织厂关联的所有计划 - const { data: planFactories } = await supabase - .from('plan_factories') - .select('plan_id') - .eq('factory_id', companyId) - .eq('factory_type', 'textile'); - - if (!planFactories || planFactories.length === 0) { - isCheckingRef.current = false; - return; - } - - const planIds = planFactories.map(pf => pf.plan_id); - - // 获取这些计划的流程步骤状态 - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('plan_id, step_type, status') - .in('plan_id', planIds) - .eq('step_type', 'confirm'); - - // 获取计划当前状态 - const { data: plansData } = await supabase - .from('production_plans') - .select('id, plan_code, status') - .in('id', planIds); - - if (!stepsData || !plansData) { - isCheckingRef.current = false; - return; - } - - // 检查状态不一致的计划 - const inconsistentPlans: PlanStatusCheckResult[] = []; - - for (const plan of plansData) { - const confirmStep = stepsData.find(s => s.plan_id === plan.id); - - // 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复 - if (confirmStep?.status === STEP_STATUS.COMPLETED && plan.status === PLAN_STATUS.PENDING) { - inconsistentPlans.push({ - planId: plan.id, - planCode: plan.plan_code, - stepStatus: confirmStep.status, - planStatus: plan.status, - needsFix: true - }); - } - } - - // 自动修复状态不一致的计划 - if (inconsistentPlans.length > 0) { - console.log(`[PlanStatusSync] 发现 ${inconsistentPlans.length} 个状态不一致的计划:`, inconsistentPlans); - - for (const item of inconsistentPlans) { - const { error } = await supabase - .from('production_plans') - .update({ status: PLAN_STATUS.PRODUCING }) - .eq('id', item.planId); - - if (error) { - console.error(`[PlanStatusSync] 修复计划 ${item.planCode} 失败:`, error); - } else { - console.log(`[PlanStatusSync] 已自动修复计划 ${item.planCode}: pending → producing`); - } - } - } - } catch (err) { - console.error('[PlanStatusSync] 检查失败:', err); - } finally { - isCheckingRef.current = false; - } - }, [companyId]); + // Server handles plan status sync and pushes updates via WebSocket + }, []); useEffect(() => { if (!companyId) return; - // 立即执行一次检查 - checkAndFixPlanStatus(); + const unsub = wsClient.subscribe('production_plans', () => { + // Server pushes status corrections; UI will refresh via other hooks + }); - // 同步间隔由配置中心管理(开发环境10秒,生产环境5分钟) - intervalRef.current = setInterval(checkAndFixPlanStatus, SYNC_CONFIG.planStatusSyncInterval); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [companyId, checkAndFixPlanStatus]); + return unsub; + }, [companyId]); return { checkAndFixPlanStatus }; } diff --git a/src/hooks/useRealtime.ts b/src/hooks/useRealtime.ts index bfe47ba..d0b8dc9 100644 --- a/src/hooks/useRealtime.ts +++ b/src/hooks/useRealtime.ts @@ -1,105 +1,14 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { supabase } from '../supabase/client'; +import { useEffect, useRef } from 'react'; +import { wsClient } from '../api/websocket'; -// 订阅配置类型 interface SubscriptionConfig { table: string; event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE'; filter?: string; } -// 回调函数类型 type ChangeCallback = () => void; -// 全局订阅管理器(单例模式) -class RealtimeSubscriptionManager { - private static instance: RealtimeSubscriptionManager; - private subscriptions: Map = new Map(); - private callbacks: Map> = new Map(); - - static getInstance(): RealtimeSubscriptionManager { - if (!RealtimeSubscriptionManager.instance) { - RealtimeSubscriptionManager.instance = new RealtimeSubscriptionManager(); - } - return RealtimeSubscriptionManager.instance; - } - - // 生成订阅唯一键 - private getKey(config: SubscriptionConfig): string { - return `${config.table}:${config.event || '*'}:${config.filter || 'all'}`; - } - - // 订阅 - subscribe(config: SubscriptionConfig, callback: ChangeCallback): () => void { - const key = this.getKey(config); - - // 添加回调 - if (!this.callbacks.has(key)) { - this.callbacks.set(key, new Set()); - } - this.callbacks.get(key)!.add(callback); - - // 如果该订阅已存在,增加引用计数 - if (this.subscriptions.has(key)) { - const sub = this.subscriptions.get(key)!; - sub.count++; - return () => this.unsubscribe(key, callback); - } - - // 创建新订阅 - const channel = supabase - .channel(`realtime_${key}`) - .on( - 'postgres_changes', - { - event: config.event || '*', - schema: 'public', - table: config.table, - filter: config.filter - }, - () => { - // 触发所有回调 - this.callbacks.get(key)?.forEach(cb => cb()); - } - ) - .subscribe(); - - this.subscriptions.set(key, { count: 1, channel }); - - // 返回取消订阅函数 - return () => this.unsubscribe(key, callback); - } - - // 取消订阅 - private unsubscribe(key: string, callback: ChangeCallback): void { - // 移除回调 - this.callbacks.get(key)?.delete(callback); - - // 减少引用计数 - const sub = this.subscriptions.get(key); - if (sub) { - sub.count--; - - // 如果没有引用,取消订阅 - if (sub.count <= 0) { - sub.channel.unsubscribe(); - this.subscriptions.delete(key); - this.callbacks.delete(key); - } - } - } -} - -// 全局实例 -const subscriptionManager = RealtimeSubscriptionManager.getInstance(); - -/** - * 统一的实时订阅 Hook - * 自动管理订阅生命周期,避免重复订阅 - * @param config 订阅配置 - * @param callback 数据变化回调 - * @param enabled 是否启用订阅(默认 true) - */ export function useRealtime( config: SubscriptionConfig, callback: ChangeCallback, @@ -107,7 +16,6 @@ export function useRealtime( ): void { const callbackRef = useRef(callback); - // 保持回调引用最新 useEffect(() => { callbackRef.current = callback; }, [callback]); @@ -115,30 +23,22 @@ export function useRealtime( useEffect(() => { if (!enabled) return; - // 包装回调,确保使用最新引用 const wrappedCallback = () => { callbackRef.current(); }; - // 订阅 - const unsubscribe = subscriptionManager.subscribe(config, wrappedCallback); + const unsubscribe = wsClient.subscribe(config.table, wrappedCallback); - // 清理 return () => { unsubscribe(); }; }, [config.table, config.event, config.filter, enabled]); } -/** - * 库存数据实时订阅 Hook - * 合并 inventory_records 和 product_inventory_records 订阅 - */ export function useInventoryRealtime( companyId: string | null, onChange: ChangeCallback ): void { - // 使用 ref 保持回调稳定 const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; @@ -149,17 +49,8 @@ export function useInventoryRealtime( const wrappedCallback = () => onChangeRef.current(); - // 订阅 inventory_records 变化 - const unsub1 = subscriptionManager.subscribe( - { table: 'inventory_records', event: '*' }, - wrappedCallback - ); - - // 订阅 product_inventory_records 变化 - const unsub2 = subscriptionManager.subscribe( - { table: 'product_inventory_records', event: '*' }, - wrappedCallback - ); + const unsub1 = wsClient.subscribe('inventory_records', wrappedCallback); + const unsub2 = wsClient.subscribe('product_inventory_records', wrappedCallback); return () => { unsub1(); @@ -168,9 +59,6 @@ export function useInventoryRealtime( }, [companyId]); } -/** - * 计划数据实时订阅 Hook - */ export function usePlanRealtime( companyId: string | null, onChange: ChangeCallback @@ -185,11 +73,7 @@ export function usePlanRealtime( const wrappedCallback = () => onChangeRef.current(); - // 订阅 inventory_records 变化(影响计划完成进度) - const unsub = subscriptionManager.subscribe( - { table: 'inventory_records', event: '*' }, - wrappedCallback - ); + const unsub = wsClient.subscribe('inventory_records', wrappedCallback); return () => { unsub(); @@ -197,16 +81,6 @@ export function usePlanRealtime( }, [companyId]); } -/** - * 手动触发刷新(用于需要主动刷新的场景) - */ -export function triggerRealtimeRefresh(table: string): void { - // 触发该表的所有订阅回调 - const manager = RealtimeSubscriptionManager.getInstance(); - // 遍历所有订阅,找到匹配的表 - manager['subscriptions'].forEach((_, key) => { - if (key.startsWith(`${table}:`)) { - manager['callbacks'].get(key)?.forEach(cb => cb()); - } - }); +export function triggerRealtimeRefresh(_table: string): void { + // no-op: server pushes changes via WebSocket } diff --git a/src/pages/ImportPlanPage.tsx b/src/pages/ImportPlanPage.tsx index 77db240..7a53228 100644 --- a/src/pages/ImportPlanPage.tsx +++ b/src/pages/ImportPlanPage.tsx @@ -1,13 +1,13 @@ import React, { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../supabase/client'; +import { http } from '../api'; import { motion } from 'framer-motion'; import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react'; import { decryptShareParams } from '../utils/shareCrypto'; import { useResponsive } from '../hooks/useResponsive'; import { useErrorHandler } from '../hooks/useErrorHandler'; -import { PLAN_STATUS, SHARE_LINK_STATUS } from '../utils/constants'; +import { SHARE_LINK_STATUS } from '../utils/constants'; import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types'; const containerVariants = { @@ -50,68 +50,52 @@ export function ImportPlanPage() { useEffect(() => { const resolveLink = async () => { if (shortCode) { - // 短链解析 - const { data, error } = await supabase - .from('share_links') - .select('*') - .eq('short_code', shortCode) - .maybeSingle(); + try { + const res = await http.get(`/common/share-links/${shortCode}`); + const data = res.data; - if (data) { - // 检查链接状态 - setShareLinkStatus(data.status as ShareLinkStatus); + if (data) { + setShareLinkStatus(data.status as ShareLinkStatus); - // 检查是否过期 - const expiresAt = data.expires_at ? new Date(data.expires_at) : null; - const now = new Date(); - if (expiresAt && expiresAt < now) { - setLinkExpired(true); - // 更新状态为过期 - await supabase - .from('share_links') - .update({ status: SHARE_LINK_STATUS.EXPIRED }) - .eq('short_code', shortCode); - } - - // 检查是否已被使用(confirmed/rejected/expired) - const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus]; - if (usedStatuses.includes(data.status as ShareLinkStatus)) { - setDecrypting(false); - return; - } - - setPlanId(data.plan_id); - setFactoryType(data.factory_type as FactoryType); - setHideSensitive(data.hide_sensitive || false); - - // 如果状态是 pending,更新为 clicked(首次点击) - if (data.status === SHARE_LINK_STATUS.PENDING) { - const { error: updateError } = await supabase - .from('share_links') - .update({ - status: SHARE_LINK_STATUS.CLICKED, - clicked_at: new Date().toISOString() - }) - .eq('short_code', shortCode); - - if (updateError) { - handleError(updateError, { userMessage: '更新链接点击状态失败', context: { shortCode } }); - } else { - setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus); + const expiresAt = data.expires_at ? new Date(data.expires_at) : null; + const now = new Date(); + if (expiresAt && expiresAt < now) { + setLinkExpired(true); + await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED }); } - setLinkClicked(true); - } else if (data.status === SHARE_LINK_STATUS.CLICKED) { - setLinkClicked(true); + + const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus]; + if (usedStatuses.includes(data.status as ShareLinkStatus)) { + setDecrypting(false); + return; + } + + setPlanId(data.plan_id); + setFactoryType(data.factory_type as FactoryType); + setHideSensitive(data.hide_sensitive || false); + + if (data.status === SHARE_LINK_STATUS.PENDING) { + try { + await http.put(`/common/share-links/${shortCode}`, { + status: SHARE_LINK_STATUS.CLICKED, + clicked_at: new Date().toISOString() + }); + setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus); + } catch (err) { + handleError(err, { userMessage: '更新链接点击状态失败', context: { shortCode } }); + } + setLinkClicked(true); + } else if (data.status === SHARE_LINK_STATUS.CLICKED) { + setLinkClicked(true); + } + + await http.post(`/common/share-links/${shortCode}/click`).catch(() => {}); } - - // 增加点击计数 - await supabase.rpc('increment_share_link_clicks', { code: shortCode }); - } else { - handleError(error, { userMessage: '短链查询失败', context: { shortCode } }); + } catch (err) { + handleError(err, { userMessage: '短链查询失败', context: { shortCode } }); } setDecrypting(false); } else if (token) { - // 长链接解析(异步,带签名验证) const decrypted = await decryptShareParams(token); if (decrypted) { setPlanId(decrypted.planId); @@ -132,40 +116,37 @@ export function ImportPlanPage() { // 倒计时显示 useEffect(() => { if (!shortCode || !shareLinkStatus) return; - - const updateTimer = async () => { - const { data } = await supabase - .from('share_links') - .select('expires_at, status') - .eq('short_code', shortCode) - .single(); - - if (!data || !data.expires_at) return; - const expiresAt = new Date(data.expires_at); - const now = new Date(); - const diff = expiresAt.getTime() - now.getTime(); - - if (diff <= 0) { - setTimeRemaining('已过期'); - setLinkExpired(true); - if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) { - await supabase - .from('share_links') - .update({ status: SHARE_LINK_STATUS.EXPIRED }) - .eq('short_code', shortCode); - setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus); + const updateTimer = async () => { + try { + const res = await http.get(`/common/share-links/${shortCode}`); + const data = res.data; + if (!data || !data.expires_at) return; + + const expiresAt = new Date(data.expires_at); + const now = new Date(); + const diff = expiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + setTimeRemaining('已过期'); + setLinkExpired(true); + if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) { + await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED }); + setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus); + } + } else { + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + setTimeRemaining(`${minutes}分${seconds}秒`); } - } else { - const minutes = Math.floor(diff / 60000); - const seconds = Math.floor((diff % 60000) / 1000); - setTimeRemaining(`${minutes}分${seconds}秒`); + } catch { + // ignore timer errors } }; - + updateTimer(); const timer = setInterval(updateTimer, 1000); - + return () => clearInterval(timer); }, [shortCode, shareLinkStatus]); @@ -185,40 +166,42 @@ export function ImportPlanPage() { }, [planId]); const fetchPlanData = async () => { - // 首先尝试从 production_plans 查询 - const { data: planData } = await supabase.from('production_plans').select('*').eq('id', planId).maybeSingle(); + try { + const planRes = await http.get(`/common/plans/${planId}`); + const planData = planRes.data; - if (planData) { - setPlan(planData); - setIsWashingPlan(false); - setPurchaserCompanyId(planData.purchaser_id); + if (planData && planData.type === 'production') { + setPlan(planData); + setIsWashingPlan(false); + setPurchaserCompanyId(planData.purchaser_id); - const { data: yarnData } = await supabase.from('yarn_ratios').select('*').eq('plan_id', planId); - setYarns(yarnData || []); - - const { data: companyData } = await supabase.from('companies').select('name').eq('id', planData.purchaser_id).maybeSingle(); - setPurchaserName(companyData?.name || '未知'); - - if (auth.company) { - const { data: existing } = await supabase.from('plan_factories').select('id').eq('plan_id', planId).eq('factory_id', auth.company.id).maybeSingle(); - setAlreadyImported(!!existing); - } - } else { - // 尝试从 washing_plans 查询 - const { data: washingPlanData } = await supabase.from('washing_plans').select('*').eq('id', planId).maybeSingle(); - if (washingPlanData) { - setPlan(washingPlanData); - setIsWashingPlan(true); - setPurchaserCompanyId(washingPlanData.company_id); - - const { data: companyData } = await supabase.from('companies').select('name').eq('id', washingPlanData.company_id).maybeSingle(); - setPurchaserName(companyData?.name || '未知'); + setYarns(planData.yarn_ratios || []); + setPurchaserName(planData.purchaser_name || '未知'); if (auth.company) { - const { data: existing } = await supabase.from('washing_plans').select('id').eq('id', planId).eq('washing_factory_id', auth.company.id).maybeSingle(); - setAlreadyImported(!!existing); + setAlreadyImported(!!planData.already_imported); + } + } else if (planData && planData.type === 'washing') { + setPlan(planData); + setIsWashingPlan(true); + setPurchaserCompanyId(planData.company_id); + setPurchaserName(planData.purchaser_name || '未知'); + + if (auth.company) { + setAlreadyImported(!!planData.already_imported); + } + } else if (planData) { + setPlan(planData); + setIsWashingPlan(false); + setPurchaserCompanyId(planData.purchaser_id || planData.company_id); + setPurchaserName(planData.purchaser_name || '未知'); + setYarns(planData.yarn_ratios || []); + if (auth.company) { + setAlreadyImported(!!planData.already_imported); } } + } catch { + // plan not found } setLoading(false); }; @@ -228,92 +211,42 @@ export function ImportPlanPage() { if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return; setImporting(true); - // 1. 创建 plan_factories 关联 - if (isWashingPlan) { - const { error } = await supabase.from('washing_plans').update({ - washing_factory_id: auth.company.id - }).eq('id', planId); - if (error) { - console.error('导入失败:', error); - setImporting(false); - return; - } - } else { - const { error } = await supabase.from('plan_factories').insert({ - plan_id: planId, + try { + await http.post(`/common/plans/${planId}/confirm`, { factory_id: auth.company.id, - factory_type: factoryType + factory_type: factoryType, + is_washing_plan: isWashingPlan, + purchaser_company_id: purchaserCompanyId, + short_code: shortCode || undefined }); - if (error) { - console.error('导入失败:', error); - setImporting(false); - return; - } + + setImportSuccess(true); + setImporting(false); + setTimeout(() => { navigate(`/${auth.currentRole}/plans`); }, 2000); + } catch (err) { + handleError(err, { userMessage: '确认失败' }); + setImporting(false); } - - // 1.5 更新计划状态为 producing(生产中) - const { error: planUpdateError } = await supabase - .from('production_plans') - .update({ status: PLAN_STATUS.PRODUCING }) - .eq('id', planId); - - if (planUpdateError) { - handleError(planUpdateError, { userMessage: '更新计划状态失败', context: { planId } }); - } - - // 2. 创建/更新公司合作关系 - const { error: relError } = await supabase.from('company_relationships').upsert({ - purchaser_company_id: purchaserCompanyId, - factory_company_id: auth.company.id, - factory_type: factoryType, - status: 'active' - }, { - onConflict: 'purchaser_company_id,factory_company_id' - }); - - if (relError) { - handleError(relError, { userMessage: '创建合作关系失败', context: { purchaserCompanyId, factoryCompanyId: auth.company.id } }); - } - - // 3. 更新分享链接状态 - if (shortCode) { - const { error: linkUpdateError } = await supabase - .from('share_links') - .update({ - status: SHARE_LINK_STATUS.CONFIRMED, - responded_at: new Date().toISOString(), - response_result: 'confirmed' - }) - .eq('short_code', shortCode); - - if (linkUpdateError) { - handleError(linkUpdateError, { userMessage: '更新分享链接状态失败', context: { shortCode } }); - } - } - - setImportSuccess(true); - setImporting(false); - setTimeout(() => { navigate(`/${auth.currentRole}/plans`); }, 2000); }; // 拒绝计划 const handleReject = async () => { if (!shortCode) return; - + setImporting(true); - - await supabase - .from('share_links') - .update({ + + try { + await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.REJECTED, responded_at: new Date().toISOString(), response_result: 'rejected', reject_reason: rejectReason - }) - .eq('short_code', shortCode); - + }); + } catch { + // ignore + } + setImporting(false); - // 显示拒绝成功,返回首页 setTimeout(() => { navigate('/role-select'); }, 2000); }; diff --git a/src/pages/MemberManage.tsx b/src/pages/MemberManage.tsx index 69ff624..a66951d 100644 --- a/src/pages/MemberManage.tsx +++ b/src/pages/MemberManage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../supabase/client'; +import { commonApi, http } from '../api'; import { motion, AnimatePresence } from 'framer-motion'; import { Users, ArrowLeft, Plus, X, User, Phone, Shield, Trash2, AlertCircle, Edit2, Building2, BookOpen, MessageSquare, Info } from 'lucide-react'; import { useResponsive } from '../hooks/useResponsive'; @@ -70,39 +70,31 @@ export function MemberManage() { const fetchCompanyInfo = async () => { if (!auth.user?.company_id) return; - const { data } = await supabase - .from('companies') - .select('name, contact_phone, role') - .eq('id', auth.user.company_id) - .maybeSingle(); - if (data) { - setCompanyInfo(data); + try { + const data = await commonApi.getCompany(auth.user.company_id); + if (data) { + setCompanyInfo(data as any); + } + } catch { + // ignore } }; const fetchMembers = async () => { setLoading(true); try { - // 获取当前公司的所有子账号(非主账号) - const companyId = auth.user?.company_id; - if (!companyId) { - setLoading(false); - return; - } - const { data, error } = await supabase - .from('profiles') - .select('id, username, phone, created_at') - .eq('company_id', companyId) - .eq('is_master', false) - .order('created_at', { ascending: false }); - - if (error) { - console.error('获取成员列表失败:', error); - } else { - setMembers(data || []); - } - } catch (err) { - console.error('获取成员列表出错:', err); + const data = await commonApi.listMembers(); + const subMembers = (data || []) + .filter((m: any) => !m.profile?.is_master) + .map((m: any) => ({ + id: m.profile?.id || m.id, + username: m.profile?.username || '', + phone: m.profile?.phone || null, + created_at: m.profile?.created_at || m.created_at + })); + setMembers(subMembers); + } catch { + setMembers([]); } setLoading(false); }; @@ -146,39 +138,20 @@ export function MemberManage() { setEditingMaster(true); try { - // 更新姓名和手机号 - const { error: updateError } = await supabase - .from('profiles') - .update({ - display_name: masterFormData.displayName, - phone: encryptPhone(masterFormData.phone) - }) - .eq('id', auth.user?.id || ''); + await http.put('/common/profile', { + display_name: masterFormData.displayName, + phone: encryptPhone(masterFormData.phone) + }); - if (updateError) { - setError('更新失败:' + updateError.message); - setEditingMaster(false); - return; - } - - // 如果填写了密码,更新密码 if (masterFormData.password && masterFormData.password.length >= 6) { - const { error: passwordError } = await supabase.auth.updateUser({ + await http.put('/common/password', { password: masterFormData.password }); - - if (passwordError) { - setError('密码更新失败:' + passwordError.message); - setEditingMaster(false); - return; - } } - // 刷新页面以获取更新后的用户信息 window.location.reload(); - } catch (err) { - setError('更新时发生错误'); - console.error(err); + } catch (err: any) { + setError('更新失败:' + (err.message || '')); } setEditingMaster(false); }; @@ -213,74 +186,17 @@ export function MemberManage() { setError(''); try { - // 检查用户名是否已存在(全局唯一性检查) - const { data: existingUser, error: checkError } = await supabase - .from('profiles') - .select('id') - .eq('username', formData.username.trim()) - .maybeSingle(); - - if (checkError) { - console.error('检查用户名失败:', checkError); - } - - if (existingUser) { - setError('该用户名已被使用,请更换其他用户名'); - setSubmitting(false); - return; - } - // 1. 创建用户(使用虚拟邮箱格式) - const email = `${formData.username}@meoo.local`; - const { data: authData, error: signUpError } = await supabase.auth.signUp({ - email, + await commonApi.createMember({ + username: formData.username.trim(), password: formData.password, - options: { - data: { - username: formData.username, - phone: formData.phone || null, - company_id: auth.user.company_id, - is_master: false, - master_id: auth.user.id - } - } + display_name: formData.displayName, + phone: formData.phone || undefined }); - if (signUpError) { - setError('创建账号失败:' + signUpError.message); - setSubmitting(false); - return; - } - - if (!authData.user) { - setError('创建账号失败,请重试'); - setSubmitting(false); - return; - } - - // 2. 创建 profile 记录(手机号加密存储) - const { error: profileError } = await supabase - .from('profiles') - .insert({ - id: authData.user.id, - username: formData.username, - display_name: formData.displayName, - phone: encryptPhone(formData.phone) || null, - company_id: auth.user.company_id, - is_master: false, - master_id: auth.user.id - }); - - if (profileError) { - console.error('创建 profile 失败:', profileError); - // 继续执行,因为用户已创建成功 - } - - // 3. 刷新成员列表 await fetchMembers(); handleCloseModal(); - } catch (err) { - setError('创建子账号时发生错误'); - console.error(err); + } catch (err: any) { + setError('创建子账号失败:' + (err.message || '')); } setSubmitting(false); @@ -288,22 +204,11 @@ export function MemberManage() { const handleDelete = async (memberId: string) => { try { - // 删除 profile 记录 - const { error: profileError } = await supabase - .from('profiles') - .delete() - .eq('id', memberId); - - if (profileError) { - console.error('删除成员失败:', profileError); - return; - } - - // 刷新列表 + await commonApi.deleteMember(memberId); setMembers(members.filter(m => m.id !== memberId)); setShowDeleteConfirm(null); - } catch (err) { - console.error('删除成员出错:', err); + } catch { + // ignore } }; diff --git a/src/pages/purchaser/AccountsPayable.tsx b/src/pages/purchaser/AccountsPayable.tsx index c5c19fc..5cff0d9 100644 --- a/src/pages/purchaser/AccountsPayable.tsx +++ b/src/pages/purchaser/AccountsPayable.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { purchaserApi, http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react'; @@ -52,68 +52,32 @@ export function AccountsPayable() { const fetchPayables = async () => { setLoading(true); - - // 获取应付账款列表 - const { data: apData, error: apError } = await supabase - .from('accounts_payable') - .select('*') - .eq('purchaser_id', auth.company!.id) - .order('created_at', { ascending: false }); - if (apError) { - console.error('获取应付账款失败:', apError); - setLoading(false); - return; + try { + const res = await purchaserApi.listAccountsPayable(); + setPayables((res.data || []) as AccountsPayable[]); + } catch { + setPayables([]); } - // 获取工厂信息 - const factoryIds = [...new Set((apData || []).map(ap => ap.textile_factory_id))]; - const { data: factoriesData } = await supabase - .from('companies') - .select('id, name') - .in('id', factoryIds); - - const factoryMap: Record = {}; - (factoriesData || []).forEach(f => { - factoryMap[f.id] = f.name; - }); - - // 获取计划信息 - const planIds = [...new Set((apData || []).map(ap => ap.plan_id))]; - const { data: plansData } = await supabase - .from('production_plans') - .select('id, plan_code, product_name, color') - .in('id', planIds); - - const planMap: Record = {}; - (plansData || []).forEach(p => { - planMap[p.id] = { plan_code: p.plan_code, product_name: p.product_name, color: p.color }; - }); - - // 组合数据 - const payablesWithInfo: AccountsPayable[] = (apData || []).map(ap => ({ - ...ap, - factory_name: factoryMap[ap.textile_factory_id] || '未知工厂', - plan_code: planMap[ap.plan_id]?.plan_code || '', - product_name: planMap[ap.plan_id]?.product_name || '', - color: planMap[ap.plan_id]?.color || '' - })); - - setPayables(payablesWithInfo); setLoading(false); }; const fetchPayableItems = async (payableId: string) => { - const { data: itemsData } = await supabase - .from('accounts_payable_items') - .select('*') - .eq('accounts_payable_id', payableId) - .order('created_at', { ascending: false }); - - setPayableItems(prev => ({ - ...prev, - [payableId]: itemsData || [] - })); + try { + const res = await http.get( + `/purchaser/accounts-payable/${payableId}/items` + ); + setPayableItems(prev => ({ + ...prev, + [payableId]: res.data || [] + })); + } catch { + setPayableItems(prev => ({ + ...prev, + [payableId]: [] + })); + } }; const toggleExpand = (payableId: string) => { diff --git a/src/pages/purchaser/Dashboard.tsx b/src/pages/purchaser/Dashboard.tsx index 780d023..d87675d 100644 --- a/src/pages/purchaser/Dashboard.tsx +++ b/src/pages/purchaser/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { purchaserApi } from '../../api'; import { motion } from 'framer-motion'; import { FileText, @@ -84,28 +84,12 @@ export function PurchaserDashboard() { }, [auth.company, dataLoaded, plans.length]); const fetchPlans = async () => { - const { data: planData } = await supabase - .from('production_plans') - .select('*') - .eq('purchaser_id', auth.company!.id) - .order('created_at', { ascending: false }); + const res = await purchaserApi.listPlans(); + const allPlans = res.data.plans; - // 获取流程步骤,检测 rejected 状态 - const planIds = (planData || []).map(p => p.id); - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('plan_id, status') - .in('plan_id', planIds); - - // 过滤掉有 rejected 步骤的计划 - const rejectedPlanIds = new Set(); - (stepsData || []).forEach(step => { - if (step.status === STEP_STATUS.REJECTED) { - rejectedPlanIds.add(step.plan_id); - } - }); - - const filteredPlans = (planData || []).filter(p => !rejectedPlanIds.has(p.id)); + const filteredPlans = allPlans.filter(p => + !p.process_steps?.some(s => s.status === STEP_STATUS.REJECTED) + ); setPlans(filteredPlans); setLoading(false); setDataLoaded(true); diff --git a/src/pages/purchaser/FactoryManage.tsx b/src/pages/purchaser/FactoryManage.tsx index 1dbde08..d258453 100644 --- a/src/pages/purchaser/FactoryManage.tsx +++ b/src/pages/purchaser/FactoryManage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -43,30 +43,22 @@ export function FactoryManage() { const fetchFactories = async () => { setLoading(true); - // 直接查询所有该类型的工厂(不再通过 plan_factories 关联) - const { data: factoryData } = await supabase - .from('companies') - .select('*') - .eq('role', activeTab) - .order('created_at', { ascending: false }); + try { + const res = await http.get('/purchaser/factories', { role: activeTab }); + setFactories(res.data || []); - setFactories(factoryData || []); - - // 计算每个工厂的计划数(通过 plan_factories 统计) - if (factoryData && factoryData.length > 0) { - const factoryIds = factoryData.map(f => f.id); - const { data: planFactories } = await supabase - .from('plan_factories') - .select('factory_id') - .in('factory_id', factoryIds) - .eq('factory_type', activeTab); - - const counts: Record = {}; - planFactories?.forEach(pf => { - counts[pf.factory_id] = (counts[pf.factory_id] || 0) + 1; - }); - setPlanCounts(counts); - } else { + if (res.data && res.data.length > 0) { + const factoryIds = res.data.map(f => f.id); + const countsRes = await http.get>('/purchaser/factories/plan-counts', { + factory_ids: factoryIds.join(','), + factory_type: activeTab + }); + setPlanCounts(countsRes.data || {}); + } else { + setPlanCounts({}); + } + } catch { + setFactories([]); setPlanCounts({}); } setLoading(false); @@ -116,67 +108,14 @@ export function FactoryManage() { setDeleting(true); try { - console.log('开始删除工厂:', deletingFactory.id, deletingFactory.name); - - // 检查该工厂是否有关联的计划 - const { data: planFactories, error: checkError } = await supabase - .from('plan_factories') - .select('id') - .eq('factory_id', deletingFactory.id) - .limit(1); - - if (checkError) { - handleError(checkError, { userMessage: '检查关联计划失败' }); - setDeleting(false); - return; - } - - if (planFactories && planFactories.length > 0) { - handleError(new Error('无法删除'), { userMessage: '该工厂已有关联计划,无法删除' }); - setDeleting(false); - return; - } - - console.log('准备删除工厂,ID:', deletingFactory.id); - - // 先删除关联的合作关系记录 - const { error: relError } = await supabase - .from('company_relationships') - .delete() - .eq('factory_company_id', deletingFactory.id); - - if (relError) { - handleError(relError, { userMessage: '删除合作关系失败' }); - setDeleting(false); - return; - } - - console.log('已删除关联的合作关系'); - - // 删除工厂 - const { data: deleteData, error: deleteError } = await supabase - .from('companies') - .delete() - .eq('id', deletingFactory.id) - .select(); - - if (deleteError) { - handleError(deleteError, { userMessage: '删除工厂失败' }); - setDeleting(false); - return; - } - - console.log('删除成功:', deleteData); - - // 刷新列表 + await http.delete(`/purchaser/factories/${deletingFactory.id}`); await fetchFactories(); - // 如果删除的是当前展开的工厂,关闭展开状态 if (expandedId === deletingFactory.id) { setExpandedId(null); } handleCloseDeleteModal(); } catch (err) { - handleError(err, { userMessage: '删除工厂时发生错误' }); + handleError(err, { userMessage: '删除工厂失败' }); } setDeleting(false); }; @@ -189,26 +128,16 @@ export function FactoryManage() { // 检查工厂名称是否已存在(同一类型下) const checkDuplicateName = async (name: string, excludeId?: string): Promise => { - const { data, error } = await supabase - .from('companies') - .select('id') - .eq('role', activeTab) - .ilike('name', name.trim()) - .limit(1); - - if (error) { - console.error('检查重名失败:', error); + try { + const res = await http.get<{ exists: boolean }>('/purchaser/factories/check-name', { + role: activeTab, + name: name.trim(), + exclude_id: excludeId + }); + return res.data.exists; + } catch { return false; } - - // 如果存在记录且不是当前编辑的工厂,则认为是重名 - if (data && data.length > 0) { - if (excludeId && data[0].id === excludeId) { - return false; // 是自己,不算重名 - } - return true; - } - return false; }; const handleSubmit = async (e: React.FormEvent) => { @@ -228,7 +157,6 @@ export function FactoryManage() { setError(''); try { - // 检查重名 const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id); if (isDuplicate) { setError(`该${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`); @@ -237,48 +165,24 @@ export function FactoryManage() { } if (editingFactory) { - // 更新工厂信息 - const { error: updateError } = await supabase - .from('companies') - .update({ - name: formData.name, - contact_phone: formData.contactPhone, - address: formData.address - }) - .eq('id', editingFactory.id); - - if (updateError) { - setError('更新工厂失败:' + updateError.message); - setSubmitting(false); - return; - } + await http.put(`/purchaser/factories/${editingFactory.id}`, { + name: formData.name, + contact_phone: formData.contactPhone, + address: formData.address + }); } else { - // 创建工厂(作为公司) - const { data: newFactory, error: factoryError } = await supabase - .from('companies') - .insert({ - name: formData.name, - role: activeTab, - contact_phone: formData.contactPhone, - address: formData.address - }) - .select() - .single(); - - if (factoryError || !newFactory) { - setError('创建工厂失败:' + factoryError?.message); - setSubmitting(false); - return; - } - // 工厂创建成功,直接显示在列表中(不再需要通过 plan_factories 关联) + await http.post('/purchaser/factories', { + name: formData.name, + role: activeTab, + contact_phone: formData.contactPhone, + address: formData.address + }); } - // 刷新工厂列表 await fetchFactories(); handleCloseModal(); - } catch (err) { - setError(editingFactory ? '更新工厂时发生错误' : '创建工厂时发生错误'); - console.error(err); + } catch (err: any) { + setError(editingFactory ? '更新工厂失败:' + (err.message || '') : '创建工厂失败:' + (err.message || '')); } setSubmitting(false); diff --git a/src/pages/purchaser/FinishedWarehouse.tsx b/src/pages/purchaser/FinishedWarehouse.tsx index c2898b9..3e90f71 100644 --- a/src/pages/purchaser/FinishedWarehouse.tsx +++ b/src/pages/purchaser/FinishedWarehouse.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -37,36 +37,23 @@ export function FinishedWarehouse() { const fetchProducts = async () => { setLoading(true); - const { data, error } = await supabase - .from('finished_products') - .select(` - *, - warehouse:warehouse_id(name) - `) - .eq('company_id', auth.company!.id) - .order('created_at', { ascending: false }); - - if (error) { - console.error('获取成品失败:', error); - } else { - const productsWithDetails = (data || []).map(product => ({ - ...product, - warehouse_name: (product.warehouse as any)?.name - })); - setProducts(productsWithDetails); + try { + const res = await http.get('/purchaser/finished-products'); + setProducts(res.data || []); + } catch { + setProducts([]); } setLoading(false); }; const fetchInventoryRecords = async (productId: string) => { - const { data, error } = await supabase - .from('finished_product_inventory_records') - .select('*') - .eq('finished_product_id', productId) - .order('created_at', { ascending: false }); - - if (!error) { - setInventoryRecords(data || []); + try { + const res = await http.get( + `/purchaser/finished-products/${productId}/records` + ); + setInventoryRecords(res.data || []); + } catch { + setInventoryRecords([]); } }; diff --git a/src/pages/purchaser/NewPlan.tsx b/src/pages/purchaser/NewPlan.tsx index 1c5995d..c76b18e 100644 --- a/src/pages/purchaser/NewPlan.tsx +++ b/src/pages/purchaser/NewPlan.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { purchaserApi } from '../../api'; +import { http } from '../../api'; import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useErrorHandler } from '../../hooks/useErrorHandler'; import type { Company, FactoryType } from '../../types'; -import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; interface YarnInput { id: string; @@ -88,26 +88,21 @@ export function NewPlan() { if (!auth.company) return; setLoading(true); - const { data: prodData } = await supabase - .from('products') - .select('*') - .eq('company_id', auth.company.id) - .order('created_at', { ascending: false }); + const res = await purchaserApi.listProducts(); + const prodData = res.data; if (prodData) { setProducts(prodData as Product[]); - // 获取每个产品的纱线配比 const productIds = prodData.map(p => p.id); if (productIds.length > 0) { - const { data: ratiosData } = await supabase - .from('product_yarn_ratios') - .select('*') - .in('product_id', productIds); + const ratiosRes = await http.get('/purchaser/products/yarn-ratios', { + product_ids: productIds.join(',') + }); - if (ratiosData) { + if (ratiosRes.data) { const ratiosMap: Record = {}; - ratiosData.forEach((r: any) => { + ratiosRes.data.forEach((r: any) => { if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = []; ratiosMap[r.product_id].push(r as ProductYarnRatio); }); @@ -120,14 +115,7 @@ export function NewPlan() { const fetchFactories = async () => { if (!auth.company) return; - - // 查询工厂管理中登记的纺织厂(companies表中role=textile的记录) - const { data } = await supabase - .from('companies') - .select('*') - .eq('role', 'textile') - .order('created_at', { ascending: false }); - + const data = await purchaserApi.listFactories(); setFactories(data || []); }; @@ -199,97 +187,21 @@ export function NewPlan() { setSubmitting(true); try { - // 生成计划编号 - const timestamp = Date.now().toString().slice(-6); - const planCode = `PLAN-2026-${timestamp}`; - - // 创建计划 - const { data: plan, error: planError } = await supabase - .from('production_plans') - .insert({ - plan_code: planCode, - product_name: productName, - color: color, - fabric_code: fabricCode.toUpperCase(), - color_code: colorCode, - remark: remark, - purchaser_id: auth.company.id, - target_quantity: parseInt(targetQuantity) || 0, - yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量 - production_price: parseFloat(productionPrice) || 0, - status: PLAN_STATUS.PENDING, - created_by: auth.user?.id - }) - .select() - .maybeSingle(); - - if (planError) { - handleError(planError, { userMessage: '创建计划失败' }); - setSubmitting(false); - return; - } - - if (plan) { - // 创建纱线配比 - const yarnErrors: string[] = []; - for (const yarn of yarns) { - if (yarn.name) { - const amountPerMeter = selectedProduct?.weight || 0; - const totalAmount = amountPerMeter * yarn.ratio * (parseInt(targetQuantity) || 0); - const { error: yarnError } = await supabase.from('yarn_ratios').insert({ - plan_id: plan.id, - yarn_name: yarn.name, - ratio: yarn.ratio, - amount_per_meter: amountPerMeter * yarn.ratio, - total_amount: totalAmount - }); - if (yarnError) { - yarnErrors.push(yarnError.message); - console.error('纱线配比创建失败:', yarnError); - } - } - } - - // 创建流程节点 - const stepErrors: string[] = []; - const steps: ('confirm' | 'yarn_purchase' | 'dyeing' | 'machine_start' | 'fabric_warehouse')[] = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse']; - for (const step of steps) { - const { error: stepError } = await supabase.from('plan_process_steps').insert({ - plan_id: plan.id, - step_type: step, - status: STEP_STATUS.PENDING - }); - if (stepError) { - stepErrors.push(stepError.message); - console.error('流程节点创建失败:', stepError); - } - } - - // 记录初始价格(旧价格为0,表示新建) - const finalPrice = parseFloat(productionPrice) || 0; - if (finalPrice > 0) { - const { error: priceError } = await supabase.from('production_price_history').insert({ - plan_id: plan.id, - old_price: 0, - new_price: finalPrice, - change_amount: finalPrice, - operator_id: auth.user?.id, - operator_name: auth.user?.username || '', - change_reason: '创建计划时设置初始价格' - }); - if (priceError) { - console.error('价格历史记录失败:', priceError); - } - } - - // 注意:工厂关联不再在创建计划时自动进行 - // 而是通过分享链接或推送功能,由工厂确认后才建立关联 - - // 如果有子操作失败,提示用户但允许继续 - if (yarnErrors.length > 0 || stepErrors.length > 0) { - console.warn('计划创建完成,但部分数据创建失败:', { yarnErrors, stepErrors }); - } - } + await purchaserApi.createPlan({ + product_name: productName, + fabric_code: fabricCode.toUpperCase(), + color_code: colorCode, + color: color, + target_quantity: parseInt(targetQuantity) || 0, + unit_price: parseFloat(productionPrice) || 0, + notes: remark, + yarn_ratios: yarns.filter(y => y.name).map(yarn => ({ + yarn_name: yarn.name, + ratio: yarn.ratio, + amount_per_meter: (selectedProduct?.weight || 0) * yarn.ratio, + yarn_type: yarn.yarn_type + })) + }); alert('计划创建成功!'); navigate('/purchaser/plans'); diff --git a/src/pages/purchaser/NewWashingPlan.tsx b/src/pages/purchaser/NewWashingPlan.tsx index 5cf76ec..1e60084 100644 --- a/src/pages/purchaser/NewWashingPlan.tsx +++ b/src/pages/purchaser/NewWashingPlan.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { purchaserApi, http } from '../../api'; import { motion } from 'framer-motion'; import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import type { ProductWithInventory, Company } from '../../types'; -import { WASHING_PLAN_STATUS } from '../../utils/constants'; interface WashingPlanForm { productId: string; @@ -41,84 +40,31 @@ export function NewWashingPlan() { const fetchProducts = useCallback(async () => { if (!auth.company) return; - // 获取产品列表 - const { data: productsData } = await supabase - .from('products') - .select('*') - .eq('company_id', auth.company.id) - .order('created_at', { ascending: false }); + const res = await purchaserApi.listProducts(); + const productsData = res.data || []; - // 获取生产计划(关联到产品) - // 注意:使用 company_id 查询,与 RLS 策略保持一致 - const fabricCodes = (productsData || []).map(p => p.fabric_code); - const { data: plansData, error: plansError } = await supabase - .from('production_plans') - .select('id, fabric_code, purchaser_id') - .in('fabric_code', fabricCodes); - - if (plansError) { - console.error('获取生产计划失败:', plansError); - } - - // 过滤出当前采购商的计划(客户端过滤,避免 RLS 问题) - const filteredPlansData = (plansData || []).filter(plan => plan.purchaser_id === auth.company?.id); - - // 获取入库记录(用于计算实际米数和匹数) - const planIds = (filteredPlansData || []).map(p => p.id); - const { data: inventoryRecords } = await supabase - .from('inventory_records') - .select('plan_id, quantity, rolls') - .in('plan_id', planIds); - - // 按 plan_id 汇总实际入库数据 - const planInventoryMap: Record = {}; - (inventoryRecords || []).forEach((r: any) => { - if (!planInventoryMap[r.plan_id]) { - planInventoryMap[r.plan_id] = { meters: 0, rolls: 0 }; - } - planInventoryMap[r.plan_id].meters += Number(r.quantity) || 0; - planInventoryMap[r.plan_id].rolls += Number(r.rolls) || 0; - }); - - // 按 fabric_code 汇总库存 - const fabricCodeInventory: Record = {}; - - (filteredPlansData || []).forEach((plan: any) => { - if (!fabricCodeInventory[plan.fabric_code]) { - fabricCodeInventory[plan.fabric_code] = { meters: 0, rolls: 0 }; - } - const planInventory = planInventoryMap[plan.id] || { meters: 0, rolls: 0 }; - fabricCodeInventory[plan.fabric_code].meters += planInventory.meters; - fabricCodeInventory[plan.fabric_code].rolls += planInventory.rolls; - }); - - // 计算每个产品的库存 - const productsWithInventory: ProductWithInventory[] = (productsData || []).map(product => { - const inventory = fabricCodeInventory[product.fabric_code] || { meters: 0, rolls: 0 }; - - return { - ...product, - raw_fabric_meters: inventory.meters, - raw_fabric_rolls: inventory.rolls, - total_stock: inventory.meters, - total_in_meters: inventory.meters, - total_in_rolls: inventory.rolls, - total_out_meters: 0, - total_out_rolls: 0 - }; - }); + const productsWithInventory: ProductWithInventory[] = productsData.map(product => ({ + ...product, + raw_fabric_meters: product.raw_fabric_meters || 0, + raw_fabric_rolls: product.raw_fabric_rolls || 0, + total_stock: product.total_stock || 0, + total_in_meters: 0, + total_in_rolls: 0, + total_out_meters: 0, + total_out_rolls: 0 + })); setProducts(productsWithInventory); }, [auth.company]); // 获取水洗厂列表 const fetchWashingFactories = useCallback(async () => { - const { data } = await supabase - .from('companies') - .select('*') - .eq('role', 'washing') - .order('name', { ascending: true }); - setWashingFactories(data || []); + try { + const res = await http.get('/purchaser/factories', { role: 'washing' }); + setWashingFactories(res.data || []); + } catch { + setWashingFactories([]); + } }, []); useEffect(() => { @@ -185,68 +131,22 @@ export function NewWashingPlan() { setSubmitting(true); - // 生成计划编号 - const planCode = generatePlanCode(); + try { + await purchaserApi.createWashingPlan({ + product_id: form.productId, + washing_factory_id: form.washingFactoryId, + planned_meters: parseFloat(form.plannedMeters), + washing_price: parseFloat(form.washingPrice), + estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0, + notes: form.notes || undefined + } as any); - // 1. 创建水洗计划 - const { data: washingPlan, error: insertError } = await supabase.from('washing_plans').insert({ - plan_code: planCode, - company_id: auth.company!.id, - product_id: form.productId, - washing_factory_id: form.washingFactoryId, - planned_meters: plannedMeters, - washing_price: parseFloat(form.washingPrice), - estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0, - notes: form.notes || null, - created_by: auth.user?.id, - status: WASHING_PLAN_STATUS.PENDING - }).select().single(); - - if (insertError) { setSubmitting(false); - setError('创建失败: ' + insertError.message); - return; - } - - // 2. 自动出库 - 创建出库记录 - // 计算需要出库的匹数(按比例计算) - const stockMeters = selectedProduct.total_stock || 0; - const stockRolls = selectedProduct.raw_fabric_rolls || 0; - const outboundRolls = stockMeters > 0 ? Math.ceil((plannedMeters / stockMeters) * stockRolls) : 0; - - const { error: outboundError } = await supabase.from('product_outbound_records').insert({ - company_id: auth.company!.id, - product_id: form.productId, - batch_no: `WS-${planCode}`, - rolls: outboundRolls, - meters: plannedMeters, - notes: `水洗计划自动出库: ${planCode}`, - operator_id: auth.user?.id || null, - washing_plan_id: washingPlan.id, - outbound_type: 'auto_washing' - }); - - if (outboundError) { + navigate('/purchaser'); + } catch (err: any) { setSubmitting(false); - setError('出库记录创建失败: ' + outboundError.message); - return; + setError('创建失败: ' + (err.message || '未知错误')); } - - // 3. 更新产品库存 - const { error: updateError } = await supabase.from('products').update({ - raw_fabric_rolls: Math.max(0, (selectedProduct.raw_fabric_rolls || 0) - outboundRolls), - raw_fabric_meters: Math.max(0, (selectedProduct.raw_fabric_meters || 0) - plannedMeters), - total_stock: Math.max(0, (selectedProduct.total_stock || 0) - plannedMeters) - }).eq('id', form.productId); - - if (updateError) { - setSubmitting(false); - setError('库存更新失败: ' + updateError.message); - return; - } - - setSubmitting(false); - navigate('/purchaser'); }; const selectedProduct = products.find(p => p.id === form.productId); diff --git a/src/pages/purchaser/WarehouseManage.tsx b/src/pages/purchaser/WarehouseManage.tsx index ba19099..d8361ef 100644 --- a/src/pages/purchaser/WarehouseManage.tsx +++ b/src/pages/purchaser/WarehouseManage.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { purchaserApi, http } from '../../api'; import { useInventoryData } from '../../hooks/useInventoryData'; import { useResponsive } from '../../hooks/useResponsive'; import { useErrorHandler } from '../../hooks/useErrorHandler'; @@ -158,7 +158,6 @@ export function WarehouseManage() { if (!auth.company) return; const productData = { - company_id: auth.company.id, product_name: formData.product_name, weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0, warp_weight: parseFloat(formData.warp_weight) || null, @@ -171,116 +170,40 @@ export function WarehouseManage() { 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(); + const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0); - if (existingFabricCode) { - handleError(new Error('产品码重复'), { userMessage: `产品码 "${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) { - handleError(new Error('色号重复'), { userMessage: `"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号` }); - return; - } - - let productId: string; - - if (editingProduct) { - // 更新产品 - const oldPrice = editingProduct.production_price || 0; - const newPrice = parseFloat(formData.production_price) || 0; - - const { error } = await supabase - .from('products') - .update(productData) - .eq('id', editingProduct.id); - - if (error) { - handleError(error, { userMessage: '更新失败' }); - return; - } - - // 记录价格变更 - if (oldPrice !== newPrice) { - await supabase.from('product_price_history').insert({ - product_id: editingProduct.id, - company_id: auth.company.id, - old_price: oldPrice, - new_price: newPrice, - change_amount: newPrice - oldPrice, - operator_id: auth.user?.id, - operator_name: auth.user?.username || '', - change_reason: '产品信息编辑' + try { + if (editingProduct) { + await http.put(`/purchaser/products/${editingProduct.id}`, { + ...productData, + yarn_ratios: yarnRatios + }); + } else { + await http.post('/purchaser/products', { + ...productData, + yarn_ratios: yarnRatios }); } - productId = editingProduct.id; - await supabase.from('product_yarn_ratios').delete().eq('product_id', productId); - } else { - // 创建产品 - const { data, error } = await supabase - .from('products') - .insert(productData) - .select() - .single(); - - if (error) { - handleError(error, { userMessage: '创建失败' }); - return; - } - productId = data.id; + setShowProductModal(false); + setEditingProduct(null); + setProductForm(initialProductForm); + await refetch(); + } catch (err) { + handleError(err, { userMessage: editingProduct ? '更新失败' : '创建失败' }); } - - // 保存纱线配比 - const validRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0); - if (validRatios.length > 0) { - await supabase.from('product_yarn_ratios').insert( - validRatios.map(r => ({ - product_id: productId, - yarn_name: r.yarn_name, - ratio: r.ratio, - yarn_type: r.yarn_type || 'all' - })) - ); - } - - // 重置并刷新 - setShowProductModal(false); - setEditingProduct(null); - setProductForm(initialProductForm); - await refetch(); }, [auth.company, auth.user, editingProduct, refetch]); // 删除产品 const handleDeleteProduct = useCallback(async (id: string) => { if (!confirm('确定要删除这个产品吗?')) return; - const { error } = await supabase.from('products').delete().eq('id', id); - if (error) { - handleError(error, { userMessage: '删除失败' }); - return; + try { + await purchaserApi.deleteProduct(id); + await refetch(); + } catch (err) { + handleError(err, { userMessage: '删除失败' }); } - await refetch(); }, [refetch]); // 编辑产品 @@ -321,15 +244,16 @@ export function WarehouseManage() { // 查看价格历史 const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => { - const { data } = await supabase - .from('product_price_history') - .select('*') - .eq('product_id', product.id) - .order('created_at', { ascending: false }); - - setSelectedPriceHistory(data || []); - setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`); - setPriceHistoryModalOpen(true); + try { + const data = await purchaserApi.priceHistory(product.id); + setSelectedPriceHistory(data || []); + setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`); + setPriceHistoryModalOpen(true); + } catch { + setSelectedPriceHistory([]); + setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`); + setPriceHistoryModalOpen(true); + } }, []); // 查看出入库历史 - 合并入库和出库记录 @@ -351,41 +275,26 @@ export function WarehouseManage() { const handleInSubmit = useCallback(async () => { if (!auth.company || !inForm.product_id || !inForm.batch_no) return; - const { error } = await supabase.from('product_inventory_records').insert({ - company_id: auth.company.id, - product_id: inForm.product_id, - batch_no: inForm.batch_no, - rolls: inForm.rolls, - meters: inForm.meters, - notes: inForm.notes || null, - operator_id: auth.user?.id || null - }); + try { + await purchaserApi.inbound(inForm.product_id, { + batch_no: inForm.batch_no, + rolls: inForm.rolls, + meters: inForm.meters, + notes: inForm.notes || undefined + }); - if (error) { - handleError(error, { userMessage: '入库失败' }); - return; + setShowInModal(false); + setInForm(initialInboundForm); + await refetch(); + } catch (err) { + handleError(err, { userMessage: '入库失败' }); } - - // 更新产品库存 - const product = products.find(p => p.id === inForm.product_id); - if (product) { - await supabase.from('products').update({ - raw_fabric_rolls: (product.raw_fabric_rolls || 0) + inForm.rolls, - raw_fabric_meters: (product.raw_fabric_meters || 0) + inForm.meters, - total_stock: (product.total_stock || 0) + inForm.meters - }).eq('id', inForm.product_id); - } - - setShowInModal(false); - setInForm(initialInboundForm); - await refetch(); }, [auth.company, auth.user, inForm, products, refetch]); // 提交出库 const handleOutSubmit = useCallback(async () => { if (!auth.company || !outForm.product_id || !outForm.batch_no || !outForm.source_batch_id || !outForm.recipient_company_id) return; - // 检查库存是否充足 const product = products.find(p => p.id === outForm.product_id); if (!product) { handleError(new Error('产品不存在'), { userMessage: '产品不存在' }); @@ -400,35 +309,22 @@ export function WarehouseManage() { return; } - const { error } = await supabase.from('product_outbound_records').insert({ - company_id: auth.company.id, - product_id: outForm.product_id, - batch_no: outForm.batch_no, - rolls: outForm.rolls, - meters: outForm.meters, - notes: outForm.notes || null, - operator_id: auth.user?.id || null, - outbound_type: 'manual', - source_batch_id: outForm.source_batch_id, - recipient_company_id: outForm.recipient_company_id, - recipient_company_name: outForm.recipient_company_name - }); + try { + await purchaserApi.outbound(outForm.product_id, { + batch_no: outForm.batch_no, + rolls: outForm.rolls, + meters: outForm.meters, + outbound_type: 'manual', + notes: outForm.notes || undefined, + recipient_company_id: outForm.recipient_company_id + }); - if (error) { - handleError(error, { userMessage: '出库失败' }); - return; + setShowOutModal(false); + setOutForm(initialOutboundForm); + await refetch(); + } catch (err) { + handleError(err, { userMessage: '出库失败' }); } - - // 更新产品库存 - await supabase.from('products').update({ - raw_fabric_rolls: Math.max(0, (product.raw_fabric_rolls || 0) - outForm.rolls), - raw_fabric_meters: Math.max(0, (product.raw_fabric_meters || 0) - outForm.meters), - total_stock: Math.max(0, (product.total_stock || 0) - outForm.meters) - }).eq('id', outForm.product_id); - - setShowOutModal(false); - setOutForm(initialOutboundForm); - await refetch(); }, [auth.company, auth.user, outForm, products, refetch]); // 打开新建产品模态框 diff --git a/src/pages/textile/Dashboard.tsx b/src/pages/textile/Dashboard.tsx index 9e1ca51..0bb7c38 100644 --- a/src/pages/textile/Dashboard.tsx +++ b/src/pages/textile/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { textileApi } from '../../api'; import { motion } from 'framer-motion'; import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -72,41 +72,20 @@ export function TextileDashboard() { }, [auth.company]); const fetchPlans = async () => { - const { data: planFactories } = await supabase - .from('plan_factories') - .select('plan_id') - .eq('factory_id', auth.company!.id) - .eq('factory_type', 'textile'); + const response = await textileApi.listPlans(); + const planData = response.data || []; - if (!planFactories || planFactories.length === 0) { - setPlans([]); - setLoading(false); - return; - } - - const planIds = planFactories.map(pf => pf.plan_id); - const { data: planData } = await supabase - .from('production_plans') - .select('*') - .in('id', planIds) - .order('created_at', { ascending: false }); - - // 获取流程步骤,用于检测 rejected 状态 - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('plan_id, status') - .in('plan_id', planIds); - - // 标记有 rejected 步骤的计划 const rejectedIds = new Set(); - (stepsData || []).forEach(step => { - if (step.status === STEP_STATUS.REJECTED) { - rejectedIds.add(step.plan_id); - } + planData.forEach(plan => { + (plan.process_steps || []).forEach(step => { + if (step.status === STEP_STATUS.REJECTED) { + rejectedIds.add(plan.id); + } + }); }); setRejectedPlanIds(rejectedIds); - setPlans(planData || []); + setPlans(planData); setLoading(false); }; diff --git a/src/pages/textile/FabricWarehouse.tsx b/src/pages/textile/FabricWarehouse.tsx index be89bb5..ac02e72 100644 --- a/src/pages/textile/FabricWarehouse.tsx +++ b/src/pages/textile/FabricWarehouse.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { textileApi, http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { InventoryRecordsModal } from '../../components/InventoryRecordsModal'; @@ -93,125 +93,68 @@ export function FabricWarehouse() { useEffect(() => { if (!auth.company) return; fetchFabricInventory(); - - // 订阅入库记录变化,实现实时更新 - const subscription = supabase - .channel('inventory_records_changes') - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'inventory_records' - }, - () => { - // 有入库记录变化时刷新数据 - fetchFabricInventory(); - } - ) - .subscribe(); - - return () => { - subscription.unsubscribe(); - }; }, [auth.company]); const fetchFabricInventory = async () => { setLoading(true); - - // 获取纺织厂关联的所有计划 - const { data: planFactories } = await supabase - .from('plan_factories') - .select('plan_id') - .eq('factory_id', auth.company!.id) - .eq('factory_type', 'textile'); - if (!planFactories || planFactories.length === 0) { - setInventory([]); + try { + const response = await textileApi.listPlans(); + const plansData = response.data || []; + + if (plansData.length === 0) { + setInventory(demoInventory); + setLoading(false); + return; + } + + const planIds = plansData.map(p => p.id); + const purchaserIds = [...new Set(plansData.map(p => p.purchaser_id))]; + + const [productsRes, ...inventoryResults] = await Promise.all([ + http.get('/common/products', { company_ids: purchaserIds.join(',') }), + ...planIds.map(id => textileApi.listInventory(id).catch(() => [])) + ]); + + const productWeightMap: Record = {}; + (productsRes.data || []).forEach((p: any) => { + productWeightMap[p.fabric_code] = p.weight; + }); + + const recordsByPlan: Record = {}; + planIds.forEach((id, index) => { + const records = inventoryResults[index] as any[] || []; + recordsByPlan[id] = records.map((r: any) => ({ + time: r.created_at, + quantity: Number(r.quantity) || 0, + rolls: Number(r.rolls) || 0 + })); + }); + + const inventoryList: FabricInventory[] = plansData.map(plan => { + const records = recordsByPlan[plan.id] || []; + const totalInFromRecords = records.reduce((sum, r) => sum + r.quantity, 0); + + const completedQty = totalInFromRecords > 0 ? totalInFromRecords : (plan.completed_quantity || 0); + + return { + id: plan.id, + plan_id: plan.id, + fabric_code: plan.fabric_code, + plan_code: plan.plan_code, + weight: productWeightMap[plan.fabric_code] || 0, + target_quantity: plan.target_quantity, + completed_quantity: completedQty, + in_records: records + }; + }); + + setInventory(inventoryList.length > 0 ? inventoryList : demoInventory); + } catch { + setInventory(demoInventory); + } + setLoading(false); - return; - } - - const planIds = planFactories.map(pf => pf.plan_id); - - // 获取计划对应的产品信息 - const { data: plansData } = await supabase - .from('production_plans') - .select('id, plan_code, fabric_code, target_quantity, completed_quantity, purchaser_id') - .in('id', planIds); - - if (!plansData || plansData.length === 0) { - setInventory([]); - setLoading(false); - return; - } - - // 获取采购商的产品信息(克重) - const purchaserIds = [...new Set(plansData.map(p => p.purchaser_id))]; - const fabricCodes = plansData.map(p => p.fabric_code); - - const { data: productsData } = await supabase - .from('products') - .select('fabric_code, weight, company_id') - .in('company_id', purchaserIds) - .in('fabric_code', fabricCodes); - - const productWeightMap: Record = {}; - productsData?.forEach(p => { - productWeightMap[p.fabric_code] = p.weight; - }); - - // 获取入库记录 - 从 inventory_records 表查询 - const { data: inventoryRecords } = await supabase - .from('inventory_records') - .select('plan_id, quantity, rolls, created_at') - .in('plan_id', planIds) - .order('created_at', { ascending: false }); - - // 按 plan_id 分组入库记录 - const recordsByPlan: Record = {}; - inventoryRecords?.forEach(record => { - if (!recordsByPlan[record.plan_id]) { - recordsByPlan[record.plan_id] = []; - } - recordsByPlan[record.plan_id].push({ - time: record.created_at, - quantity: Number(record.quantity) || 0, - rolls: Number(record.rolls) || 0 - }); - }); - - // 构建库存数据 - 每个计划一条记录 - // 以 inventory_records 实际入库记录为准,计算准确的已完成数量 - const inventoryList: FabricInventory[] = plansData.map(plan => { - const records = recordsByPlan[plan.id] || []; - // 从实际入库记录计算总米数和总匹/件数 - const totalInFromRecords = records.reduce((sum, r) => sum + r.quantity, 0); - const totalRollsFromRecords = records.reduce((sum, r) => sum + r.rolls, 0); - - // 使用实际入库记录的总和作为已完成数量(更准确) - const completedQty = totalInFromRecords > 0 ? totalInFromRecords : (plan.completed_quantity || 0); - - return { - id: plan.id, - plan_id: plan.id, - fabric_code: plan.fabric_code, - plan_code: plan.plan_code, - weight: productWeightMap[plan.fabric_code] || 0, - target_quantity: plan.target_quantity, - completed_quantity: completedQty, // 使用实际入库记录的总和 - in_records: records - }; - }); - - setInventory(inventoryList); - - // 如果没有真实数据,使用示例数据 - if (inventoryList.length === 0) { - setInventory(demoInventory); - } - - setLoading(false); }; const toggleExpand = (planId: string) => { diff --git a/src/pages/textile/PaymentPending.tsx b/src/pages/textile/PaymentPending.tsx index 44b68ff..fb0728e 100644 --- a/src/pages/textile/PaymentPending.tsx +++ b/src/pages/textile/PaymentPending.tsx @@ -3,9 +3,8 @@ import { motion } from 'framer-motion'; import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { textileApi } from '../../api'; import type { Payment, ProductionPlan } from '../../types'; -import { PAYMENT_STATUS } from '../../utils/constants'; export function PaymentPending() { const navigate = useNavigate(); @@ -21,29 +20,20 @@ export function PaymentPending() { }, [auth.company]); const fetchPayments = async () => { - // 获取当前纺织厂作为收款方的待结款 - const { data: paymentData } = await supabase - .from('payments') - .select('*') - .eq('to_company_id', auth.company!.id) - .eq('status', PAYMENT_STATUS.PENDING); + const response = await textileApi.listPayments(); + const paymentData = response.data || []; - if (!paymentData || paymentData.length === 0) { + if (paymentData.length === 0) { setPayments([]); setPlanMap({}); setLoading(false); return; } - // 获取关联的计划信息 - const planIds = paymentData.map(p => p.plan_id); - const { data: planData } = await supabase - .from('production_plans') - .select('*') - .in('id', planIds); - const map: Record = {}; - (planData || []).forEach(p => { map[p.id] = p; }); + paymentData.forEach((p: any) => { + if (p.plan) map[p.plan_id] = p.plan; + }); setPayments(paymentData); setPlanMap(map); @@ -54,19 +44,10 @@ export function PaymentPending() { const handleConfirmPay = async (paymentId: string) => { setConfirming(paymentId); - const now = new Date().toISOString(); - const { error } = await supabase - .from('payments') - .update({ - status: PAYMENT_STATUS.COMPLETED, - paid_at: now - }) - .eq('id', paymentId); - - if (error) { - console.error('结款失败:', error); - } else { + try { + await textileApi.confirmPayment(paymentId); await fetchPayments(); + } catch { } setConfirming(null); }; diff --git a/src/pages/textile/PlanOverview.tsx b/src/pages/textile/PlanOverview.tsx index 7fc9675..14c1ae3 100644 --- a/src/pages/textile/PlanOverview.tsx +++ b/src/pages/textile/PlanOverview.tsx @@ -1,13 +1,12 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { textileApi, http } from '../../api'; import { usePlanStatusSync } from '../../hooks/usePlanStatusSync'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react'; import { ProcessFlow } from '../../components/ProcessFlow'; import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications'; -import { getSupabaseUrl } from '../../supabase/client'; import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; import { extractParamsFromLink } from '../../utils/helpers'; import { STORAGE_KEYS } from '../../config/app'; @@ -59,148 +58,73 @@ export function TextilePlanOverview() { useEffect(() => { if (!auth.company) return; fetchPlans(); - - // 订阅入库记录变化,实现实时更新 - const subscription = supabase - .channel('plan_overview_inventory_changes') - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'inventory_records' - }, - () => { - // 有入库记录变化时刷新数据 - fetchPlans(); - } - ) - // 订阅 plan_factories 变化,导入新计划后自动刷新 - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'plan_factories' - }, - (payload) => { - // 仅当关联到当前纺织厂时刷新 - if (payload.new && (payload.new as any).factory_id === auth.company!.id) { - fetchPlans(); - } - // 删除关联时也刷新 - if (payload.eventType === 'DELETE') { - fetchPlans(); - } - } - ) - .subscribe(); - - return () => { - subscription.unsubscribe(); - }; }, [auth.company]); const fetchPlans = async () => { - // 获取关联到当前纺织厂的计划 - const { data: planFactories } = await supabase - .from('plan_factories') - .select('plan_id') - .eq('factory_id', auth.company!.id) - .eq('factory_type', 'textile'); + const response = await textileApi.listPlans(); + const planData = response.data || []; - if (!planFactories || planFactories.length === 0) { + if (planData.length === 0) { setGroups([]); setLoading(false); return; } - const planIds = planFactories.map(pf => pf.plan_id); + const planIds = planData.map(p => p.id); - // 获取计划详情 - const { data: planData } = await supabase - .from('production_plans') - .select('*') - .in('id', planIds) - .order('created_at', { ascending: false }); - - if (!planData || planData.length === 0) { - setGroups([]); - setLoading(false); - return; - } - - // 获取所有流程步骤 - const { data: stepsData } = await supabase - .from('plan_process_steps') - .select('*') - .in('plan_id', planIds); - - // 获取操作人信息 - const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[]; + const operatorIds = [...new Set( + planData.flatMap(p => (p.process_steps || []).filter(s => s.operator_id).map(s => s.operator_id!)) + )]; if (operatorIds.length > 0) { - const { data: profilesData } = await supabase - .from('profiles') - .select('id, username') - .in('id', operatorIds); - - const names: Record = {}; - (profilesData || []).forEach(p => { - names[p.id] = p.username; - }); - setOperatorNames(names); + try { + const { data: profilesData } = await http.get('/common/profiles', { ids: operatorIds.join(',') }); + const names: Record = {}; + (profilesData || []).forEach(p => { names[p.id] = p.username; }); + setOperatorNames(names); + } catch {} } - // 获取采购商公司信息 const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))]; - const { data: companiesData } = await supabase - .from('companies') - .select('*') - .in('id', purchaserIds); - const companyMap: Record = {}; - (companiesData || []).forEach(c => { companyMap[c.id] = c.name; }); + planData.forEach(p => { + if (p.purchaser_name) companyMap[p.purchaser_id] = p.purchaser_name; + }); - // 获取所有计划的入库记录 - const { data: inventoryData } = await supabase - .from('inventory_records') - .select('plan_id, quantity, rolls, created_at') - .in('plan_id', planIds) - .order('created_at', { ascending: false }); + const inventoryResults = await Promise.all( + planIds.map(id => textileApi.listInventory(id).catch(() => [] as any[])) + ); + const inventoryByPlan: Record = {}; + planIds.forEach((id, idx) => { inventoryByPlan[id] = inventoryResults[idx] || []; }); - // 获取纱线配比 - const { data: yarnRatiosData } = await supabase - .from('yarn_ratios') - .select('*') - .in('plan_id', planIds); + let yarnRatiosData: any[] = []; + let productsData: any[] = []; + try { + const [yrRes, prRes] = await Promise.all([ + http.get('/textile/yarn-ratios', { plan_ids: planIds.join(',') }), + http.get('/common/products', { company_ids: purchaserIds.join(',') }) + ]); + yarnRatiosData = yrRes.data || []; + productsData = prRes.data || []; + } catch {} - // 获取产品信息(克重、图片等) - const { data: productsData } = await supabase - .from('products') - .select('*') - .in('company_id', purchaserIds); - - // 按采购商分组 const grouped: Record = {}; planData.forEach(plan => { const pid = plan.purchaser_id; if (!grouped[pid]) { grouped[pid] = { purchaserId: pid, - purchaserName: companyMap[pid] || '未知布行', + purchaserName: companyMap[pid] || plan.purchaser_name || '未知布行', plans: [] }; } - const processSteps = (stepsData || []) - .filter(s => s.plan_id === plan.id) + const processSteps = (plan.process_steps || []) .sort((a, b) => { const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse']; return order.indexOf(a.step_type) - order.indexOf(b.step_type); }); - const inventoryRecords = (inventoryData || []) - .filter(r => r.plan_id === plan.id) - .map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls })); - const yarnRatios = (yarnRatiosData || []) + const inventoryRecords = (inventoryByPlan[plan.id] || []) + .map((r: any) => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls })); + const yarnRatios = (yarnRatiosData) .filter(y => y.plan_id === plan.id) .map(y => ({ id: y.id, @@ -213,8 +137,7 @@ export function TextilePlanOverview() { created_at: y.created_at, company_id: y.company_id })); - // 查找关联的产品信息 - const product = (productsData || []).find(p => + const product = productsData.find(p => p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id ); grouped[pid].plans.push({ @@ -232,7 +155,6 @@ export function TextilePlanOverview() { const groupList = Object.values(grouped); setGroups(groupList); - // 默认展开第一个组 if (groupList.length > 0) { setExpandedGroups(new Set([groupList[0].purchaserId])); } @@ -310,54 +232,31 @@ export function TextilePlanOverview() { } setRejecting(rejectStepId); - const now = new Date().toISOString(); try { - // 获取步骤信息 - const { data: step } = await supabase - .from('plan_process_steps') - .select('plan_id') - .eq('id', rejectStepId) - .maybeSingle(); + const planId = groups.flatMap(g => g.plans).find( + p => p.processSteps.some(s => s.id === rejectStepId) + )?.id; - if (!step?.plan_id) { + if (!planId) { setRejecting(null); handleCloseRejectModal(); return; } - // 更新步骤状态为拒绝(使用 notes 字段记录拒绝原因) - const { error } = await supabase - .from('plan_process_steps') - .update({ - status: STEP_STATUS.REJECTED, - notes: `拒绝原因: ${rejectReason.trim()}`, - timestamp: now, - operator_id: auth.user?.id - }) - .eq('id', rejectStepId); + await textileApi.rejectStep(planId, rejectStepId, { reason: rejectReason.trim() }); - if (error) { - handleError(error, { userMessage: '拒绝失败,请重试' }); - setRejecting(null); - return; - } + await notifyPlanUpdated(planId, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id); - // 发送拒绝通知给采购商 - await notifyPlanUpdated(step.plan_id, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id); + setRejectedPlans(prev => new Set([...prev, planId])); - // 将该计划标记为已拒绝(本地状态,显示灰色) - setRejectedPlans(prev => new Set([...prev, step.plan_id])); - - // 存储到 localStorage,让 Dashboard 也能过滤掉 const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans); const rejectedArray = stored ? JSON.parse(stored) : []; - if (!rejectedArray.includes(step.plan_id)) { - rejectedArray.push(step.plan_id); + if (!rejectedArray.includes(planId)) { + rejectedArray.push(planId); localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray)); } - // 关闭弹窗并收起计划卡片 handleCloseRejectModal(); setExpandedPlan(null); } catch (err) { @@ -368,248 +267,54 @@ export function TextilePlanOverview() { const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => { setConfirming(stepId); - const now = new Date().toISOString(); - // 获取步骤信息 - const { data: step } = await supabase - .from('plan_process_steps') - .select('step_type, plan_id') - .eq('id', stepId) - .maybeSingle(); + const plan = groups.flatMap(g => g.plans).find( + p => p.processSteps.some(s => s.id === stepId) + ); + const step = plan?.processSteps.find(s => s.id === stepId); + const planId = plan?.id; - const planId = step?.plan_id; - - if (!planId) { + if (!planId || !step) { setConfirming(null); return; } - // 如果是坯布入库,使用 Edge Function 进行事务处理 - if (step?.step_type === 'fabric_warehouse' && quantity && quantity > 0) { - // 获取纺织厂的仓库和工厂信息 - const { data: warehouse } = await supabase - .from('warehouses') - .select('id') - .eq('company_id', auth.company!.id) - .eq('type', 'fabric') - .maybeSingle(); - - // 获取工厂信息(地址或名称) - const { data: factoryInfo } = await supabase - .from('companies') - .select('name, address') - .eq('id', auth.company!.id) - .maybeSingle(); - - // 仓库位置:优先使用地址,没有则使用工厂名称 - const warehouseLocation = factoryInfo?.address || factoryInfo?.name || ''; - - // 获取计划信息(包括采购商ID和采购价) - const { data: plan } = await supabase - .from('production_plans') - .select('completed_quantity, target_quantity, purchaser_id, production_price') - .eq('id', planId) - .maybeSingle(); - - if (plan) { - const newCompletedQuantity = plan.completed_quantity + quantity; - const progressPercent = (newCompletedQuantity / plan.target_quantity) * 100; - const pricePerMeter = plan.production_price || 0; - const amount = quantity * pricePerMeter; - - // 使用 Edge Function 进行入库事务处理 - const session = (await supabase.auth.getSession()).data.session; - const headers: Record = { 'Content-Type': 'application/json' }; - if (session) { - headers['Authorization'] = `Bearer ${session.access_token}`; - } - - const response = await fetch(`${getSupabaseUrl()}/functions/v1/fabric-warehouse-inbound`, { - method: 'POST', - headers, - body: JSON.stringify({ - planId: planId, - quantity: quantity, - rolls: rolls || 0, - warehouseId: warehouse?.id || null, - warehouseLocation: warehouseLocation, - operatorId: auth.user?.id, - pricePerMeter: pricePerMeter, - }), + try { + if (step.step_type === 'fabric_warehouse' && quantity && quantity > 0) { + await textileApi.inbound(planId, { + quantity, + rolls: rolls || 0, }); - const result = await response.json(); - if (!response.ok) { - throw new Error(result.error || '入库操作失败'); - } - - const inventoryRecord = result.data?.inventoryRecord; - - // 创建或更新应付账款记录 - if (inventoryRecord) { - // 检查是否已存在应付账款记录 - const { data: existingAP } = await supabase - .from('accounts_payable') - .select('*') - .eq('plan_id', planId) - .eq('textile_factory_id', auth.company!.id) - .maybeSingle(); - - if (existingAP) { - // 更新应付账款 - await supabase - .from('accounts_payable') - .update({ - total_quantity: existingAP.total_quantity + quantity, - total_amount: existingAP.total_amount + amount, - unpaid_quantity: existingAP.unpaid_quantity + quantity, - unpaid_amount: existingAP.unpaid_amount + amount, - updated_at: now - }) - .eq('id', existingAP.id); - - // 创建应付账款明细 - await supabase - .from('accounts_payable_items') - .insert({ - accounts_payable_id: existingAP.id, - inventory_record_id: inventoryRecord.id, - quantity: quantity, - rolls: rolls || 0, - price_per_meter: pricePerMeter, - amount: amount, - status: 'unpaid', - created_at: now - }); - } else { - // 创建新的应付账款 - const { data: newAP } = await supabase - .from('accounts_payable') - .insert({ - plan_id: planId, - purchaser_id: plan.purchaser_id, - textile_factory_id: auth.company!.id, - total_quantity: quantity, - total_amount: amount, - unpaid_quantity: quantity, - unpaid_amount: amount, - price_per_meter: pricePerMeter, - status: 'unpaid', - created_at: now, - updated_at: now - }) - .select() - .single(); - - if (newAP) { - // 创建应付账款明细 - await supabase - .from('accounts_payable_items') - .insert({ - accounts_payable_id: newAP.id, - inventory_record_id: inventoryRecord.id, - quantity: quantity, - rolls: rolls || 0, - price_per_meter: pricePerMeter, - amount: amount, - status: 'unpaid', - created_at: now - }); - } - } - } - - // 如果进度达到97%或以上,标记步骤为完成,否则标记为进行中 - if (progressPercent >= 97) { - await supabase - .from('plan_process_steps') - .update({ - status: STEP_STATUS.COMPLETED, - timestamp: now, - operator_id: auth.user?.id - }) - .eq('id', stepId); - } else { - // 进度未达到97%,标记步骤为进行中(active) - await supabase - .from('plan_process_steps') - .update({ - status: STEP_STATUS.ACTIVE, - timestamp: now, - operator_id: auth.user?.id - }) - .eq('id', stepId); - } - } - } else { - // 其他步骤直接更新状态 - const { error } = await supabase - .from('plan_process_steps') - .update({ - status: STEP_STATUS.COMPLETED, - timestamp: now, - operator_id: auth.user?.id - }) - .eq('id', stepId); - - if (error) { - handleError(error, { userMessage: '确认失败,请重试' }); - setConfirming(null); - return; - } - } - - // 更新计划状态 - 无论步骤类型,只要确认成功就更新计划状态 - if (step?.step_type === 'confirm') { - const { error: planUpdateError } = await supabase - .from('production_plans') - .update({ status: PLAN_STATUS.PRODUCING }) - .eq('id', planId); - - if (planUpdateError) { - handleError(planUpdateError, { userMessage: '步骤已确认,但更新计划状态失败,请刷新页面' }); + await textileApi.completeStep(planId, stepId, { + operator_name: auth.user?.username + }); } else { - // 发送计划开始生产通知 - await notifyPlanUpdated(planId, PLAN_STATUS.PRODUCING, auth.company!.id, auth.user!.id); + await textileApi.completeStep(planId, stepId, { + operator_name: auth.user?.username + }); } + + const stepNames: Record = { + confirm: '确认计划', + yarn_purchase: '采纱', + dyeing: '染纱', + machine_start: '上机', + fabric_warehouse: '坯布生产' + }; + const stepName = stepNames[step.step_type || ''] || '生产节点'; + await notifyStepCompleted( + planId, + stepName, + auth.company!.id, + auth.user!.id, + step.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined + ); + + await fetchPlans(); + } catch (err) { + handleError(err, { userMessage: '确认失败,请重试' }); } - - // 检查是否所有步骤都已完成 - const { data: allSteps } = await supabase - .from('plan_process_steps') - .select('status') - .eq('plan_id', planId); - - const allCompleted = allSteps?.every(s => s.status === STEP_STATUS.COMPLETED); - if (allCompleted) { - await supabase - .from('production_plans') - .update({ status: PLAN_STATUS.COMPLETED }) - .eq('id', planId); - - // 发送计划完成通知 - await notifyPlanUpdated(planId, PLAN_STATUS.COMPLETED, auth.company!.id, auth.user!.id); - } - - // 发送节点完成通知 - const stepNames: Record = { - confirm: '确认计划', - yarn_purchase: '采纱', - dyeing: '染纱', - machine_start: '上机', - fabric_warehouse: '坯布生产' - }; - const stepName = stepNames[step?.step_type || ''] || '生产节点'; - await notifyStepCompleted( - planId, - stepName, - auth.company!.id, - auth.user!.id, - step?.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined - ); - - // 刷新数据 - await fetchPlans(); setConfirming(null); }; diff --git a/src/pages/textile/YarnWarehouse.tsx b/src/pages/textile/YarnWarehouse.tsx index 964bf99..89bc22a 100644 --- a/src/pages/textile/YarnWarehouse.tsx +++ b/src/pages/textile/YarnWarehouse.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { textileApi, http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import type { YarnStock, Warehouse } from '../../types'; @@ -42,45 +42,25 @@ export function YarnWarehouse() { }, [activeTab, auth.company]); const fetchData = async () => { - const { data: yarnData } = await supabase - .from('yarn_stock') - .select('*') - .eq('company_id', auth.company!.id) - .order('name'); + const response = await textileApi.listYarnStock(); + setYarns(response.data || []); - const { data: whData } = await supabase - .from('warehouses') - .select('*') - .eq('company_id', auth.company!.id) - .eq('type', 'yarn'); - - setYarns(yarnData || []); + const { data: whData } = await http.get('/textile/warehouses', { type: 'yarn' }); setWarehouses(whData || []); 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 { data: recordsData } = await http.get('/textile/yarn-stock/records'); - // 获取纱线名称 - 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(yarns.map(y => [y.id, y.name])); - const yarnMap = new Map(yarnsData?.map(y => [y.id, y.name]) || []); - - const recordsWithNames: YarnStockRecord[] = recordsData?.map(r => ({ + const recordsWithNames: YarnStockRecord[] = (recordsData || []).map(r => ({ ...r, - yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || '未知纱线' : '未知纱线', + yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || r.yarn_name || '未知纱线' : '未知纱线', record_type: r.record_type as 'inbound' | 'outbound' - })) || []; + })); setRecords(recordsWithNames); setLoading(false); @@ -88,16 +68,10 @@ export function YarnWarehouse() { const handleAddYarn = async () => { if (!newYarn.name) return; - const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库 + const yarnWarehouse = warehouses[0]; - // 获取当前用户ID - const { data: { user } } = await supabase.auth.getUser(); - const operatorId = user?.id; - - // 插入纱线库存 - const { data: newYarnData, error } = await supabase - .from('yarn_stock') - .insert({ + try { + const newYarnData = await textileApi.createYarnStock({ company_id: auth.company!.id, warehouse_id: yarnWarehouse?.id || null, name: newYarn.name, @@ -105,27 +79,18 @@ export function YarnWarehouse() { quantity: newYarn.quantity, min_stock: newYarn.minStock, unit: newYarn.unit - }) - .select() - .single(); + } as any); - if (error) { - console.error('添加失败:', error); - } else { - // 记录入库 - await supabase.from('yarn_stock_records').insert({ - company_id: auth.company!.id, - yarn_stock_id: newYarnData.id, - record_type: 'inbound', + await textileApi.addYarnRecord(newYarnData.id, { + change_type: 'inbound', quantity: newYarn.quantity, - unit: newYarn.unit, - notes: `纱线入库 - ${newYarn.name}`, - operator_id: operatorId + notes: `纱线入库 - ${newYarn.name}` }); setShowAddModal(false); setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' }); await fetchData(); + } catch { } }; diff --git a/src/pages/washing/CompletedFabric.tsx b/src/pages/washing/CompletedFabric.tsx index cf86f47..3fbed2c 100644 --- a/src/pages/washing/CompletedFabric.tsx +++ b/src/pages/washing/CompletedFabric.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi } from '../../api'; import { motion } from 'framer-motion'; import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; -import type { WashingPlan, WashingPlanCompletion } from '../../types'; -import { WASHING_PLAN_STATUS } from '../../utils/constants'; +import type { WashingPlan } from '../../types'; interface CompletedFabricItem { id: string; @@ -37,52 +36,26 @@ export function CompletedFabric() { const fetchCompletedFabric = async () => { setLoading(true); - - // 获取已完成的水洗计划 - const { data: washingPlans, error } = await supabase - .from('washing_plans') - .select(` - *, - product:product_id(product_name, color, fabric_code), - company:company_id(name) - `) - .eq('washing_factory_id', auth.company!.id) - .eq('status', WASHING_PLAN_STATUS.COMPLETED) - .order('updated_at', { ascending: false }); - if (error) { - console.error('获取已完成坯布失败:', error); - } else { - // 获取完成记录详情 - const planIds = (washingPlans || []).map(p => p.id); - const { data: completions } = await supabase - .from('washing_plan_completions') - .select('*') - .in('washing_plan_id', planIds); + try { + const data = await washingApi.listCompletedPlans(); - const completionMap: Record = {}; - (completions || []).forEach(c => { - completionMap[c.washing_plan_id] = c; - }); - - const formattedItems = (washingPlans || []).map(plan => { - const completion = completionMap[plan.id]; - return { - id: plan.id, - plan_code: plan.plan_code, - purchaser_name: (plan.company as any)?.name || '未知采购商', - product_name: (plan.product as any)?.product_name || '-', - product_color: (plan.product as any)?.color || '-', - fabric_code: (plan.product as any)?.fabric_code || '-', - planned_meters: plan.planned_meters, - actual_washed_meters: completion?.actual_washed_meters || plan.actual_washed_meters || 0, - shrinkage_rate: completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0, - washing_date: completion?.washing_date || plan.washing_date || '', - rolls: completion?.rolls || 0, - completed_at: completion?.completed_at || plan.updated_at - }; - }); + const formattedItems = (data || []).map((plan: any) => ({ + id: plan.id, + plan_code: plan.plan_code, + purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商', + product_name: plan.product_name || plan.product?.product_name || '-', + product_color: plan.product_color || plan.product?.color || '-', + fabric_code: plan.fabric_code || plan.product?.fabric_code || '-', + planned_meters: plan.planned_meters, + actual_washed_meters: plan.completion?.actual_washed_meters || plan.actual_washed_meters || 0, + shrinkage_rate: plan.completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0, + washing_date: plan.completion?.washing_date || plan.washing_date || '', + rolls: plan.completion?.rolls || 0, + completed_at: plan.completion?.completed_at || plan.updated_at + })); setItems(formattedItems); + } catch { } setLoading(false); }; diff --git a/src/pages/washing/Dashboard.tsx b/src/pages/washing/Dashboard.tsx index 88375dc..7d3f682 100644 --- a/src/pages/washing/Dashboard.tsx +++ b/src/pages/washing/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi } from '../../api'; import { motion } from 'framer-motion'; import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -74,13 +74,8 @@ export function WashingDashboard() { }, [auth.company]); const fetchPlans = async () => { - const { data: washingPlans } = await supabase - .from('washing_plans') - .select('*') - .eq('washing_factory_id', auth.company!.id) - .order('created_at', { ascending: false }); - - setPlans(washingPlans || []); + const response = await washingApi.listPlans(); + setPlans(response.data || []); setLoading(false); }; diff --git a/src/pages/washing/FinishedWarehouse.tsx b/src/pages/washing/FinishedWarehouse.tsx index 2ae228f..70a23aa 100644 --- a/src/pages/washing/FinishedWarehouse.tsx +++ b/src/pages/washing/FinishedWarehouse.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi, http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -39,45 +39,33 @@ export function FinishedWarehouse() { const fetchProducts = async () => { setLoading(true); - const { data, error } = await supabase - .from('finished_products') - .select(` - *, - warehouse:warehouse_id(name) - `) - .eq('company_id', auth.company!.id) - .order('created_at', { ascending: false }); - - if (error) { - console.error('获取成品失败:', error); - } else { - const productsWithDetails = (data || []).map(product => ({ + try { + const response = await washingApi.listFinishedProducts(); + const productsWithDetails = (response.data || []).map((product: any) => ({ ...product, - warehouse_name: (product.warehouse as any)?.name + warehouse_name: product.warehouse?.name || product.warehouse_name })); setProducts(productsWithDetails); + } catch { } setLoading(false); }; const fetchWarehouses = async () => { - const { data } = await supabase - .from('warehouses') - .select('*') - .eq('company_id', auth.company!.id) - .eq('type', 'finished'); - setWarehouses(data || []); + try { + const { data } = await http.get('/washing/warehouses', { type: 'finished' }); + setWarehouses(data || []); + } catch { + } }; const fetchInventoryRecords = async (productId: string) => { - const { data, error } = await supabase - .from('finished_product_inventory_records') - .select('*') - .eq('finished_product_id', productId) - .order('created_at', { ascending: false }); - - if (!error) { + try { + const { data } = await http.get( + `/washing/finished-products/${productId}/records` + ); setInventoryRecords(data || []); + } catch { } }; diff --git a/src/pages/washing/PaymentPending.tsx b/src/pages/washing/PaymentPending.tsx index 9cb1167..15dfa90 100644 --- a/src/pages/washing/PaymentPending.tsx +++ b/src/pages/washing/PaymentPending.tsx @@ -3,9 +3,8 @@ import { motion } from 'framer-motion'; import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi } from '../../api'; import type { Payment, WashingPlan } from '../../types'; -import { PAYMENT_STATUS } from '../../utils/constants'; interface PaymentWithPlan extends Payment { plan?: WashingPlan; @@ -24,32 +23,18 @@ export function WashingPaymentPending() { }, [auth.company]); const fetchPayments = async () => { - // 获取当前水洗厂作为收款方的待结款 - const { data: paymentData } = await supabase - .from('payments') - .select('*') - .eq('to_company_id', auth.company!.id) - .eq('status', PAYMENT_STATUS.PENDING); + const response = await washingApi.listPayments(); + const paymentData = response.data || []; - if (!paymentData || paymentData.length === 0) { + if (paymentData.length === 0) { setPayments([]); setLoading(false); return; } - // 获取关联的水洗计划信息 - const planIds = paymentData.map(p => p.plan_id); - const { data: planData } = await supabase - .from('washing_plans') - .select('*') - .in('id', planIds); - - const planMap: Record = {}; - (planData || []).forEach(p => { planMap[p.id] = p; }); - - const paymentsWithPlans = paymentData.map(p => ({ + const paymentsWithPlans = paymentData.map((p: any) => ({ ...p, - plan: planMap[p.plan_id] + plan: p.washing_plan })); setPayments(paymentsWithPlans); @@ -60,19 +45,10 @@ export function WashingPaymentPending() { const handleConfirmPay = async (paymentId: string) => { setConfirming(paymentId); - const now = new Date().toISOString(); - const { error } = await supabase - .from('payments') - .update({ - status: PAYMENT_STATUS.COMPLETED, - paid_at: now - }) - .eq('id', paymentId); - - if (error) { - console.error('结款失败:', error); - } else { + try { + await washingApi.confirmPayment(paymentId); await fetchPayments(); + } catch { } setConfirming(null); }; diff --git a/src/pages/washing/PendingFabric.tsx b/src/pages/washing/PendingFabric.tsx index ce1b193..9948e20 100644 --- a/src/pages/washing/PendingFabric.tsx +++ b/src/pages/washing/PendingFabric.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi } from '../../api'; import { motion } from 'framer-motion'; import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; import type { WashingPlan } from '../../types'; -import { WASHING_PLAN_STATUS } from '../../utils/constants'; interface PendingFabricItem { id: string; @@ -39,33 +38,22 @@ export function PendingFabric() { const fetchPendingFabric = async () => { setLoading(true); - - const { data, error } = await supabase - .from('washing_plans') - .select(` - *, - product:product_id(product_name, color, fabric_code), - company:company_id(name) - `) - .eq('washing_factory_id', auth.company!.id) - .in('status', [WASHING_PLAN_STATUS.PENDING, 'processing']) - .order('created_at', { ascending: false }); - if (error) { - console.error('获取待处理坯布失败:', error); - } else { - const formattedItems = (data || []).map(plan => ({ + try { + const data = await washingApi.listPendingPlans(); + const formattedItems = (data || []).map((plan: any) => ({ id: plan.id, plan_code: plan.plan_code, - purchaser_name: (plan.company as any)?.name || '未知采购商', - product_name: (plan.product as any)?.product_name || '-', - product_color: (plan.product as any)?.color || '-', - fabric_code: (plan.product as any)?.fabric_code || '-', + purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商', + product_name: plan.product_name || plan.product?.product_name || '-', + product_color: plan.product_color || plan.product?.color || '-', + fabric_code: plan.fabric_code || plan.product?.fabric_code || '-', planned_meters: plan.planned_meters, status: plan.status, created_at: plan.created_at })); setItems(formattedItems); + } catch { } setLoading(false); }; diff --git a/src/pages/washing/PlanOverview.tsx b/src/pages/washing/PlanOverview.tsx index e3af45e..3d6ac3b 100644 --- a/src/pages/washing/PlanOverview.tsx +++ b/src/pages/washing/PlanOverview.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { supabase } from '../../supabase/client'; +import { washingApi, http } from '../../api'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react'; import { useResponsive } from '../../hooks/useResponsive'; @@ -63,88 +63,68 @@ export function WashingPlanOverview() { const fetchPlans = async () => { setLoading(true); - - // 获取水洗计划 - const { data: washingPlans, error } = await supabase - .from('washing_plans') - .select(` - *, - product:product_id(product_name, color, fabric_code), - company:company_id(name) - `) - .eq('washing_factory_id', auth.company!.id) - .order('created_at', { ascending: false }); - if (error) { - handleError(error, { userMessage: '获取水洗计划失败' }); - setLoading(false); - return; - } + try { + const response = await washingApi.listPlans(); + const washingPlans = response.data || []; - if (!washingPlans || washingPlans.length === 0) { - setGroups([]); - setLoading(false); - return; - } - - const planIds = washingPlans.map(p => p.id); - - // 获取流程步骤 - const { data: stepsData } = await supabase - .from('washing_process_steps') - .select('*') - .in('washing_plan_id', planIds); - - // 获取操作人信息 - const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[]; - if (operatorIds.length > 0) { - const { data: profilesData } = await supabase - .from('profiles') - .select('id, username') - .in('id', operatorIds); - - const names: Record = {}; - (profilesData || []).forEach(p => { - names[p.id] = p.username; - }); - setOperatorNames(names); - } - - // 按采购商分组 - const grouped: Record = {}; - washingPlans.forEach(plan => { - const purchaserId = plan.company_id; - const purchaserName = (plan.company as any)?.name || '未知采购商'; - - if (!grouped[purchaserId]) { - grouped[purchaserId] = { - purchaserId, - purchaserName, - plans: [] - }; + if (washingPlans.length === 0) { + setGroups([]); + setLoading(false); + return; } - const processSteps = (stepsData || []) - .filter(s => s.washing_plan_id === plan.id) - .sort((a, b) => { - const order = ['confirm', 'start_washing', 'complete']; - return order.indexOf(a.step_type) - order.indexOf(b.step_type); + const planIds = washingPlans.map(p => p.id); + + let stepsData: any[] = []; + try { + const { data } = await http.get('/washing/process-steps', { plan_ids: planIds.join(',') }); + stepsData = data || []; + } catch {} + + const operatorIds = [...new Set(stepsData.filter(s => s.operator_id).map(s => s.operator_id))] as string[]; + if (operatorIds.length > 0) { + try { + const { data: profilesData } = await http.get('/common/profiles', { ids: operatorIds.join(',') }); + const names: Record = {}; + (profilesData || []).forEach(p => { names[p.id] = p.username; }); + setOperatorNames(names); + } catch {} + } + + const grouped: Record = {}; + washingPlans.forEach((plan: any) => { + const purchaserId = plan.company_id; + const purchaserName = plan.purchaser_name || plan.company?.name || '未知采购商'; + + if (!grouped[purchaserId]) { + grouped[purchaserId] = { purchaserId, purchaserName, plans: [] }; + } + + const processSteps = stepsData + .filter(s => s.washing_plan_id === plan.id) + .sort((a, b) => { + const order = ['confirm', 'start_washing', 'complete']; + return order.indexOf(a.step_type) - order.indexOf(b.step_type); + }); + + grouped[purchaserId].plans.push({ + ...plan, + product_name: plan.product_name || plan.product?.product_name, + product_color: plan.product_color || plan.product?.color, + fabric_code: plan.fabric_code || plan.product?.fabric_code, + purchaser_name: purchaserName, + processSteps }); - - grouped[purchaserId].plans.push({ - ...plan, - product_name: (plan.product as any)?.product_name, - product_color: (plan.product as any)?.color, - fabric_code: (plan.product as any)?.fabric_code, - purchaser_name: purchaserName, - processSteps }); - }); - const groupList = Object.values(grouped); - setGroups(groupList); - if (groupList.length > 0) { - setExpandedGroups(new Set([groupList[0].purchaserId])); + const groupList = Object.values(grouped); + setGroups(groupList); + if (groupList.length > 0) { + setExpandedGroups(new Set([groupList[0].purchaserId])); + } + } catch (err) { + handleError(err, { userMessage: '获取水洗计划失败' }); } setLoading(false); }; @@ -201,41 +181,25 @@ export function WashingPlanOverview() { // 确认流程步骤 const handleConfirmStep = async (stepId: string, stepType: string) => { setConfirming(stepId); - const now = new Date().toISOString(); try { - // 更新步骤状态 - await supabase - .from('washing_process_steps') - .update({ - status: WASHING_PLAN_STATUS.COMPLETED, - timestamp: now, - operator_id: auth.user?.id - }) - .eq('id', stepId); + const plan = groups.flatMap(g => g.plans).find( + p => p.processSteps.some(s => s.id === stepId) + ); + if (!plan) { setConfirming(null); return; } - // 获取计划ID - const { data: step } = await supabase - .from('washing_process_steps') - .select('washing_plan_id') - .eq('id', stepId) - .single(); - - if (step) { - // 更新计划状态 - if (stepType === 'confirm') { - await supabase - .from('washing_plans') - .update({ status: WASHING_PLAN_STATUS.IN_PROGRESS }) - .eq('id', step.washing_plan_id); - } else if (stepType === 'complete') { - await supabase - .from('washing_plans') - .update({ status: WASHING_PLAN_STATUS.COMPLETED }) - .eq('id', step.washing_plan_id); - } + if (stepType === 'confirm') { + await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.IN_PROGRESS }); + } else if (stepType === 'complete') { + await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.COMPLETED }); } + await http.patch(`/washing/process-steps/${stepId}`, { + status: WASHING_PLAN_STATUS.COMPLETED, + timestamp: new Date().toISOString(), + operator_id: auth.user?.id + }); + await fetchPlans(); } catch (err) { handleError(err, { userMessage: '确认失败,请重试', showAlert: true }); @@ -259,29 +223,13 @@ export function WashingPlanOverview() { e.preventDefault(); if (!selectedPlan || !auth.user) return; - const now = new Date().toISOString(); - try { - // 创建完成记录 - await supabase.from('washing_plan_completions').insert({ - company_id: auth.company!.id, - washing_plan_id: selectedPlan.id, + await washingApi.completePlan(selectedPlan.id, { actual_washed_meters: parseFloat(completeForm.meters), actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate), rolls: parseInt(completeForm.rolls) || 0, washing_date: new Date().toISOString().split('T')[0], - completed_by: auth.user.id - }); - - // 更新计划状态 - await supabase - .from('washing_plans') - .update({ - status: WASHING_PLAN_STATUS.COMPLETED, - actual_washed_meters: parseFloat(completeForm.meters), - actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate) - }) - .eq('id', selectedPlan.id); + } as any); setCompleteModalOpen(false); setSelectedPlan(null); diff --git a/src/supabase/client.ts b/src/supabase/client.ts deleted file mode 100644 index 3c3d43c..0000000 --- a/src/supabase/client.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 🚨 此文件由 Meoo Cloud 自动生成,请勿手动修改! - * This file is auto-generated by Meoo Cloud. Do not edit manually! - */ - -import { createClient } from '@supabase/supabase-js'; -import type { Database } from './types'; - -export function getSupabaseUrl(): string { - return `${(window as any).MEOO_CONFIG?.meoo_app_access_url || location.origin}/sb-api`; -} - -export const supabaseUrl = getSupabaseUrl(); -export const supabaseAnonKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzc5MzM2NjUyLCJleHAiOjEzMjg5OTc2NjUyfQ.NQrukIeqa-IEBtFEf8YOsVK4vxbi6kQqIi-eyRsb2pA'; - -export const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: { - persistSession: true, - autoRefreshToken: true, - }, -}); diff --git a/src/utils/dataValidation.ts b/src/utils/dataValidation.ts index 2c5556e..39cbb04 100644 --- a/src/utils/dataValidation.ts +++ b/src/utils/dataValidation.ts @@ -3,7 +3,7 @@ * 确保财务与核心数据100%正确 */ -import { supabase } from '../supabase/client'; +import { http } from '../api'; // ============================================================================ // 类型定义 @@ -150,34 +150,17 @@ export async function checkPlanQuantityConsistency(planId: string): Promise<{ inventoryTotal: number; difference: number; }> { - const { data: plan, error: planError } = await supabase - .from('production_plans') - .select('completed_quantity') - .eq('id', planId) - .single(); + const { data } = await http.get(`/textile/plans/${planId}/consistency`); - if (planError || !plan) { - throw new Error(`查询计划失败: ${planError?.message}`); + if (!data) { + throw new Error(`查询计划一致性失败`); } - const { data: inventorySum, error: sumError } = await supabase - .from('inventory_records') - .select('quantity') - .eq('plan_id', planId); - - if (sumError) { - throw new Error(`查询入库记录失败: ${sumError.message}`); - } - - const inventoryTotal = inventorySum?.reduce((sum, r) => sum + (r.quantity || 0), 0) || 0; - const difference = plan.completed_quantity - inventoryTotal; - const tolerance = 0.01; - return { - isConsistent: Math.abs(difference) <= tolerance, - completedQuantity: plan.completed_quantity, - inventoryTotal, - difference, + isConsistent: data.is_consistent ?? Math.abs(data.difference) <= 0.01, + completedQuantity: data.completed_quantity ?? 0, + inventoryTotal: data.inventory_total ?? 0, + difference: data.difference ?? 0, }; } @@ -197,14 +180,7 @@ export async function runFullDataIntegrityCheck(): Promise }; try { - // 调用数据库函数进行检查 - const { data, error } = await supabase.rpc('run_data_integrity_check'); - - if (error) { - console.error('数据完整性检查失败:', error); - report.overallStatus = 'FAIL'; - return report; - } + const { data } = await http.post('/common/data-integrity-check'); if (data && Array.isArray(data)) { for (const check of data) { @@ -264,7 +240,6 @@ export async function runFullDataIntegrityCheck(): Promise report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL'; } catch (err) { - console.error('数据完整性检查异常:', err); report.overallStatus = 'FAIL'; } @@ -279,51 +254,17 @@ export async function runFullDataIntegrityCheck(): Promise * 获取最近的审计日志 */ export async function getRecentAuditLogs(limit: number = 100) { - const { data, error } = await supabase - .from('data_audit_logs') - .select('*') - .order('changed_at', { ascending: false }) - .limit(limit); - - if (error) { - throw new Error(`查询审计日志失败: ${error.message}`); - } - + const { data } = await http.get('/common/audit-logs', { limit }); return data; } -/** - * 获取失败的验证记录 - */ export async function getFailedValidations(limit: number = 50) { - const { data, error } = await supabase - .from('data_audit_logs') - .select('*') - .eq('validation_passed', false) - .order('changed_at', { ascending: false }) - .limit(limit); - - if (error) { - throw new Error(`查询失败记录失败: ${error.message}`); - } - + const { data } = await http.get('/common/audit-logs', { validation_passed: 'false', limit }); return data; } -/** - * 获取错误率统计 - */ export async function getErrorRateStats(days: number = 30) { - const { data, error } = await supabase - .from('v_error_rate_monitor') - .select('*') - .gte('check_date', new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()) - .order('check_date', { ascending: false }); - - if (error) { - throw new Error(`查询错误率统计失败: ${error.message}`); - } - + const { data } = await http.get('/common/error-rate-stats', { days }); return data; } @@ -343,35 +284,22 @@ export async function safeInsertInventoryRecord(record: { operator_id: string; price_per_meter?: number; }) { - // 第一层:前端校验 const validation = validateInventoryRecord(record); if (!validation.isValid) { throw new Error(`数据校验失败: ${validation.errors.join(', ')}`); } - // 第二层:数据库写入(触发器会进行二次校验) - const { data, error } = await supabase - .from('inventory_records') - .insert(record) - .select(); + const { data } = await http.post(`/textile/plans/${record.plan_id}/inventory`, record); - if (error) { - throw new Error(`写入失败: ${error.message}`); + if (!data) { + throw new Error('写入失败'); } - if (!data || data.length === 0) { - throw new Error('写入失败:可能被 RLS 策略拦截'); - } - - // 第三层:写入后验证 const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id); if (!consistencyCheck.isConsistent) { - console.warn( - `警告:入库后数据不一致,计划 ${record.plan_id} 完成数量 ${consistencyCheck.completedQuantity},入库总和 ${consistencyCheck.inventoryTotal}` - ); } - return data[0]; + return data; } /** @@ -386,25 +314,16 @@ export async function safeInsertPayment(payment: { price_per_meter: number; status: 'pending' | 'completed'; }) { - // 第一层:前端校验 const validation = validatePayment(payment); if (!validation.isValid) { throw new Error(`数据校验失败: ${validation.errors.join(', ')}`); } - // 第二层:数据库写入 - const { data, error } = await supabase - .from('payments') - .insert(payment) - .select(); + const { data } = await http.post('/textile/payments', payment); - if (error) { - throw new Error(`写入失败: ${error.message}`); + if (!data) { + throw new Error('写入失败'); } - if (!data || data.length === 0) { - throw new Error('写入失败:可能被 RLS 策略拦截'); - } - - return data[0]; + return data; } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 497219e..7a553a9 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -1,4 +1,5 @@ -import { supabase } from '../supabase/client'; +import { http } from '../api'; +import { commonApi } from '../api'; export type NotificationType = 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system'; @@ -12,13 +13,9 @@ interface NotificationData { content: string; } -/** - * 发送通知给指定用户 - */ export async function sendNotification(data: NotificationData): Promise { - const { error } = await supabase - .from('notifications') - .insert({ + try { + await http.post('/common/notifications', { recipient_id: data.recipient_id, sender_id: data.sender_id, sender_company_id: data.sender_company_id, @@ -27,72 +24,31 @@ export async function sendNotification(data: NotificationData): Promise { title: data.title, content: data.content }); - - if (error) { - console.error('发送通知失败:', error); + } catch { } } -/** - * 获取计划的采购商用户ID - */ export async function getPlanPurchaserUserId(planId: string): Promise { - const { data: plan } = await supabase - .from('production_plans') - .select('purchaser_id, created_by') - .eq('id', planId) - .maybeSingle(); - - if (!plan) return null; - - // 优先返回创建者,如果没有则返回公司的主账号 - if (plan.created_by) { - return plan.created_by; + try { + const { data: plan } = await http.get(`/common/plans/${planId}/purchaser`); + if (!plan) return null; + return plan.user_id || null; + } catch { + return null; } - - // 获取公司的主账号 - const { data: profile } = await supabase - .from('profiles') - .select('id') - .eq('company_id', plan.purchaser_id) - .eq('is_master', true) - .maybeSingle(); - - return profile?.id || null; } -/** - * 获取计划的关联工厂用户ID列表 - */ export async function getPlanFactoryUserIds(planId: string, factoryType?: 'textile' | 'washing'): Promise { - let query = supabase - .from('plan_factories') - .select('factory_id') - .eq('plan_id', planId); - - if (factoryType) { - query = query.eq('factory_type', factoryType); + try { + const params: Record = {}; + if (factoryType) params.factory_type = factoryType; + const { data } = await http.get(`/common/plans/${planId}/factory-users`, params); + return (data || []).map(p => p.id); + } catch { + return []; } - - const { data: factories } = await query; - - if (!factories || factories.length === 0) return []; - - const factoryIds = factories.map(f => f.factory_id); - - // 获取这些工厂的主账号用户ID - const { data: profiles } = await supabase - .from('profiles') - .select('id') - .in('company_id', factoryIds) - .eq('is_master', true); - - return (profiles || []).map(p => p.id); } -/** - * 发送流程节点完成通知给采购商 - */ export async function notifyStepCompleted( planId: string, stepName: string, @@ -100,48 +56,17 @@ export async function notifyStepCompleted( senderUserId: string, details?: { quantity?: number; rolls?: number } ): Promise { - const purchaserId = await getPlanPurchaserUserId(planId); - if (!purchaserId) return; - - const { data: plan } = await supabase - .from('production_plans') - .select('plan_code, product_name, color, fabric_code, color_code') - .eq('id', planId) - .maybeSingle(); - - if (!plan) return; - - const { data: senderCompany } = await supabase - .from('companies') - .select('name') - .eq('id', senderCompanyId) - .maybeSingle(); - - let title = `生产节点完成: ${stepName}`; - let content = `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的"${stepName}"节点已由 ${senderCompany?.name || '工厂'} 确认完成`; - - if (stepName === '坯布生产' && details?.quantity) { - title = `坯布生产: ${plan.fabric_code}`; - content = `${senderCompany?.name || '工厂'} 完成入库 ${details.quantity}米`; - if (details.rolls) { - content += ` (${details.rolls}匹)`; - } + try { + await http.post(`/common/plans/${planId}/notify/step-completed`, { + step_name: stepName, + sender_company_id: senderCompanyId, + sender_user_id: senderUserId, + details + }); + } catch { } - - await sendNotification({ - recipient_id: purchaserId, - sender_id: senderUserId, - sender_company_id: senderCompanyId, - plan_id: planId, - type: 'step_completed', - title, - content - }); } -/** - * 发送入库通知给采购商 - */ export async function notifyInventoryIn( planId: string, quantity: number, @@ -152,80 +77,34 @@ export async function notifyInventoryIn( await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls }); } -/** - * 发送计划创建通知给关联工厂 - */ export async function notifyPlanCreated( planId: string, factoryIds: string[], senderCompanyId: string, senderUserId: string ): Promise { - const { data: plan } = await supabase - .from('production_plans') - .select('plan_code, product_name, color, fabric_code, color_code, target_quantity') - .eq('id', planId) - .maybeSingle(); - - if (!plan) return; - - const { data: senderCompany } = await supabase - .from('companies') - .select('name') - .eq('id', senderCompanyId) - .maybeSingle(); - - // 获取工厂的主账号用户ID - const { data: profiles } = await supabase - .from('profiles') - .select('id') - .in('company_id', factoryIds) - .eq('is_master', true); - - const notifications = (profiles || []).map(profile => - sendNotification({ - recipient_id: profile.id, - sender_id: senderUserId, + try { + await http.post(`/common/plans/${planId}/notify/created`, { + factory_ids: factoryIds, sender_company_id: senderCompanyId, - plan_id: planId, - type: 'plan_created', - title: `新计划关联: ${plan.fabric_code}`, - content: `${senderCompany?.name || '采购商'} 将您添加为计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的纺织厂,计划产量 ${plan.target_quantity}米` - }) - ); - - await Promise.all(notifications); + sender_user_id: senderUserId + }); + } catch { + } } -/** - * 发送计划状态更新通知 - */ export async function notifyPlanUpdated( planId: string, status: string, senderCompanyId: string, senderUserId: string ): Promise { - const purchaserId = await getPlanPurchaserUserId(planId); - if (!purchaserId) return; - - const { data: plan } = await supabase - .from('production_plans') - .select('plan_code, fabric_code, color_code') - .eq('id', planId) - .maybeSingle(); - - if (!plan) return; - - const statusText = status === 'completed' ? '已完成' : status === 'producing' ? '生产中' : '待确定'; - - await sendNotification({ - recipient_id: purchaserId, - sender_id: senderUserId, - sender_company_id: senderCompanyId, - plan_id: planId, - type: 'plan_updated', - title: `计划状态更新: ${plan.fabric_code}`, - content: `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 状态已更新为"${statusText}"` - }); + try { + await http.post(`/common/plans/${planId}/notify/updated`, { + status, + sender_company_id: senderCompanyId, + sender_user_id: senderUserId + }); + } catch { + } } diff --git a/webpack.config.js b/webpack.config.js index 81e10f0..bacac91 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -94,6 +94,14 @@ module.exports = (env, argv) => { allowedHosts: 'all', hot: true, historyApiFallback: true, + proxy: [ + { + context: ['/api', '/ws'], + target: `http://localhost:${process.env.GATEWAY_PORT || '8080'}`, + ws: true, + changeOrigin: true, + }, + ], }, plugins: [ new HtmlWebpackPlugin({ @@ -102,8 +110,6 @@ module.exports = (env, argv) => { }), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), - 'process.env.VITE_SUPABASE_URL': JSON.stringify(process.env.VITE_SUPABASE_URL || ''), - 'process.env.VITE_SUPABASE_ANON_KEY': JSON.stringify(process.env.VITE_SUPABASE_ANON_KEY || ''), '__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'), }), ],