Files
smart-crop-ui/crop-x/src/app/(app)/central-config/tenant/enterprise-management/page.tsx

673 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* filekorolheader: 企业管理 - 企业信息管理与维护页面
* 功能:企业列表查询、详情查看、状态管理、分页筛选
* 路径:/central-config/tenant/enterprise-management
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理API集成shadcn语义化样式
*/
'use client';
import { useReducer, useEffect, useMemo } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Building2, Eye, Power, PowerOff, Search, FileText, CreditCard, User, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { enterpriseReducer, initialState, EnterpriseState, EnterpriseAction } from './components/enterpriseReducer';
import { fetchTenants, transformTenantData, enableTenant, disableTenant, TenantsQueryParams, Enterprise } from './components/enterpriseApi';
// Utility functions
const getStatusBadge = (status: 'active' | 'inactive') => {
if (status === 'active') {
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light"></Badge>;
}
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light"></Badge>;
};
const getAuditStatusBadge = (auditStatus: Enterprise['auditStatus']) => {
switch (auditStatus) {
case 'draft':
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">稿</Badge>;
case 'pending':
return <Badge className="bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 font-light"></Badge>;
case 'approved':
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light"></Badge>;
case 'rejected':
return <Badge className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 font-light"></Badge>;
default:
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">稿</Badge>;
}
};
export default function EnterpriseManagement() {
const [state, dispatch] = useReducer(enterpriseReducer, initialState);
// 加载企业数据
const loadEnterprises = async (resetPage = false) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
const params: TenantsQueryParams = {
search: state.filters.search || undefined,
audit_status: state.filters.audit_status || undefined,
page: resetPage ? 1 : state.pagination.page,
size: state.pagination.size,
order_by: state.sortBy,
sort_order: state.sortOrder,
};
const response = await fetchTenants(params);
const transformedData = response.data.map(transformTenantData);
console.log('API Response:', response);
console.log('Transformed Data:', transformedData);
dispatch({
type: 'SET_ENTERPRISES',
payload: {
data: transformedData,
pagination: {
page: response.page,
size: response.size,
total: response.total,
totalPages: response.total_pages,
hasNext: response.has_next,
hasPrev: response.has_prev,
}
}
});
} catch (error) {
console.error('Failed to load enterprises:', error);
const errorMessage = error instanceof Error ? error.message : '加载企业数据失败';
dispatch({ type: 'SET_ERROR', payload: errorMessage });
toast.error(errorMessage);
}
};
// 初始加载
useEffect(() => {
loadEnterprises(true);
}, [state.filters.search, state.filters.audit_status, state.sortBy, state.sortOrder]);
// 分页加载
useEffect(() => {
if (state.pagination.page > 1) {
loadEnterprises(false);
}
}, [state.pagination.page]);
// 计算统计数据
const stats = useMemo(() => ({
total: state.enterprises.length,
active: state.enterprises.filter(e => e.status === 'active').length,
inactive: state.enterprises.filter(e => e.status === 'inactive').length,
}), [state.enterprises]);
// 事件处理器
const handleSearch = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { search: value } });
};
const handleAuditStatusFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { audit_status: value === 'all' ? '' : value } });
};
const handleSort = (sortBy?: string) => {
const newSortOrder = state.sortBy === sortBy && state.sortOrder === 'desc' ? 'asc' : 'desc';
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder: newSortOrder } });
};
const handlePageChange = (page: number) => {
// 边界检查,确保页码在有效范围内
if (page < 1) {
page = 1;
} else if (page > state.pagination.totalPages && state.pagination.totalPages > 0) {
page = state.pagination.totalPages;
}
dispatch({ type: 'SET_PAGINATION', payload: { page } });
};
const handleRefresh = () => {
dispatch({ type: 'REFRESH_DATA' });
loadEnterprises(true);
toast.success('数据已刷新');
};
const handleView = (enterprise: Enterprise) => {
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: true });
};
const handleStatusChange = (enterprise: Enterprise, action: 'enable' | 'disable') => {
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
dispatch({ type: 'SET_STATUS_ACTION', payload: action });
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: true });
};
const confirmStatusChange = async () => {
if (!state.selectedEnterprise) return;
try {
dispatch({ type: 'SET_LOADING', payload: true });
const tenantId = state.selectedEnterprise.id;
let updatedTenant;
if (state.statusAction === 'enable') {
updatedTenant = await enableTenant(tenantId);
toast.success('企业已启用');
} else {
updatedTenant = await disableTenant(tenantId);
toast.success('企业已禁用');
}
// 验证返回的数据是否正确更新了状态
console.log('API返回的更新数据:', updatedTenant);
// 更新本地状态
const updatedEnterprise = transformTenantData(updatedTenant);
dispatch({
type: 'SET_ENTERPRISES',
payload: {
data: state.enterprises.map(ent =>
ent.id === tenantId ? updatedEnterprise : ent
),
pagination: state.pagination
}
});
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: false });
// 不需要立即刷新,因为本地状态已经更新
// 如果用户需要看到最新数据,可以手动点击刷新按钮
} catch (error) {
console.error('Status change failed:', error);
const errorMessage = error instanceof Error ? error.message : '状态更新失败';
toast.error(errorMessage);
} finally {
dispatch({ type: 'SET_LOADING', payload: false });
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<Card className="p-6 bg-gradient-to-r from-blue-50 dark:from-blue-950 to-indigo-50 dark:to-indigo-950 border-blue-200 dark:border-blue-800">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Building2 className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="mb-2"></h2>
<p className="text-sm text-muted-foreground mb-3">
/
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<Search className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<Power className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<Eye className="w-3 h-3 mr-1" />
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={state.loading}>
<RefreshCw className={`w-4 h-4 mr-1 ${state.loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
</Card>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-6 bg-card hover:bg-muted transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<Building2 className="w-5 h-5 text-blue-500" />
</div>
<div className="text-3xl font-bold mb-1">{state.pagination.total}</div>
<div className="text-xs text-muted-foreground">
</div>
</Card>
<Card className="p-6 bg-card hover:bg-muted transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<Power className="w-5 h-5 text-green-500" />
</div>
<div className="text-3xl font-bold mb-1 text-green-600 dark:text-green-400">{stats.active}</div>
<div className="text-xs text-green-600 dark:text-green-400">
</div>
</Card>
<Card className="p-6 bg-card hover:bg-muted transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<PowerOff className="w-5 h-5 text-gray-500" />
</div>
<div className="text-3xl font-bold mb-1 text-gray-600 dark:text-gray-400">{stats.inactive}</div>
<div className="text-xs text-muted-foreground">
使
</div>
</Card>
</div>
{/* Enterprise List */}
<Card className="p-6 bg-card">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<h3></h3>
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索企业名称、编码..."
value={state.filters.search}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 w-64"
/>
</div>
<Select value={state.filters.audit_status || 'all'} onValueChange={handleAuditStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="审核状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="草稿">稿</SelectItem>
<SelectItem value="待审核"></SelectItem>
<SelectItem value="已通过"></SelectItem>
<SelectItem value="已拒绝"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Error Display */}
{state.error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4" />
<span>{state.error}</span>
</div>
</div>
)}
{/* Loading State */}
{state.loading && (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="w-8 h-8 mx-auto mb-2 animate-spin" />
<p>...</p>
</div>
)}
{/* Data Table */}
{!state.loading && !state.error && (
<>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('tenant_code')}
>
{state.sortBy === 'tenant_code' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('company_name')}
>
{state.sortBy === 'company_name' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('created_at')}
>
{state.sortBy === 'created_at' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.enterprises.map((enterprise) => (
<TableRow key={enterprise.id}>
<TableCell className="font-medium">{enterprise.code}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-500" />
<span className="font-medium">{enterprise.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-light">{enterprise.type}</Badge>
</TableCell>
<TableCell>{enterprise.registrant || '-'}</TableCell>
<TableCell>{enterprise.contactPhone || '-'}</TableCell>
<TableCell className="text-sm">{enterprise.createdAt}</TableCell>
<TableCell>{getAuditStatusBadge(enterprise.auditStatus)}</TableCell>
<TableCell>{getStatusBadge(enterprise.status)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleView(enterprise)}
>
<Eye className="w-3 h-3 mr-1" />
</Button>
{enterprise.status === 'active' ? (
<Button
size="sm"
variant="outline"
className="text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600"
onClick={() => handleStatusChange(enterprise, 'disable')}
>
<PowerOff className="w-3 h-3 mr-1" />
</Button>
) : (
<Button
size="sm"
variant="outline"
className="text-green-600 dark:text-green-400 border-green-300 dark:border-green-600"
onClick={() => handleStatusChange(enterprise, 'enable')}
>
<Power className="w-3 h-3 mr-1" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{state.enterprises.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p></p>
</div>
)}
{/* Pagination */}
{state.pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
{state.pagination.page} {state.pagination.totalPages}
<span className="ml-2"> {state.pagination.total} </span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page - 1)}
disabled={!state.pagination.hasPrev || state.loading}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium px-2">
{state.pagination.page} / {state.pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page + 1)}
disabled={!state.pagination.hasNext || state.loading}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
{/* View Enterprise Details Dialog */}
<Dialog open={state.showViewDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: open })}>
<DialogContent className="w-[80vw] max-w-6xl max-h-[90vh]">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle></DialogTitle>
{state.selectedEnterprise && (
<div className="flex gap-2">
{getAuditStatusBadge(state.selectedEnterprise.auditStatus)}
{getStatusBadge(state.selectedEnterprise.status)}
</div>
)}
</div>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
{state.selectedEnterprise && (
<ScrollArea className="max-h-[calc(90vh-200px)]">
<Tabs defaultValue="basic" className="space-y-4">
<TabsList className="grid grid-cols-4 w-full">
<TabsTrigger value="basic">
<Building2 className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="other">
<FileText className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="bank">
<CreditCard className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="legal">
<User className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* Basic Information */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.name}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.code}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.type}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{state.selectedEnterprise.province || '-'} {state.selectedEnterprise.city || ''} {state.selectedEnterprise.district || ''}
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.address || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.registrant || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.contactPhone || '-'}</div>
</div>
</div>
</TabsContent>
{/* Other Information */}
<TabsContent value="other" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.companySize || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.registeredCapital || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.establishmentDate || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.invoiceType || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{state.selectedEnterprise.socialCreditCode ? (
<code className="text-sm font-mono">
{state.selectedEnterprise.socialCreditCode}
</code>
) : '-'}
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.businessScope || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.submitTime || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.auditTime || '-'}</div>
</div>
</div>
</TabsContent>
{/* Bank Information */}
<TabsContent value="bank" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{state.selectedEnterprise.bankAccount ? (
<code className="text-sm font-mono">
{state.selectedEnterprise.bankAccount}
</code>
) : '-'}
</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.bankName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.bankFullName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.bankAddress || '-'}</div>
</div>
</div>
</TabsContent>
{/* Legal Person Information */}
<TabsContent value="legal" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.legalPerson || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.registrant || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.auditor || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{state.selectedEnterprise.auditComment || '-'}</div>
</div>
</div>
</TabsContent>
</Tabs>
</ScrollArea>
)}
<DialogFooter>
<Button variant="outline" onClick={() => dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: false })}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Status Change Confirmation Dialog */}
<AlertDialog open={state.showStatusDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: open })}>
<AlertDialogContent className="w-[80vw] max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>
{state.statusAction === 'enable' ? '启用' : '禁用'}
</AlertDialogTitle>
<AlertDialogDescription>
{state.statusAction === 'enable' ? (
<>
<strong>{state.selectedEnterprise?.name}</strong> 使
</>
) : (
<>
<strong>{state.selectedEnterprise?.name}</strong>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmStatusChange}
className={state.statusAction === 'enable' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-600 hover:bg-gray-700'}
>
{state.statusAction === 'enable' ? '启用' : '禁用'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}