attendance.ts 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085
  1. import { Component, OnInit, signal, computed, Inject, ViewChild, ElementRef, OnDestroy, NgZone, ChangeDetectorRef, AfterViewInit } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { MatButtonModule } from '@angular/material/button';
  5. import { MatTabsModule } from '@angular/material/tabs';
  6. import { MatIconModule } from '@angular/material/icon';
  7. import { MatTableModule } from '@angular/material/table';
  8. import { MatCardModule } from '@angular/material/card';
  9. import { MatTooltipModule } from '@angular/material/tooltip';
  10. import { MatDialogModule, MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
  11. import { Attendance as AttendanceModel, Employee } from '../../../models/hr.model';
  12. import * as echarts from 'echarts';
  13. // 补卡申请对话框组件
  14. @Component({
  15. selector: 'app-attendance-dialog',
  16. standalone: true,
  17. imports: [
  18. CommonModule,
  19. FormsModule,
  20. MatButtonModule,
  21. MatIconModule
  22. ],
  23. template: `
  24. <div class="dialog-header">
  25. <h2>补卡申请</h2>
  26. <button class="close-btn" (click)="dialogRef.close()">
  27. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  28. <line x1="18" y1="6" x2="6" y2="18"></line>
  29. <line x1="6" y1="6" x2="18" y2="18"></line>
  30. </svg>
  31. </button>
  32. </div>
  33. <div class="dialog-content">
  34. <div class="info-item">
  35. <label>员工姓名:</label>
  36. <span>{{ attendanceData.employeeName }}</span>
  37. </div>
  38. <div class="info-item">
  39. <label>异常日期:</label>
  40. <span>{{ formatDate(attendanceData.date) }}</span>
  41. </div>
  42. <div class="info-item">
  43. <label>异常类型:</label>
  44. <span>{{ attendanceData.status }}</span>
  45. </div>
  46. <div class="form-group">
  47. <label>补卡说明:</label>
  48. <textarea
  49. [(ngModel)]="reason"
  50. placeholder="请输入补卡原因..."
  51. rows="3"
  52. class="reason-input"
  53. ></textarea>
  54. </div>
  55. </div>
  56. <div class="dialog-actions">
  57. <button mat-button (click)="dialogRef.close()">取消</button>
  58. <button mat-raised-button color="primary" (click)="submit()">提交申请</button>
  59. </div>
  60. `,
  61. styles: [`
  62. .dialog-header {
  63. display: flex;
  64. justify-content: space-between;
  65. align-items: center;
  66. margin-bottom: 24px;
  67. padding-bottom: 16px;
  68. border-bottom: 1px solid #e5e7eb;
  69. }
  70. .close-btn {
  71. background: none;
  72. border: none;
  73. cursor: pointer;
  74. color: #6b7280;
  75. padding: 4px;
  76. }
  77. .dialog-content {
  78. max-width: 500px;
  79. }
  80. .info-item {
  81. display: flex;
  82. justify-content: space-between;
  83. margin-bottom: 12px;
  84. padding: 8px 0;
  85. border-bottom: 1px solid #f3f4f6;
  86. }
  87. .form-group {
  88. margin-top: 20px;
  89. }
  90. label {
  91. display: block;
  92. margin-bottom: 4px;
  93. font-weight: 500;
  94. color: #374151;
  95. }
  96. .reason-input {
  97. width: 100%;
  98. padding: 8px 12px;
  99. border: 1px solid #e5e7eb;
  100. border-radius: 6px;
  101. font-size: 14px;
  102. resize: vertical;
  103. }
  104. .dialog-actions {
  105. display: flex;
  106. justify-content: flex-end;
  107. gap: 12px;
  108. margin-top: 24px;
  109. }
  110. `]
  111. }) class AttendanceDialog {
  112. attendanceData: any;
  113. reason = '';
  114. constructor(
  115. public dialogRef: MatDialogRef<AttendanceDialog>,
  116. @Inject(MAT_DIALOG_DATA) public data: any
  117. ) {
  118. this.attendanceData = data;
  119. }
  120. formatDate(date: Date | string): string {
  121. if (!date) return '';
  122. const d = new Date(date);
  123. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  124. }
  125. submit() {
  126. if (!this.reason.trim()) {
  127. alert('请输入补卡原因');
  128. return;
  129. }
  130. this.dialogRef.close({ reason: this.reason });
  131. }
  132. }
  133. // 生成模拟考勤数据
  134. const generateMockAttendanceData = (): AttendanceModel[] => {
  135. const statuses: Array<'正常' | '迟到' | '早退' | '旷工' | '请假'> = ['正常', '迟到', '早退', '旷工', '请假'];
  136. const attendanceList: AttendanceModel[] = [];
  137. const today = new Date();
  138. const projectIds = ['proj-001', 'proj-002', 'proj-003', 'proj-004'];
  139. const projectNames = ['现代风格客厅设计', '欧式厨房改造', '极简卧室设计', '办公室规划'];
  140. // 生成最近30天的考勤数据
  141. for (let i = 29; i >= 0; i--) {
  142. const date = new Date();
  143. date.setDate(today.getDate() - i);
  144. // 为每个部门生成3-5个员工的考勤数据
  145. const departmentCount = Math.floor(Math.random() * 3) + 3;
  146. for (let j = 1; j <= departmentCount; j++) {
  147. const statusIndex = Math.floor(Math.random() * 10);
  148. let status: typeof statuses[0];
  149. // 80%概率正常,10%概率迟到,5%概率早退,3%概率请假,2%概率旷工
  150. if (statusIndex < 8) {
  151. status = '正常';
  152. } else if (statusIndex < 9) {
  153. status = '迟到';
  154. } else if (statusIndex < 9.5) {
  155. status = '早退';
  156. } else if (statusIndex < 9.8) {
  157. status = '请假';
  158. } else {
  159. status = '旷工';
  160. }
  161. // 随机选择一个项目
  162. const projectIndex = Math.floor(Math.random() * projectIds.length);
  163. const projectId = projectIds[projectIndex];
  164. const projectName = projectNames[projectIndex];
  165. // 生成打卡时间
  166. let checkInTime: Date | undefined;
  167. let checkOutTime: Date | undefined;
  168. let workHours = 0;
  169. if (status !== '旷工' && status !== '请假') {
  170. checkInTime = new Date(date);
  171. checkInTime.setHours(9 + (status === '迟到' ? Math.floor(Math.random() * 2) : 0));
  172. checkInTime.setMinutes(Math.floor(Math.random() * 60));
  173. checkOutTime = new Date(date);
  174. checkOutTime.setHours(18 - (status === '早退' ? Math.floor(Math.random() * 2) : 0));
  175. checkOutTime.setMinutes(Math.floor(Math.random() * 60));
  176. // 计算工作时长(小时)
  177. workHours = Math.round((checkOutTime.getTime() - checkInTime.getTime()) / (1000 * 60 * 60) * 10) / 10;
  178. }
  179. attendanceList.push({
  180. id: `att-${date.getTime()}-${j}`,
  181. employeeId: `emp-${j}`,
  182. date,
  183. checkInTime,
  184. checkOutTime,
  185. status,
  186. workHours,
  187. projectId,
  188. projectName
  189. });
  190. }
  191. }
  192. return attendanceList;
  193. };
  194. // 生成模拟员工数据
  195. const generateMockEmployees = (): Employee[] => {
  196. const employees: Employee[] = [];
  197. const departments = ['设计部', '客户服务部', '财务部', '人力资源部'];
  198. const positions = ['设计师', '客服专员', '财务专员', '人事专员', '经理', '助理'];
  199. const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'];
  200. for (let i = 1; i <= 20; i++) {
  201. employees.push({
  202. id: `emp-${i}`,
  203. name: names[i % names.length] + i,
  204. department: departments[Math.floor(Math.random() * departments.length)],
  205. position: positions[Math.floor(Math.random() * positions.length)],
  206. employeeId: `EMP2023${String(i).padStart(3, '0')}`,
  207. phone: `138${Math.floor(Math.random() * 100000000)}`,
  208. email: `employee${i}@example.com`,
  209. gender: i % 2 === 0 ? '女' : '男',
  210. birthDate: new Date(1980 + Math.floor(Math.random() * 20), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
  211. hireDate: new Date(2020 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
  212. status: '在职',
  213. avatar: `https://via.placeholder.com/40?text=${i}`
  214. });
  215. }
  216. return employees;
  217. };
  218. // 主组件
  219. @Component({
  220. selector: 'app-attendance',
  221. standalone: true,
  222. imports: [
  223. CommonModule,
  224. FormsModule,
  225. MatButtonModule,
  226. MatTabsModule,
  227. MatIconModule,
  228. MatTableModule,
  229. MatCardModule,
  230. MatTooltipModule,
  231. MatDialogModule
  232. ],
  233. templateUrl: './attendance.html',
  234. styleUrl: './attendance.scss'
  235. }) export class Attendance implements OnDestroy, OnInit, AfterViewInit {
  236. @ViewChild('ganttChartRef') ganttChartRef!: ElementRef;
  237. private ganttChart: any = null;
  238. private onResize = () => {
  239. if (this.ganttChart) {
  240. this.ganttChart.resize();
  241. }
  242. };
  243. private onChartClick = (params: any) => {
  244. this.zone.run(() => {
  245. const v = params?.value;
  246. if (!v) return;
  247. const start = new Date(v[1]);
  248. const end = new Date(v[2]);
  249. const employee = v[3];
  250. const progress = v[4];
  251. alert(`任务:${params.name}\n负责人:${employee}\n进度:${progress}%\n起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`);
  252. });
  253. };
  254. showGanttView = signal(false);
  255. ganttScale = signal<'day' | 'week' | 'month'>('week');
  256. ganttMode = signal<'attendance' | 'workload'>('attendance');
  257. selectedDepartment = 'all';
  258. // 数据
  259. attendanceData = signal<AttendanceModel[]>(generateMockAttendanceData());
  260. employees = signal<Employee[]>(generateMockEmployees());
  261. // 计算属性
  262. departments = computed(() => {
  263. const depts = new Set(this.employees().map(emp => emp.department));
  264. return Array.from(depts).sort();
  265. });
  266. filteredEmployees = computed(() => {
  267. if (this.selectedDepartment === 'all') {
  268. return this.employees();
  269. }
  270. return this.employees().filter(emp => emp.department === this.selectedDepartment);
  271. });
  272. selectedView = signal<'day' | 'week' | 'month'>('month');
  273. selectedDate = signal<Date>(new Date());
  274. selectedEmployeeId = signal<string>('');
  275. selectedProjectId = signal<string>('');
  276. // 通过computed缓存当月日历,避免模板每次变更检测都创建新数组
  277. calendarDays = computed(() => this.computeCalendarDays());
  278. // 获取日期tooltip信息
  279. getDayTooltip(day: any): string {
  280. if (!day.attendance) {
  281. return '休息日';
  282. }
  283. const status = day.attendance.status;
  284. let tooltip = `状态: ${status}`;
  285. if (day.attendance.workHours) {
  286. tooltip += `\n工作时长: ${day.attendance.workHours}小时`;
  287. }
  288. if (day.attendance.checkInTime) {
  289. const checkIn = new Date(day.attendance.checkInTime);
  290. tooltip += `\n签到: ${checkIn.getHours().toString().padStart(2, '0')}:${checkIn.getMinutes().toString().padStart(2, '0')}`;
  291. }
  292. if (day.attendance.checkOutTime) {
  293. const checkOut = new Date(day.attendance.checkOutTime);
  294. tooltip += `\n签退: ${checkOut.getHours().toString().padStart(2, '0')}:${checkOut.getMinutes().toString().padStart(2, '0')}`;
  295. }
  296. if (day.attendance.projectName) {
  297. tooltip += `\n项目: ${day.attendance.projectName}`;
  298. }
  299. return tooltip;
  300. }
  301. // 检查日期是否为今天
  302. isToday(date: Date): boolean {
  303. const today = new Date();
  304. const checkDate = new Date(date);
  305. return today.toDateString() === checkDate.toDateString();
  306. }
  307. // 计算属性
  308. filteredAttendance = computed(() => {
  309. let filtered = this.attendanceData();
  310. // 按员工筛选
  311. if (this.selectedEmployeeId()) {
  312. filtered = filtered.filter(item => item.employeeId === this.selectedEmployeeId());
  313. }
  314. // 按项目筛选
  315. if (this.selectedProjectId()) {
  316. filtered = filtered.filter(item => item.projectId === this.selectedProjectId());
  317. }
  318. // 按视图筛选(日/周/月)
  319. const selectedDate = this.selectedDate();
  320. if (this.selectedView() === 'day') {
  321. filtered = filtered.filter(item => {
  322. const itemDate = new Date(item.date);
  323. return itemDate.getDate() === selectedDate.getDate() &&
  324. itemDate.getMonth() === selectedDate.getMonth() &&
  325. itemDate.getFullYear() === selectedDate.getFullYear();
  326. });
  327. } else if (this.selectedView() === 'week') {
  328. // 获取本周的起止日期
  329. const startOfWeek = new Date(selectedDate);
  330. const day = startOfWeek.getDay() || 7; // 调整周日为7
  331. startOfWeek.setDate(startOfWeek.getDate() - day + 1);
  332. const endOfWeek = new Date(startOfWeek);
  333. endOfWeek.setDate(endOfWeek.getDate() + 6);
  334. filtered = filtered.filter(item => {
  335. const itemDate = new Date(item.date);
  336. return itemDate >= startOfWeek && itemDate <= endOfWeek;
  337. });
  338. } else if (this.selectedView() === 'month') {
  339. filtered = filtered.filter(item => {
  340. const itemDate = new Date(item.date);
  341. return itemDate.getMonth() === selectedDate.getMonth() &&
  342. itemDate.getFullYear() === selectedDate.getFullYear();
  343. });
  344. }
  345. return filtered;
  346. });
  347. // 异常考勤列表
  348. exceptionAttendance = computed(() => {
  349. return this.filteredAttendance().filter(item =>
  350. item.status === '迟到' || item.status === '早退' || item.status === '旷工'
  351. ).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
  352. });
  353. // 考勤统计
  354. attendanceStats = computed(() => {
  355. const data = this.filteredAttendance();
  356. const totalDays = data.length;
  357. const normalDays = data.filter(item => item.status === '正常').length;
  358. const lateDays = data.filter(item => item.status === '迟到').length;
  359. const earlyLeaveDays = data.filter(item => item.status === '早退').length;
  360. const absentDays = data.filter(item => item.status === '旷工').length;
  361. const leaveDays = data.filter(item => item.status === '请假').length;
  362. const totalWorkHours = data.reduce((sum, item) => sum + item.workHours, 0);
  363. const avgWorkHours = totalDays > 0 ? (totalWorkHours / (totalDays - leaveDays - absentDays || 1)).toFixed(1) : '0.0';
  364. return {
  365. totalDays,
  366. normalDays,
  367. lateDays,
  368. earlyLeaveDays,
  369. absentDays,
  370. leaveDays,
  371. totalWorkHours: totalWorkHours.toFixed(1),
  372. avgWorkHours,
  373. complianceRate: totalDays > 0 ? Math.round((normalDays / totalDays) * 100) : 0
  374. };
  375. });
  376. // 部门考勤对比数据
  377. departmentAttendanceData = computed(() => {
  378. const departmentMap = new Map<string, { total: number; compliant: number }>();
  379. this.attendanceData().forEach(item => {
  380. const employee = this.employees().find(emp => emp.id === item.employeeId);
  381. if (employee) {
  382. const dept = employee.department;
  383. const current = departmentMap.get(dept) || { total: 0, compliant: 0 };
  384. current.total++;
  385. if (item.status === '正常') {
  386. current.compliant++;
  387. }
  388. departmentMap.set(dept, current);
  389. }
  390. });
  391. const result: { department: string; complianceRate: number; total: number; compliant: number }[] = [];
  392. departmentMap.forEach((value, key) => {
  393. result.push({
  394. department: key,
  395. complianceRate: Math.round((value.compliant / value.total) * 100),
  396. total: value.total,
  397. compliant: value.compliant
  398. });
  399. });
  400. return result.sort((a, b) => b.complianceRate - a.complianceRate);
  401. });
  402. // 显示的表格列
  403. displayedColumns = ['date', 'employeeName', 'status', 'workHours', 'projectName', 'actions'];
  404. constructor(private dialog: MatDialog, private zone: NgZone, private cdr: ChangeDetectorRef) {}
  405. ngOnInit() {
  406. // 已在字段声明处初始化模拟数据,避免首次变更检测期间的状态跃迁导致 ExpressionChanged
  407. }
  408. ngAfterViewInit() {
  409. // 稳定首次变更检测,避免控制流指令在初始化过程中值变化触发 NG0100
  410. this.cdr.detectChanges();
  411. }
  412. ngOnDestroy() {
  413. if (this.ganttChart) {
  414. this.ganttChart.off('click', this.onChartClick);
  415. this.ganttChart.dispose();
  416. this.ganttChart = null;
  417. }
  418. window.removeEventListener('resize', this.onResize);
  419. }
  420. // 移除 ngAfterViewInit 钩子
  421. // 切换视图(日/周/月)
  422. switchView(view: 'day' | 'week' | 'month') {
  423. this.selectedView.set(view);
  424. // 重置到当前日期
  425. this.selectedDate.set(new Date());
  426. }
  427. // 切换视图(考勤/任务)
  428. toggleView(target?: 'attendance' | 'task') {
  429. try {
  430. const next = target ? (target === 'task') : !this.showGanttView();
  431. this.showGanttView.set(next);
  432. if (next) {
  433. // 等待DOM渲染后初始化图表
  434. setTimeout(() => {
  435. try {
  436. this.initOrUpdateGantt();
  437. } catch (error) {
  438. console.error('甘特图初始化失败:', error);
  439. }
  440. }, 100);
  441. } else {
  442. this.cleanupGanttChart();
  443. }
  444. } catch (error) {
  445. console.error('视图切换失败:', error);
  446. }
  447. }
  448. // 清理甘特图资源
  449. private cleanupGanttChart() {
  450. try {
  451. if (this.ganttChart) {
  452. this.ganttChart.off('click', this.onChartClick);
  453. this.ganttChart.dispose();
  454. this.ganttChart = null;
  455. window.removeEventListener('resize', this.onResize);
  456. }
  457. } catch (error) {
  458. console.error('甘特图清理失败:', error);
  459. }
  460. }
  461. // 设置甘特时间尺度
  462. setGanttScale(scale: 'day' | 'week' | 'month') {
  463. if (this.ganttScale() !== scale) {
  464. this.ganttScale.set(scale);
  465. this.updateGantt();
  466. }
  467. }
  468. // 设置甘特图显示模式
  469. setGanttMode(mode: 'attendance' | 'workload') {
  470. if (this.ganttMode() !== mode) {
  471. this.ganttMode.set(mode);
  472. this.updateGantt();
  473. }
  474. }
  475. // 部门筛选变化
  476. onDepartmentChange() {
  477. this.updateGantt();
  478. }
  479. // 获取时间范围文本
  480. getTimeRangeText(): string {
  481. const date = this.selectedDate();
  482. const scale = this.ganttScale();
  483. if (scale === 'day') {
  484. return date.toLocaleDateString('zh-CN');
  485. } else if (scale === 'week') {
  486. const startOfWeek = new Date(date);
  487. const day = startOfWeek.getDay();
  488. const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
  489. startOfWeek.setDate(diff);
  490. const endOfWeek = new Date(startOfWeek);
  491. endOfWeek.setDate(startOfWeek.getDate() + 6);
  492. return `${startOfWeek.toLocaleDateString('zh-CN')} - ${endOfWeek.toLocaleDateString('zh-CN')}`;
  493. } else {
  494. return `${date.getFullYear()}年${date.getMonth() + 1}月`;
  495. }
  496. }
  497. // 获取平均出勤率
  498. getAverageAttendanceRate(): number {
  499. const employees = this.filteredEmployees();
  500. if (employees.length === 0) return 0;
  501. const totalRate = employees.reduce((sum, emp) => {
  502. const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
  503. const normalDays = empAttendance.filter(att => att.status === '正常').length;
  504. const rate = empAttendance.length > 0 ? (normalDays / empAttendance.length) * 100 : 0;
  505. return sum + rate;
  506. }, 0);
  507. return Math.round(totalRate / employees.length);
  508. }
  509. // 获取平均工时
  510. getAverageWorkHours(): number {
  511. const employees = this.filteredEmployees();
  512. if (employees.length === 0) return 0;
  513. const totalHours = employees.reduce((sum, emp) => {
  514. const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
  515. const avgHours = empAttendance.reduce((h, att) => h + (att.workHours || 0), 0) / empAttendance.length;
  516. return sum + (avgHours || 0);
  517. }, 0);
  518. return Math.round((totalHours / employees.length) * 10) / 10;
  519. }
  520. // 获取员工姓名
  521. getEmployeeName(employeeId: string): string {
  522. const employee = this.employees().find(emp => emp.id === employeeId);
  523. return employee ? employee.name : '未知员工';
  524. }
  525. // 格式化日期
  526. formatDate(date: Date | string): string {
  527. if (!date) return '';
  528. const d = new Date(date);
  529. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  530. }
  531. // 格式化时间
  532. formatTime(date: Date | string): string {
  533. if (!date) return '';
  534. const d = new Date(date);
  535. return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
  536. }
  537. // 获取状态样式类
  538. getStatusClass(status: string): string {
  539. switch (status) {
  540. case '正常':
  541. return 'status-normal';
  542. case '迟到':
  543. return 'status-late';
  544. case '早退':
  545. return 'status-early';
  546. case '旷工':
  547. return 'status-absent';
  548. case '请假':
  549. return 'status-leave';
  550. default:
  551. return '';
  552. }
  553. }
  554. // 切换日期
  555. navigateDate(direction: 'prev' | 'next') {
  556. const newDate = new Date(this.selectedDate());
  557. if (this.selectedView() === 'day') {
  558. newDate.setDate(newDate.getDate() + (direction === 'prev' ? -1 : 1));
  559. } else if (this.selectedView() === 'week') {
  560. newDate.setDate(newDate.getDate() + (direction === 'prev' ? -7 : 7));
  561. } else if (this.selectedView() === 'month') {
  562. newDate.setMonth(newDate.getMonth() + (direction === 'prev' ? -1 : 1));
  563. }
  564. this.selectedDate.set(newDate);
  565. }
  566. private isDialogOpening = false;
  567. // 打开补卡申请对话框
  568. openAttendanceDialog(attendance: AttendanceModel) {
  569. // 防止重复点击
  570. if (this.isDialogOpening) {
  571. return;
  572. }
  573. try {
  574. this.isDialogOpening = true;
  575. const employee = this.employees().find(emp => emp.id === attendance.employeeId);
  576. const dialogRef = this.dialog.open(AttendanceDialog, {
  577. width: '500px',
  578. maxWidth: '90vw',
  579. disableClose: true,
  580. panelClass: 'hr-dialog',
  581. backdropClass: 'hr-dialog-backdrop',
  582. data: {
  583. ...attendance,
  584. employeeName: employee ? employee.name : '未知员工'
  585. }
  586. });
  587. dialogRef.afterClosed().subscribe({
  588. next: (result) => {
  589. this.isDialogOpening = false;
  590. if (result) {
  591. // 在实际应用中,这里会提交补卡申请到服务器
  592. alert('补卡申请已提交,等待审核');
  593. }
  594. },
  595. error: (error) => {
  596. this.isDialogOpening = false;
  597. console.error('对话框关闭时出错:', error);
  598. }
  599. });
  600. } catch (error) {
  601. this.isDialogOpening = false;
  602. console.error('打开补卡对话框失败:', error);
  603. alert('打开对话框失败,请重试');
  604. }
  605. }
  606. // 导出考勤数据
  607. exportAttendanceData(): void {
  608. try {
  609. const data = this.filteredAttendance();
  610. if (data.length === 0) {
  611. alert('暂无数据可导出');
  612. return;
  613. }
  614. const csvContent = this.convertToCSV(data);
  615. this.downloadCSV(csvContent, '考勤数据.csv');
  616. // 显示成功提示
  617. setTimeout(() => {
  618. alert(`成功导出 ${data.length} 条考勤记录`);
  619. }, 100);
  620. } catch (error) {
  621. console.error('导出数据失败:', error);
  622. alert('导出失败,请重试');
  623. }
  624. }
  625. // 将数据转换为CSV格式
  626. private convertToCSV(data: any[]): string {
  627. if (data.length === 0) return '';
  628. const headers = ['员工ID', '员工姓名', '日期', '上班时间', '下班时间', '状态', '工作时长', '所属项目'];
  629. const rows = data.map(item => {
  630. const employee = this.employees().find(emp => emp.id === item.employeeId);
  631. return [
  632. item.employeeId,
  633. employee ? employee.name : '未知员工',
  634. this.formatDate(item.date),
  635. item.checkInTime ? this.formatTime(item.checkInTime) : '',
  636. item.checkOutTime ? this.formatTime(item.checkOutTime) : '',
  637. item.status,
  638. item.workHours || '',
  639. item.projectName
  640. ];
  641. });
  642. return [headers, ...rows].map(row => row.map(field => `"${field}"`).join(',')).join('\n');
  643. }
  644. // 下载CSV文件
  645. private downloadCSV(csvContent: string, filename: string): void {
  646. const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
  647. const link = document.createElement('a');
  648. const url = URL.createObjectURL(blob);
  649. link.setAttribute('href', url);
  650. link.setAttribute('download', filename);
  651. link.style.visibility = 'hidden';
  652. document.body.appendChild(link);
  653. link.click();
  654. document.body.removeChild(link);
  655. }
  656. // 获取日历数据
  657. getCalendarDays() {
  658. return this.calendarDays();
  659. }
  660. // 实际计算日历数据
  661. private computeCalendarDays() {
  662. const year = this.selectedDate().getFullYear();
  663. const month = this.selectedDate().getMonth();
  664. // 获取当月第一天和最后一天
  665. const firstDay = new Date(year, month, 1);
  666. const lastDay = new Date(year, month + 1, 0);
  667. // 获取当月第一天是星期几
  668. const firstDayIndex = firstDay.getDay();
  669. const days: any[] = [];
  670. // 添加上月的最后几天
  671. for (let i = firstDayIndex; i > 0; i--) {
  672. const day = new Date(year, month, -i + 1);
  673. days.push({
  674. date: day,
  675. dayOfMonth: day.getDate(),
  676. currentMonth: false,
  677. attendance: null
  678. });
  679. }
  680. // 添加当月的天数
  681. for (let i = 1; i <= lastDay.getDate(); i++) {
  682. const day = new Date(year, month, i);
  683. const dayAttendance = this.attendanceData().filter(item => {
  684. const itemDate = new Date(item.date);
  685. return itemDate.getDate() === i &&
  686. itemDate.getMonth() === month &&
  687. itemDate.getFullYear() === year &&
  688. (!this.selectedEmployeeId() || item.employeeId === this.selectedEmployeeId());
  689. });
  690. days.push({
  691. date: day,
  692. dayOfMonth: i,
  693. currentMonth: true,
  694. attendance: dayAttendance.length > 0 ? dayAttendance[0] : null
  695. });
  696. }
  697. // 添加下月的前几天,凑够42天(6行7列)
  698. const remainingDays = 42 - days.length;
  699. for (let i = 1; i <= remainingDays; i++) {
  700. const day = new Date(year, month + 1, i);
  701. days.push({
  702. date: day,
  703. dayOfMonth: i,
  704. currentMonth: false,
  705. attendance: null
  706. });
  707. }
  708. return days;
  709. }
  710. // 获取星期几的中文名称
  711. getWeekdayName(index: number): string {
  712. const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
  713. return weekdays[index];
  714. }
  715. private initOrUpdateGantt(): void {
  716. if (!this.ganttChartRef?.nativeElement) {
  717. console.warn('甘特图容器未找到');
  718. return;
  719. }
  720. const el = this.ganttChartRef.nativeElement;
  721. this.zone.runOutsideAngular(() => {
  722. try {
  723. if (!this.ganttChart) {
  724. // 检查容器尺寸
  725. if (el.offsetWidth === 0 || el.offsetHeight === 0) {
  726. console.warn('甘特图容器尺寸为0,延迟初始化');
  727. setTimeout(() => this.initOrUpdateGantt(), 200);
  728. return;
  729. }
  730. this.ganttChart = echarts.init(el);
  731. window.addEventListener('resize', this.onResize);
  732. }
  733. this.updateGantt();
  734. } catch (error) {
  735. console.error('甘特图初始化失败:', error);
  736. }
  737. });
  738. }
  739. private updateGantt(): void {
  740. if (!this.ganttChart) {
  741. console.warn('甘特图实例不存在');
  742. return;
  743. }
  744. try {
  745. // 工作负荷模式和考勤状态模式使用相同的数据结构,只是颜色映射不同
  746. // 考勤状态甘特图
  747. const employees = this.filteredEmployees();
  748. const categories = employees.map(emp => emp.name);
  749. // 考勤状态颜色映射
  750. const statusColorMap: Record<string, string> = {
  751. '正常': '#22c55e',
  752. '迟到': '#f59e0b',
  753. '早退': '#f97316',
  754. '旷工': '#ef4444',
  755. '请假': '#8b5cf6',
  756. '加班': '#06b6d4'
  757. };
  758. const DAY = 24 * 60 * 60 * 1000;
  759. const now = new Date();
  760. const selectedDate = this.selectedDate();
  761. // 计算时间范围
  762. let xMin: number;
  763. let xMax: number;
  764. let xSplitNumber: number;
  765. let xLabelFormatter: (value: number) => string;
  766. if (this.ganttScale() === 'day') {
  767. // 日视图:显示当天的小时
  768. const startOfDay = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());
  769. const endOfDay = new Date(startOfDay.getTime() + DAY - 1);
  770. xMin = startOfDay.getTime();
  771. xMax = endOfDay.getTime();
  772. xSplitNumber = 24;
  773. xLabelFormatter = (val) => `${new Date(val).getHours()}:00`;
  774. } else if (this.ganttScale() === 'week') {
  775. // 周视图:显示一周的天数
  776. const day = selectedDate.getDay();
  777. const diffToMonday = (day === 0 ? 6 : day - 1);
  778. const startOfWeek = new Date(selectedDate.getTime() - diffToMonday * DAY);
  779. const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
  780. xMin = startOfWeek.getTime();
  781. xMax = endOfWeek.getTime();
  782. xSplitNumber = 7;
  783. const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
  784. xLabelFormatter = (val) => WEEK_LABELS[new Date(val).getDay()];
  785. } else {
  786. // 月视图:显示一个月的周数
  787. const startOfMonth = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
  788. const endOfMonth = new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, 0, 23, 59, 59, 999);
  789. xMin = startOfMonth.getTime();
  790. xMax = endOfMonth.getTime();
  791. xSplitNumber = 4;
  792. xLabelFormatter = (val) => `第${Math.ceil(new Date(val).getDate() / 7)}周`;
  793. }
  794. // 生成考勤数据
  795. const data: any[] = [];
  796. employees.forEach((emp, empIndex) => {
  797. const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
  798. if (this.ganttScale() === 'day') {
  799. // 日视图:显示工作时间段
  800. const dayAttendance = empAttendance.find(att => {
  801. const attDate = new Date(att.date);
  802. return attDate.toDateString() === selectedDate.toDateString();
  803. });
  804. if (dayAttendance && dayAttendance.checkInTime && dayAttendance.checkOutTime) {
  805. const checkIn = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkInTime}`);
  806. const checkOut = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkOutTime}`);
  807. data.push({
  808. name: emp.name,
  809. value: [empIndex, checkIn.getTime(), checkOut.getTime(), dayAttendance.status, dayAttendance.workHours],
  810. itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
  811. });
  812. }
  813. } else {
  814. // 周视图和月视图:显示每天的考勤状态
  815. const timeRange = this.ganttScale() === 'week' ? 7 : 30;
  816. const startTime = this.ganttScale() === 'week' ?
  817. new Date(selectedDate.getTime() - ((selectedDate.getDay() === 0 ? 6 : selectedDate.getDay() - 1) * DAY)) :
  818. new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
  819. for (let i = 0; i < timeRange; i++) {
  820. const currentDay = new Date(startTime.getTime() + i * DAY);
  821. const dayAttendance = empAttendance.find(att => {
  822. const attDate = new Date(att.date);
  823. return attDate.toDateString() === currentDay.toDateString();
  824. });
  825. if (dayAttendance) {
  826. const dayStart = new Date(currentDay.getFullYear(), currentDay.getMonth(), currentDay.getDate());
  827. const dayEnd = new Date(dayStart.getTime() + DAY - 1);
  828. data.push({
  829. name: emp.name,
  830. value: [empIndex, dayStart.getTime(), dayEnd.getTime(), dayAttendance.status, dayAttendance.workHours],
  831. itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
  832. });
  833. }
  834. }
  835. }
  836. });
  837. // 计算默认可视区域
  838. const total = categories.length;
  839. const visible = Math.min(total, 15);
  840. const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
  841. const todayTs = now.getTime();
  842. const option = {
  843. backgroundColor: 'transparent',
  844. tooltip: {
  845. trigger: 'item',
  846. formatter: (params: any) => {
  847. const v = params.value;
  848. const start = new Date(v[1]);
  849. const end = new Date(v[2]);
  850. const status = v[3];
  851. const workHours = v[4];
  852. if (this.ganttScale() === 'day') {
  853. 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}小时`;
  854. } else {
  855. return `员工:${params.name}<br/>日期:${start.toLocaleDateString('zh-CN')}<br/>状态:${status}<br/>工时:${workHours || 0}小时`;
  856. }
  857. }
  858. },
  859. grid: { left: 120, right: 64, top: 30, bottom: 30 },
  860. xAxis: {
  861. type: 'time',
  862. min: xMin,
  863. max: xMax,
  864. splitNumber: xSplitNumber,
  865. axisLine: { lineStyle: { color: '#e5e7eb' } },
  866. axisLabel: { color: '#6b7280', formatter: xLabelFormatter },
  867. splitLine: { lineStyle: { color: '#f1f5f9' } }
  868. },
  869. yAxis: {
  870. type: 'category',
  871. data: categories,
  872. inverse: true,
  873. axisLabel: {
  874. color: '#374151',
  875. margin: 8,
  876. formatter: (val: string) => {
  877. const text = val.length > 12 ? val.slice(0, 12) + '…' : val;
  878. return text;
  879. }
  880. },
  881. axisTick: { show: false },
  882. axisLine: { lineStyle: { color: '#e5e7eb' } }
  883. },
  884. dataZoom: [
  885. {
  886. type: 'slider',
  887. yAxisIndex: 0,
  888. orient: 'vertical',
  889. right: 6,
  890. width: 14,
  891. start: 0,
  892. end: defaultEndPercent,
  893. zoomLock: false
  894. },
  895. {
  896. type: 'inside',
  897. yAxisIndex: 0,
  898. zoomOnMouseWheel: true,
  899. moveOnMouseMove: true,
  900. moveOnMouseWheel: true
  901. }
  902. ],
  903. series: [
  904. {
  905. type: 'custom',
  906. renderItem: (params: any, api: any) => {
  907. const categoryIndex = api.value(0);
  908. const start = api.coord([api.value(1), categoryIndex]);
  909. const end = api.coord([api.value(2), categoryIndex]);
  910. const height = Math.max(api.size([0, 1])[1] * 0.6, 12);
  911. // 考勤状态条
  912. const rectShape = echarts.graphic.clipRectByRect({
  913. x: start[0],
  914. y: start[1] - height / 2,
  915. width: Math.max(end[0] - start[0], 2),
  916. height
  917. }, {
  918. x: params.coordSys.x,
  919. y: params.coordSys.y,
  920. width: params.coordSys.width,
  921. height: params.coordSys.height
  922. });
  923. // 今日标记线
  924. const isToday = this.ganttScale() === 'day' ||
  925. (api.value(1) <= todayTs && api.value(2) >= todayTs);
  926. const elements = [
  927. {
  928. type: 'rect',
  929. shape: rectShape,
  930. style: {
  931. ...api.style(),
  932. stroke: isToday ? '#3b82f6' : 'transparent',
  933. strokeWidth: isToday ? 2 : 0
  934. }
  935. }
  936. ];
  937. return {
  938. type: 'group',
  939. children: elements
  940. };
  941. },
  942. encode: { x: [1, 2], y: 0 },
  943. data,
  944. itemStyle: { borderRadius: 6 },
  945. emphasis: { focus: 'self' }
  946. }
  947. ]
  948. };
  949. this.ganttChart.setOption(option, true);
  950. // 绑定点击事件,避免重复绑定
  951. this.ganttChart.off('click', this.onChartClick);
  952. this.ganttChart.on('click', this.onChartClick);
  953. this.ganttChart.resize();
  954. } catch (error) {
  955. console.error('甘特图更新失败:', error);
  956. }
  957. }
  958. }