12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085 |
- import { Component, OnInit, signal, computed, Inject, ViewChild, ElementRef, OnDestroy, NgZone, ChangeDetectorRef, AfterViewInit } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { FormsModule } from '@angular/forms';
- import { MatButtonModule } from '@angular/material/button';
- import { MatTabsModule } from '@angular/material/tabs';
- import { MatIconModule } from '@angular/material/icon';
- import { MatTableModule } from '@angular/material/table';
- import { MatCardModule } from '@angular/material/card';
- import { MatTooltipModule } from '@angular/material/tooltip';
- import { MatDialogModule, MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
- import { Attendance as AttendanceModel, Employee } from '../../../models/hr.model';
- import * as echarts from 'echarts';
- // 补卡申请对话框组件
- @Component({
- selector: 'app-attendance-dialog',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatButtonModule,
- MatIconModule
- ],
- template: `
- <div class="dialog-header">
- <h2>补卡申请</h2>
- <button class="close-btn" (click)="dialogRef.close()">
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <line x1="18" y1="6" x2="6" y2="18"></line>
- <line x1="6" y1="6" x2="18" y2="18"></line>
- </svg>
- </button>
- </div>
- <div class="dialog-content">
- <div class="info-item">
- <label>员工姓名:</label>
- <span>{{ attendanceData.employeeName }}</span>
- </div>
- <div class="info-item">
- <label>异常日期:</label>
- <span>{{ formatDate(attendanceData.date) }}</span>
- </div>
- <div class="info-item">
- <label>异常类型:</label>
- <span>{{ attendanceData.status }}</span>
- </div>
- <div class="form-group">
- <label>补卡说明:</label>
- <textarea
- [(ngModel)]="reason"
- placeholder="请输入补卡原因..."
- rows="3"
- class="reason-input"
- ></textarea>
- </div>
- </div>
- <div class="dialog-actions">
- <button mat-button (click)="dialogRef.close()">取消</button>
- <button mat-raised-button color="primary" (click)="submit()">提交申请</button>
- </div>
- `,
- styles: [`
- .dialog-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 24px;
- padding-bottom: 16px;
- border-bottom: 1px solid #e5e7eb;
- }
- .close-btn {
- background: none;
- border: none;
- cursor: pointer;
- color: #6b7280;
- padding: 4px;
- }
- .dialog-content {
- max-width: 500px;
- }
- .info-item {
- display: flex;
- justify-content: space-between;
- margin-bottom: 12px;
- padding: 8px 0;
- border-bottom: 1px solid #f3f4f6;
- }
- .form-group {
- margin-top: 20px;
- }
- label {
- display: block;
- margin-bottom: 4px;
- font-weight: 500;
- color: #374151;
- }
- .reason-input {
- width: 100%;
- padding: 8px 12px;
- border: 1px solid #e5e7eb;
- border-radius: 6px;
- font-size: 14px;
- resize: vertical;
- }
- .dialog-actions {
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- margin-top: 24px;
- }
- `]
- }) class AttendanceDialog {
- attendanceData: any;
- reason = '';
-
- constructor(
- public dialogRef: MatDialogRef<AttendanceDialog>,
- @Inject(MAT_DIALOG_DATA) public data: any
- ) {
- this.attendanceData = data;
- }
-
- formatDate(date: Date | string): string {
- if (!date) return '';
- const d = new Date(date);
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- }
-
- submit() {
- if (!this.reason.trim()) {
- alert('请输入补卡原因');
- return;
- }
- this.dialogRef.close({ reason: this.reason });
- }
- }
- // 生成模拟考勤数据
- const generateMockAttendanceData = (): AttendanceModel[] => {
- const statuses: Array<'正常' | '迟到' | '早退' | '旷工' | '请假'> = ['正常', '迟到', '早退', '旷工', '请假'];
- const attendanceList: AttendanceModel[] = [];
- const today = new Date();
- const projectIds = ['proj-001', 'proj-002', 'proj-003', 'proj-004'];
- const projectNames = ['现代风格客厅设计', '欧式厨房改造', '极简卧室设计', '办公室规划'];
-
- // 生成最近30天的考勤数据
- for (let i = 29; i >= 0; i--) {
- const date = new Date();
- date.setDate(today.getDate() - i);
-
- // 为每个部门生成3-5个员工的考勤数据
- const departmentCount = Math.floor(Math.random() * 3) + 3;
-
- for (let j = 1; j <= departmentCount; j++) {
- const statusIndex = Math.floor(Math.random() * 10);
- let status: typeof statuses[0];
-
- // 80%概率正常,10%概率迟到,5%概率早退,3%概率请假,2%概率旷工
- if (statusIndex < 8) {
- status = '正常';
- } else if (statusIndex < 9) {
- status = '迟到';
- } else if (statusIndex < 9.5) {
- status = '早退';
- } else if (statusIndex < 9.8) {
- status = '请假';
- } else {
- status = '旷工';
- }
-
- // 随机选择一个项目
- const projectIndex = Math.floor(Math.random() * projectIds.length);
- const projectId = projectIds[projectIndex];
- const projectName = projectNames[projectIndex];
-
- // 生成打卡时间
- let checkInTime: Date | undefined;
- let checkOutTime: Date | undefined;
- let workHours = 0;
-
- if (status !== '旷工' && status !== '请假') {
- checkInTime = new Date(date);
- checkInTime.setHours(9 + (status === '迟到' ? Math.floor(Math.random() * 2) : 0));
- checkInTime.setMinutes(Math.floor(Math.random() * 60));
-
- checkOutTime = new Date(date);
- checkOutTime.setHours(18 - (status === '早退' ? Math.floor(Math.random() * 2) : 0));
- checkOutTime.setMinutes(Math.floor(Math.random() * 60));
-
- // 计算工作时长(小时)
- workHours = Math.round((checkOutTime.getTime() - checkInTime.getTime()) / (1000 * 60 * 60) * 10) / 10;
- }
-
- attendanceList.push({
- id: `att-${date.getTime()}-${j}`,
- employeeId: `emp-${j}`,
- date,
- checkInTime,
- checkOutTime,
- status,
- workHours,
- projectId,
- projectName
- });
- }
- }
-
- return attendanceList;
- };
- // 生成模拟员工数据
- const generateMockEmployees = (): Employee[] => {
- const employees: Employee[] = [];
- const departments = ['设计部', '客户服务部', '财务部', '人力资源部'];
- const positions = ['设计师', '客服专员', '财务专员', '人事专员', '经理', '助理'];
- const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'];
-
- for (let i = 1; i <= 20; i++) {
- employees.push({
- id: `emp-${i}`,
- name: names[i % names.length] + i,
- department: departments[Math.floor(Math.random() * departments.length)],
- position: positions[Math.floor(Math.random() * positions.length)],
- employeeId: `EMP2023${String(i).padStart(3, '0')}`,
- phone: `138${Math.floor(Math.random() * 100000000)}`,
- email: `employee${i}@example.com`,
- gender: i % 2 === 0 ? '女' : '男',
- birthDate: new Date(1980 + Math.floor(Math.random() * 20), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
- hireDate: new Date(2020 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
- status: '在职',
- avatar: `https://via.placeholder.com/40?text=${i}`
- });
- }
-
- return employees;
- };
- // 主组件
- @Component({
- selector: 'app-attendance',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatButtonModule,
- MatTabsModule,
- MatIconModule,
- MatTableModule,
- MatCardModule,
- MatTooltipModule,
- MatDialogModule
- ],
- templateUrl: './attendance.html',
- styleUrl: './attendance.scss'
- }) export class Attendance implements OnDestroy, OnInit, AfterViewInit {
- @ViewChild('ganttChartRef') ganttChartRef!: ElementRef;
- private ganttChart: any = null;
- private onResize = () => {
- if (this.ganttChart) {
- this.ganttChart.resize();
- }
- };
- private onChartClick = (params: any) => {
- this.zone.run(() => {
- const v = params?.value;
- if (!v) return;
- const start = new Date(v[1]);
- const end = new Date(v[2]);
- const employee = v[3];
- const progress = v[4];
- alert(`任务:${params.name}\n负责人:${employee}\n进度:${progress}%\n起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`);
- });
- };
- showGanttView = signal(false);
- ganttScale = signal<'day' | 'week' | 'month'>('week');
- ganttMode = signal<'attendance' | 'workload'>('attendance');
- selectedDepartment = 'all';
-
- // 数据
- attendanceData = signal<AttendanceModel[]>(generateMockAttendanceData());
- employees = signal<Employee[]>(generateMockEmployees());
-
- // 计算属性
- departments = computed(() => {
- const depts = new Set(this.employees().map(emp => emp.department));
- return Array.from(depts).sort();
- });
-
- filteredEmployees = computed(() => {
- if (this.selectedDepartment === 'all') {
- return this.employees();
- }
- return this.employees().filter(emp => emp.department === this.selectedDepartment);
- });
- selectedView = signal<'day' | 'week' | 'month'>('month');
- selectedDate = signal<Date>(new Date());
- selectedEmployeeId = signal<string>('');
- selectedProjectId = signal<string>('');
-
- // 通过computed缓存当月日历,避免模板每次变更检测都创建新数组
- calendarDays = computed(() => this.computeCalendarDays());
-
- // 获取日期tooltip信息
- getDayTooltip(day: any): string {
- if (!day.attendance) {
- return '休息日';
- }
-
- const status = day.attendance.status;
- let tooltip = `状态: ${status}`;
-
- if (day.attendance.workHours) {
- tooltip += `\n工作时长: ${day.attendance.workHours}小时`;
- }
-
- if (day.attendance.checkInTime) {
- const checkIn = new Date(day.attendance.checkInTime);
- tooltip += `\n签到: ${checkIn.getHours().toString().padStart(2, '0')}:${checkIn.getMinutes().toString().padStart(2, '0')}`;
- }
-
- if (day.attendance.checkOutTime) {
- const checkOut = new Date(day.attendance.checkOutTime);
- tooltip += `\n签退: ${checkOut.getHours().toString().padStart(2, '0')}:${checkOut.getMinutes().toString().padStart(2, '0')}`;
- }
-
- if (day.attendance.projectName) {
- tooltip += `\n项目: ${day.attendance.projectName}`;
- }
-
- return tooltip;
- }
-
- // 检查日期是否为今天
- isToday(date: Date): boolean {
- const today = new Date();
- const checkDate = new Date(date);
- return today.toDateString() === checkDate.toDateString();
- }
-
- // 计算属性
- filteredAttendance = computed(() => {
- let filtered = this.attendanceData();
-
- // 按员工筛选
- if (this.selectedEmployeeId()) {
- filtered = filtered.filter(item => item.employeeId === this.selectedEmployeeId());
- }
-
- // 按项目筛选
- if (this.selectedProjectId()) {
- filtered = filtered.filter(item => item.projectId === this.selectedProjectId());
- }
-
- // 按视图筛选(日/周/月)
- const selectedDate = this.selectedDate();
- if (this.selectedView() === 'day') {
- filtered = filtered.filter(item => {
- const itemDate = new Date(item.date);
- return itemDate.getDate() === selectedDate.getDate() &&
- itemDate.getMonth() === selectedDate.getMonth() &&
- itemDate.getFullYear() === selectedDate.getFullYear();
- });
- } else if (this.selectedView() === 'week') {
- // 获取本周的起止日期
- const startOfWeek = new Date(selectedDate);
- const day = startOfWeek.getDay() || 7; // 调整周日为7
- startOfWeek.setDate(startOfWeek.getDate() - day + 1);
- const endOfWeek = new Date(startOfWeek);
- endOfWeek.setDate(endOfWeek.getDate() + 6);
-
- filtered = filtered.filter(item => {
- const itemDate = new Date(item.date);
- return itemDate >= startOfWeek && itemDate <= endOfWeek;
- });
- } else if (this.selectedView() === 'month') {
- filtered = filtered.filter(item => {
- const itemDate = new Date(item.date);
- return itemDate.getMonth() === selectedDate.getMonth() &&
- itemDate.getFullYear() === selectedDate.getFullYear();
- });
- }
-
- return filtered;
- });
-
- // 异常考勤列表
- exceptionAttendance = computed(() => {
- return this.filteredAttendance().filter(item =>
- item.status === '迟到' || item.status === '早退' || item.status === '旷工'
- ).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
- });
-
- // 考勤统计
- attendanceStats = computed(() => {
- const data = this.filteredAttendance();
- const totalDays = data.length;
- const normalDays = data.filter(item => item.status === '正常').length;
- const lateDays = data.filter(item => item.status === '迟到').length;
- const earlyLeaveDays = data.filter(item => item.status === '早退').length;
- const absentDays = data.filter(item => item.status === '旷工').length;
- const leaveDays = data.filter(item => item.status === '请假').length;
-
- const totalWorkHours = data.reduce((sum, item) => sum + item.workHours, 0);
- const avgWorkHours = totalDays > 0 ? (totalWorkHours / (totalDays - leaveDays - absentDays || 1)).toFixed(1) : '0.0';
-
- return {
- totalDays,
- normalDays,
- lateDays,
- earlyLeaveDays,
- absentDays,
- leaveDays,
- totalWorkHours: totalWorkHours.toFixed(1),
- avgWorkHours,
- complianceRate: totalDays > 0 ? Math.round((normalDays / totalDays) * 100) : 0
- };
- });
-
- // 部门考勤对比数据
- departmentAttendanceData = computed(() => {
- const departmentMap = new Map<string, { total: number; compliant: number }>();
-
- this.attendanceData().forEach(item => {
- const employee = this.employees().find(emp => emp.id === item.employeeId);
- if (employee) {
- const dept = employee.department;
- const current = departmentMap.get(dept) || { total: 0, compliant: 0 };
- current.total++;
- if (item.status === '正常') {
- current.compliant++;
- }
- departmentMap.set(dept, current);
- }
- });
-
- const result: { department: string; complianceRate: number; total: number; compliant: number }[] = [];
- departmentMap.forEach((value, key) => {
- result.push({
- department: key,
- complianceRate: Math.round((value.compliant / value.total) * 100),
- total: value.total,
- compliant: value.compliant
- });
- });
-
- return result.sort((a, b) => b.complianceRate - a.complianceRate);
- });
-
- // 显示的表格列
- displayedColumns = ['date', 'employeeName', 'status', 'workHours', 'projectName', 'actions'];
-
- constructor(private dialog: MatDialog, private zone: NgZone, private cdr: ChangeDetectorRef) {}
-
- ngOnInit() {
- // 已在字段声明处初始化模拟数据,避免首次变更检测期间的状态跃迁导致 ExpressionChanged
- }
-
- ngAfterViewInit() {
- // 稳定首次变更检测,避免控制流指令在初始化过程中值变化触发 NG0100
- this.cdr.detectChanges();
- }
-
- ngOnDestroy() {
- if (this.ganttChart) {
- this.ganttChart.off('click', this.onChartClick);
- this.ganttChart.dispose();
- this.ganttChart = null;
- }
- window.removeEventListener('resize', this.onResize);
- }
-
- // 移除 ngAfterViewInit 钩子
-
- // 切换视图(日/周/月)
- switchView(view: 'day' | 'week' | 'month') {
- this.selectedView.set(view);
- // 重置到当前日期
- this.selectedDate.set(new Date());
- }
- // 切换视图(考勤/任务)
- toggleView(target?: 'attendance' | 'task') {
- try {
- const next = target ? (target === 'task') : !this.showGanttView();
- this.showGanttView.set(next);
-
- if (next) {
- // 等待DOM渲染后初始化图表
- setTimeout(() => {
- try {
- this.initOrUpdateGantt();
- } catch (error) {
- console.error('甘特图初始化失败:', error);
- }
- }, 100);
- } else {
- this.cleanupGanttChart();
- }
- } catch (error) {
- console.error('视图切换失败:', error);
- }
- }
- // 清理甘特图资源
- private cleanupGanttChart() {
- try {
- if (this.ganttChart) {
- this.ganttChart.off('click', this.onChartClick);
- this.ganttChart.dispose();
- this.ganttChart = null;
- window.removeEventListener('resize', this.onResize);
- }
- } catch (error) {
- console.error('甘特图清理失败:', error);
- }
- }
- // 设置甘特时间尺度
- setGanttScale(scale: 'day' | 'week' | 'month') {
- if (this.ganttScale() !== scale) {
- this.ganttScale.set(scale);
- this.updateGantt();
- }
- }
- // 设置甘特图显示模式
- setGanttMode(mode: 'attendance' | 'workload') {
- if (this.ganttMode() !== mode) {
- this.ganttMode.set(mode);
- this.updateGantt();
- }
- }
- // 部门筛选变化
- onDepartmentChange() {
- this.updateGantt();
- }
- // 获取时间范围文本
- getTimeRangeText(): string {
- const date = this.selectedDate();
- const scale = this.ganttScale();
-
- if (scale === 'day') {
- return date.toLocaleDateString('zh-CN');
- } else if (scale === 'week') {
- const startOfWeek = new Date(date);
- const day = startOfWeek.getDay();
- const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
- startOfWeek.setDate(diff);
-
- const endOfWeek = new Date(startOfWeek);
- endOfWeek.setDate(startOfWeek.getDate() + 6);
-
- return `${startOfWeek.toLocaleDateString('zh-CN')} - ${endOfWeek.toLocaleDateString('zh-CN')}`;
- } else {
- return `${date.getFullYear()}年${date.getMonth() + 1}月`;
- }
- }
- // 获取平均出勤率
- getAverageAttendanceRate(): number {
- const employees = this.filteredEmployees();
- if (employees.length === 0) return 0;
-
- const totalRate = employees.reduce((sum, emp) => {
- const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
- const normalDays = empAttendance.filter(att => att.status === '正常').length;
- const rate = empAttendance.length > 0 ? (normalDays / empAttendance.length) * 100 : 0;
- return sum + rate;
- }, 0);
-
- return Math.round(totalRate / employees.length);
- }
- // 获取平均工时
- getAverageWorkHours(): number {
- const employees = this.filteredEmployees();
- if (employees.length === 0) return 0;
-
- const totalHours = employees.reduce((sum, emp) => {
- const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
- const avgHours = empAttendance.reduce((h, att) => h + (att.workHours || 0), 0) / empAttendance.length;
- return sum + (avgHours || 0);
- }, 0);
-
- return Math.round((totalHours / employees.length) * 10) / 10;
- }
-
- // 获取员工姓名
- getEmployeeName(employeeId: string): string {
- const employee = this.employees().find(emp => emp.id === employeeId);
- return employee ? employee.name : '未知员工';
- }
-
- // 格式化日期
- formatDate(date: Date | string): string {
- if (!date) return '';
- const d = new Date(date);
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- }
-
- // 格式化时间
- formatTime(date: Date | string): string {
- if (!date) return '';
- const d = new Date(date);
- return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
- }
-
- // 获取状态样式类
- getStatusClass(status: string): string {
- switch (status) {
- case '正常':
- return 'status-normal';
- case '迟到':
- return 'status-late';
- case '早退':
- return 'status-early';
- case '旷工':
- return 'status-absent';
- case '请假':
- return 'status-leave';
- default:
- return '';
- }
- }
-
- // 切换日期
- navigateDate(direction: 'prev' | 'next') {
- const newDate = new Date(this.selectedDate());
-
- if (this.selectedView() === 'day') {
- newDate.setDate(newDate.getDate() + (direction === 'prev' ? -1 : 1));
- } else if (this.selectedView() === 'week') {
- newDate.setDate(newDate.getDate() + (direction === 'prev' ? -7 : 7));
- } else if (this.selectedView() === 'month') {
- newDate.setMonth(newDate.getMonth() + (direction === 'prev' ? -1 : 1));
- }
-
- this.selectedDate.set(newDate);
- }
-
- private isDialogOpening = false;
- // 打开补卡申请对话框
- openAttendanceDialog(attendance: AttendanceModel) {
- // 防止重复点击
- if (this.isDialogOpening) {
- return;
- }
-
- try {
- this.isDialogOpening = true;
- const employee = this.employees().find(emp => emp.id === attendance.employeeId);
-
- const dialogRef = this.dialog.open(AttendanceDialog, {
- width: '500px',
- maxWidth: '90vw',
- disableClose: true,
- panelClass: 'hr-dialog',
- backdropClass: 'hr-dialog-backdrop',
- data: {
- ...attendance,
- employeeName: employee ? employee.name : '未知员工'
- }
- });
-
- dialogRef.afterClosed().subscribe({
- next: (result) => {
- this.isDialogOpening = false;
- if (result) {
- // 在实际应用中,这里会提交补卡申请到服务器
- alert('补卡申请已提交,等待审核');
- }
- },
- error: (error) => {
- this.isDialogOpening = false;
- console.error('对话框关闭时出错:', error);
- }
- });
- } catch (error) {
- this.isDialogOpening = false;
- console.error('打开补卡对话框失败:', error);
- alert('打开对话框失败,请重试');
- }
- }
-
- // 导出考勤数据
- exportAttendanceData(): void {
- try {
- const data = this.filteredAttendance();
- if (data.length === 0) {
- alert('暂无数据可导出');
- return;
- }
-
- const csvContent = this.convertToCSV(data);
- this.downloadCSV(csvContent, '考勤数据.csv');
-
- // 显示成功提示
- setTimeout(() => {
- alert(`成功导出 ${data.length} 条考勤记录`);
- }, 100);
- } catch (error) {
- console.error('导出数据失败:', error);
- alert('导出失败,请重试');
- }
- }
- // 将数据转换为CSV格式
- private convertToCSV(data: any[]): string {
- if (data.length === 0) return '';
-
- const headers = ['员工ID', '员工姓名', '日期', '上班时间', '下班时间', '状态', '工作时长', '所属项目'];
- const rows = data.map(item => {
- const employee = this.employees().find(emp => emp.id === item.employeeId);
- return [
- item.employeeId,
- employee ? employee.name : '未知员工',
- this.formatDate(item.date),
- item.checkInTime ? this.formatTime(item.checkInTime) : '',
- item.checkOutTime ? this.formatTime(item.checkOutTime) : '',
- item.status,
- item.workHours || '',
- item.projectName
- ];
- });
-
- return [headers, ...rows].map(row => row.map(field => `"${field}"`).join(',')).join('\n');
- }
- // 下载CSV文件
- private downloadCSV(csvContent: string, filename: string): void {
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
- const link = document.createElement('a');
- const url = URL.createObjectURL(blob);
-
- link.setAttribute('href', url);
- link.setAttribute('download', filename);
- link.style.visibility = 'hidden';
-
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- }
-
- // 获取日历数据
- getCalendarDays() {
- return this.calendarDays();
- }
-
- // 实际计算日历数据
- private computeCalendarDays() {
- const year = this.selectedDate().getFullYear();
- const month = this.selectedDate().getMonth();
-
- // 获取当月第一天和最后一天
- const firstDay = new Date(year, month, 1);
- const lastDay = new Date(year, month + 1, 0);
-
- // 获取当月第一天是星期几
- const firstDayIndex = firstDay.getDay();
-
- const days: any[] = [];
-
- // 添加上月的最后几天
- for (let i = firstDayIndex; i > 0; i--) {
- const day = new Date(year, month, -i + 1);
- days.push({
- date: day,
- dayOfMonth: day.getDate(),
- currentMonth: false,
- attendance: null
- });
- }
-
- // 添加当月的天数
- for (let i = 1; i <= lastDay.getDate(); i++) {
- const day = new Date(year, month, i);
- const dayAttendance = this.attendanceData().filter(item => {
- const itemDate = new Date(item.date);
- return itemDate.getDate() === i &&
- itemDate.getMonth() === month &&
- itemDate.getFullYear() === year &&
- (!this.selectedEmployeeId() || item.employeeId === this.selectedEmployeeId());
- });
-
- days.push({
- date: day,
- dayOfMonth: i,
- currentMonth: true,
- attendance: dayAttendance.length > 0 ? dayAttendance[0] : null
- });
- }
-
- // 添加下月的前几天,凑够42天(6行7列)
- const remainingDays = 42 - days.length;
- for (let i = 1; i <= remainingDays; i++) {
- const day = new Date(year, month + 1, i);
- days.push({
- date: day,
- dayOfMonth: i,
- currentMonth: false,
- attendance: null
- });
- }
-
- return days;
- }
-
- // 获取星期几的中文名称
- getWeekdayName(index: number): string {
- const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
- return weekdays[index];
- }
- private initOrUpdateGantt(): void {
- if (!this.ganttChartRef?.nativeElement) {
- console.warn('甘特图容器未找到');
- return;
- }
-
- const el = this.ganttChartRef.nativeElement;
-
- this.zone.runOutsideAngular(() => {
- try {
- if (!this.ganttChart) {
- // 检查容器尺寸
- if (el.offsetWidth === 0 || el.offsetHeight === 0) {
- console.warn('甘特图容器尺寸为0,延迟初始化');
- setTimeout(() => this.initOrUpdateGantt(), 200);
- return;
- }
-
- this.ganttChart = echarts.init(el);
- window.addEventListener('resize', this.onResize);
- }
- this.updateGantt();
- } catch (error) {
- console.error('甘特图初始化失败:', error);
- }
- });
- }
- private updateGantt(): void {
- if (!this.ganttChart) {
- console.warn('甘特图实例不存在');
- return;
- }
-
- try {
- // 工作负荷模式和考勤状态模式使用相同的数据结构,只是颜色映射不同
- // 考勤状态甘特图
- const employees = this.filteredEmployees();
- const categories = employees.map(emp => emp.name);
-
- // 考勤状态颜色映射
- const statusColorMap: Record<string, string> = {
- '正常': '#22c55e',
- '迟到': '#f59e0b',
- '早退': '#f97316',
- '旷工': '#ef4444',
- '请假': '#8b5cf6',
- '加班': '#06b6d4'
- };
- const DAY = 24 * 60 * 60 * 1000;
- const now = new Date();
- const selectedDate = this.selectedDate();
-
- // 计算时间范围
- let xMin: number;
- let xMax: number;
- let xSplitNumber: number;
- let xLabelFormatter: (value: number) => string;
- if (this.ganttScale() === 'day') {
- // 日视图:显示当天的小时
- const startOfDay = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());
- const endOfDay = new Date(startOfDay.getTime() + DAY - 1);
- xMin = startOfDay.getTime();
- xMax = endOfDay.getTime();
- xSplitNumber = 24;
- xLabelFormatter = (val) => `${new Date(val).getHours()}:00`;
- } else if (this.ganttScale() === 'week') {
- // 周视图:显示一周的天数
- const day = selectedDate.getDay();
- const diffToMonday = (day === 0 ? 6 : day - 1);
- const startOfWeek = new Date(selectedDate.getTime() - diffToMonday * DAY);
- const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
- xMin = startOfWeek.getTime();
- xMax = endOfWeek.getTime();
- xSplitNumber = 7;
- const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
- xLabelFormatter = (val) => WEEK_LABELS[new Date(val).getDay()];
- } else {
- // 月视图:显示一个月的周数
- const startOfMonth = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
- const endOfMonth = new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, 0, 23, 59, 59, 999);
- xMin = startOfMonth.getTime();
- xMax = endOfMonth.getTime();
- xSplitNumber = 4;
- xLabelFormatter = (val) => `第${Math.ceil(new Date(val).getDate() / 7)}周`;
- }
- // 生成考勤数据
- const data: any[] = [];
- employees.forEach((emp, empIndex) => {
- const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
-
- if (this.ganttScale() === 'day') {
- // 日视图:显示工作时间段
- const dayAttendance = empAttendance.find(att => {
- const attDate = new Date(att.date);
- return attDate.toDateString() === selectedDate.toDateString();
- });
-
- if (dayAttendance && dayAttendance.checkInTime && dayAttendance.checkOutTime) {
- const checkIn = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkInTime}`);
- const checkOut = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkOutTime}`);
-
- data.push({
- name: emp.name,
- value: [empIndex, checkIn.getTime(), checkOut.getTime(), dayAttendance.status, dayAttendance.workHours],
- itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
- });
- }
- } else {
- // 周视图和月视图:显示每天的考勤状态
- const timeRange = this.ganttScale() === 'week' ? 7 : 30;
- const startTime = this.ganttScale() === 'week' ?
- new Date(selectedDate.getTime() - ((selectedDate.getDay() === 0 ? 6 : selectedDate.getDay() - 1) * DAY)) :
- new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
-
- for (let i = 0; i < timeRange; i++) {
- const currentDay = new Date(startTime.getTime() + i * DAY);
- const dayAttendance = empAttendance.find(att => {
- const attDate = new Date(att.date);
- return attDate.toDateString() === currentDay.toDateString();
- });
-
- if (dayAttendance) {
- const dayStart = new Date(currentDay.getFullYear(), currentDay.getMonth(), currentDay.getDate());
- const dayEnd = new Date(dayStart.getTime() + DAY - 1);
-
- data.push({
- name: emp.name,
- value: [empIndex, dayStart.getTime(), dayEnd.getTime(), dayAttendance.status, dayAttendance.workHours],
- itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
- });
- }
- }
- }
- });
- // 计算默认可视区域
- const total = categories.length;
- const visible = Math.min(total, 15);
- const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
- const todayTs = now.getTime();
- const option = {
- backgroundColor: 'transparent',
- tooltip: {
- trigger: 'item',
- formatter: (params: any) => {
- const v = params.value;
- const start = new Date(v[1]);
- const end = new Date(v[2]);
- const status = v[3];
- const workHours = v[4];
-
- if (this.ganttScale() === 'day') {
- return `员工:${params.name}<br/>状态:${status}<br/>工作时间:${start.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})} - ${end.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}<br/>工时:${workHours || 0}小时`;
- } else {
- return `员工:${params.name}<br/>日期:${start.toLocaleDateString('zh-CN')}<br/>状态:${status}<br/>工时:${workHours || 0}小时`;
- }
- }
- },
- grid: { left: 120, right: 64, top: 30, bottom: 30 },
- xAxis: {
- type: 'time',
- min: xMin,
- max: xMax,
- splitNumber: xSplitNumber,
- axisLine: { lineStyle: { color: '#e5e7eb' } },
- axisLabel: { color: '#6b7280', formatter: xLabelFormatter },
- splitLine: { lineStyle: { color: '#f1f5f9' } }
- },
- yAxis: {
- type: 'category',
- data: categories,
- inverse: true,
- axisLabel: {
- color: '#374151',
- margin: 8,
- formatter: (val: string) => {
- const text = val.length > 12 ? val.slice(0, 12) + '…' : val;
- return text;
- }
- },
- axisTick: { show: false },
- axisLine: { lineStyle: { color: '#e5e7eb' } }
- },
- dataZoom: [
- {
- type: 'slider',
- yAxisIndex: 0,
- orient: 'vertical',
- right: 6,
- width: 14,
- start: 0,
- end: defaultEndPercent,
- zoomLock: false
- },
- {
- type: 'inside',
- yAxisIndex: 0,
- zoomOnMouseWheel: true,
- moveOnMouseMove: true,
- moveOnMouseWheel: true
- }
- ],
- series: [
- {
- type: 'custom',
- renderItem: (params: any, api: any) => {
- const categoryIndex = api.value(0);
- const start = api.coord([api.value(1), categoryIndex]);
- const end = api.coord([api.value(2), categoryIndex]);
- const height = Math.max(api.size([0, 1])[1] * 0.6, 12);
-
- // 考勤状态条
- const rectShape = echarts.graphic.clipRectByRect({
- x: start[0],
- y: start[1] - height / 2,
- width: Math.max(end[0] - start[0], 2),
- height
- }, {
- x: params.coordSys.x,
- y: params.coordSys.y,
- width: params.coordSys.width,
- height: params.coordSys.height
- });
- // 今日标记线
- const isToday = this.ganttScale() === 'day' ||
- (api.value(1) <= todayTs && api.value(2) >= todayTs);
-
- const elements = [
- {
- type: 'rect',
- shape: rectShape,
- style: {
- ...api.style(),
- stroke: isToday ? '#3b82f6' : 'transparent',
- strokeWidth: isToday ? 2 : 0
- }
- }
- ];
- return {
- type: 'group',
- children: elements
- };
- },
- encode: { x: [1, 2], y: 0 },
- data,
- itemStyle: { borderRadius: 6 },
- emphasis: { focus: 'self' }
- }
- ]
- };
- this.ganttChart.setOption(option, true);
- // 绑定点击事件,避免重复绑定
- this.ganttChart.off('click', this.onChartClick);
- this.ganttChart.on('click', this.onChartClick);
- this.ganttChart.resize();
- } catch (error) {
- console.error('甘特图更新失败:', error);
- }
- }
- }
|