|
|
@@ -0,0 +1,317 @@
|
|
|
+import React, { useState } from 'react';
|
|
|
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
|
|
|
+import {
|
|
|
+ Dialog,
|
|
|
+ DialogContent,
|
|
|
+ DialogDescription,
|
|
|
+ DialogFooter,
|
|
|
+ DialogHeader,
|
|
|
+ DialogTitle,
|
|
|
+} from '@d8d/shared-ui-components/components/ui/dialog';
|
|
|
+import {
|
|
|
+ Select,
|
|
|
+ SelectContent,
|
|
|
+ SelectItem,
|
|
|
+ SelectTrigger,
|
|
|
+ SelectValue,
|
|
|
+} from '@d8d/shared-ui-components/components/ui/select';
|
|
|
+import { Label } from '@d8d/shared-ui-components/components/ui/label';
|
|
|
+import { Input } from '@d8d/shared-ui-components/components/ui/input';
|
|
|
+import { Calendar } from 'lucide-react';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import * as XLSX from 'xlsx';
|
|
|
+
|
|
|
+interface AttendanceModalProps {
|
|
|
+ open: boolean;
|
|
|
+ onOpenChange: (open: boolean) => void;
|
|
|
+ orderId: number;
|
|
|
+ orderName: string;
|
|
|
+ orderPersons: Array<{
|
|
|
+ personId: number;
|
|
|
+ personName: string;
|
|
|
+ disabilityId?: string;
|
|
|
+ disabilityType?: string;
|
|
|
+ }>;
|
|
|
+}
|
|
|
+
|
|
|
+// 月份选项
|
|
|
+const MONTH_OPTIONS = [
|
|
|
+ { value: '1', label: '1月' },
|
|
|
+ { value: '2', label: '2月' },
|
|
|
+ { value: '3', label: '3月' },
|
|
|
+ { value: '4', label: '4月' },
|
|
|
+ { value: '5', label: '5月' },
|
|
|
+ { value: '6', label: '6月' },
|
|
|
+ { value: '7', label: '7月' },
|
|
|
+ { value: '8', label: '8月' },
|
|
|
+ { value: '9', label: '9月' },
|
|
|
+ { value: '10', label: '10月' },
|
|
|
+ { value: '11', label: '11月' },
|
|
|
+ { value: '12', label: '12月' },
|
|
|
+];
|
|
|
+
|
|
|
+// 出勤天数选项
|
|
|
+const ATTENDANCE_DAYS_OPTIONS = [
|
|
|
+ { value: '5', label: '5天' },
|
|
|
+ { value: '6', label: '6天' },
|
|
|
+ { value: '7', label: '7天' },
|
|
|
+ { value: '8', label: '8天' },
|
|
|
+ { value: '9', label: '9天' },
|
|
|
+ { value: '10', label: '10天' },
|
|
|
+ { value: '11', label: '11天' },
|
|
|
+ { value: '12', label: '12天' },
|
|
|
+ { value: '13', label: '13天' },
|
|
|
+ { value: '14', label: '14天' },
|
|
|
+ { value: '15', label: '15天' },
|
|
|
+ { value: '16', label: '16天' },
|
|
|
+ { value: '17', label: '17天' },
|
|
|
+ { value: '18', label: '18天' },
|
|
|
+ { value: '19', label: '19天' },
|
|
|
+ { value: '20', label: '20天' },
|
|
|
+ { value: '21', label: '21天' },
|
|
|
+ { value: '22', label: '22天' },
|
|
|
+ { value: '23', label: '23天' },
|
|
|
+ { value: '24', label: '24天' },
|
|
|
+ { value: '25', label: '25天' },
|
|
|
+ { value: '26', label: '26天' },
|
|
|
+ { value: '27', label: '27天' },
|
|
|
+ { value: '28', label: '28天' },
|
|
|
+ { value: '29', label: '29天' },
|
|
|
+ { value: '30', label: '30天' },
|
|
|
+ { value: '31', label: '31天' },
|
|
|
+];
|
|
|
+
|
|
|
+const AttendanceModal: React.FC<AttendanceModalProps> = ({
|
|
|
+ open,
|
|
|
+ onOpenChange,
|
|
|
+ orderId,
|
|
|
+ orderName,
|
|
|
+ orderPersons,
|
|
|
+}) => {
|
|
|
+ const [selectedMonth, setSelectedMonth] = useState<string>('1');
|
|
|
+ const [selectedDays, setSelectedDays] = useState<string>('20');
|
|
|
+ const [isGenerating, setIsGenerating] = useState(false);
|
|
|
+
|
|
|
+ // 生成出勤表数据
|
|
|
+ const generateAttendanceData = () => {
|
|
|
+ const month = parseInt(selectedMonth);
|
|
|
+ const days = parseInt(selectedDays);
|
|
|
+
|
|
|
+ // 获取当前年份
|
|
|
+ const currentYear = new Date().getFullYear();
|
|
|
+
|
|
|
+ // 生成日期标题行
|
|
|
+ const dateHeaders = ['姓名', '残疾证号', '残疾类型'];
|
|
|
+ for (let day = 1; day <= days; day++) {
|
|
|
+ dateHeaders.push(`${day}日`);
|
|
|
+ }
|
|
|
+ dateHeaders.push('出勤天数', '缺勤天数', '备注');
|
|
|
+
|
|
|
+ // 生成数据行
|
|
|
+ const dataRows = orderPersons.map(person => {
|
|
|
+ const row = [
|
|
|
+ person.personName,
|
|
|
+ person.disabilityId || '-',
|
|
|
+ person.disabilityType || '-',
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 模拟出勤数据:√表示出勤,空白表示缺勤
|
|
|
+ let attendanceCount = 0;
|
|
|
+ let absenceCount = 0;
|
|
|
+
|
|
|
+ for (let day = 1; day <= days; day++) {
|
|
|
+ // 模拟算法:根据人员ID和日期生成"随机"但确定性的出勤数据
|
|
|
+ const shouldAttend = (person.personId + day + month) % 3 !== 0; // 大约2/3的出勤率
|
|
|
+ if (shouldAttend) {
|
|
|
+ row.push('√');
|
|
|
+ attendanceCount++;
|
|
|
+ } else {
|
|
|
+ row.push('');
|
|
|
+ absenceCount++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ row.push(attendanceCount.toString(), absenceCount.toString(), '');
|
|
|
+ return row;
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ headers: dateHeaders,
|
|
|
+ data: dataRows,
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ // 生成Excel文件
|
|
|
+ const generateExcel = () => {
|
|
|
+ try {
|
|
|
+ setIsGenerating(true);
|
|
|
+
|
|
|
+ const { headers, data } = generateAttendanceData();
|
|
|
+
|
|
|
+ // 创建工作簿
|
|
|
+ const wb = XLSX.utils.book_new();
|
|
|
+
|
|
|
+ // 创建工作表数据
|
|
|
+ const wsData = [headers, ...data];
|
|
|
+ const ws = XLSX.utils.aoa_to_sheet(wsData);
|
|
|
+
|
|
|
+ // 设置列宽
|
|
|
+ const colWidths = headers.map((_, index) => {
|
|
|
+ if (index < 3) return { wch: 15 }; // 姓名、残疾证号、残疾类型列宽
|
|
|
+ if (index < headers.length - 3) return { wch: 8 }; // 日期列宽
|
|
|
+ return { wch: 12 }; // 统计列宽
|
|
|
+ });
|
|
|
+ ws['!cols'] = colWidths;
|
|
|
+
|
|
|
+ // 添加工作表到工作簿
|
|
|
+ XLSX.utils.book_append_sheet(wb, ws, `出勤表`);
|
|
|
+
|
|
|
+ // 生成文件名
|
|
|
+ const fileName = `${orderName}_${new Date().getFullYear()}年${selectedMonth}月_出勤表.xlsx`;
|
|
|
+
|
|
|
+ // 写入文件
|
|
|
+ XLSX.writeFile(wb, fileName);
|
|
|
+
|
|
|
+ toast.success(`出勤表已生成:${fileName}`);
|
|
|
+ onOpenChange(false);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('生成Excel文件失败:', error);
|
|
|
+ toast.error('生成出勤表失败,请重试');
|
|
|
+ } finally {
|
|
|
+ setIsGenerating(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理表单提交
|
|
|
+ const handleSubmit = (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+
|
|
|
+ if (orderPersons.length === 0) {
|
|
|
+ toast.warning('订单中没有人员,无法生成出勤表');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ generateExcel();
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
+ <DialogContent className="sm:max-w-[600px]" data-testid="attendance-modal">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle data-testid="attendance-modal-title">
|
|
|
+ 出勤表导出
|
|
|
+ </DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ 为订单"{orderName}"生成月度出勤Excel表
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+
|
|
|
+ <form onSubmit={handleSubmit} role="form">
|
|
|
+ <div className="grid gap-4 py-4">
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="month">选择月份</Label>
|
|
|
+ <Select
|
|
|
+ value={selectedMonth}
|
|
|
+ onValueChange={setSelectedMonth}
|
|
|
+ >
|
|
|
+ <SelectTrigger id="month" data-testid="attendance-month-select">
|
|
|
+ <SelectValue placeholder="选择月份" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ {MONTH_OPTIONS.map(option => (
|
|
|
+ <SelectItem
|
|
|
+ key={option.value}
|
|
|
+ value={option.value}
|
|
|
+ data-testid={`attendance-month-option-${option.value}`}
|
|
|
+ >
|
|
|
+ {option.label}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="days">出勤天数</Label>
|
|
|
+ <Select
|
|
|
+ value={selectedDays}
|
|
|
+ onValueChange={setSelectedDays}
|
|
|
+ >
|
|
|
+ <SelectTrigger id="days" data-testid="attendance-days-select">
|
|
|
+ <SelectValue placeholder="选择出勤天数" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ {ATTENDANCE_DAYS_OPTIONS.map(option => (
|
|
|
+ <SelectItem
|
|
|
+ key={option.value}
|
|
|
+ value={option.value}
|
|
|
+ data-testid={`attendance-days-option-${option.value}`}
|
|
|
+ >
|
|
|
+ {option.label}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label>订单人员信息</Label>
|
|
|
+ <div className="border rounded-md p-3 bg-muted/50">
|
|
|
+ <div className="text-sm">
|
|
|
+ <div className="flex justify-between mb-1">
|
|
|
+ <span>订单名称:</span>
|
|
|
+ <span className="font-medium">{orderName}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between mb-1">
|
|
|
+ <span>订单ID:</span>
|
|
|
+ <span className="font-medium">{orderId}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between">
|
|
|
+ <span>人员数量:</span>
|
|
|
+ <span className="font-medium">{orderPersons.length}人</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label>出勤表预览</Label>
|
|
|
+ <div className="border rounded-md p-3 bg-muted/50 max-h-40 overflow-y-auto">
|
|
|
+ <div className="text-sm space-y-1">
|
|
|
+ <div className="font-medium">表格结构:</div>
|
|
|
+ <div>• 列: 姓名、残疾证号、残疾类型、1-{selectedDays}日、出勤天数、缺勤天数、备注</div>
|
|
|
+ <div>• 行: {orderPersons.length}名人员</div>
|
|
|
+ <div>• 出勤标记: √ (出勤), 空白 (缺勤)</div>
|
|
|
+ <div>• 文件格式: Excel (.xlsx)</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <DialogFooter>
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => onOpenChange(false)}
|
|
|
+ data-testid="attendance-cancel-button"
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="submit"
|
|
|
+ disabled={isGenerating || orderPersons.length === 0}
|
|
|
+ data-testid="attendance-generate-button"
|
|
|
+ >
|
|
|
+ <Calendar className="mr-2 h-4 w-4" />
|
|
|
+ {isGenerating ? '生成中...' : '生成出勤表'}
|
|
|
+ </Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </form>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default AttendanceModal;
|