refactor: replace Supabase SDK with self-hosted REST API client layer
WHY: migrating from Supabase BaaS to self-hosted iloom backend (Go microservices) deployed on K3s. Supabase client and realtime subscriptions no longer applicable. HOW: - add src/api/ layer (client.ts, auth.ts, purchaser.ts, textile.ts, washing.ts, websocket.ts) targeting iloom-gateway REST endpoints - rewrite all hooks (usePlanData, useInventoryData, useDashboardData, useRealtime, etc.) to use new API client instead of Supabase queries - rewrite all page/component data fetching and mutations accordingly - delete src/supabase/client.ts, remove @supabase/supabase-js dep - add Dockerfile (multi-stage node build + nginx) and nginx.conf with reverse proxy to iloom-gateway for /api/ and /ws/ routes - adjust webpack.config.js for API_URL environment variable injection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3093ae18c2
commit
b1c2c33aa2
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -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;"]
|
||||||
42
nginx.conf
Normal file
42
nginx.conf
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,8 +20,6 @@
|
|||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"framer-motion": "^11.16.1",
|
"framer-motion": "^11.16.1",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
|
||||||
"base64-arraybuffer": "^1.0.2",
|
|
||||||
"zustand": "^4.4.7",
|
"zustand": "^4.4.7",
|
||||||
"immer": "^10.0.3"
|
"immer": "^10.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
52
src/api/auth.ts
Normal file
52
src/api/auth.ts
Normal file
@ -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<LoginResponse>('/auth/login', data);
|
||||||
|
setAccessToken(res.data.access_token);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
register: (data: RegisterRequest) =>
|
||||||
|
http.post<LoginResponse>('/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<MeResponse>('/auth/me').then(r => r.data),
|
||||||
|
};
|
||||||
119
src/api/client.ts
Normal file
119
src/api/client.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
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<boolean> | null = null;
|
||||||
|
|
||||||
|
export function setAccessToken(token: string | null) {
|
||||||
|
accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(): Promise<boolean> {
|
||||||
|
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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
params?: Record<string, string | number | undefined>,
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<T> = 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: <T>(path: string, params?: Record<string, string | number | undefined>) =>
|
||||||
|
request<T>('GET', path, undefined, params),
|
||||||
|
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>('POST', path, body),
|
||||||
|
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>('PUT', path, body),
|
||||||
|
|
||||||
|
patch: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>('PATCH', path, body),
|
||||||
|
|
||||||
|
delete: <T>(path: string) =>
|
||||||
|
request<T>('DELETE', path),
|
||||||
|
};
|
||||||
49
src/api/common.ts
Normal file
49
src/api/common.ts
Normal file
@ -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<Company>(`/common/companies/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
listRelationships: () =>
|
||||||
|
http.get<CompanyRelationship[]>('/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<CompanyMember>('/common/members', data).then(r => r.data),
|
||||||
|
|
||||||
|
deleteMember: (id: string) =>
|
||||||
|
http.delete(`/common/members/${id}`),
|
||||||
|
|
||||||
|
listNotifications: () =>
|
||||||
|
http.get<Notification[]>('/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<unknown>(`/common/share-links/${code}`).then(r => r.data),
|
||||||
|
|
||||||
|
submitFeedback: (data: { content: string }) =>
|
||||||
|
http.post('/common/feedback', data),
|
||||||
|
};
|
||||||
8
src/api/index.ts
Normal file
8
src/api/index.ts
Normal file
@ -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';
|
||||||
155
src/api/purchaser.ts
Normal file
155
src/api/purchaser.ts
Normal file
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DashboardStats>('/purchaser/dashboard/stats').then(r => r.data),
|
||||||
|
|
||||||
|
// Plans
|
||||||
|
listPlans: (params?: PaginationParams) =>
|
||||||
|
http.get<PlanListResponse>('/purchaser/plans', params as Record<string, number>),
|
||||||
|
|
||||||
|
createPlan: (data: CreatePlanRequest) =>
|
||||||
|
http.post<ProductionPlan>('/purchaser/plans', data).then(r => r.data),
|
||||||
|
|
||||||
|
getPlan: (id: string) =>
|
||||||
|
http.get<PlanDetail>(`/purchaser/plans/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
updatePlan: (id: string, data: Partial<CreatePlanRequest>) =>
|
||||||
|
http.put<ProductionPlan>(`/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<PlanFactory>(`/purchaser/plans/${planId}/factories`, data).then(r => r.data),
|
||||||
|
|
||||||
|
getPlanSteps: (planId: string) =>
|
||||||
|
http.get<PlanProcessStep[]>(`/purchaser/plans/${planId}/steps`).then(r => r.data),
|
||||||
|
|
||||||
|
getPlanInventory: (planId: string) =>
|
||||||
|
http.get<unknown[]>(`/purchaser/plans/${planId}/inventory`).then(r => r.data),
|
||||||
|
|
||||||
|
// Products
|
||||||
|
listProducts: (params?: PaginationParams) =>
|
||||||
|
http.get<Product[]>('/purchaser/products', params as Record<string, number>),
|
||||||
|
|
||||||
|
createProduct: (data: Record<string, unknown>) =>
|
||||||
|
http.post<Product>('/purchaser/products', data).then(r => r.data),
|
||||||
|
|
||||||
|
updateProduct: (id: string, data: Record<string, unknown>) =>
|
||||||
|
http.put<Product>(`/purchaser/products/${id}`, data).then(r => r.data),
|
||||||
|
|
||||||
|
deleteProduct: (id: string) =>
|
||||||
|
http.delete(`/purchaser/products/${id}`),
|
||||||
|
|
||||||
|
inbound: (productId: string, data: InboundRequest) =>
|
||||||
|
http.post<ProductInRecord>(`/purchaser/products/${productId}/inbound`, data).then(r => r.data),
|
||||||
|
|
||||||
|
outbound: (productId: string, data: OutboundRequest) =>
|
||||||
|
http.post<ProductInRecord>(`/purchaser/products/${productId}/outbound`, data).then(r => r.data),
|
||||||
|
|
||||||
|
getRecords: (productId: string) =>
|
||||||
|
http.get<ProductInRecord[]>(`/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<ProductPriceHistory[]>(`/purchaser/products/${productId}/price-history`).then(r => r.data),
|
||||||
|
|
||||||
|
// Factories
|
||||||
|
listFactories: () =>
|
||||||
|
http.get<Company[]>('/purchaser/factories').then(r => r.data),
|
||||||
|
|
||||||
|
// Accounts Payable
|
||||||
|
listAccountsPayable: (params?: PaginationParams) =>
|
||||||
|
http.get<unknown[]>('/purchaser/accounts-payable', params as Record<string, number>),
|
||||||
|
|
||||||
|
payAccountsPayable: (id: string, data: { amount: number; notes?: string }) =>
|
||||||
|
http.post(`/purchaser/accounts-payable/${id}/pay`, data),
|
||||||
|
|
||||||
|
// Washing Plans
|
||||||
|
createWashingPlan: (data: Partial<WashingPlan>) =>
|
||||||
|
http.post<WashingPlan>('/purchaser/washing-plans', data).then(r => r.data),
|
||||||
|
|
||||||
|
listWashingPlans: (params?: PaginationParams) =>
|
||||||
|
http.get<WashingPlan[]>('/purchaser/washing-plans', params as Record<string, number>),
|
||||||
|
};
|
||||||
74
src/api/textile.ts
Normal file
74
src/api/textile.ts
Normal file
@ -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<TextileDashboardStats>('/textile/dashboard/stats').then(r => r.data),
|
||||||
|
|
||||||
|
// Plans (read-only from textile perspective)
|
||||||
|
listPlans: (params?: PaginationParams) =>
|
||||||
|
http.get<TextilePlanDetail[]>('/textile/plans', params as Record<string, number>),
|
||||||
|
|
||||||
|
getPlan: (id: string) =>
|
||||||
|
http.get<TextilePlanDetail>(`/textile/plans/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
// Process Steps
|
||||||
|
completeStep: (planId: string, stepId: string, data?: { notes?: string; operator_name?: string }) =>
|
||||||
|
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/complete`, data).then(r => r.data),
|
||||||
|
|
||||||
|
rejectStep: (planId: string, stepId: string, data: { reason: string }) =>
|
||||||
|
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/reject`, data).then(r => r.data),
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
inbound: (planId: string, data: { quantity: number; rolls?: number; notes?: string }) =>
|
||||||
|
http.post<InventoryRecord>(`/textile/plans/${planId}/inventory`, data).then(r => r.data),
|
||||||
|
|
||||||
|
listInventory: (planId: string) =>
|
||||||
|
http.get<InventoryRecord[]>(`/textile/plans/${planId}/inventory`).then(r => r.data),
|
||||||
|
|
||||||
|
// Yarn Stock
|
||||||
|
listYarnStock: (params?: PaginationParams) =>
|
||||||
|
http.get<YarnStock[]>('/textile/yarn-stock', params as Record<string, number>),
|
||||||
|
|
||||||
|
createYarnStock: (data: Partial<YarnStock>) =>
|
||||||
|
http.post<YarnStock>('/textile/yarn-stock', data).then(r => r.data),
|
||||||
|
|
||||||
|
updateYarnStock: (id: string, data: Partial<YarnStock>) =>
|
||||||
|
http.put<YarnStock>(`/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<Payment[]>('/textile/payments', params as Record<string, number>),
|
||||||
|
|
||||||
|
confirmPayment: (id: string) =>
|
||||||
|
http.post<Payment>(`/textile/payments/${id}/confirm`).then(r => r.data),
|
||||||
|
};
|
||||||
63
src/api/washing.ts
Normal file
63
src/api/washing.ts
Normal file
@ -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<WashingDashboardStats>('/washing/dashboard/stats').then(r => r.data),
|
||||||
|
|
||||||
|
// Plans
|
||||||
|
listPlans: (params?: PaginationParams) =>
|
||||||
|
http.get<WashingPlan[]>('/washing/plans', params as Record<string, number>),
|
||||||
|
|
||||||
|
listPendingPlans: () =>
|
||||||
|
http.get<WashingPlan[]>('/washing/plans/pending').then(r => r.data),
|
||||||
|
|
||||||
|
listCompletedPlans: () =>
|
||||||
|
http.get<WashingPlan[]>('/washing/plans/completed').then(r => r.data),
|
||||||
|
|
||||||
|
getPlan: (id: string) =>
|
||||||
|
http.get<WashingPlan>(`/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<FinishedProduct[]>('/washing/finished-products', params as Record<string, number>),
|
||||||
|
|
||||||
|
createFinishedProduct: (data: Partial<FinishedProduct>) =>
|
||||||
|
http.post<FinishedProduct>('/washing/finished-products', data).then(r => r.data),
|
||||||
|
|
||||||
|
outboundFinishedProduct: (id: string, data: { rolls: number; meters: number; notes?: string }) =>
|
||||||
|
http.post<FinishedProductInventoryRecord>(`/washing/finished-products/${id}/outbound`, data).then(r => r.data),
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
listPayments: (params?: PaginationParams) =>
|
||||||
|
http.get<Payment[]>('/washing/payments', params as Record<string, number>),
|
||||||
|
|
||||||
|
confirmPayment: (id: string) =>
|
||||||
|
http.post<Payment>(`/washing/payments/${id}/confirm`).then(r => r.data),
|
||||||
|
};
|
||||||
89
src/api/websocket.ts
Normal file
89
src/api/websocket.ts
Normal file
@ -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<string, Set<ChangeCallback>>();
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | 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();
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
Droplets
|
Droplets
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../supabase/client';
|
import { http } from '../api';
|
||||||
|
|
||||||
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
||||||
|
|
||||||
@ -79,34 +79,30 @@ export function AccountProfileCard({
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!auth.user) return;
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileExt = file.name.split('.').pop();
|
// TODO: 头像上传接口待后端实现
|
||||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
const formDataObj = new FormData();
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
formDataObj.append('file', file);
|
||||||
|
formDataObj.append('bucket', 'avatars');
|
||||||
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const res = await fetch('/api/v1/common/upload', {
|
||||||
.from('avatars')
|
method: 'POST',
|
||||||
.upload(fileName, arrayBuffer, { contentType: file.type, upsert: true });
|
body: formDataObj,
|
||||||
|
});
|
||||||
|
|
||||||
if (uploadError) {
|
if (!res.ok) {
|
||||||
alert('头像上传失败: ' + uploadError.message);
|
alert('头像上传失败');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(fileName);
|
const json = await res.json();
|
||||||
const { error: updateError } = await supabase
|
const publicUrl = json.data?.url || '';
|
||||||
.from('profiles')
|
|
||||||
.update({ image_url: publicUrl } as any)
|
|
||||||
.eq('id', user.id);
|
|
||||||
|
|
||||||
if (updateError) {
|
await http.patch('/auth/me', { image_url: publicUrl });
|
||||||
alert('头像更新失败: ' + updateError.message);
|
|
||||||
} else {
|
window.location.reload();
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('头像上传出错,请重试');
|
alert('头像上传出错,请重试');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react';
|
import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react';
|
||||||
import { supabase } from '../supabase/client';
|
import { http } from '../api';
|
||||||
|
|
||||||
interface CrashReport {
|
interface CrashReport {
|
||||||
error: Error;
|
error: Error;
|
||||||
@ -40,19 +40,13 @@ export function CrashReporter() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户信息
|
await http.post('/common/crash-reports', {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
// 提交崩溃报告
|
|
||||||
await supabase.from('crash_reports').insert({
|
|
||||||
error_message: crash.error.message,
|
error_message: crash.error.message,
|
||||||
error_stack: crash.error.stack,
|
error_stack: crash.error.stack,
|
||||||
component_stack: crash.errorInfo.componentStack,
|
component_stack: crash.errorInfo.componentStack,
|
||||||
user_description: userDescription.trim() || null,
|
user_description: userDescription.trim() || null,
|
||||||
user_agent: crash.userAgent,
|
user_agent: crash.userAgent,
|
||||||
url: crash.url,
|
url: crash.url,
|
||||||
user_id: user?.id || null,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react';
|
import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react';
|
||||||
import { supabase } from '../supabase/client';
|
import { commonApi } from '../api';
|
||||||
|
|
||||||
interface FeedbackModalProps {
|
interface FeedbackModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -29,16 +29,8 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户信息
|
await commonApi.submitFeedback({
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
content: `[${type}] ${content.trim()}${contact.trim() ? ` (联系方式: ${contact.trim()})` : ''}`,
|
||||||
|
|
||||||
// 提交反馈到数据库
|
|
||||||
await supabase.from('user_feedback').insert({
|
|
||||||
type,
|
|
||||||
content: content.trim(),
|
|
||||||
contact: contact.trim() || null,
|
|
||||||
user_id: user?.id || null,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
||||||
import { supabase } from '../supabase/client';
|
import { http } from '../api';
|
||||||
import { decode } from 'base64-arraybuffer';
|
|
||||||
|
|
||||||
export type UploadBucket = 'avatars' | 'product_images';
|
export type UploadBucket = 'avatars' | 'product_images';
|
||||||
|
|
||||||
@ -69,44 +68,29 @@ export function ImageUploader({
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 读取文件为 base64
|
const formDataObj = new FormData();
|
||||||
const reader = new FileReader();
|
formDataObj.append('file', file);
|
||||||
reader.onload = async (event) => {
|
formDataObj.append('bucket', bucket);
|
||||||
const base64 = (event.target?.result as string).split(',')[1];
|
formDataObj.append('path_prefix', pathPrefix);
|
||||||
|
|
||||||
// 生成唯一文件名
|
// TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
|
||||||
const fileExt = file.name.split('.').pop();
|
const res = await fetch('/api/v1/common/upload', {
|
||||||
const fileName = bucket === 'avatars'
|
method: 'POST',
|
||||||
? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`
|
body: formDataObj,
|
||||||
: `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
});
|
||||||
|
|
||||||
// 上传图片到 Supabase Storage
|
if (!res.ok) {
|
||||||
const { error } = await supabase.storage
|
alert('图片上传失败');
|
||||||
.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);
|
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
};
|
return;
|
||||||
reader.readAsDataURL(file);
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const publicUrl = json.data?.url || '';
|
||||||
|
setPreviewUrl(publicUrl);
|
||||||
|
onChange(publicUrl);
|
||||||
|
setUploading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传错误:', error);
|
|
||||||
alert('图片上传失败');
|
alert('图片上传失败');
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
|
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '../supabase/client';
|
import { commonApi } from '../api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@ -52,23 +52,15 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.user) return;
|
if (!auth.user) return;
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
const channel = subscribeToNotifications();
|
|
||||||
|
|
||||||
return () => {
|
const interval = setInterval(fetchNotifications, 30000);
|
||||||
channel.unsubscribe();
|
return () => clearInterval(interval);
|
||||||
};
|
|
||||||
}, [auth.user]);
|
}, [auth.user]);
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
const fetchNotifications = async () => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('notifications')
|
const data = await commonApi.listNotifications();
|
||||||
.select('*, sender_company_id(name)')
|
const formatted = (data as any[]).map((n: any) => ({
|
||||||
.eq('recipient_id', auth.user!.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(20);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
const formatted = data.map(n => ({
|
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type as Notification['type'],
|
type: n.type as Notification['type'],
|
||||||
title: n.title,
|
title: n.title,
|
||||||
@ -76,39 +68,17 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
|||||||
is_read: n.is_read || false,
|
is_read: n.is_read || false,
|
||||||
created_at: n.created_at || '',
|
created_at: n.created_at || '',
|
||||||
plan_id: n.plan_id,
|
plan_id: n.plan_id,
|
||||||
sender_company_name: n.sender_company_id?.name
|
sender_company_name: n.sender_company_name
|
||||||
}));
|
}));
|
||||||
setNotifications(formatted);
|
setNotifications(formatted);
|
||||||
setUnreadCount(formatted.filter(n => !n.is_read).length);
|
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) => {
|
const markAsRead = async (id: string) => {
|
||||||
await supabase
|
await commonApi.markNotificationRead(id);
|
||||||
.from('notifications')
|
|
||||||
.update({ is_read: true, read_at: new Date().toISOString() })
|
|
||||||
.eq('id', id);
|
|
||||||
|
|
||||||
setNotifications(prev => prev.map(n =>
|
setNotifications(prev => prev.map(n =>
|
||||||
n.id === id ? { ...n, is_read: true } : n
|
n.id === id ? { ...n, is_read: true } : n
|
||||||
@ -117,11 +87,8 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = async () => {
|
||||||
await supabase
|
const unread = notifications.filter(n => !n.is_read);
|
||||||
.from('notifications')
|
await Promise.all(unread.map(n => commonApi.markNotificationRead(n.id)));
|
||||||
.update({ is_read: true, read_at: new Date().toISOString() })
|
|
||||||
.eq('recipient_id', auth.user!.id)
|
|
||||||
.eq('is_read', false);
|
|
||||||
|
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, AlertTriangle, Package, Check, Loader2, Edit3, Search } from 'lucide-react';
|
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';
|
import type { YarnRatio, YarnStock } from '../types';
|
||||||
|
|
||||||
interface YarnAllocation {
|
interface YarnAllocation {
|
||||||
@ -54,13 +54,12 @@ export function YarnAllocationModal({
|
|||||||
|
|
||||||
const fetchYarnStock = async () => {
|
const fetchYarnStock = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('yarn_stock')
|
const res = await http.get<YarnStock[]>('/textile/yarn-stock', { company_id: companyId });
|
||||||
.select('*')
|
setYarnStock(res.data || []);
|
||||||
.eq('company_id', companyId)
|
} catch {
|
||||||
.order('name');
|
setYarnStock([]);
|
||||||
|
}
|
||||||
setYarnStock(data || []);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -186,40 +185,24 @@ export function YarnAllocationModal({
|
|||||||
setConfirming(true);
|
setConfirming(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户ID
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
const operatorId = user?.id;
|
|
||||||
|
|
||||||
if (isManualMode) {
|
if (isManualMode) {
|
||||||
// 手动模式:在仓库中查找或创建纱线并扣除库存
|
|
||||||
for (const alloc of allocations) {
|
for (const alloc of allocations) {
|
||||||
const manualName = alloc.manualName!;
|
const manualName = alloc.manualName!;
|
||||||
const manualQty = alloc.manualQuantity!;
|
const manualQty = alloc.manualQuantity!;
|
||||||
|
|
||||||
// 查找是否已有同名纱线
|
|
||||||
const existingYarn = yarnStock.find(y =>
|
const existingYarn = yarnStock.find(y =>
|
||||||
y.name.toLowerCase() === manualName.toLowerCase()
|
y.name.toLowerCase() === manualName.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingYarn) {
|
if (existingYarn) {
|
||||||
// 扣除现有纱线库存
|
|
||||||
if (existingYarn.quantity >= manualQty) {
|
if (existingYarn.quantity >= manualQty) {
|
||||||
const newQuantity = existingYarn.quantity - manualQty;
|
const newQuantity = existingYarn.quantity - manualQty;
|
||||||
await supabase
|
await http.put(`/textile/yarn-stock/${existingYarn.id}`, { quantity: newQuantity });
|
||||||
.from('yarn_stock')
|
|
||||||
.update({ quantity: newQuantity })
|
|
||||||
.eq('id', existingYarn.id);
|
|
||||||
|
|
||||||
// 记录出库
|
await http.post(`/textile/yarn-stock/${existingYarn.id}/records`, {
|
||||||
await supabase.from('yarn_stock_records').insert({
|
change_type: 'outbound',
|
||||||
company_id: companyId,
|
|
||||||
yarn_stock_id: existingYarn.id,
|
|
||||||
plan_id: planId || null,
|
|
||||||
record_type: 'outbound',
|
|
||||||
quantity: manualQty,
|
quantity: manualQty,
|
||||||
unit: 'kg',
|
|
||||||
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
||||||
operator_id: operatorId
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg,库存 ${existingYarn.quantity}kg`);
|
alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg,库存 ${existingYarn.quantity}kg`);
|
||||||
@ -227,54 +210,34 @@ export function YarnAllocationModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 创建新纱线记录并扣除(负库存表示已使用但未入库)
|
const res = await http.post<YarnStock>('/textile/yarn-stock', {
|
||||||
const { data: newYarn } = await supabase
|
company_id: companyId,
|
||||||
.from('yarn_stock')
|
name: manualName,
|
||||||
.insert({
|
quantity: -manualQty,
|
||||||
company_id: companyId,
|
unit: 'kg',
|
||||||
name: manualName,
|
min_stock: 0
|
||||||
quantity: -manualQty,
|
});
|
||||||
unit: 'kg',
|
const newYarn = res.data;
|
||||||
min_stock: 0
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// 记录出库
|
|
||||||
if (newYarn) {
|
if (newYarn) {
|
||||||
await supabase.from('yarn_stock_records').insert({
|
await http.post(`/textile/yarn-stock/${newYarn.id}/records`, {
|
||||||
company_id: companyId,
|
change_type: 'outbound',
|
||||||
yarn_stock_id: newYarn.id,
|
|
||||||
plan_id: planId || null,
|
|
||||||
record_type: 'outbound',
|
|
||||||
quantity: manualQty,
|
quantity: manualQty,
|
||||||
unit: 'kg',
|
|
||||||
notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`,
|
notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`,
|
||||||
operator_id: operatorId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 自动模式:扣除库存
|
|
||||||
for (const alloc of allocations) {
|
for (const alloc of allocations) {
|
||||||
if (alloc.allocatedYarn && alloc.isSufficient) {
|
if (alloc.allocatedYarn && alloc.isSufficient) {
|
||||||
const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity;
|
const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity;
|
||||||
await supabase
|
await http.put(`/textile/yarn-stock/${alloc.allocatedYarn.id}`, { quantity: newQuantity });
|
||||||
.from('yarn_stock')
|
|
||||||
.update({ quantity: newQuantity })
|
|
||||||
.eq('id', alloc.allocatedYarn.id);
|
|
||||||
|
|
||||||
// 记录出库
|
await http.post(`/textile/yarn-stock/${alloc.allocatedYarn.id}/records`, {
|
||||||
await supabase.from('yarn_stock_records').insert({
|
change_type: 'outbound',
|
||||||
company_id: companyId,
|
|
||||||
yarn_stock_id: alloc.allocatedYarn.id,
|
|
||||||
plan_id: planId || null,
|
|
||||||
record_type: 'outbound',
|
|
||||||
quantity: alloc.requiredQuantity,
|
quantity: alloc.requiredQuantity,
|
||||||
unit: 'kg',
|
|
||||||
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
||||||
operator_id: operatorId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react';
|
import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi } from '../../api';
|
||||||
import type { PlanWithSteps } from '../../types';
|
import type { PlanWithSteps } from '../../types';
|
||||||
|
|
||||||
interface PlanEditModalProps {
|
interface PlanEditModalProps {
|
||||||
@ -39,24 +39,20 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
try {
|
||||||
.from('production_plans')
|
await purchaserApi.updatePlan(plan.id, {
|
||||||
.update({
|
|
||||||
product_name: editForm.product_name,
|
product_name: editForm.product_name,
|
||||||
color: editForm.color,
|
color_code: editForm.color,
|
||||||
target_quantity: editForm.target_quantity,
|
target_quantity: editForm.target_quantity,
|
||||||
remark: editForm.remark || null,
|
notes: editForm.remark || undefined,
|
||||||
updated_at: new Date().toISOString()
|
});
|
||||||
})
|
|
||||||
.eq('id', plan.id);
|
|
||||||
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
setError('更新失败: ' + updateError.message);
|
|
||||||
} else {
|
|
||||||
onPlanUpdated?.();
|
onPlanUpdated?.();
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setError('更新失败: ' + (err.message || '未知错误'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,25 +65,15 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// 先删除关联的 process_steps
|
try {
|
||||||
await supabase.from('plan_process_steps').delete().eq('plan_id', plan.id);
|
await purchaserApi.deletePlan(plan.id);
|
||||||
|
|
||||||
// 再删除 plan_factories 关联
|
setIsSubmitting(false);
|
||||||
await supabase.from('plan_factories').delete().eq('plan_id', plan.id);
|
|
||||||
|
|
||||||
// 最后删除计划
|
|
||||||
const { error: deleteError } = await supabase
|
|
||||||
.from('production_plans')
|
|
||||||
.delete()
|
|
||||||
.eq('id', plan.id);
|
|
||||||
|
|
||||||
setIsSubmitting(false);
|
|
||||||
|
|
||||||
if (deleteError) {
|
|
||||||
setError('删除失败: ' + deleteError.message);
|
|
||||||
} else {
|
|
||||||
onPlanUpdated?.();
|
onPlanUpdated?.();
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setError('删除失败: ' + (err.message || '未知错误'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react';
|
import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { encryptShareParams } from '../../utils/shareCrypto';
|
import { encryptShareParams } from '../../utils/shareCrypto';
|
||||||
import { supabase } from '../../supabase/client';
|
import { http, commonApi } from '../../api';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -53,6 +54,7 @@ export function ShareModal({
|
|||||||
onDirectPush
|
onDirectPush
|
||||||
}: ShareModalProps) {
|
}: ShareModalProps) {
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
const { auth } = useAuth();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [recipientCompany, setRecipientCompany] = useState<string>('');
|
const [recipientCompany, setRecipientCompany] = useState<string>('');
|
||||||
@ -77,63 +79,41 @@ export function ShareModal({
|
|||||||
|
|
||||||
setIsCheckingRelationship(true);
|
setIsCheckingRelationship(true);
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!auth.user) {
|
||||||
if (!user) {
|
|
||||||
setIsCheckingRelationship(false);
|
setIsCheckingRelationship(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前用户的公司信息
|
try {
|
||||||
const { data: profile } = await supabase
|
if (auth.company) {
|
||||||
.from('profiles')
|
setRecipientCompany(auth.company.name);
|
||||||
.select('company_id')
|
|
||||||
.eq('id', user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (profile?.company_id) {
|
const res = await http.get<{ has_relationship: boolean }>('/common/companies/check-relationship', {
|
||||||
// 获取公司名称
|
factory_id: factoryId,
|
||||||
const { data: company } = await supabase
|
});
|
||||||
.from('companies')
|
setHasRelationship(res.data.has_relationship);
|
||||||
.select('name')
|
|
||||||
.eq('id', profile.company_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (company) {
|
if (!res.data.has_relationship) {
|
||||||
setRecipientCompany(company.name);
|
try {
|
||||||
}
|
const linkRes = await http.get<ShareLinkStatus | null>('/common/share-links/active', {
|
||||||
|
plan_id: planId,
|
||||||
// 检查是否已有合作关系
|
factory_type: factoryType,
|
||||||
const { data: relationship } = await supabase
|
});
|
||||||
.from('company_relationships')
|
if (linkRes.data) {
|
||||||
.select('*')
|
const link = linkRes.data;
|
||||||
.eq('purchaser_company_id', profile.company_id)
|
const expiresAt = link.expires_at ? new Date(link.expires_at) : null;
|
||||||
.eq('factory_company_id', factoryId)
|
if (expiresAt && expiresAt > new Date()) {
|
||||||
.eq('status', 'active')
|
setExistingLink(link);
|
||||||
.maybeSingle();
|
setShortCode(link.short_code);
|
||||||
|
}
|
||||||
setHasRelationship(!!relationship);
|
}
|
||||||
|
} catch {
|
||||||
// 如果没有合作关系,检查是否已有待处理的分享链接
|
// no existing link
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setHasRelationship(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCheckingRelationship(false);
|
setIsCheckingRelationship(false);
|
||||||
@ -141,7 +121,6 @@ export function ShareModal({
|
|||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
// 清理
|
|
||||||
return () => {
|
return () => {
|
||||||
shortLinkGenerated.current = false;
|
shortLinkGenerated.current = false;
|
||||||
setShortCode('');
|
setShortCode('');
|
||||||
@ -157,64 +136,41 @@ export function ShareModal({
|
|||||||
|
|
||||||
setIsGeneratingLink(true);
|
setIsGeneratingLink(true);
|
||||||
|
|
||||||
const baseUrl = window.location.origin + window.location.pathname;
|
try {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const res = await commonApi.createShareLink({
|
||||||
let code = '';
|
type: factoryType,
|
||||||
for (let i = 0; i < 6; i++) {
|
target_id: planId,
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (res.code) {
|
||||||
if (user) {
|
setShortCode(res.code);
|
||||||
// 取消该计划之前未处理的链接
|
|
||||||
await supabase
|
|
||||||
.from('share_links')
|
|
||||||
.update({ status: 'cancelled' })
|
|
||||||
.eq('plan_id', planId)
|
|
||||||
.eq('status', 'pending');
|
|
||||||
|
|
||||||
// 创建新链接(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);
|
|
||||||
} else {
|
} else {
|
||||||
setError('生成链接失败,请重试');
|
setError('生成链接失败,请重试');
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setError('生成链接失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsGeneratingLink(false);
|
setIsGeneratingLink(false);
|
||||||
}, [planId, factoryType, hideSensitive, factoryId]);
|
}, [planId, factoryType, hideSensitive, factoryId]);
|
||||||
|
|
||||||
// 直接推送计划(已有合作关系)
|
|
||||||
const handleDirectPush = async () => {
|
const handleDirectPush = async () => {
|
||||||
if (!factoryId || !onDirectPush) return;
|
if (!factoryId || !onDirectPush) return;
|
||||||
|
|
||||||
setIsGeneratingLink(true);
|
setIsGeneratingLink(true);
|
||||||
|
|
||||||
// 直接创建 plan_factories 关联
|
try {
|
||||||
const { error } = await supabase.from('plan_factories').insert({
|
await http.post(`/purchaser/plans/${planId}/factories`, {
|
||||||
plan_id: planId,
|
factory_id: factoryId,
|
||||||
factory_id: factoryId,
|
factory_type: factoryType,
|
||||||
factory_type: factoryType
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
setPushSuccess(true);
|
setPushSuccess(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onDirectPush();
|
onDirectPush();
|
||||||
onClose();
|
onClose();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} catch {
|
||||||
setError('推送失败,请重试');
|
setError('推送失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,14 +222,13 @@ export function ShareModal({
|
|||||||
const checkLinkStatus = async () => {
|
const checkLinkStatus = async () => {
|
||||||
if (!existingLink) return;
|
if (!existingLink) return;
|
||||||
|
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('share_links')
|
const data = await commonApi.getShareLink(existingLink.short_code || existingLink.id);
|
||||||
.select('*')
|
if (data) {
|
||||||
.eq('id', existingLink.id)
|
setExistingLink(data as ShareLinkStatus);
|
||||||
.single();
|
}
|
||||||
|
} catch {
|
||||||
if (data) {
|
// ignore
|
||||||
setExistingLink(data as ShareLinkStatus);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X, Package, AlertCircle, Building2, Tag } from 'lucide-react';
|
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';
|
import type { ProductWithInventory, ProductInRecord, Company } from '../../types';
|
||||||
|
|
||||||
interface OutboundFormProps {
|
interface OutboundFormProps {
|
||||||
@ -49,26 +50,21 @@ export function OutboundForm({
|
|||||||
const fetchInboundBatches = async () => {
|
const fetchInboundBatches = async () => {
|
||||||
if (!formData.product_id) return;
|
if (!formData.product_id) return;
|
||||||
setLoadingBatches(true);
|
setLoadingBatches(true);
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('product_inventory_records')
|
const data = await purchaserApi.getRecords(formData.product_id);
|
||||||
.select('*')
|
setInboundBatches(data || []);
|
||||||
.eq('product_id', formData.product_id)
|
} catch {
|
||||||
.order('created_at', { ascending: false });
|
setInboundBatches([]);
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setInboundBatches(data);
|
|
||||||
}
|
}
|
||||||
setLoadingBatches(false);
|
setLoadingBatches(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCompanies = async () => {
|
const fetchCompanies = async () => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('companies')
|
const data = await purchaserApi.listFactories();
|
||||||
.select('*')
|
setCompanies(data || []);
|
||||||
.order('name', { ascending: true });
|
} catch {
|
||||||
|
setCompanies([]);
|
||||||
if (data) {
|
|
||||||
setCompanies(data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
|
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { supabase } from '../../supabase/client';
|
import { http } from '../../api';
|
||||||
import { decode } from 'base64-arraybuffer';
|
|
||||||
import type { Product, ProductYarnRatio } from '../../types';
|
import type { Product, ProductYarnRatio } from '../../types';
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
@ -138,22 +137,15 @@ export function ProductForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setShowProductSuggestions(true); // 立即显示建议框(带加载状态)
|
setShowProductSuggestions(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const res = await http.get<ProductSuggestion[]>('/purchaser/products', {
|
||||||
.from('products')
|
search: query,
|
||||||
.select('id, product_name, total_weight, warp_weight, weft_weight, color, fabric_code, color_code, production_price, image_url')
|
page_size: 10,
|
||||||
.eq('company_id', companyId)
|
});
|
||||||
.ilike('product_name', `%${query}%`)
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
if (!error && data) {
|
setProductSuggestions(res.data || []);
|
||||||
setProductSuggestions(data as ProductSuggestion[]);
|
} catch {
|
||||||
} else {
|
|
||||||
setProductSuggestions([]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('搜索产品失败:', err);
|
|
||||||
setProductSuggestions([]);
|
setProductSuggestions([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
@ -168,22 +160,18 @@ export function ProductForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const res = await http.get<{ total_weight: number | null }[]>('/purchaser/products', {
|
||||||
.from('products')
|
search: productName,
|
||||||
.select('total_weight')
|
page_size: 20,
|
||||||
.eq('company_id', companyId)
|
});
|
||||||
.eq('product_name', productName)
|
|
||||||
.not('total_weight', 'is', null)
|
|
||||||
.limit(20);
|
|
||||||
|
|
||||||
if (!error && data) {
|
const weights = (res.data || [])
|
||||||
// 提取唯一的克重值,过滤掉 null 值
|
.map(p => p.total_weight)
|
||||||
const weights = data.map(p => p.total_weight).filter((w): w is number => w !== null && w > 0);
|
.filter((w): w is number => w !== null && w > 0);
|
||||||
const uniqueWeights = Array.from(new Set(weights));
|
const uniqueWeights = Array.from(new Set(weights));
|
||||||
setWeightSuggestions(uniqueWeights.sort((a, b) => a - b));
|
setWeightSuggestions(uniqueWeights.sort((a, b) => a - b));
|
||||||
}
|
} catch {
|
||||||
} catch (err) {
|
setWeightSuggestions([]);
|
||||||
console.error('搜索克重失败:', err);
|
|
||||||
}
|
}
|
||||||
}, [companyId]);
|
}, [companyId]);
|
||||||
|
|
||||||
@ -214,19 +202,16 @@ export function ProductForm({
|
|||||||
if (!prefix) return '';
|
if (!prefix) return '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 查询该公司所有以该前缀开头的产品码
|
const res = await http.get<{ fabric_code: string }[]>('/purchaser/products', {
|
||||||
const { data, error } = await supabase
|
search: prefix,
|
||||||
.from('products')
|
page_size: 100,
|
||||||
.select('fabric_code')
|
});
|
||||||
.eq('company_id', companyId)
|
const data = res.data || [];
|
||||||
.ilike('fabric_code', `${prefix}%`);
|
|
||||||
|
|
||||||
if (error || !data || data.length === 0) {
|
if (data.length === 0) {
|
||||||
// 如果没有匹配的产品,从1开始
|
|
||||||
return `${prefix}1`;
|
return `${prefix}1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取所有产品码中的数字,找出最大值
|
|
||||||
let maxNum = 0;
|
let maxNum = 0;
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const code = item.fabric_code;
|
const code = item.fabric_code;
|
||||||
@ -239,10 +224,8 @@ export function ProductForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回前缀 + (最大数字 + 1)
|
|
||||||
return `${prefix}${maxNum + 1}`;
|
return `${prefix}${maxNum + 1}`;
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('生成产品码失败:', err);
|
|
||||||
return `${prefix}1`;
|
return `${prefix}1`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -254,20 +237,16 @@ export function ProductForm({
|
|||||||
totalWeight: number | null
|
totalWeight: number | null
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
// 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增)
|
const res = await http.get<{ color_code: string }[]>('/purchaser/products', {
|
||||||
const { data, error } = await supabase
|
search: productName,
|
||||||
.from('products')
|
page_size: 100,
|
||||||
.select('color_code')
|
});
|
||||||
.eq('company_id', companyId)
|
const data = res.data || [];
|
||||||
.eq('product_name', productName)
|
|
||||||
.eq('total_weight', totalWeight || 0);
|
|
||||||
|
|
||||||
if (error || !data || data.length === 0) {
|
if (data.length === 0) {
|
||||||
// 如果没有匹配的产品,从01开始
|
|
||||||
return '01';
|
return '01';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取所有色号中的数字,找出最大值
|
|
||||||
let maxNum = 0;
|
let maxNum = 0;
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const code = item.color_code;
|
const code = item.color_code;
|
||||||
@ -277,11 +256,9 @@ export function ProductForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回最大数字 + 1,保持两位数格式
|
|
||||||
const nextNum = maxNum + 1;
|
const nextNum = maxNum + 1;
|
||||||
return nextNum < 10 ? `0${nextNum}` : String(nextNum);
|
return nextNum < 10 ? `0${nextNum}` : String(nextNum);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('生成色号失败:', err);
|
|
||||||
return '01';
|
return '01';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -402,42 +379,29 @@ export function ProductForm({
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 读取文件为 base64
|
// TODO: 文件上传接口待后端实现
|
||||||
const reader = new FileReader();
|
const formDataObj = new FormData();
|
||||||
reader.onload = async (event) => {
|
formDataObj.append('file', file);
|
||||||
const base64 = (event.target?.result as string).split(',')[1];
|
formDataObj.append('bucket', 'product_images');
|
||||||
|
formDataObj.append('path_prefix', 'products');
|
||||||
|
|
||||||
// 生成唯一文件名
|
const res = await fetch('/api/v1/common/upload', {
|
||||||
const fileExt = file.name.split('.').pop();
|
method: 'POST',
|
||||||
const fileName = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
body: formDataObj,
|
||||||
const filePath = `products/${fileName}`;
|
});
|
||||||
|
|
||||||
// 上传图片到 Supabase Storage
|
if (!res.ok) {
|
||||||
const { data, error } = await supabase.storage
|
alert('图片上传失败');
|
||||||
.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 }));
|
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
};
|
return;
|
||||||
reader.readAsDataURL(file);
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const publicUrl = json.data?.url || '';
|
||||||
|
setPreviewUrl(publicUrl);
|
||||||
|
setFormData(prev => ({ ...prev, image_url: publicUrl }));
|
||||||
|
setUploading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传错误:', error);
|
|
||||||
alert('图片上传失败');
|
alert('图片上传失败');
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
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 type { Profile, Company, UserRole, AuthState } from '../types';
|
||||||
import { encryptPhone, decryptPhone } from '../utils/crypto';
|
|
||||||
|
|
||||||
interface RegisterData {
|
interface RegisterData {
|
||||||
username: string;
|
username: string;
|
||||||
@ -32,195 +33,92 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// 初始化:检查已有 session
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
try {
|
||||||
if (session?.user) {
|
await authApi.refresh();
|
||||||
const { data: profile } = await supabase
|
const { user, company } = await authApi.me();
|
||||||
.from('profiles')
|
setAuth({
|
||||||
.select('*')
|
isLoggedIn: true,
|
||||||
.eq('id', session.user.id)
|
user,
|
||||||
.maybeSingle();
|
company,
|
||||||
|
currentRole: company?.role || null
|
||||||
if (profile) {
|
});
|
||||||
let company: Company | null = null;
|
if (company?.role) {
|
||||||
if (profile.company_id) {
|
wsClient.connect(company.role);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// not logged in
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
initAuth();
|
initAuth();
|
||||||
|
|
||||||
// 监听 auth 状态变化
|
const handleExpired = () => {
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
wsClient.disconnect();
|
||||||
if (event === 'SIGNED_IN' && session?.user) {
|
setAuth({
|
||||||
setTimeout(async () => {
|
isLoggedIn: false,
|
||||||
const { data: profile } = await supabase
|
user: null,
|
||||||
.from('profiles')
|
company: null,
|
||||||
.select('*')
|
currentRole: null
|
||||||
.eq('id', session.user.id)
|
});
|
||||||
.maybeSingle();
|
};
|
||||||
|
window.addEventListener('auth:expired', handleExpired);
|
||||||
if (profile) {
|
return () => window.removeEventListener('auth:expired', handleExpired);
|
||||||
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 login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
try {
|
||||||
email: `${username}@meoo.local`,
|
const { user, company } = await authApi.login({ username, password });
|
||||||
password,
|
setAuth({
|
||||||
});
|
isLoggedIn: true,
|
||||||
if (error) {
|
user,
|
||||||
console.error('登录失败:', error.message);
|
company,
|
||||||
|
currentRole: company?.role || null
|
||||||
|
});
|
||||||
|
if (company?.role) {
|
||||||
|
wsClient.connect(company.role);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => {
|
const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => {
|
||||||
try {
|
try {
|
||||||
// 1. 创建公司
|
const res = await authApi.register({
|
||||||
const { data: companyData, error: companyError } = await supabase
|
username: data.username,
|
||||||
.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`,
|
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
display_name: data.displayName,
|
||||||
|
company_name: data.companyName,
|
||||||
|
phone: data.phone,
|
||||||
|
role: data.role,
|
||||||
});
|
});
|
||||||
|
setAccessToken(res.access_token);
|
||||||
if (authError || !authData.user) {
|
setAuth({
|
||||||
console.error('注册失败:', authError?.message);
|
isLoggedIn: true,
|
||||||
// 回滚:删除创建的公司
|
user: res.user,
|
||||||
await supabase.from('companies').delete().eq('id', companyData.id);
|
company: res.company,
|
||||||
|
currentRole: res.company?.role || null
|
||||||
// 根据错误类型返回具体错误信息
|
});
|
||||||
if (authError?.message?.includes('already registered') || authError?.message?.includes('already exists')) {
|
if (res.company?.role) {
|
||||||
return { success: false, error: '用户名已被注册,请更换用户名' };
|
wsClient.connect(res.company.role);
|
||||||
}
|
|
||||||
if (authError?.message?.includes('password')) {
|
|
||||||
return { success: false, error: '密码不符合要求,请使用至少6位字符' };
|
|
||||||
}
|
|
||||||
return { success: false, error: '注册失败:' + (authError?.message || '请检查信息后重试') };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('注册过程出错:', err);
|
const message = err instanceof Error ? err.message : '注册失败,请稍后重试';
|
||||||
return { success: false, error: '注册过程出错:' + (err?.message || '请稍后重试') };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
// 清除所有 cookie
|
try { await authApi.logout(); } catch { /* ignore */ }
|
||||||
const cookies = document.cookie.split(';');
|
wsClient.disconnect();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除免密登录和记住密码相关存储
|
|
||||||
localStorage.removeItem('saved_username');
|
localStorage.removeItem('saved_username');
|
||||||
localStorage.removeItem('saved_password');
|
localStorage.removeItem('saved_password');
|
||||||
localStorage.removeItem('auto_login');
|
localStorage.removeItem('auto_login');
|
||||||
localStorage.removeItem('auto_login_expiry');
|
localStorage.removeItem('auto_login_expiry');
|
||||||
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
setAuth({
|
setAuth({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { supabase } from '../supabase/client';
|
import { purchaserApi, textileApi, washingApi } from '../api';
|
||||||
import type { ProductionPlan, WashingPlan } from '../types';
|
import type { ProductionPlan, WashingPlan } from '../types';
|
||||||
|
|
||||||
export type DashboardRole = 'purchaser' | 'textile' | 'washing';
|
export type DashboardRole = 'purchaser' | 'textile' | 'washing';
|
||||||
@ -23,10 +23,6 @@ interface DashboardDataState<T> {
|
|||||||
rejectedPlanIds: Set<string>;
|
rejectedPlanIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的 Dashboard 数据获取 Hook
|
|
||||||
* 根据角色自动选择正确的数据源和过滤逻辑
|
|
||||||
*/
|
|
||||||
export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||||
role,
|
role,
|
||||||
companyId
|
companyId
|
||||||
@ -39,134 +35,61 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
|||||||
rejectedPlanIds: new Set()
|
rejectedPlanIds: new Set()
|
||||||
});
|
});
|
||||||
|
|
||||||
// 缓存引用,避免重复请求
|
|
||||||
const cacheRef = useRef<{
|
const cacheRef = useRef<{
|
||||||
data: DashboardDataState<T> | null;
|
data: DashboardDataState<T> | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>({ data: null, timestamp: 0 });
|
}>({ data: null, timestamp: 0 });
|
||||||
|
|
||||||
const CACHE_DURATION = 5000; // 5秒缓存
|
const CACHE_DURATION = 5000;
|
||||||
|
|
||||||
// 计算统计数据
|
const fetchPurchaserData = useCallback(async () => {
|
||||||
const calculateStats = useCallback((plans: T[], roleType: DashboardRole): DashboardStats => {
|
const [statsData, plansRes] = await Promise.all([
|
||||||
const all = plans.length;
|
purchaserApi.dashboardStats(),
|
||||||
let producing = 0;
|
purchaserApi.listPlans(),
|
||||||
let completed = 0;
|
]);
|
||||||
|
const plans = plansRes.data.plans as unknown as T[];
|
||||||
if (roleType === 'washing') {
|
const stats: DashboardStats = {
|
||||||
// 水洗厂使用 processing 状态
|
all: statsData.total_plans,
|
||||||
producing = (plans as WashingPlan[]).filter(p => p.status === 'processing').length;
|
producing: statsData.producing_plans,
|
||||||
completed = (plans as WashingPlan[]).filter(p => p.status === 'completed').length;
|
completed: statsData.completed_plans,
|
||||||
} else {
|
};
|
||||||
// 采购商和纺织厂使用 producing 状态
|
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||||
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 () => {
|
|
||||||
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<string>();
|
|
||||||
(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 () => {
|
const fetchTextileData = useCallback(async () => {
|
||||||
if (!companyId) return;
|
const [statsData, plansRes] = await Promise.all([
|
||||||
|
textileApi.dashboardStats(),
|
||||||
// 先获取关联的计划ID
|
textileApi.listPlans(),
|
||||||
const { data: planFactories } = await supabase
|
]);
|
||||||
.from('plan_factories')
|
const plans = plansRes.data as unknown as T[];
|
||||||
.select('plan_id')
|
const stats: DashboardStats = {
|
||||||
.eq('factory_id', companyId)
|
all: statsData.total_plans,
|
||||||
.eq('factory_type', 'textile');
|
producing: statsData.producing_plans,
|
||||||
|
completed: statsData.completed_plans,
|
||||||
if (!planFactories || planFactories.length === 0) {
|
};
|
||||||
return { plans: [] as T[], stats: { all: 0, producing: 0, completed: 0 }, rejectedPlanIds: new Set<string>() };
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string>();
|
|
||||||
(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');
|
|
||||||
|
|
||||||
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||||
}, [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<string>() };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 主数据获取函数
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
setState(prev => ({ ...prev, loading: false }));
|
setState(prev => ({ ...prev, loading: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) {
|
if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) {
|
||||||
setState(cacheRef.current.data);
|
setState(cacheRef.current.data);
|
||||||
@ -174,22 +97,22 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: DashboardDataState<T> | undefined;
|
let result: { plans: T[]; stats: DashboardStats; rejectedPlanIds: Set<string> } | undefined;
|
||||||
|
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'purchaser':
|
case 'purchaser':
|
||||||
result = await fetchPurchaserData() as DashboardDataState<T> | undefined;
|
result = await fetchPurchaserData();
|
||||||
break;
|
break;
|
||||||
case 'textile':
|
case 'textile':
|
||||||
result = await fetchTextileData() as DashboardDataState<T> | undefined;
|
result = await fetchTextileData();
|
||||||
break;
|
break;
|
||||||
case 'washing':
|
case 'washing':
|
||||||
result = await fetchWashingData() as DashboardDataState<T> | undefined;
|
result = await fetchWashingData();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
const newState = {
|
const newState: DashboardDataState<T> = {
|
||||||
plans: result.plans,
|
plans: result.plans,
|
||||||
stats: result.stats,
|
stats: result.stats,
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -208,12 +131,10 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
|||||||
}
|
}
|
||||||
}, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]);
|
}, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]);
|
||||||
|
|
||||||
// 初始加载和 companyId 变化时重新获取
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// 提供手动刷新方法
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
cacheRef.current = { data: null, timestamp: 0 };
|
cacheRef.current = { data: null, timestamp: 0 };
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { supabase } from '../supabase/client';
|
import { purchaserApi } from '../api';
|
||||||
import { CACHE_CONFIG } from '../config/app';
|
import { CACHE_CONFIG } from '../config/app';
|
||||||
import { useInventoryRealtime } from './useRealtime';
|
import { useInventoryRealtime } from './useRealtime';
|
||||||
import type { Tables } from '../supabase/types';
|
|
||||||
import type {
|
import type {
|
||||||
Product,
|
Product,
|
||||||
ProductWithInventory,
|
ProductWithInventory,
|
||||||
ProductYarnRatio,
|
ProductYarnRatio,
|
||||||
FabricBatch,
|
FabricBatch,
|
||||||
ProductInRecord
|
ProductInRecord,
|
||||||
|
FactoryInventory,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
interface UseInventoryDataOptions {
|
interface UseInventoryDataOptions {
|
||||||
@ -24,32 +24,6 @@ interface InventoryDataState {
|
|||||||
error: Error | null;
|
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) {
|
export function useInventoryData({ companyId, activeType }: UseInventoryDataOptions) {
|
||||||
const [state, setState] = useState<InventoryDataState>({
|
const [state, setState] = useState<InventoryDataState>({
|
||||||
products: [],
|
products: [],
|
||||||
@ -76,7 +50,6 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
|||||||
const cacheKey = `${companyId}-${activeType}`;
|
const cacheKey = `${companyId}-${activeType}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
if (
|
if (
|
||||||
cacheRef.current.data &&
|
cacheRef.current.data &&
|
||||||
cacheRef.current.key === cacheKey &&
|
cacheRef.current.key === cacheKey &&
|
||||||
@ -89,135 +62,86 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
|||||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 并行获取数据
|
const productsRes = await purchaserApi.listProducts();
|
||||||
const [productsResult, batchesResult, outboundResult] = await Promise.all([
|
const products: Product[] = productsRes.data;
|
||||||
// 获取产品列表
|
|
||||||
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 })
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (productsResult.error) throw productsResult.error;
|
const recordResults = await Promise.all(
|
||||||
if (batchesResult.error) throw batchesResult.error;
|
products.map(p =>
|
||||||
if (outboundResult.error) throw outboundResult.error;
|
purchaserApi.getRecords(p.id).catch(() => [] as ProductInRecord[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const productsData = productsResult.data || [];
|
const allBatches: FabricBatch[] = [];
|
||||||
const batchesData: ProductInventoryRecord[] = batchesResult.data || [];
|
const updatedProducts: ProductWithInventory[] = products.map((p, i) => {
|
||||||
const outboundData: ProductOutboundRecord[] = outboundResult.data || [];
|
const records = recordResults[i];
|
||||||
|
const inRecords = records.filter(r => r.record_type !== 'out');
|
||||||
|
const outRecords = records.filter(r => r.record_type === 'out');
|
||||||
|
|
||||||
// 获取纱线配比
|
const totalInRolls = inRecords.reduce((sum, r) => sum + (r.rolls || 0), 0);
|
||||||
const productIds = productsData.map(p => p.id);
|
const totalInMeters = inRecords.reduce((sum, r) => sum + (r.meters || 0), 0);
|
||||||
let ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
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 factoryInventories: Record<string, FactoryInventory> = {};
|
||||||
const { data: ratiosData, error: ratiosError } = await supabase
|
inRecords
|
||||||
.from('product_yarn_ratios')
|
.filter(r => r.source === 'textile' && r.factory_name)
|
||||||
.select('*')
|
.forEach(r => {
|
||||||
.in('product_id', productIds);
|
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;
|
inRecords.forEach(r => {
|
||||||
|
allBatches.push({
|
||||||
ratiosData?.forEach((r: ProductYarnRatio) => {
|
id: r.id,
|
||||||
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
product_id: r.product_id,
|
||||||
ratiosMap[r.product_id].push(r);
|
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<string, string> = {};
|
|
||||||
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 {
|
return {
|
||||||
id: batch.id,
|
...p,
|
||||||
product_id: batch.product_id,
|
production_price: p.production_price || 0,
|
||||||
company_id: batch.company_id || '',
|
raw_fabric_rolls: Math.max(0, totalInRolls - totalOutRolls),
|
||||||
batch_no: batch.batch_no,
|
raw_fabric_meters: Math.max(0, totalInMeters - totalOutMeters),
|
||||||
rolls: batch.rolls,
|
total_stock: Math.max(0, totalInMeters - totalOutMeters),
|
||||||
meters: batch.meters,
|
factory_inventories: factoryInventories,
|
||||||
notes: batch.notes,
|
in_records: inRecords,
|
||||||
created_at: batch.created_at,
|
out_records: outRecords,
|
||||||
product: product || undefined
|
total_in_rolls: totalInRolls,
|
||||||
} as FabricBatch;
|
total_in_meters: totalInMeters,
|
||||||
|
total_out_rolls: totalOutRolls,
|
||||||
|
total_out_meters: totalOutMeters,
|
||||||
|
} as ProductWithInventory;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newState: InventoryDataState = {
|
const newState: InventoryDataState = {
|
||||||
products: updatedProducts,
|
products: updatedProducts,
|
||||||
productRatios: ratiosMap,
|
productRatios: {},
|
||||||
batches: batchesWithProduct,
|
batches: allBatches,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
cacheRef.current = {
|
cacheRef.current = {
|
||||||
data: newState,
|
data: newState,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
@ -238,9 +162,7 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// 使用统一的实时订阅管理器
|
|
||||||
useInventoryRealtime(companyId, () => {
|
useInventoryRealtime(companyId, () => {
|
||||||
// 清除缓存并刷新
|
|
||||||
cacheRef.current.timestamp = 0;
|
cacheRef.current.timestamp = 0;
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
@ -250,189 +172,3 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
|||||||
refetch: fetchData
|
refetch: fetchData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理库存数据的辅助函数
|
|
||||||
interface ProcessInventoryDataParams {
|
|
||||||
products: Product[];
|
|
||||||
plansData: PlanSummary[];
|
|
||||||
textileInventoryRecords: InventoryRecord[];
|
|
||||||
planToFactoryName: Record<string, string>;
|
|
||||||
batchesData: ProductInventoryRecord[];
|
|
||||||
outboundData: ProductOutboundRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function processInventoryData({
|
|
||||||
products,
|
|
||||||
plansData,
|
|
||||||
textileInventoryRecords,
|
|
||||||
planToFactoryName,
|
|
||||||
batchesData,
|
|
||||||
outboundData
|
|
||||||
}: ProcessInventoryDataParams): ProductWithInventory[] {
|
|
||||||
// 建立 plan_id 到 fabric_code 的映射
|
|
||||||
const planToFabricCode: Record<string, string> = {};
|
|
||||||
const planCompletedMap: Record<string, number> = {};
|
|
||||||
plansData.forEach((plan: PlanSummary) => {
|
|
||||||
planToFabricCode[plan.id] = plan.fabric_code;
|
|
||||||
planCompletedMap[plan.id] = plan.completed_quantity || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 按 plan_id 汇总库存
|
|
||||||
const planInventoryMap: Record<string, { meters: number; rolls: number; factory_id: string; factory_name: string }> = {};
|
|
||||||
|
|
||||||
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<string, { meters: number; rolls: number }> = {};
|
|
||||||
const fabricCodeFactoryInventory: Record<string, Record<string, { factory_id: string; factory_name: string; rolls: number; meters: number; records: ProductInRecord[] }>> = {};
|
|
||||||
|
|
||||||
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<string, ProductInRecord[]> = {};
|
|
||||||
|
|
||||||
// 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<string, string> = {};
|
|
||||||
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<string, { totalRolls: number; totalMeters: number; records: ProductOutboundRecord[] }> = {};
|
|
||||||
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<string, { rolls: number; meters: number }> = {};
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { supabase } from '../supabase/client';
|
import { purchaserApi } from '../api';
|
||||||
import { statusMap } from '../utils/constants';
|
import type { PlanDetail } from '../api/purchaser';
|
||||||
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
|
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
|
||||||
import { usePlanRealtime } from './useRealtime';
|
import { usePlanRealtime } from './useRealtime';
|
||||||
import type {
|
import type {
|
||||||
ProductionPlan,
|
|
||||||
PlanWithSteps,
|
PlanWithSteps,
|
||||||
PlanProcessStep,
|
|
||||||
Company,
|
Company,
|
||||||
PlanFactory,
|
PlanFactory,
|
||||||
YarnRatio,
|
YarnRatio,
|
||||||
Product
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
interface UsePlanDataOptions {
|
interface UsePlanDataOptions {
|
||||||
@ -31,10 +28,15 @@ interface PlanDataState {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function mapPlanDetail(plan: PlanDetail): PlanWithSteps {
|
||||||
* 优化的计划数据获取 Hook
|
return {
|
||||||
* 支持分页加载和缓存
|
...plan,
|
||||||
*/
|
processSteps: plan.process_steps,
|
||||||
|
inventoryRecords: plan.inventory_records,
|
||||||
|
priceHistory: plan.price_history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) {
|
export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) {
|
||||||
const [state, setState] = useState<PlanDataState>({
|
const [state, setState] = useState<PlanDataState>({
|
||||||
plans: [],
|
plans: [],
|
||||||
@ -49,7 +51,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
|||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 ref 存储缓存,避免不必要的重渲染
|
|
||||||
const cacheRef = useRef<{
|
const cacheRef = useRef<{
|
||||||
data: PlanDataState | null;
|
data: PlanDataState | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@ -57,83 +58,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
|||||||
|
|
||||||
const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
|
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<string, string> = {};
|
|
||||||
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) => {
|
const fetchData = useCallback(async (page: number = 1, append: boolean = false) => {
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
setState(prev => ({ ...prev, loading: false }));
|
setState(prev => ({ ...prev, loading: false }));
|
||||||
@ -143,7 +67,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
|||||||
const isFirstPage = page === 1;
|
const isFirstPage = page === 1;
|
||||||
|
|
||||||
if (isFirstPage) {
|
if (isFirstPage) {
|
||||||
// 检查缓存
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
|
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
|
||||||
setState(cacheRef.current.data);
|
setState(cacheRef.current.data);
|
||||||
@ -155,114 +78,32 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 plansWithSteps = planDetails.map(mapPlanDetail);
|
||||||
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);
|
|
||||||
|
|
||||||
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<string, YarnRatio[]> = {};
|
const yarnRatiosMap: Record<string, YarnRatio[]> = {};
|
||||||
(yarnRatiosResult.data || []).forEach((y: any) => {
|
planDetails.forEach(p => {
|
||||||
if (!yarnRatiosMap[y.plan_id]) yarnRatiosMap[y.plan_id] = [];
|
if (p.yarn_ratios && p.yarn_ratios.length > 0) {
|
||||||
yarnRatiosMap[y.plan_id].push(y as YarnRatio);
|
yarnRatiosMap[p.id] = p.yarn_ratios;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
const newState: PlanDataState = {
|
const newState: PlanDataState = {
|
||||||
plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps,
|
plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps,
|
||||||
factories: isFirstPage ? factories : prev.factories,
|
factories: isFirstPage ? factories : prev.factories,
|
||||||
planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories,
|
planFactories: isFirstPage ? plan_factories : prev.planFactories,
|
||||||
operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames },
|
operatorNames: isFirstPage ? operator_names : { ...prev.operatorNames, ...operator_names },
|
||||||
yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap },
|
yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap },
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
hasMore: planData.length === pageSize,
|
hasMore: res.meta?.has_more ?? planDetails.length === pageSize,
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
if (isFirstPage) {
|
if (isFirstPage) {
|
||||||
cacheRef.current = { data: newState, timestamp: Date.now() };
|
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')
|
error: err instanceof Error ? err : new Error('Unknown error')
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [companyId, pageSize, fetchOperatorNames, processPlansData]);
|
}, [companyId, pageSize]);
|
||||||
|
|
||||||
// 加载更多
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (state.loadingMore || !state.hasMore) return;
|
if (state.loadingMore || !state.hasMore) return;
|
||||||
fetchData(state.currentPage + 1, true);
|
fetchData(state.currentPage + 1, true);
|
||||||
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
|
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refetch = useCallback(() => {
|
const refetch = useCallback(() => {
|
||||||
cacheRef.current.timestamp = 0;
|
cacheRef.current.timestamp = 0;
|
||||||
fetchData(1, false);
|
fetchData(1, false);
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(1, false);
|
fetchData(1, false);
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// 使用统一的实时订阅管理器
|
|
||||||
usePlanRealtime(companyId, () => {
|
usePlanRealtime(companyId, () => {
|
||||||
cacheRef.current.timestamp = 0;
|
cacheRef.current.timestamp = 0;
|
||||||
fetchData(1, false);
|
fetchData(1, false);
|
||||||
|
|||||||
@ -1,120 +1,20 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '../supabase/client';
|
import { wsClient } from '../api/websocket';
|
||||||
import { SYNC_CONFIG } from '../config/app';
|
|
||||||
import { PLAN_STATUS, STEP_STATUS } from '../utils/constants';
|
|
||||||
|
|
||||||
interface PlanStatusCheckResult {
|
|
||||||
planId: string;
|
|
||||||
planCode: string;
|
|
||||||
stepStatus: string;
|
|
||||||
planStatus: string;
|
|
||||||
needsFix: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查计划状态是否一致
|
|
||||||
* 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复
|
|
||||||
*/
|
|
||||||
export function usePlanStatusSync(companyId: string | undefined) {
|
export function usePlanStatusSync(companyId: string | undefined) {
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const isCheckingRef = useRef(false);
|
|
||||||
|
|
||||||
const checkAndFixPlanStatus = useCallback(async () => {
|
const checkAndFixPlanStatus = useCallback(async () => {
|
||||||
if (!companyId || isCheckingRef.current) return;
|
// Server handles plan status sync and pushes updates via WebSocket
|
||||||
|
}, []);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
|
|
||||||
// 立即执行一次检查
|
const unsub = wsClient.subscribe('production_plans', () => {
|
||||||
checkAndFixPlanStatus();
|
// Server pushes status corrections; UI will refresh via other hooks
|
||||||
|
});
|
||||||
|
|
||||||
// 同步间隔由配置中心管理(开发环境10秒,生产环境5分钟)
|
return unsub;
|
||||||
intervalRef.current = setInterval(checkAndFixPlanStatus, SYNC_CONFIG.planStatusSyncInterval);
|
}, [companyId]);
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [companyId, checkAndFixPlanStatus]);
|
|
||||||
|
|
||||||
return { checkAndFixPlanStatus };
|
return { checkAndFixPlanStatus };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +1,14 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { supabase } from '../supabase/client';
|
import { wsClient } from '../api/websocket';
|
||||||
|
|
||||||
// 订阅配置类型
|
|
||||||
interface SubscriptionConfig {
|
interface SubscriptionConfig {
|
||||||
table: string;
|
table: string;
|
||||||
event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE';
|
event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
filter?: string;
|
filter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回调函数类型
|
|
||||||
type ChangeCallback = () => void;
|
type ChangeCallback = () => void;
|
||||||
|
|
||||||
// 全局订阅管理器(单例模式)
|
|
||||||
class RealtimeSubscriptionManager {
|
|
||||||
private static instance: RealtimeSubscriptionManager;
|
|
||||||
private subscriptions: Map<string, { count: number; channel: any }> = new Map();
|
|
||||||
private callbacks: Map<string, Set<ChangeCallback>> = 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(
|
export function useRealtime(
|
||||||
config: SubscriptionConfig,
|
config: SubscriptionConfig,
|
||||||
callback: ChangeCallback,
|
callback: ChangeCallback,
|
||||||
@ -107,7 +16,6 @@ export function useRealtime(
|
|||||||
): void {
|
): void {
|
||||||
const callbackRef = useRef(callback);
|
const callbackRef = useRef(callback);
|
||||||
|
|
||||||
// 保持回调引用最新
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
callbackRef.current = callback;
|
callbackRef.current = callback;
|
||||||
}, [callback]);
|
}, [callback]);
|
||||||
@ -115,30 +23,22 @@ export function useRealtime(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
// 包装回调,确保使用最新引用
|
|
||||||
const wrappedCallback = () => {
|
const wrappedCallback = () => {
|
||||||
callbackRef.current();
|
callbackRef.current();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 订阅
|
const unsubscribe = wsClient.subscribe(config.table, wrappedCallback);
|
||||||
const unsubscribe = subscriptionManager.subscribe(config, wrappedCallback);
|
|
||||||
|
|
||||||
// 清理
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [config.table, config.event, config.filter, enabled]);
|
}, [config.table, config.event, config.filter, enabled]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 库存数据实时订阅 Hook
|
|
||||||
* 合并 inventory_records 和 product_inventory_records 订阅
|
|
||||||
*/
|
|
||||||
export function useInventoryRealtime(
|
export function useInventoryRealtime(
|
||||||
companyId: string | null,
|
companyId: string | null,
|
||||||
onChange: ChangeCallback
|
onChange: ChangeCallback
|
||||||
): void {
|
): void {
|
||||||
// 使用 ref 保持回调稳定
|
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
@ -149,17 +49,8 @@ export function useInventoryRealtime(
|
|||||||
|
|
||||||
const wrappedCallback = () => onChangeRef.current();
|
const wrappedCallback = () => onChangeRef.current();
|
||||||
|
|
||||||
// 订阅 inventory_records 变化
|
const unsub1 = wsClient.subscribe('inventory_records', wrappedCallback);
|
||||||
const unsub1 = subscriptionManager.subscribe(
|
const unsub2 = wsClient.subscribe('product_inventory_records', wrappedCallback);
|
||||||
{ table: 'inventory_records', event: '*' },
|
|
||||||
wrappedCallback
|
|
||||||
);
|
|
||||||
|
|
||||||
// 订阅 product_inventory_records 变化
|
|
||||||
const unsub2 = subscriptionManager.subscribe(
|
|
||||||
{ table: 'product_inventory_records', event: '*' },
|
|
||||||
wrappedCallback
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub1();
|
unsub1();
|
||||||
@ -168,9 +59,6 @@ export function useInventoryRealtime(
|
|||||||
}, [companyId]);
|
}, [companyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 计划数据实时订阅 Hook
|
|
||||||
*/
|
|
||||||
export function usePlanRealtime(
|
export function usePlanRealtime(
|
||||||
companyId: string | null,
|
companyId: string | null,
|
||||||
onChange: ChangeCallback
|
onChange: ChangeCallback
|
||||||
@ -185,11 +73,7 @@ export function usePlanRealtime(
|
|||||||
|
|
||||||
const wrappedCallback = () => onChangeRef.current();
|
const wrappedCallback = () => onChangeRef.current();
|
||||||
|
|
||||||
// 订阅 inventory_records 变化(影响计划完成进度)
|
const unsub = wsClient.subscribe('inventory_records', wrappedCallback);
|
||||||
const unsub = subscriptionManager.subscribe(
|
|
||||||
{ table: 'inventory_records', event: '*' },
|
|
||||||
wrappedCallback
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
@ -197,16 +81,6 @@ export function usePlanRealtime(
|
|||||||
}, [companyId]);
|
}, [companyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function triggerRealtimeRefresh(_table: string): void {
|
||||||
* 手动触发刷新(用于需要主动刷新的场景)
|
// no-op: server pushes changes via WebSocket
|
||||||
*/
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../supabase/client';
|
import { http } from '../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react';
|
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react';
|
||||||
import { decryptShareParams } from '../utils/shareCrypto';
|
import { decryptShareParams } from '../utils/shareCrypto';
|
||||||
import { useResponsive } from '../hooks/useResponsive';
|
import { useResponsive } from '../hooks/useResponsive';
|
||||||
import { useErrorHandler } from '../hooks/useErrorHandler';
|
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';
|
import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types';
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
@ -50,68 +50,52 @@ export function ImportPlanPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resolveLink = async () => {
|
const resolveLink = async () => {
|
||||||
if (shortCode) {
|
if (shortCode) {
|
||||||
// 短链解析
|
try {
|
||||||
const { data, error } = await supabase
|
const res = await http.get<any>(`/common/share-links/${shortCode}`);
|
||||||
.from('share_links')
|
const data = res.data;
|
||||||
.select('*')
|
|
||||||
.eq('short_code', shortCode)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
// 检查链接状态
|
setShareLinkStatus(data.status as ShareLinkStatus);
|
||||||
setShareLinkStatus(data.status as ShareLinkStatus);
|
|
||||||
|
|
||||||
// 检查是否过期
|
const expiresAt = data.expires_at ? new Date(data.expires_at) : null;
|
||||||
const expiresAt = data.expires_at ? new Date(data.expires_at) : null;
|
const now = new Date();
|
||||||
const now = new Date();
|
if (expiresAt && expiresAt < now) {
|
||||||
if (expiresAt && expiresAt < now) {
|
setLinkExpired(true);
|
||||||
setLinkExpired(true);
|
await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
|
||||||
// 更新状态为过期
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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];
|
||||||
await supabase.rpc('increment_share_link_clicks', { code: shortCode });
|
if (usedStatuses.includes(data.status as ShareLinkStatus)) {
|
||||||
} else {
|
setDecrypting(false);
|
||||||
handleError(error, { userMessage: '短链查询失败', context: { shortCode } });
|
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(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, { userMessage: '短链查询失败', context: { shortCode } });
|
||||||
}
|
}
|
||||||
setDecrypting(false);
|
setDecrypting(false);
|
||||||
} else if (token) {
|
} else if (token) {
|
||||||
// 长链接解析(异步,带签名验证)
|
|
||||||
const decrypted = await decryptShareParams(token);
|
const decrypted = await decryptShareParams(token);
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
setPlanId(decrypted.planId);
|
setPlanId(decrypted.planId);
|
||||||
@ -134,32 +118,29 @@ export function ImportPlanPage() {
|
|||||||
if (!shortCode || !shareLinkStatus) return;
|
if (!shortCode || !shareLinkStatus) return;
|
||||||
|
|
||||||
const updateTimer = async () => {
|
const updateTimer = async () => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('share_links')
|
const res = await http.get<any>(`/common/share-links/${shortCode}`);
|
||||||
.select('expires_at, status')
|
const data = res.data;
|
||||||
.eq('short_code', shortCode)
|
if (!data || !data.expires_at) return;
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!data || !data.expires_at) return;
|
const expiresAt = new Date(data.expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = expiresAt.getTime() - now.getTime();
|
||||||
|
|
||||||
const expiresAt = new Date(data.expires_at);
|
if (diff <= 0) {
|
||||||
const now = new Date();
|
setTimeRemaining('已过期');
|
||||||
const diff = expiresAt.getTime() - now.getTime();
|
setLinkExpired(true);
|
||||||
|
if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) {
|
||||||
if (diff <= 0) {
|
await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
|
||||||
setTimeRemaining('已过期');
|
setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus);
|
||||||
setLinkExpired(true);
|
}
|
||||||
if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) {
|
} else {
|
||||||
await supabase
|
const minutes = Math.floor(diff / 60000);
|
||||||
.from('share_links')
|
const seconds = Math.floor((diff % 60000) / 1000);
|
||||||
.update({ status: SHARE_LINK_STATUS.EXPIRED })
|
setTimeRemaining(`${minutes}分${seconds}秒`);
|
||||||
.eq('short_code', shortCode);
|
|
||||||
setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus);
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch {
|
||||||
const minutes = Math.floor(diff / 60000);
|
// ignore timer errors
|
||||||
const seconds = Math.floor((diff % 60000) / 1000);
|
|
||||||
setTimeRemaining(`${minutes}分${seconds}秒`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -185,40 +166,42 @@ export function ImportPlanPage() {
|
|||||||
}, [planId]);
|
}, [planId]);
|
||||||
|
|
||||||
const fetchPlanData = async () => {
|
const fetchPlanData = async () => {
|
||||||
// 首先尝试从 production_plans 查询
|
try {
|
||||||
const { data: planData } = await supabase.from('production_plans').select('*').eq('id', planId).maybeSingle();
|
const planRes = await http.get<any>(`/common/plans/${planId}`);
|
||||||
|
const planData = planRes.data;
|
||||||
|
|
||||||
if (planData) {
|
if (planData && planData.type === 'production') {
|
||||||
setPlan(planData);
|
setPlan(planData);
|
||||||
setIsWashingPlan(false);
|
setIsWashingPlan(false);
|
||||||
setPurchaserCompanyId(planData.purchaser_id);
|
setPurchaserCompanyId(planData.purchaser_id);
|
||||||
|
|
||||||
const { data: yarnData } = await supabase.from('yarn_ratios').select('*').eq('plan_id', planId);
|
setYarns(planData.yarn_ratios || []);
|
||||||
setYarns(yarnData || []);
|
setPurchaserName(planData.purchaser_name || '未知');
|
||||||
|
|
||||||
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 || '未知');
|
|
||||||
|
|
||||||
if (auth.company) {
|
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(!!planData.already_imported);
|
||||||
setAlreadyImported(!!existing);
|
}
|
||||||
|
} 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);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@ -228,72 +211,22 @@ export function ImportPlanPage() {
|
|||||||
if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return;
|
if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
|
|
||||||
// 1. 创建 plan_factories 关联
|
try {
|
||||||
if (isWashingPlan) {
|
await http.post(`/common/plans/${planId}/confirm`, {
|
||||||
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,
|
|
||||||
factory_id: auth.company.id,
|
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);
|
setImportSuccess(true);
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
return;
|
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 拒绝计划
|
// 拒绝计划
|
||||||
@ -302,18 +235,18 @@ export function ImportPlanPage() {
|
|||||||
|
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
|
|
||||||
await supabase
|
try {
|
||||||
.from('share_links')
|
await http.put(`/common/share-links/${shortCode}`, {
|
||||||
.update({
|
|
||||||
status: SHARE_LINK_STATUS.REJECTED,
|
status: SHARE_LINK_STATUS.REJECTED,
|
||||||
responded_at: new Date().toISOString(),
|
responded_at: new Date().toISOString(),
|
||||||
response_result: 'rejected',
|
response_result: 'rejected',
|
||||||
reject_reason: rejectReason
|
reject_reason: rejectReason
|
||||||
})
|
});
|
||||||
.eq('short_code', shortCode);
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
// 显示拒绝成功,返回首页
|
|
||||||
setTimeout(() => { navigate('/role-select'); }, 2000);
|
setTimeout(() => { navigate('/role-select'); }, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../supabase/client';
|
import { commonApi, http } from '../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { Users, ArrowLeft, Plus, X, User, Phone, Shield, Trash2, AlertCircle, Edit2, Building2, BookOpen, MessageSquare, Info } from 'lucide-react';
|
||||||
import { useResponsive } from '../hooks/useResponsive';
|
import { useResponsive } from '../hooks/useResponsive';
|
||||||
@ -70,39 +70,31 @@ export function MemberManage() {
|
|||||||
|
|
||||||
const fetchCompanyInfo = async () => {
|
const fetchCompanyInfo = async () => {
|
||||||
if (!auth.user?.company_id) return;
|
if (!auth.user?.company_id) return;
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('companies')
|
const data = await commonApi.getCompany(auth.user.company_id);
|
||||||
.select('name, contact_phone, role')
|
if (data) {
|
||||||
.eq('id', auth.user.company_id)
|
setCompanyInfo(data as any);
|
||||||
.maybeSingle();
|
}
|
||||||
if (data) {
|
} catch {
|
||||||
setCompanyInfo(data);
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 获取当前公司的所有子账号(非主账号)
|
const data = await commonApi.listMembers();
|
||||||
const companyId = auth.user?.company_id;
|
const subMembers = (data || [])
|
||||||
if (!companyId) {
|
.filter((m: any) => !m.profile?.is_master)
|
||||||
setLoading(false);
|
.map((m: any) => ({
|
||||||
return;
|
id: m.profile?.id || m.id,
|
||||||
}
|
username: m.profile?.username || '',
|
||||||
const { data, error } = await supabase
|
phone: m.profile?.phone || null,
|
||||||
.from('profiles')
|
created_at: m.profile?.created_at || m.created_at
|
||||||
.select('id, username, phone, created_at')
|
}));
|
||||||
.eq('company_id', companyId)
|
setMembers(subMembers);
|
||||||
.eq('is_master', false)
|
} catch {
|
||||||
.order('created_at', { ascending: false });
|
setMembers([]);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('获取成员列表失败:', error);
|
|
||||||
} else {
|
|
||||||
setMembers(data || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取成员列表出错:', err);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@ -146,39 +138,20 @@ export function MemberManage() {
|
|||||||
|
|
||||||
setEditingMaster(true);
|
setEditingMaster(true);
|
||||||
try {
|
try {
|
||||||
// 更新姓名和手机号
|
await http.put('/common/profile', {
|
||||||
const { error: updateError } = await supabase
|
display_name: masterFormData.displayName,
|
||||||
.from('profiles')
|
phone: encryptPhone(masterFormData.phone)
|
||||||
.update({
|
});
|
||||||
display_name: masterFormData.displayName,
|
|
||||||
phone: encryptPhone(masterFormData.phone)
|
|
||||||
})
|
|
||||||
.eq('id', auth.user?.id || '');
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
setError('更新失败:' + updateError.message);
|
|
||||||
setEditingMaster(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果填写了密码,更新密码
|
|
||||||
if (masterFormData.password && masterFormData.password.length >= 6) {
|
if (masterFormData.password && masterFormData.password.length >= 6) {
|
||||||
const { error: passwordError } = await supabase.auth.updateUser({
|
await http.put('/common/password', {
|
||||||
password: masterFormData.password
|
password: masterFormData.password
|
||||||
});
|
});
|
||||||
|
|
||||||
if (passwordError) {
|
|
||||||
setError('密码更新失败:' + passwordError.message);
|
|
||||||
setEditingMaster(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新页面以获取更新后的用户信息
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError('更新时发生错误');
|
setError('更新失败:' + (err.message || ''));
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
setEditingMaster(false);
|
setEditingMaster(false);
|
||||||
};
|
};
|
||||||
@ -213,74 +186,17 @@ export function MemberManage() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查用户名是否已存在(全局唯一性检查)
|
await commonApi.createMember({
|
||||||
const { data: existingUser, error: checkError } = await supabase
|
username: formData.username.trim(),
|
||||||
.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,
|
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
options: {
|
display_name: formData.displayName,
|
||||||
data: {
|
phone: formData.phone || undefined
|
||||||
username: formData.username,
|
|
||||||
phone: formData.phone || null,
|
|
||||||
company_id: auth.user.company_id,
|
|
||||||
is_master: false,
|
|
||||||
master_id: auth.user.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
await fetchMembers();
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError('创建子账号时发生错误');
|
setError('创建子账号失败:' + (err.message || ''));
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@ -288,22 +204,11 @@ export function MemberManage() {
|
|||||||
|
|
||||||
const handleDelete = async (memberId: string) => {
|
const handleDelete = async (memberId: string) => {
|
||||||
try {
|
try {
|
||||||
// 删除 profile 记录
|
await commonApi.deleteMember(memberId);
|
||||||
const { error: profileError } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.delete()
|
|
||||||
.eq('id', memberId);
|
|
||||||
|
|
||||||
if (profileError) {
|
|
||||||
console.error('删除成员失败:', profileError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新列表
|
|
||||||
setMembers(members.filter(m => m.id !== memberId));
|
setMembers(members.filter(m => m.id !== memberId));
|
||||||
setShowDeleteConfirm(null);
|
setShowDeleteConfirm(null);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('删除成员出错:', err);
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi, http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
@ -53,67 +53,31 @@ export function AccountsPayable() {
|
|||||||
const fetchPayables = async () => {
|
const fetchPayables = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取应付账款列表
|
try {
|
||||||
const { data: apData, error: apError } = await supabase
|
const res = await purchaserApi.listAccountsPayable();
|
||||||
.from('accounts_payable')
|
setPayables((res.data || []) as AccountsPayable[]);
|
||||||
.select('*')
|
} catch {
|
||||||
.eq('purchaser_id', auth.company!.id)
|
setPayables([]);
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (apError) {
|
|
||||||
console.error('获取应付账款失败:', apError);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取工厂信息
|
|
||||||
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<string, string> = {};
|
|
||||||
(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<string, { plan_code: string; product_name: string; color: string }> = {};
|
|
||||||
(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);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPayableItems = async (payableId: string) => {
|
const fetchPayableItems = async (payableId: string) => {
|
||||||
const { data: itemsData } = await supabase
|
try {
|
||||||
.from('accounts_payable_items')
|
const res = await http.get<AccountsPayableItem[]>(
|
||||||
.select('*')
|
`/purchaser/accounts-payable/${payableId}/items`
|
||||||
.eq('accounts_payable_id', payableId)
|
);
|
||||||
.order('created_at', { ascending: false });
|
setPayableItems(prev => ({
|
||||||
|
...prev,
|
||||||
setPayableItems(prev => ({
|
[payableId]: res.data || []
|
||||||
...prev,
|
}));
|
||||||
[payableId]: itemsData || []
|
} catch {
|
||||||
}));
|
setPayableItems(prev => ({
|
||||||
|
...prev,
|
||||||
|
[payableId]: []
|
||||||
|
}));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpand = (payableId: string) => {
|
const toggleExpand = (payableId: string) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@ -84,28 +84,12 @@ export function PurchaserDashboard() {
|
|||||||
}, [auth.company, dataLoaded, plans.length]);
|
}, [auth.company, dataLoaded, plans.length]);
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
const { data: planData } = await supabase
|
const res = await purchaserApi.listPlans();
|
||||||
.from('production_plans')
|
const allPlans = res.data.plans;
|
||||||
.select('*')
|
|
||||||
.eq('purchaser_id', auth.company!.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
// 获取流程步骤,检测 rejected 状态
|
const filteredPlans = allPlans.filter(p =>
|
||||||
const planIds = (planData || []).map(p => p.id);
|
!p.process_steps?.some(s => s.status === STEP_STATUS.REJECTED)
|
||||||
const { data: stepsData } = await supabase
|
);
|
||||||
.from('plan_process_steps')
|
|
||||||
.select('plan_id, status')
|
|
||||||
.in('plan_id', planIds);
|
|
||||||
|
|
||||||
// 过滤掉有 rejected 步骤的计划
|
|
||||||
const rejectedPlanIds = new Set<string>();
|
|
||||||
(stepsData || []).forEach(step => {
|
|
||||||
if (step.status === STEP_STATUS.REJECTED) {
|
|
||||||
rejectedPlanIds.add(step.plan_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredPlans = (planData || []).filter(p => !rejectedPlanIds.has(p.id));
|
|
||||||
setPlans(filteredPlans);
|
setPlans(filteredPlans);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setDataLoaded(true);
|
setDataLoaded(true);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -43,30 +43,22 @@ export function FactoryManage() {
|
|||||||
|
|
||||||
const fetchFactories = async () => {
|
const fetchFactories = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// 直接查询所有该类型的工厂(不再通过 plan_factories 关联)
|
try {
|
||||||
const { data: factoryData } = await supabase
|
const res = await http.get<Company[]>('/purchaser/factories', { role: activeTab });
|
||||||
.from('companies')
|
setFactories(res.data || []);
|
||||||
.select('*')
|
|
||||||
.eq('role', activeTab)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
setFactories(factoryData || []);
|
if (res.data && res.data.length > 0) {
|
||||||
|
const factoryIds = res.data.map(f => f.id);
|
||||||
// 计算每个工厂的计划数(通过 plan_factories 统计)
|
const countsRes = await http.get<Record<string, number>>('/purchaser/factories/plan-counts', {
|
||||||
if (factoryData && factoryData.length > 0) {
|
factory_ids: factoryIds.join(','),
|
||||||
const factoryIds = factoryData.map(f => f.id);
|
factory_type: activeTab
|
||||||
const { data: planFactories } = await supabase
|
});
|
||||||
.from('plan_factories')
|
setPlanCounts(countsRes.data || {});
|
||||||
.select('factory_id')
|
} else {
|
||||||
.in('factory_id', factoryIds)
|
setPlanCounts({});
|
||||||
.eq('factory_type', activeTab);
|
}
|
||||||
|
} catch {
|
||||||
const counts: Record<string, number> = {};
|
setFactories([]);
|
||||||
planFactories?.forEach(pf => {
|
|
||||||
counts[pf.factory_id] = (counts[pf.factory_id] || 0) + 1;
|
|
||||||
});
|
|
||||||
setPlanCounts(counts);
|
|
||||||
} else {
|
|
||||||
setPlanCounts({});
|
setPlanCounts({});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -116,67 +108,14 @@ export function FactoryManage() {
|
|||||||
|
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
console.log('开始删除工厂:', deletingFactory.id, deletingFactory.name);
|
await http.delete(`/purchaser/factories/${deletingFactory.id}`);
|
||||||
|
|
||||||
// 检查该工厂是否有关联的计划
|
|
||||||
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 fetchFactories();
|
await fetchFactories();
|
||||||
// 如果删除的是当前展开的工厂,关闭展开状态
|
|
||||||
if (expandedId === deletingFactory.id) {
|
if (expandedId === deletingFactory.id) {
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
}
|
}
|
||||||
handleCloseDeleteModal();
|
handleCloseDeleteModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err, { userMessage: '删除工厂时发生错误' });
|
handleError(err, { userMessage: '删除工厂失败' });
|
||||||
}
|
}
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
};
|
};
|
||||||
@ -189,26 +128,16 @@ export function FactoryManage() {
|
|||||||
|
|
||||||
// 检查工厂名称是否已存在(同一类型下)
|
// 检查工厂名称是否已存在(同一类型下)
|
||||||
const checkDuplicateName = async (name: string, excludeId?: string): Promise<boolean> => {
|
const checkDuplicateName = async (name: string, excludeId?: string): Promise<boolean> => {
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('companies')
|
const res = await http.get<{ exists: boolean }>('/purchaser/factories/check-name', {
|
||||||
.select('id')
|
role: activeTab,
|
||||||
.eq('role', activeTab)
|
name: name.trim(),
|
||||||
.ilike('name', name.trim())
|
exclude_id: excludeId
|
||||||
.limit(1);
|
});
|
||||||
|
return res.data.exists;
|
||||||
if (error) {
|
} catch {
|
||||||
console.error('检查重名失败:', error);
|
|
||||||
return false;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -228,7 +157,6 @@ export function FactoryManage() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查重名
|
|
||||||
const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id);
|
const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id);
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
setError(`该${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`);
|
setError(`该${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`);
|
||||||
@ -237,48 +165,24 @@ export function FactoryManage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editingFactory) {
|
if (editingFactory) {
|
||||||
// 更新工厂信息
|
await http.put(`/purchaser/factories/${editingFactory.id}`, {
|
||||||
const { error: updateError } = await supabase
|
name: formData.name,
|
||||||
.from('companies')
|
contact_phone: formData.contactPhone,
|
||||||
.update({
|
address: formData.address
|
||||||
name: formData.name,
|
});
|
||||||
contact_phone: formData.contactPhone,
|
|
||||||
address: formData.address
|
|
||||||
})
|
|
||||||
.eq('id', editingFactory.id);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
setError('更新工厂失败:' + updateError.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 创建工厂(作为公司)
|
await http.post('/purchaser/factories', {
|
||||||
const { data: newFactory, error: factoryError } = await supabase
|
name: formData.name,
|
||||||
.from('companies')
|
role: activeTab,
|
||||||
.insert({
|
contact_phone: formData.contactPhone,
|
||||||
name: formData.name,
|
address: formData.address
|
||||||
role: activeTab,
|
});
|
||||||
contact_phone: formData.contactPhone,
|
|
||||||
address: formData.address
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (factoryError || !newFactory) {
|
|
||||||
setError('创建工厂失败:' + factoryError?.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 工厂创建成功,直接显示在列表中(不再需要通过 plan_factories 关联)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新工厂列表
|
|
||||||
await fetchFactories();
|
await fetchFactories();
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError(editingFactory ? '更新工厂时发生错误' : '创建工厂时发生错误');
|
setError(editingFactory ? '更新工厂失败:' + (err.message || '') : '创建工厂失败:' + (err.message || ''));
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react';
|
import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -37,36 +37,23 @@ export function FinishedWarehouse() {
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('finished_products')
|
const res = await http.get<FinishedProductWithDetails[]>('/purchaser/finished-products');
|
||||||
.select(`
|
setProducts(res.data || []);
|
||||||
*,
|
} catch {
|
||||||
warehouse:warehouse_id(name)
|
setProducts([]);
|
||||||
`)
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchInventoryRecords = async (productId: string) => {
|
const fetchInventoryRecords = async (productId: string) => {
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('finished_product_inventory_records')
|
const res = await http.get<FinishedProductInventoryRecord[]>(
|
||||||
.select('*')
|
`/purchaser/finished-products/${productId}/records`
|
||||||
.eq('finished_product_id', productId)
|
);
|
||||||
.order('created_at', { ascending: false });
|
setInventoryRecords(res.data || []);
|
||||||
|
} catch {
|
||||||
if (!error) {
|
setInventoryRecords([]);
|
||||||
setInventoryRecords(data || []);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi } from '../../api';
|
||||||
|
import { http } from '../../api';
|
||||||
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
|
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||||
import type { Company, FactoryType } from '../../types';
|
import type { Company, FactoryType } from '../../types';
|
||||||
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
interface YarnInput {
|
interface YarnInput {
|
||||||
id: string;
|
id: string;
|
||||||
@ -88,26 +88,21 @@ export function NewPlan() {
|
|||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const { data: prodData } = await supabase
|
const res = await purchaserApi.listProducts();
|
||||||
.from('products')
|
const prodData = res.data;
|
||||||
.select('*')
|
|
||||||
.eq('company_id', auth.company.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (prodData) {
|
if (prodData) {
|
||||||
setProducts(prodData as Product[]);
|
setProducts(prodData as Product[]);
|
||||||
|
|
||||||
// 获取每个产品的纱线配比
|
|
||||||
const productIds = prodData.map(p => p.id);
|
const productIds = prodData.map(p => p.id);
|
||||||
if (productIds.length > 0) {
|
if (productIds.length > 0) {
|
||||||
const { data: ratiosData } = await supabase
|
const ratiosRes = await http.get<ProductYarnRatio[]>('/purchaser/products/yarn-ratios', {
|
||||||
.from('product_yarn_ratios')
|
product_ids: productIds.join(',')
|
||||||
.select('*')
|
});
|
||||||
.in('product_id', productIds);
|
|
||||||
|
|
||||||
if (ratiosData) {
|
if (ratiosRes.data) {
|
||||||
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
||||||
ratiosData.forEach((r: any) => {
|
ratiosRes.data.forEach((r: any) => {
|
||||||
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
||||||
ratiosMap[r.product_id].push(r as ProductYarnRatio);
|
ratiosMap[r.product_id].push(r as ProductYarnRatio);
|
||||||
});
|
});
|
||||||
@ -120,14 +115,7 @@ export function NewPlan() {
|
|||||||
|
|
||||||
const fetchFactories = async () => {
|
const fetchFactories = async () => {
|
||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
|
const data = await purchaserApi.listFactories();
|
||||||
// 查询工厂管理中登记的纺织厂(companies表中role=textile的记录)
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('companies')
|
|
||||||
.select('*')
|
|
||||||
.eq('role', 'textile')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
setFactories(data || []);
|
setFactories(data || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -199,97 +187,21 @@ export function NewPlan() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 生成计划编号
|
await purchaserApi.createPlan({
|
||||||
const timestamp = Date.now().toString().slice(-6);
|
product_name: productName,
|
||||||
const planCode = `PLAN-2026-${timestamp}`;
|
fabric_code: fabricCode.toUpperCase(),
|
||||||
|
color_code: colorCode,
|
||||||
// 创建计划
|
color: color,
|
||||||
const { data: plan, error: planError } = await supabase
|
target_quantity: parseInt(targetQuantity) || 0,
|
||||||
.from('production_plans')
|
unit_price: parseFloat(productionPrice) || 0,
|
||||||
.insert({
|
notes: remark,
|
||||||
plan_code: planCode,
|
yarn_ratios: yarns.filter(y => y.name).map(yarn => ({
|
||||||
product_name: productName,
|
yarn_name: yarn.name,
|
||||||
color: color,
|
ratio: yarn.ratio,
|
||||||
fabric_code: fabricCode.toUpperCase(),
|
amount_per_meter: (selectedProduct?.weight || 0) * yarn.ratio,
|
||||||
color_code: colorCode,
|
yarn_type: yarn.yarn_type
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('计划创建成功!');
|
alert('计划创建成功!');
|
||||||
navigate('/purchaser/plans');
|
navigate('/purchaser/plans');
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi, http } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
|
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import type { ProductWithInventory, Company } from '../../types';
|
import type { ProductWithInventory, Company } from '../../types';
|
||||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
interface WashingPlanForm {
|
interface WashingPlanForm {
|
||||||
productId: string;
|
productId: string;
|
||||||
@ -41,84 +40,31 @@ export function NewWashingPlan() {
|
|||||||
const fetchProducts = useCallback(async () => {
|
const fetchProducts = useCallback(async () => {
|
||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
|
|
||||||
// 获取产品列表
|
const res = await purchaserApi.listProducts();
|
||||||
const { data: productsData } = await supabase
|
const productsData = res.data || [];
|
||||||
.from('products')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', auth.company.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
// 获取生产计划(关联到产品)
|
const productsWithInventory: ProductWithInventory[] = productsData.map(product => ({
|
||||||
// 注意:使用 company_id 查询,与 RLS 策略保持一致
|
...product,
|
||||||
const fabricCodes = (productsData || []).map(p => p.fabric_code);
|
raw_fabric_meters: product.raw_fabric_meters || 0,
|
||||||
const { data: plansData, error: plansError } = await supabase
|
raw_fabric_rolls: product.raw_fabric_rolls || 0,
|
||||||
.from('production_plans')
|
total_stock: product.total_stock || 0,
|
||||||
.select('id, fabric_code, purchaser_id')
|
total_in_meters: 0,
|
||||||
.in('fabric_code', fabricCodes);
|
total_in_rolls: 0,
|
||||||
|
total_out_meters: 0,
|
||||||
if (plansError) {
|
total_out_rolls: 0
|
||||||
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<string, { meters: number; rolls: number }> = {};
|
|
||||||
(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<string, { meters: number; rolls: number }> = {};
|
|
||||||
|
|
||||||
(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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setProducts(productsWithInventory);
|
setProducts(productsWithInventory);
|
||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
// 获取水洗厂列表
|
// 获取水洗厂列表
|
||||||
const fetchWashingFactories = useCallback(async () => {
|
const fetchWashingFactories = useCallback(async () => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('companies')
|
const res = await http.get<Company[]>('/purchaser/factories', { role: 'washing' });
|
||||||
.select('*')
|
setWashingFactories(res.data || []);
|
||||||
.eq('role', 'washing')
|
} catch {
|
||||||
.order('name', { ascending: true });
|
setWashingFactories([]);
|
||||||
setWashingFactories(data || []);
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -185,68 +131,22 @@ export function NewWashingPlan() {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
// 生成计划编号
|
try {
|
||||||
const planCode = generatePlanCode();
|
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);
|
setSubmitting(false);
|
||||||
setError('创建失败: ' + insertError.message);
|
navigate('/purchaser');
|
||||||
return;
|
} catch (err: any) {
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setError('出库记录创建失败: ' + outboundError.message);
|
setError('创建失败: ' + (err.message || '未知错误'));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
const selectedProduct = products.find(p => p.id === form.productId);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { purchaserApi, http } from '../../api';
|
||||||
import { useInventoryData } from '../../hooks/useInventoryData';
|
import { useInventoryData } from '../../hooks/useInventoryData';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||||
@ -158,7 +158,6 @@ export function WarehouseManage() {
|
|||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
|
|
||||||
const productData = {
|
const productData = {
|
||||||
company_id: auth.company.id,
|
|
||||||
product_name: formData.product_name,
|
product_name: formData.product_name,
|
||||||
weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0,
|
weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0,
|
||||||
warp_weight: parseFloat(formData.warp_weight) || null,
|
warp_weight: parseFloat(formData.warp_weight) || null,
|
||||||
@ -171,116 +170,40 @@ export function WarehouseManage() {
|
|||||||
image_url: formData.image_url || null
|
image_url: formData.image_url || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// 校验1:产品码全局唯一
|
const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
|
||||||
const { data: existingFabricCode } = await supabase
|
|
||||||
.from('products')
|
|
||||||
.select('id, product_name, color')
|
|
||||||
.eq('company_id', auth.company.id)
|
|
||||||
.eq('fabric_code', productData.fabric_code)
|
|
||||||
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingFabricCode) {
|
try {
|
||||||
handleError(new Error('产品码重复'), { userMessage: `产品码 "${productData.fabric_code}" 已存在,请使用其他产品码` });
|
if (editingProduct) {
|
||||||
return;
|
await http.put(`/purchaser/products/${editingProduct.id}`, {
|
||||||
}
|
...productData,
|
||||||
|
yarn_ratios: yarnRatios
|
||||||
// 校验2:色号在"成品名称-克重-颜色"组合中唯一
|
});
|
||||||
// 即:同一个"成品名称-克重-颜色"下,色号不能重复
|
} else {
|
||||||
const { data: existingColorCode } = await supabase
|
await http.post('/purchaser/products', {
|
||||||
.from('products')
|
...productData,
|
||||||
.select('id, fabric_code')
|
yarn_ratios: yarnRatios
|
||||||
.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: '产品信息编辑'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
productId = editingProduct.id;
|
setShowProductModal(false);
|
||||||
await supabase.from('product_yarn_ratios').delete().eq('product_id', productId);
|
setEditingProduct(null);
|
||||||
} else {
|
setProductForm(initialProductForm);
|
||||||
// 创建产品
|
await refetch();
|
||||||
const { data, error } = await supabase
|
} catch (err) {
|
||||||
.from('products')
|
handleError(err, { userMessage: editingProduct ? '更新失败' : '创建失败' });
|
||||||
.insert(productData)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
handleError(error, { userMessage: '创建失败' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
productId = data.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存纱线配比
|
|
||||||
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]);
|
}, [auth.company, auth.user, editingProduct, refetch]);
|
||||||
|
|
||||||
// 删除产品
|
// 删除产品
|
||||||
const handleDeleteProduct = useCallback(async (id: string) => {
|
const handleDeleteProduct = useCallback(async (id: string) => {
|
||||||
if (!confirm('确定要删除这个产品吗?')) return;
|
if (!confirm('确定要删除这个产品吗?')) return;
|
||||||
|
|
||||||
const { error } = await supabase.from('products').delete().eq('id', id);
|
try {
|
||||||
if (error) {
|
await purchaserApi.deleteProduct(id);
|
||||||
handleError(error, { userMessage: '删除失败' });
|
await refetch();
|
||||||
return;
|
} catch (err) {
|
||||||
|
handleError(err, { userMessage: '删除失败' });
|
||||||
}
|
}
|
||||||
await refetch();
|
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
// 编辑产品
|
// 编辑产品
|
||||||
@ -321,15 +244,16 @@ export function WarehouseManage() {
|
|||||||
|
|
||||||
// 查看价格历史
|
// 查看价格历史
|
||||||
const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => {
|
const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('product_price_history')
|
const data = await purchaserApi.priceHistory(product.id);
|
||||||
.select('*')
|
setSelectedPriceHistory(data || []);
|
||||||
.eq('product_id', product.id)
|
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
|
||||||
.order('created_at', { ascending: false });
|
setPriceHistoryModalOpen(true);
|
||||||
|
} catch {
|
||||||
setSelectedPriceHistory(data || []);
|
setSelectedPriceHistory([]);
|
||||||
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
|
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
|
||||||
setPriceHistoryModalOpen(true);
|
setPriceHistoryModalOpen(true);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 查看出入库历史 - 合并入库和出库记录
|
// 查看出入库历史 - 合并入库和出库记录
|
||||||
@ -351,41 +275,26 @@ export function WarehouseManage() {
|
|||||||
const handleInSubmit = useCallback(async () => {
|
const handleInSubmit = useCallback(async () => {
|
||||||
if (!auth.company || !inForm.product_id || !inForm.batch_no) return;
|
if (!auth.company || !inForm.product_id || !inForm.batch_no) return;
|
||||||
|
|
||||||
const { error } = await supabase.from('product_inventory_records').insert({
|
try {
|
||||||
company_id: auth.company.id,
|
await purchaserApi.inbound(inForm.product_id, {
|
||||||
product_id: inForm.product_id,
|
batch_no: inForm.batch_no,
|
||||||
batch_no: inForm.batch_no,
|
rolls: inForm.rolls,
|
||||||
rolls: inForm.rolls,
|
meters: inForm.meters,
|
||||||
meters: inForm.meters,
|
notes: inForm.notes || undefined
|
||||||
notes: inForm.notes || null,
|
});
|
||||||
operator_id: auth.user?.id || null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
setShowInModal(false);
|
||||||
handleError(error, { userMessage: '入库失败' });
|
setInForm(initialInboundForm);
|
||||||
return;
|
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]);
|
}, [auth.company, auth.user, inForm, products, refetch]);
|
||||||
|
|
||||||
// 提交出库
|
// 提交出库
|
||||||
const handleOutSubmit = useCallback(async () => {
|
const handleOutSubmit = useCallback(async () => {
|
||||||
if (!auth.company || !outForm.product_id || !outForm.batch_no || !outForm.source_batch_id || !outForm.recipient_company_id) return;
|
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);
|
const product = products.find(p => p.id === outForm.product_id);
|
||||||
if (!product) {
|
if (!product) {
|
||||||
handleError(new Error('产品不存在'), { userMessage: '产品不存在' });
|
handleError(new Error('产品不存在'), { userMessage: '产品不存在' });
|
||||||
@ -400,35 +309,22 @@ export function WarehouseManage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from('product_outbound_records').insert({
|
try {
|
||||||
company_id: auth.company.id,
|
await purchaserApi.outbound(outForm.product_id, {
|
||||||
product_id: outForm.product_id,
|
batch_no: outForm.batch_no,
|
||||||
batch_no: outForm.batch_no,
|
rolls: outForm.rolls,
|
||||||
rolls: outForm.rolls,
|
meters: outForm.meters,
|
||||||
meters: outForm.meters,
|
outbound_type: 'manual',
|
||||||
notes: outForm.notes || null,
|
notes: outForm.notes || undefined,
|
||||||
operator_id: auth.user?.id || null,
|
recipient_company_id: outForm.recipient_company_id
|
||||||
outbound_type: 'manual',
|
});
|
||||||
source_batch_id: outForm.source_batch_id,
|
|
||||||
recipient_company_id: outForm.recipient_company_id,
|
|
||||||
recipient_company_name: outForm.recipient_company_name
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
setShowOutModal(false);
|
||||||
handleError(error, { userMessage: '出库失败' });
|
setOutForm(initialOutboundForm);
|
||||||
return;
|
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]);
|
}, [auth.company, auth.user, outForm, products, refetch]);
|
||||||
|
|
||||||
// 打开新建产品模态框
|
// 打开新建产品模态框
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { textileApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react';
|
import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -72,41 +72,20 @@ export function TextileDashboard() {
|
|||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
const { data: planFactories } = await supabase
|
const response = await textileApi.listPlans();
|
||||||
.from('plan_factories')
|
const planData = response.data || [];
|
||||||
.select('plan_id')
|
|
||||||
.eq('factory_id', auth.company!.id)
|
|
||||||
.eq('factory_type', 'textile');
|
|
||||||
|
|
||||||
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<string>();
|
const rejectedIds = new Set<string>();
|
||||||
(stepsData || []).forEach(step => {
|
planData.forEach(plan => {
|
||||||
if (step.status === STEP_STATUS.REJECTED) {
|
(plan.process_steps || []).forEach(step => {
|
||||||
rejectedIds.add(step.plan_id);
|
if (step.status === STEP_STATUS.REJECTED) {
|
||||||
}
|
rejectedIds.add(plan.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setRejectedPlanIds(rejectedIds);
|
setRejectedPlanIds(rejectedIds);
|
||||||
|
|
||||||
setPlans(planData || []);
|
setPlans(planData);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react';
|
import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { textileApi, http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
|
import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
|
||||||
|
|
||||||
@ -93,125 +93,68 @@ export function FabricWarehouse() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
fetchFabricInventory();
|
fetchFabricInventory();
|
||||||
|
|
||||||
// 订阅入库记录变化,实现实时更新
|
|
||||||
const subscription = supabase
|
|
||||||
.channel('inventory_records_changes')
|
|
||||||
.on(
|
|
||||||
'postgres_changes',
|
|
||||||
{
|
|
||||||
event: '*',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'inventory_records'
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
// 有入库记录变化时刷新数据
|
|
||||||
fetchFabricInventory();
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchFabricInventory = async () => {
|
const fetchFabricInventory = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取纺织厂关联的所有计划
|
try {
|
||||||
const { data: planFactories } = await supabase
|
const response = await textileApi.listPlans();
|
||||||
.from('plan_factories')
|
const plansData = response.data || [];
|
||||||
.select('plan_id')
|
|
||||||
.eq('factory_id', auth.company!.id)
|
if (plansData.length === 0) {
|
||||||
.eq('factory_type', 'textile');
|
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<any[]>('/common/products', { company_ids: purchaserIds.join(',') }),
|
||||||
|
...planIds.map(id => textileApi.listInventory(id).catch(() => []))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const productWeightMap: Record<string, number> = {};
|
||||||
|
(productsRes.data || []).forEach((p: any) => {
|
||||||
|
productWeightMap[p.fabric_code] = p.weight;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordsByPlan: Record<string, { time: string; quantity: number; rolls: number }[]> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (!planFactories || planFactories.length === 0) {
|
|
||||||
setInventory([]);
|
|
||||||
setLoading(false);
|
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<string, number> = {};
|
|
||||||
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<string, { time: string; quantity: number; rolls: number }[]> = {};
|
|
||||||
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) => {
|
const toggleExpand = (planId: string) => {
|
||||||
|
|||||||
@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { textileApi } from '../../api';
|
||||||
import type { Payment, ProductionPlan } from '../../types';
|
import type { Payment, ProductionPlan } from '../../types';
|
||||||
import { PAYMENT_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
export function PaymentPending() {
|
export function PaymentPending() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -21,29 +20,20 @@ export function PaymentPending() {
|
|||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchPayments = async () => {
|
const fetchPayments = async () => {
|
||||||
// 获取当前纺织厂作为收款方的待结款
|
const response = await textileApi.listPayments();
|
||||||
const { data: paymentData } = await supabase
|
const paymentData = response.data || [];
|
||||||
.from('payments')
|
|
||||||
.select('*')
|
|
||||||
.eq('to_company_id', auth.company!.id)
|
|
||||||
.eq('status', PAYMENT_STATUS.PENDING);
|
|
||||||
|
|
||||||
if (!paymentData || paymentData.length === 0) {
|
if (paymentData.length === 0) {
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
setPlanMap({});
|
setPlanMap({});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取关联的计划信息
|
|
||||||
const planIds = paymentData.map(p => p.plan_id);
|
|
||||||
const { data: planData } = await supabase
|
|
||||||
.from('production_plans')
|
|
||||||
.select('*')
|
|
||||||
.in('id', planIds);
|
|
||||||
|
|
||||||
const map: Record<string, ProductionPlan> = {};
|
const map: Record<string, ProductionPlan> = {};
|
||||||
(planData || []).forEach(p => { map[p.id] = p; });
|
paymentData.forEach((p: any) => {
|
||||||
|
if (p.plan) map[p.plan_id] = p.plan;
|
||||||
|
});
|
||||||
|
|
||||||
setPayments(paymentData);
|
setPayments(paymentData);
|
||||||
setPlanMap(map);
|
setPlanMap(map);
|
||||||
@ -54,19 +44,10 @@ export function PaymentPending() {
|
|||||||
|
|
||||||
const handleConfirmPay = async (paymentId: string) => {
|
const handleConfirmPay = async (paymentId: string) => {
|
||||||
setConfirming(paymentId);
|
setConfirming(paymentId);
|
||||||
const now = new Date().toISOString();
|
try {
|
||||||
const { error } = await supabase
|
await textileApi.confirmPayment(paymentId);
|
||||||
.from('payments')
|
|
||||||
.update({
|
|
||||||
status: PAYMENT_STATUS.COMPLETED,
|
|
||||||
paid_at: now
|
|
||||||
})
|
|
||||||
.eq('id', paymentId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('结款失败:', error);
|
|
||||||
} else {
|
|
||||||
await fetchPayments();
|
await fetchPayments();
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
setConfirming(null);
|
setConfirming(null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { textileApi, http } from '../../api';
|
||||||
import { usePlanStatusSync } from '../../hooks/usePlanStatusSync';
|
import { usePlanStatusSync } from '../../hooks/usePlanStatusSync';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react';
|
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react';
|
||||||
import { ProcessFlow } from '../../components/ProcessFlow';
|
import { ProcessFlow } from '../../components/ProcessFlow';
|
||||||
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
|
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
|
||||||
import { getSupabaseUrl } from '../../supabase/client';
|
|
||||||
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
||||||
import { extractParamsFromLink } from '../../utils/helpers';
|
import { extractParamsFromLink } from '../../utils/helpers';
|
||||||
import { STORAGE_KEYS } from '../../config/app';
|
import { STORAGE_KEYS } from '../../config/app';
|
||||||
@ -59,148 +58,73 @@ export function TextilePlanOverview() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.company) return;
|
if (!auth.company) return;
|
||||||
fetchPlans();
|
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]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
// 获取关联到当前纺织厂的计划
|
const response = await textileApi.listPlans();
|
||||||
const { data: planFactories } = await supabase
|
const planData = response.data || [];
|
||||||
.from('plan_factories')
|
|
||||||
.select('plan_id')
|
|
||||||
.eq('factory_id', auth.company!.id)
|
|
||||||
.eq('factory_type', 'textile');
|
|
||||||
|
|
||||||
if (!planFactories || planFactories.length === 0) {
|
if (planData.length === 0) {
|
||||||
setGroups([]);
|
setGroups([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const planIds = planFactories.map(pf => pf.plan_id);
|
const planIds = planData.map(p => p.id);
|
||||||
|
|
||||||
// 获取计划详情
|
const operatorIds = [...new Set(
|
||||||
const { data: planData } = await supabase
|
planData.flatMap(p => (p.process_steps || []).filter(s => s.operator_id).map(s => s.operator_id!))
|
||||||
.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[];
|
|
||||||
if (operatorIds.length > 0) {
|
if (operatorIds.length > 0) {
|
||||||
const { data: profilesData } = await supabase
|
try {
|
||||||
.from('profiles')
|
const { data: profilesData } = await http.get<any[]>('/common/profiles', { ids: operatorIds.join(',') });
|
||||||
.select('id, username')
|
const names: Record<string, string> = {};
|
||||||
.in('id', operatorIds);
|
(profilesData || []).forEach(p => { names[p.id] = p.username; });
|
||||||
|
setOperatorNames(names);
|
||||||
const names: Record<string, string> = {};
|
} catch {}
|
||||||
(profilesData || []).forEach(p => {
|
|
||||||
names[p.id] = p.username;
|
|
||||||
});
|
|
||||||
setOperatorNames(names);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取采购商公司信息
|
|
||||||
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
|
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
|
||||||
const { data: companiesData } = await supabase
|
|
||||||
.from('companies')
|
|
||||||
.select('*')
|
|
||||||
.in('id', purchaserIds);
|
|
||||||
|
|
||||||
const companyMap: Record<string, string> = {};
|
const companyMap: Record<string, string> = {};
|
||||||
(companiesData || []).forEach(c => { companyMap[c.id] = c.name; });
|
planData.forEach(p => {
|
||||||
|
if (p.purchaser_name) companyMap[p.purchaser_id] = p.purchaser_name;
|
||||||
|
});
|
||||||
|
|
||||||
// 获取所有计划的入库记录
|
const inventoryResults = await Promise.all(
|
||||||
const { data: inventoryData } = await supabase
|
planIds.map(id => textileApi.listInventory(id).catch(() => [] as any[]))
|
||||||
.from('inventory_records')
|
);
|
||||||
.select('plan_id, quantity, rolls, created_at')
|
const inventoryByPlan: Record<string, any[]> = {};
|
||||||
.in('plan_id', planIds)
|
planIds.forEach((id, idx) => { inventoryByPlan[id] = inventoryResults[idx] || []; });
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
// 获取纱线配比
|
let yarnRatiosData: any[] = [];
|
||||||
const { data: yarnRatiosData } = await supabase
|
let productsData: any[] = [];
|
||||||
.from('yarn_ratios')
|
try {
|
||||||
.select('*')
|
const [yrRes, prRes] = await Promise.all([
|
||||||
.in('plan_id', planIds);
|
http.get<any[]>('/textile/yarn-ratios', { plan_ids: planIds.join(',') }),
|
||||||
|
http.get<any[]>('/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<string, PlanGroup> = {};
|
const grouped: Record<string, PlanGroup> = {};
|
||||||
planData.forEach(plan => {
|
planData.forEach(plan => {
|
||||||
const pid = plan.purchaser_id;
|
const pid = plan.purchaser_id;
|
||||||
if (!grouped[pid]) {
|
if (!grouped[pid]) {
|
||||||
grouped[pid] = {
|
grouped[pid] = {
|
||||||
purchaserId: pid,
|
purchaserId: pid,
|
||||||
purchaserName: companyMap[pid] || '未知布行',
|
purchaserName: companyMap[pid] || plan.purchaser_name || '未知布行',
|
||||||
plans: []
|
plans: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const processSteps = (stepsData || [])
|
const processSteps = (plan.process_steps || [])
|
||||||
.filter(s => s.plan_id === plan.id)
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||||
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
|
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
|
||||||
});
|
});
|
||||||
const inventoryRecords = (inventoryData || [])
|
const inventoryRecords = (inventoryByPlan[plan.id] || [])
|
||||||
.filter(r => r.plan_id === plan.id)
|
.map((r: any) => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
||||||
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
const yarnRatios = (yarnRatiosData)
|
||||||
const yarnRatios = (yarnRatiosData || [])
|
|
||||||
.filter(y => y.plan_id === plan.id)
|
.filter(y => y.plan_id === plan.id)
|
||||||
.map(y => ({
|
.map(y => ({
|
||||||
id: y.id,
|
id: y.id,
|
||||||
@ -213,8 +137,7 @@ export function TextilePlanOverview() {
|
|||||||
created_at: y.created_at,
|
created_at: y.created_at,
|
||||||
company_id: y.company_id
|
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
|
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
|
||||||
);
|
);
|
||||||
grouped[pid].plans.push({
|
grouped[pid].plans.push({
|
||||||
@ -232,7 +155,6 @@ export function TextilePlanOverview() {
|
|||||||
|
|
||||||
const groupList = Object.values(grouped);
|
const groupList = Object.values(grouped);
|
||||||
setGroups(groupList);
|
setGroups(groupList);
|
||||||
// 默认展开第一个组
|
|
||||||
if (groupList.length > 0) {
|
if (groupList.length > 0) {
|
||||||
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
||||||
}
|
}
|
||||||
@ -310,54 +232,31 @@ export function TextilePlanOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRejecting(rejectStepId);
|
setRejecting(rejectStepId);
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取步骤信息
|
const planId = groups.flatMap(g => g.plans).find(
|
||||||
const { data: step } = await supabase
|
p => p.processSteps.some(s => s.id === rejectStepId)
|
||||||
.from('plan_process_steps')
|
)?.id;
|
||||||
.select('plan_id')
|
|
||||||
.eq('id', rejectStepId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (!step?.plan_id) {
|
if (!planId) {
|
||||||
setRejecting(null);
|
setRejecting(null);
|
||||||
handleCloseRejectModal();
|
handleCloseRejectModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新步骤状态为拒绝(使用 notes 字段记录拒绝原因)
|
await textileApi.rejectStep(planId, rejectStepId, { reason: rejectReason.trim() });
|
||||||
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);
|
|
||||||
|
|
||||||
if (error) {
|
await notifyPlanUpdated(planId, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
|
||||||
handleError(error, { userMessage: '拒绝失败,请重试' });
|
|
||||||
setRejecting(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送拒绝通知给采购商
|
setRejectedPlans(prev => new Set([...prev, planId]));
|
||||||
await notifyPlanUpdated(step.plan_id, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
|
|
||||||
|
|
||||||
// 将该计划标记为已拒绝(本地状态,显示灰色)
|
|
||||||
setRejectedPlans(prev => new Set([...prev, step.plan_id]));
|
|
||||||
|
|
||||||
// 存储到 localStorage,让 Dashboard 也能过滤掉
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
|
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
|
||||||
const rejectedArray = stored ? JSON.parse(stored) : [];
|
const rejectedArray = stored ? JSON.parse(stored) : [];
|
||||||
if (!rejectedArray.includes(step.plan_id)) {
|
if (!rejectedArray.includes(planId)) {
|
||||||
rejectedArray.push(step.plan_id);
|
rejectedArray.push(planId);
|
||||||
localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray));
|
localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗并收起计划卡片
|
|
||||||
handleCloseRejectModal();
|
handleCloseRejectModal();
|
||||||
setExpandedPlan(null);
|
setExpandedPlan(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -368,248 +267,54 @@ export function TextilePlanOverview() {
|
|||||||
|
|
||||||
const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => {
|
const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => {
|
||||||
setConfirming(stepId);
|
setConfirming(stepId);
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// 获取步骤信息
|
const plan = groups.flatMap(g => g.plans).find(
|
||||||
const { data: step } = await supabase
|
p => p.processSteps.some(s => s.id === stepId)
|
||||||
.from('plan_process_steps')
|
);
|
||||||
.select('step_type, plan_id')
|
const step = plan?.processSteps.find(s => s.id === stepId);
|
||||||
.eq('id', stepId)
|
const planId = plan?.id;
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
const planId = step?.plan_id;
|
if (!planId || !step) {
|
||||||
|
|
||||||
if (!planId) {
|
|
||||||
setConfirming(null);
|
setConfirming(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是坯布入库,使用 Edge Function 进行事务处理
|
try {
|
||||||
if (step?.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
|
if (step.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
|
||||||
// 获取纺织厂的仓库和工厂信息
|
await textileApi.inbound(planId, {
|
||||||
const { data: warehouse } = await supabase
|
quantity,
|
||||||
.from('warehouses')
|
rolls: rolls || 0,
|
||||||
.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<string, string> = { '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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
await textileApi.completeStep(planId, stepId, {
|
||||||
if (!response.ok) {
|
operator_name: auth.user?.username
|
||||||
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: '步骤已确认,但更新计划状态失败,请刷新页面' });
|
|
||||||
} else {
|
} else {
|
||||||
// 发送计划开始生产通知
|
await textileApi.completeStep(planId, stepId, {
|
||||||
await notifyPlanUpdated(planId, PLAN_STATUS.PRODUCING, auth.company!.id, auth.user!.id);
|
operator_name: auth.user?.username
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stepNames: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
|
||||||
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);
|
setConfirming(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
|
import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { textileApi, http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { YarnStock, Warehouse } from '../../types';
|
import type { YarnStock, Warehouse } from '../../types';
|
||||||
|
|
||||||
@ -42,45 +42,25 @@ export function YarnWarehouse() {
|
|||||||
}, [activeTab, auth.company]);
|
}, [activeTab, auth.company]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const { data: yarnData } = await supabase
|
const response = await textileApi.listYarnStock();
|
||||||
.from('yarn_stock')
|
setYarns(response.data || []);
|
||||||
.select('*')
|
|
||||||
.eq('company_id', auth.company!.id)
|
|
||||||
.order('name');
|
|
||||||
|
|
||||||
const { data: whData } = await supabase
|
const { data: whData } = await http.get<Warehouse[]>('/textile/warehouses', { type: 'yarn' });
|
||||||
.from('warehouses')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', auth.company!.id)
|
|
||||||
.eq('type', 'yarn');
|
|
||||||
|
|
||||||
setYarns(yarnData || []);
|
|
||||||
setWarehouses(whData || []);
|
setWarehouses(whData || []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data: recordsData } = await supabase
|
const { data: recordsData } = await http.get<any[]>('/textile/yarn-stock/records');
|
||||||
.from('yarn_stock_records')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', auth.company!.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
// 获取纱线名称
|
const yarnMap = new Map(yarns.map(y => [y.id, y.name]));
|
||||||
const yarnIds = recordsData?.map(r => r.yarn_stock_id).filter((id): id is string => !!id) || [];
|
|
||||||
const { data: yarnsData } = await supabase
|
|
||||||
.from('yarn_stock')
|
|
||||||
.select('id, name')
|
|
||||||
.in('id', yarnIds.length > 0 ? yarnIds : ['00000000-0000-0000-0000-000000000000']);
|
|
||||||
|
|
||||||
const yarnMap = new Map(yarnsData?.map(y => [y.id, y.name]) || []);
|
const recordsWithNames: YarnStockRecord[] = (recordsData || []).map(r => ({
|
||||||
|
|
||||||
const recordsWithNames: YarnStockRecord[] = recordsData?.map(r => ({
|
|
||||||
...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'
|
record_type: r.record_type as 'inbound' | 'outbound'
|
||||||
})) || [];
|
}));
|
||||||
|
|
||||||
setRecords(recordsWithNames);
|
setRecords(recordsWithNames);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -88,16 +68,10 @@ export function YarnWarehouse() {
|
|||||||
|
|
||||||
const handleAddYarn = async () => {
|
const handleAddYarn = async () => {
|
||||||
if (!newYarn.name) return;
|
if (!newYarn.name) return;
|
||||||
const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库
|
const yarnWarehouse = warehouses[0];
|
||||||
|
|
||||||
// 获取当前用户ID
|
try {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const newYarnData = await textileApi.createYarnStock({
|
||||||
const operatorId = user?.id;
|
|
||||||
|
|
||||||
// 插入纱线库存
|
|
||||||
const { data: newYarnData, error } = await supabase
|
|
||||||
.from('yarn_stock')
|
|
||||||
.insert({
|
|
||||||
company_id: auth.company!.id,
|
company_id: auth.company!.id,
|
||||||
warehouse_id: yarnWarehouse?.id || null,
|
warehouse_id: yarnWarehouse?.id || null,
|
||||||
name: newYarn.name,
|
name: newYarn.name,
|
||||||
@ -105,27 +79,18 @@ export function YarnWarehouse() {
|
|||||||
quantity: newYarn.quantity,
|
quantity: newYarn.quantity,
|
||||||
min_stock: newYarn.minStock,
|
min_stock: newYarn.minStock,
|
||||||
unit: newYarn.unit
|
unit: newYarn.unit
|
||||||
})
|
} as any);
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
await textileApi.addYarnRecord(newYarnData.id, {
|
||||||
console.error('添加失败:', error);
|
change_type: 'inbound',
|
||||||
} else {
|
|
||||||
// 记录入库
|
|
||||||
await supabase.from('yarn_stock_records').insert({
|
|
||||||
company_id: auth.company!.id,
|
|
||||||
yarn_stock_id: newYarnData.id,
|
|
||||||
record_type: 'inbound',
|
|
||||||
quantity: newYarn.quantity,
|
quantity: newYarn.quantity,
|
||||||
unit: newYarn.unit,
|
notes: `纱线入库 - ${newYarn.name}`
|
||||||
notes: `纱线入库 - ${newYarn.name}`,
|
|
||||||
operator_id: operatorId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
|
setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
|
||||||
await fetchData();
|
await fetchData();
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
|
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import type { WashingPlan, WashingPlanCompletion } from '../../types';
|
import type { WashingPlan } from '../../types';
|
||||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
interface CompletedFabricItem {
|
interface CompletedFabricItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -38,51 +37,25 @@ export function CompletedFabric() {
|
|||||||
const fetchCompletedFabric = async () => {
|
const fetchCompletedFabric = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取已完成的水洗计划
|
try {
|
||||||
const { data: washingPlans, error } = await supabase
|
const data = await washingApi.listCompletedPlans();
|
||||||
.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) {
|
const formattedItems = (data || []).map((plan: any) => ({
|
||||||
console.error('获取已完成坯布失败:', error);
|
id: plan.id,
|
||||||
} else {
|
plan_code: plan.plan_code,
|
||||||
// 获取完成记录详情
|
purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
|
||||||
const planIds = (washingPlans || []).map(p => p.id);
|
product_name: plan.product_name || plan.product?.product_name || '-',
|
||||||
const { data: completions } = await supabase
|
product_color: plan.product_color || plan.product?.color || '-',
|
||||||
.from('washing_plan_completions')
|
fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
|
||||||
.select('*')
|
planned_meters: plan.planned_meters,
|
||||||
.in('washing_plan_id', planIds);
|
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,
|
||||||
const completionMap: Record<string, WashingPlanCompletion> = {};
|
washing_date: plan.completion?.washing_date || plan.washing_date || '',
|
||||||
(completions || []).forEach(c => {
|
rolls: plan.completion?.rolls || 0,
|
||||||
completionMap[c.washing_plan_id] = c;
|
completed_at: plan.completion?.completed_at || plan.updated_at
|
||||||
});
|
}));
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setItems(formattedItems);
|
setItems(formattedItems);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
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 { 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';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -74,13 +74,8 @@ export function WashingDashboard() {
|
|||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
const { data: washingPlans } = await supabase
|
const response = await washingApi.listPlans();
|
||||||
.from('washing_plans')
|
setPlans(response.data || []);
|
||||||
.select('*')
|
|
||||||
.eq('washing_factory_id', auth.company!.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
setPlans(washingPlans || []);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi, http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react';
|
import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -39,45 +39,33 @@ export function FinishedWarehouse() {
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('finished_products')
|
const response = await washingApi.listFinishedProducts();
|
||||||
.select(`
|
const productsWithDetails = (response.data || []).map((product: any) => ({
|
||||||
*,
|
|
||||||
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,
|
...product,
|
||||||
warehouse_name: (product.warehouse as any)?.name
|
warehouse_name: product.warehouse?.name || product.warehouse_name
|
||||||
}));
|
}));
|
||||||
setProducts(productsWithDetails);
|
setProducts(productsWithDetails);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchWarehouses = async () => {
|
const fetchWarehouses = async () => {
|
||||||
const { data } = await supabase
|
try {
|
||||||
.from('warehouses')
|
const { data } = await http.get<Warehouse[]>('/washing/warehouses', { type: 'finished' });
|
||||||
.select('*')
|
setWarehouses(data || []);
|
||||||
.eq('company_id', auth.company!.id)
|
} catch {
|
||||||
.eq('type', 'finished');
|
}
|
||||||
setWarehouses(data || []);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchInventoryRecords = async (productId: string) => {
|
const fetchInventoryRecords = async (productId: string) => {
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('finished_product_inventory_records')
|
const { data } = await http.get<FinishedProductInventoryRecord[]>(
|
||||||
.select('*')
|
`/washing/finished-products/${productId}/records`
|
||||||
.eq('finished_product_id', productId)
|
);
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
setInventoryRecords(data || []);
|
setInventoryRecords(data || []);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi } from '../../api';
|
||||||
import type { Payment, WashingPlan } from '../../types';
|
import type { Payment, WashingPlan } from '../../types';
|
||||||
import { PAYMENT_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
interface PaymentWithPlan extends Payment {
|
interface PaymentWithPlan extends Payment {
|
||||||
plan?: WashingPlan;
|
plan?: WashingPlan;
|
||||||
@ -24,32 +23,18 @@ export function WashingPaymentPending() {
|
|||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
|
||||||
const fetchPayments = async () => {
|
const fetchPayments = async () => {
|
||||||
// 获取当前水洗厂作为收款方的待结款
|
const response = await washingApi.listPayments();
|
||||||
const { data: paymentData } = await supabase
|
const paymentData = response.data || [];
|
||||||
.from('payments')
|
|
||||||
.select('*')
|
|
||||||
.eq('to_company_id', auth.company!.id)
|
|
||||||
.eq('status', PAYMENT_STATUS.PENDING);
|
|
||||||
|
|
||||||
if (!paymentData || paymentData.length === 0) {
|
if (paymentData.length === 0) {
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取关联的水洗计划信息
|
const paymentsWithPlans = paymentData.map((p: any) => ({
|
||||||
const planIds = paymentData.map(p => p.plan_id);
|
|
||||||
const { data: planData } = await supabase
|
|
||||||
.from('washing_plans')
|
|
||||||
.select('*')
|
|
||||||
.in('id', planIds);
|
|
||||||
|
|
||||||
const planMap: Record<string, WashingPlan> = {};
|
|
||||||
(planData || []).forEach(p => { planMap[p.id] = p; });
|
|
||||||
|
|
||||||
const paymentsWithPlans = paymentData.map(p => ({
|
|
||||||
...p,
|
...p,
|
||||||
plan: planMap[p.plan_id]
|
plan: p.washing_plan
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setPayments(paymentsWithPlans);
|
setPayments(paymentsWithPlans);
|
||||||
@ -60,19 +45,10 @@ export function WashingPaymentPending() {
|
|||||||
|
|
||||||
const handleConfirmPay = async (paymentId: string) => {
|
const handleConfirmPay = async (paymentId: string) => {
|
||||||
setConfirming(paymentId);
|
setConfirming(paymentId);
|
||||||
const now = new Date().toISOString();
|
try {
|
||||||
const { error } = await supabase
|
await washingApi.confirmPayment(paymentId);
|
||||||
.from('payments')
|
|
||||||
.update({
|
|
||||||
status: PAYMENT_STATUS.COMPLETED,
|
|
||||||
paid_at: now
|
|
||||||
})
|
|
||||||
.eq('id', paymentId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('结款失败:', error);
|
|
||||||
} else {
|
|
||||||
await fetchPayments();
|
await fetchPayments();
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
setConfirming(null);
|
setConfirming(null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi } from '../../api';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react';
|
import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
import type { WashingPlan } from '../../types';
|
import type { WashingPlan } from '../../types';
|
||||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
|
||||||
|
|
||||||
interface PendingFabricItem {
|
interface PendingFabricItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -40,32 +39,21 @@ export function PendingFabric() {
|
|||||||
const fetchPendingFabric = async () => {
|
const fetchPendingFabric = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from('washing_plans')
|
const data = await washingApi.listPendingPlans();
|
||||||
.select(`
|
const formattedItems = (data || []).map((plan: any) => ({
|
||||||
*,
|
|
||||||
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 => ({
|
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
plan_code: plan.plan_code,
|
plan_code: plan.plan_code,
|
||||||
purchaser_name: (plan.company as any)?.name || '未知采购商',
|
purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
|
||||||
product_name: (plan.product as any)?.product_name || '-',
|
product_name: plan.product_name || plan.product?.product_name || '-',
|
||||||
product_color: (plan.product as any)?.color || '-',
|
product_color: plan.product_color || plan.product?.color || '-',
|
||||||
fabric_code: (plan.product as any)?.fabric_code || '-',
|
fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
|
||||||
planned_meters: plan.planned_meters,
|
planned_meters: plan.planned_meters,
|
||||||
status: plan.status,
|
status: plan.status,
|
||||||
created_at: plan.created_at
|
created_at: plan.created_at
|
||||||
}));
|
}));
|
||||||
setItems(formattedItems);
|
setItems(formattedItems);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../supabase/client';
|
import { washingApi, http } from '../../api';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react';
|
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react';
|
||||||
import { useResponsive } from '../../hooks/useResponsive';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
@ -64,87 +64,67 @@ export function WashingPlanOverview() {
|
|||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取水洗计划
|
try {
|
||||||
const { data: washingPlans, error } = await supabase
|
const response = await washingApi.listPlans();
|
||||||
.from('washing_plans')
|
const washingPlans = response.data || [];
|
||||||
.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) {
|
if (washingPlans.length === 0) {
|
||||||
handleError(error, { userMessage: '获取水洗计划失败' });
|
setGroups([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> = {};
|
|
||||||
(profilesData || []).forEach(p => {
|
|
||||||
names[p.id] = p.username;
|
|
||||||
});
|
|
||||||
setOperatorNames(names);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按采购商分组
|
|
||||||
const grouped: Record<string, PlanGroup> = {};
|
|
||||||
washingPlans.forEach(plan => {
|
|
||||||
const purchaserId = plan.company_id;
|
|
||||||
const purchaserName = (plan.company as any)?.name || '未知采购商';
|
|
||||||
|
|
||||||
if (!grouped[purchaserId]) {
|
|
||||||
grouped[purchaserId] = {
|
|
||||||
purchaserId,
|
|
||||||
purchaserName,
|
|
||||||
plans: []
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const processSteps = (stepsData || [])
|
const planIds = washingPlans.map(p => p.id);
|
||||||
.filter(s => s.washing_plan_id === plan.id)
|
|
||||||
.sort((a, b) => {
|
let stepsData: any[] = [];
|
||||||
const order = ['confirm', 'start_washing', 'complete'];
|
try {
|
||||||
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
|
const { data } = await http.get<any[]>('/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<any[]>('/common/profiles', { ids: operatorIds.join(',') });
|
||||||
|
const names: Record<string, string> = {};
|
||||||
|
(profilesData || []).forEach(p => { names[p.id] = p.username; });
|
||||||
|
setOperatorNames(names);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<string, PlanGroup> = {};
|
||||||
|
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);
|
const groupList = Object.values(grouped);
|
||||||
setGroups(groupList);
|
setGroups(groupList);
|
||||||
if (groupList.length > 0) {
|
if (groupList.length > 0) {
|
||||||
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, { userMessage: '获取水洗计划失败' });
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@ -201,41 +181,25 @@ export function WashingPlanOverview() {
|
|||||||
// 确认流程步骤
|
// 确认流程步骤
|
||||||
const handleConfirmStep = async (stepId: string, stepType: string) => {
|
const handleConfirmStep = async (stepId: string, stepType: string) => {
|
||||||
setConfirming(stepId);
|
setConfirming(stepId);
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 更新步骤状态
|
const plan = groups.flatMap(g => g.plans).find(
|
||||||
await supabase
|
p => p.processSteps.some(s => s.id === stepId)
|
||||||
.from('washing_process_steps')
|
);
|
||||||
.update({
|
if (!plan) { setConfirming(null); return; }
|
||||||
status: WASHING_PLAN_STATUS.COMPLETED,
|
|
||||||
timestamp: now,
|
|
||||||
operator_id: auth.user?.id
|
|
||||||
})
|
|
||||||
.eq('id', stepId);
|
|
||||||
|
|
||||||
// 获取计划ID
|
if (stepType === 'confirm') {
|
||||||
const { data: step } = await supabase
|
await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.IN_PROGRESS });
|
||||||
.from('washing_process_steps')
|
} else if (stepType === 'complete') {
|
||||||
.select('washing_plan_id')
|
await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.COMPLETED });
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await http.patch(`/washing/process-steps/${stepId}`, {
|
||||||
|
status: WASHING_PLAN_STATUS.COMPLETED,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
operator_id: auth.user?.id
|
||||||
|
});
|
||||||
|
|
||||||
await fetchPlans();
|
await fetchPlans();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err, { userMessage: '确认失败,请重试', showAlert: true });
|
handleError(err, { userMessage: '确认失败,请重试', showAlert: true });
|
||||||
@ -259,29 +223,13 @@ export function WashingPlanOverview() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedPlan || !auth.user) return;
|
if (!selectedPlan || !auth.user) return;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 创建完成记录
|
await washingApi.completePlan(selectedPlan.id, {
|
||||||
await supabase.from('washing_plan_completions').insert({
|
|
||||||
company_id: auth.company!.id,
|
|
||||||
washing_plan_id: selectedPlan.id,
|
|
||||||
actual_washed_meters: parseFloat(completeForm.meters),
|
actual_washed_meters: parseFloat(completeForm.meters),
|
||||||
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate),
|
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate),
|
||||||
rolls: parseInt(completeForm.rolls) || 0,
|
rolls: parseInt(completeForm.rolls) || 0,
|
||||||
washing_date: new Date().toISOString().split('T')[0],
|
washing_date: new Date().toISOString().split('T')[0],
|
||||||
completed_by: auth.user.id
|
} as any);
|
||||||
});
|
|
||||||
|
|
||||||
// 更新计划状态
|
|
||||||
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);
|
|
||||||
|
|
||||||
setCompleteModalOpen(false);
|
setCompleteModalOpen(false);
|
||||||
setSelectedPlan(null);
|
setSelectedPlan(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<Database>(supabaseUrl, supabaseAnonKey, {
|
|
||||||
auth: {
|
|
||||||
persistSession: true,
|
|
||||||
autoRefreshToken: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
* 确保财务与核心数据100%正确
|
* 确保财务与核心数据100%正确
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { supabase } from '../supabase/client';
|
import { http } from '../api';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 类型定义
|
// 类型定义
|
||||||
@ -150,34 +150,17 @@ export async function checkPlanQuantityConsistency(planId: string): Promise<{
|
|||||||
inventoryTotal: number;
|
inventoryTotal: number;
|
||||||
difference: number;
|
difference: number;
|
||||||
}> {
|
}> {
|
||||||
const { data: plan, error: planError } = await supabase
|
const { data } = await http.get<any>(`/textile/plans/${planId}/consistency`);
|
||||||
.from('production_plans')
|
|
||||||
.select('completed_quantity')
|
|
||||||
.eq('id', planId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (planError || !plan) {
|
if (!data) {
|
||||||
throw new Error(`查询计划失败: ${planError?.message}`);
|
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 {
|
return {
|
||||||
isConsistent: Math.abs(difference) <= tolerance,
|
isConsistent: data.is_consistent ?? Math.abs(data.difference) <= 0.01,
|
||||||
completedQuantity: plan.completed_quantity,
|
completedQuantity: data.completed_quantity ?? 0,
|
||||||
inventoryTotal,
|
inventoryTotal: data.inventory_total ?? 0,
|
||||||
difference,
|
difference: data.difference ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,14 +180,7 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调用数据库函数进行检查
|
const { data } = await http.post<any[]>('/common/data-integrity-check');
|
||||||
const { data, error } = await supabase.rpc('run_data_integrity_check');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('数据完整性检查失败:', error);
|
|
||||||
report.overallStatus = 'FAIL';
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
for (const check of data) {
|
for (const check of data) {
|
||||||
@ -264,7 +240,6 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
|||||||
report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL';
|
report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL';
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('数据完整性检查异常:', err);
|
|
||||||
report.overallStatus = 'FAIL';
|
report.overallStatus = 'FAIL';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,51 +254,17 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
|||||||
* 获取最近的审计日志
|
* 获取最近的审计日志
|
||||||
*/
|
*/
|
||||||
export async function getRecentAuditLogs(limit: number = 100) {
|
export async function getRecentAuditLogs(limit: number = 100) {
|
||||||
const { data, error } = await supabase
|
const { data } = await http.get<any[]>('/common/audit-logs', { limit });
|
||||||
.from('data_audit_logs')
|
|
||||||
.select('*')
|
|
||||||
.order('changed_at', { ascending: false })
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(`查询审计日志失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取失败的验证记录
|
|
||||||
*/
|
|
||||||
export async function getFailedValidations(limit: number = 50) {
|
export async function getFailedValidations(limit: number = 50) {
|
||||||
const { data, error } = await supabase
|
const { data } = await http.get<any[]>('/common/audit-logs', { validation_passed: 'false', limit });
|
||||||
.from('data_audit_logs')
|
|
||||||
.select('*')
|
|
||||||
.eq('validation_passed', false)
|
|
||||||
.order('changed_at', { ascending: false })
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(`查询失败记录失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取错误率统计
|
|
||||||
*/
|
|
||||||
export async function getErrorRateStats(days: number = 30) {
|
export async function getErrorRateStats(days: number = 30) {
|
||||||
const { data, error } = await supabase
|
const { data } = await http.get<any[]>('/common/error-rate-stats', { days });
|
||||||
.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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,35 +284,22 @@ export async function safeInsertInventoryRecord(record: {
|
|||||||
operator_id: string;
|
operator_id: string;
|
||||||
price_per_meter?: number;
|
price_per_meter?: number;
|
||||||
}) {
|
}) {
|
||||||
// 第一层:前端校验
|
|
||||||
const validation = validateInventoryRecord(record);
|
const validation = validateInventoryRecord(record);
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二层:数据库写入(触发器会进行二次校验)
|
const { data } = await http.post<any>(`/textile/plans/${record.plan_id}/inventory`, record);
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('inventory_records')
|
|
||||||
.insert(record)
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
if (!data) {
|
||||||
throw new Error(`写入失败: ${error.message}`);
|
throw new Error('写入失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第三层:写入后验证
|
|
||||||
const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id);
|
const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id);
|
||||||
if (!consistencyCheck.isConsistent) {
|
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;
|
price_per_meter: number;
|
||||||
status: 'pending' | 'completed';
|
status: 'pending' | 'completed';
|
||||||
}) {
|
}) {
|
||||||
// 第一层:前端校验
|
|
||||||
const validation = validatePayment(payment);
|
const validation = validatePayment(payment);
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二层:数据库写入
|
const { data } = await http.post<any>('/textile/payments', payment);
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('payments')
|
|
||||||
.insert(payment)
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
if (!data) {
|
||||||
throw new Error(`写入失败: ${error.message}`);
|
throw new Error('写入失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
return data;
|
||||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[0];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
export type NotificationType = 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
|
||||||
|
|
||||||
@ -12,13 +13,9 @@ interface NotificationData {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送通知给指定用户
|
|
||||||
*/
|
|
||||||
export async function sendNotification(data: NotificationData): Promise<void> {
|
export async function sendNotification(data: NotificationData): Promise<void> {
|
||||||
const { error } = await supabase
|
try {
|
||||||
.from('notifications')
|
await http.post('/common/notifications', {
|
||||||
.insert({
|
|
||||||
recipient_id: data.recipient_id,
|
recipient_id: data.recipient_id,
|
||||||
sender_id: data.sender_id,
|
sender_id: data.sender_id,
|
||||||
sender_company_id: data.sender_company_id,
|
sender_company_id: data.sender_company_id,
|
||||||
@ -27,72 +24,31 @@ export async function sendNotification(data: NotificationData): Promise<void> {
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
content: data.content
|
content: data.content
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
if (error) {
|
|
||||||
console.error('发送通知失败:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取计划的采购商用户ID
|
|
||||||
*/
|
|
||||||
export async function getPlanPurchaserUserId(planId: string): Promise<string | null> {
|
export async function getPlanPurchaserUserId(planId: string): Promise<string | null> {
|
||||||
const { data: plan } = await supabase
|
try {
|
||||||
.from('production_plans')
|
const { data: plan } = await http.get<any>(`/common/plans/${planId}/purchaser`);
|
||||||
.select('purchaser_id, created_by')
|
if (!plan) return null;
|
||||||
.eq('id', planId)
|
return plan.user_id || null;
|
||||||
.maybeSingle();
|
} catch {
|
||||||
|
return null;
|
||||||
if (!plan) return null;
|
|
||||||
|
|
||||||
// 优先返回创建者,如果没有则返回公司的主账号
|
|
||||||
if (plan.created_by) {
|
|
||||||
return plan.created_by;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取公司的主账号
|
|
||||||
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<string[]> {
|
export async function getPlanFactoryUserIds(planId: string, factoryType?: 'textile' | 'washing'): Promise<string[]> {
|
||||||
let query = supabase
|
try {
|
||||||
.from('plan_factories')
|
const params: Record<string, string> = {};
|
||||||
.select('factory_id')
|
if (factoryType) params.factory_type = factoryType;
|
||||||
.eq('plan_id', planId);
|
const { data } = await http.get<any[]>(`/common/plans/${planId}/factory-users`, params);
|
||||||
|
return (data || []).map(p => p.id);
|
||||||
if (factoryType) {
|
} catch {
|
||||||
query = query.eq('factory_type', factoryType);
|
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(
|
export async function notifyStepCompleted(
|
||||||
planId: string,
|
planId: string,
|
||||||
stepName: string,
|
stepName: string,
|
||||||
@ -100,48 +56,17 @@ export async function notifyStepCompleted(
|
|||||||
senderUserId: string,
|
senderUserId: string,
|
||||||
details?: { quantity?: number; rolls?: number }
|
details?: { quantity?: number; rolls?: number }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const purchaserId = await getPlanPurchaserUserId(planId);
|
try {
|
||||||
if (!purchaserId) return;
|
await http.post(`/common/plans/${planId}/notify/step-completed`, {
|
||||||
|
step_name: stepName,
|
||||||
const { data: plan } = await supabase
|
sender_company_id: senderCompanyId,
|
||||||
.from('production_plans')
|
sender_user_id: senderUserId,
|
||||||
.select('plan_code, product_name, color, fabric_code, color_code')
|
details
|
||||||
.eq('id', planId)
|
});
|
||||||
.maybeSingle();
|
} catch {
|
||||||
|
|
||||||
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}匹)`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendNotification({
|
|
||||||
recipient_id: purchaserId,
|
|
||||||
sender_id: senderUserId,
|
|
||||||
sender_company_id: senderCompanyId,
|
|
||||||
plan_id: planId,
|
|
||||||
type: 'step_completed',
|
|
||||||
title,
|
|
||||||
content
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送入库通知给采购商
|
|
||||||
*/
|
|
||||||
export async function notifyInventoryIn(
|
export async function notifyInventoryIn(
|
||||||
planId: string,
|
planId: string,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
@ -152,80 +77,34 @@ export async function notifyInventoryIn(
|
|||||||
await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls });
|
await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送计划创建通知给关联工厂
|
|
||||||
*/
|
|
||||||
export async function notifyPlanCreated(
|
export async function notifyPlanCreated(
|
||||||
planId: string,
|
planId: string,
|
||||||
factoryIds: string[],
|
factoryIds: string[],
|
||||||
senderCompanyId: string,
|
senderCompanyId: string,
|
||||||
senderUserId: string
|
senderUserId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { data: plan } = await supabase
|
try {
|
||||||
.from('production_plans')
|
await http.post(`/common/plans/${planId}/notify/created`, {
|
||||||
.select('plan_code, product_name, color, fabric_code, color_code, target_quantity')
|
factory_ids: factoryIds,
|
||||||
.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,
|
|
||||||
sender_company_id: senderCompanyId,
|
sender_company_id: senderCompanyId,
|
||||||
plan_id: planId,
|
sender_user_id: senderUserId
|
||||||
type: 'plan_created',
|
});
|
||||||
title: `新计划关联: ${plan.fabric_code}`,
|
} catch {
|
||||||
content: `${senderCompany?.name || '采购商'} 将您添加为计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的纺织厂,计划产量 ${plan.target_quantity}米`
|
}
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(notifications);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送计划状态更新通知
|
|
||||||
*/
|
|
||||||
export async function notifyPlanUpdated(
|
export async function notifyPlanUpdated(
|
||||||
planId: string,
|
planId: string,
|
||||||
status: string,
|
status: string,
|
||||||
senderCompanyId: string,
|
senderCompanyId: string,
|
||||||
senderUserId: string
|
senderUserId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const purchaserId = await getPlanPurchaserUserId(planId);
|
try {
|
||||||
if (!purchaserId) return;
|
await http.post(`/common/plans/${planId}/notify/updated`, {
|
||||||
|
status,
|
||||||
const { data: plan } = await supabase
|
sender_company_id: senderCompanyId,
|
||||||
.from('production_plans')
|
sender_user_id: senderUserId
|
||||||
.select('plan_code, fabric_code, color_code')
|
});
|
||||||
.eq('id', planId)
|
} catch {
|
||||||
.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}"`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,14 @@ module.exports = (env, argv) => {
|
|||||||
allowedHosts: 'all',
|
allowedHosts: 'all',
|
||||||
hot: true,
|
hot: true,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
|
proxy: [
|
||||||
|
{
|
||||||
|
context: ['/api', '/ws'],
|
||||||
|
target: `http://localhost:${process.env.GATEWAY_PORT || '8080'}`,
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
@ -102,8 +110,6 @@ module.exports = (env, argv) => {
|
|||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
'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'),
|
'__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user