with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver', 'approval.steps.approver', ]) ->forTenant($tenantId); if (! empty($filters['q'])) { $search = $filters['q']; $query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%")); } if (! empty($filters['user_id'])) { $query->where('user_id', $filters['user_id']); } if (! empty($filters['department_id'])) { $deptId = $filters['department_id']; $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { $q->where('tenant_id', $tenantId)->where('department_id', $deptId); }); } if (! empty($filters['leave_type'])) { $query->where('leave_type', $filters['leave_type']); } if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { $query->betweenDates($filters['date_from'], $filters['date_to']); } elseif (! empty($filters['date_from'])) { $query->where('start_date', '>=', $filters['date_from']); } elseif (! empty($filters['date_to'])) { $query->where('end_date', '<=', $filters['date_to']); } return $query ->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')") ->orderBy('created_at', 'desc') ->paginate($perPage); } /** * 휴가/근태신청/사유서 등록 → 결재 자동 생성 + 상신 */ public function storeLeave(array $data): Leave { $tenantId = session('selected_tenant_id'); $leaveType = $data['leave_type']; // 사유서는 days=0, 그 외는 자동 계산 if (in_array($leaveType, Leave::REASON_REPORT_TYPES)) { $days = 0; } else { $days = $this->calculateDays($leaveType, $data['start_date'], $data['end_date']); } // 연차 차감 대상이면 잔여일수 검증 if (in_array($leaveType, Leave::DEDUCTIBLE_TYPES)) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($data['user_id']) ->forYear(now()->year) ->first(); if (! $balance || ! $balance->canUse($days)) { throw new \RuntimeException('잔여 연차가 부족합니다.'); } } return DB::transaction(function () use ($data, $tenantId, $days, $leaveType) { $leave = Leave::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], 'leave_type' => $leaveType, 'start_date' => $data['start_date'], 'end_date' => $data['end_date'], 'days' => $days, 'reason' => $data['reason'] ?? null, 'status' => 'pending', 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); // 결재 자동 생성 + 상신 (유형에 맞는 결재양식 자동 선택) $approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null); $leave->update(['approval_id' => $approval->id]); return $leave; }); } /** * 승인 → LeaveBalance 차감 → 유형별 Attendance 반영 */ public function approve(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('pending') ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { $leave->update([ 'status' => 'approved', 'approved_by' => auth()->id(), 'approved_at' => now(), 'updated_by' => auth()->id(), ]); // 연차 차감 대상이면 LeaveBalance 차감 if ($leave->is_deductible) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); if ($balance) { $balance->useLeave($leave->days); } } // 유형별 Attendance 반영 $this->applyAttendanceByType($leave, $tenantId); return $leave->fresh(['user', 'approver']); }); } /** * 반려 */ public function reject(int $id, ?string $reason = null): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('pending') ->find($id); if (! $leave) { return null; } $leave->update([ 'status' => 'rejected', 'approved_by' => auth()->id(), 'approved_at' => now(), 'reject_reason' => $reason, 'updated_by' => auth()->id(), ]); return $leave->fresh(['user', 'approver']); } /** * 취소 → LeaveBalance 복원 → Attendance 삭제 → 결재 취소 */ public function cancel(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('approved') ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { $leave->update([ 'status' => 'cancelled', 'updated_by' => auth()->id(), ]); // 연차 차감 대상이면 LeaveBalance 복원 if ($leave->is_deductible) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); if ($balance) { $balance->restoreLeave($leave->days); } } // 해당 기간 vacation Attendance soft delete $this->deleteAttendanceRecords($leave, $tenantId); return $leave->fresh(['user']); }); } /** * pending 상태 휴가 삭제 → 연결된 결재 취소 */ public function deletePendingLeave(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('pending') ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave) { // 연결된 결재가 있고 아직 진행 중이면 취소 if ($leave->approval_id) { $approval = Approval::find($leave->approval_id); if ($approval && in_array($approval->status, ['draft', 'pending'])) { try { app(ApprovalService::class)->cancel($approval->id); } catch (\Throwable $e) { // 결재 취소 실패해도 휴가 삭제는 진행 report($e); } } } $leave->update(['deleted_by' => auth()->id()]); $leave->delete(); return $leave; }); } /** * 휴가/신청 삭제 (슈퍼관리자 전용 — 모든 상태 허용) */ public function deleteLeave(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { // 연결된 결재 취소 시도 if ($leave->approval_id) { $approval = Approval::find($leave->approval_id); if ($approval && in_array($approval->status, ['draft', 'pending'])) { try { app(ApprovalService::class)->cancel($approval->id); } catch (\Throwable $e) { report($e); } } } // 승인된 연차 차감 복원 if ($leave->status === 'approved' && $leave->is_deductible) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); $balance?->restoreLeave($leave->days); } $leave->update(['deleted_by' => auth()->id()]); $leave->delete(); return $leave; }); } /** * 휴가/신청 영구삭제 (슈퍼관리자 전용) */ public function forceDeleteLeave(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::withTrashed() ->forTenant($tenantId) ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { // 승인된 연차 차감 복원 (아직 soft-deleted 아닌 경우) if ($leave->status === 'approved' && $leave->is_deductible && ! $leave->trashed()) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); $balance?->restoreLeave($leave->days); } // 연결된 결재 정리 if ($leave->approval_id) { $approval = Approval::withTrashed()->find($leave->approval_id); $approval?->forceDelete(); } $leave->forceDelete(); return $leave; }); } /** * 결재 승인에 의한 휴가/근태신청/사유서 자동 승인 */ public function approveByApproval(Leave $leave, Approval $approval): Leave { $tenantId = $leave->tenant_id; // 최종 결재자 ID 찾기 $lastApprover = $approval->steps() ->where('status', 'approved') ->reorder('step_order', 'desc') ->first(); $leave->update([ 'status' => 'approved', 'approved_by' => $lastApprover?->approver_id ?? auth()->id(), 'approved_at' => now(), 'updated_by' => auth()->id(), ]); // 연차 차감 (DEDUCTIBLE_TYPES만) if ($leave->is_deductible) { $balance = LeaveBalance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('year', $leave->start_date->year) ->first(); if ($balance) { $balance->useLeave($leave->days); } } // 유형별 Attendance 반영 $this->applyAttendanceByType($leave, $tenantId); return $leave->fresh(['user', 'approver']); } /** * 유형별 Attendance 반영 분기 */ private function applyAttendanceByType(Leave $leave, int $tenantId): void { $attendanceStatus = Leave::ATTENDANCE_STATUS_MAP[$leave->leave_type] ?? null; if ($attendanceStatus) { // 휴가/출장/재택/외근 → Attendance 자동 생성 $this->createAttendanceRecords($leave, $tenantId, $attendanceStatus); } elseif ($leave->leave_type === 'early_leave') { // 조퇴 → 기존 Attendance에 비고 기록 $this->markEarlyLeave($leave, $tenantId); } elseif (in_array($leave->leave_type, Leave::REASON_REPORT_TYPES)) { // 지각/결근 사유서 → 기존 Attendance에 사유 기록 $this->addReasonToAttendance($leave, $tenantId); } } /** * 조퇴 승인 시 기존 Attendance에 비고 기록 */ private function markEarlyLeave(Leave $leave, int $tenantId): void { $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('base_date', $leave->start_date->toDateString()) ->first(); if ($attendance) { $remarks = $attendance->remarks; $reason = $leave->reason ? " ({$leave->reason})" : ''; $attendance->update([ 'remarks' => trim(($remarks ? $remarks.' / ' : '').'조퇴'.$reason), 'updated_by' => auth()->id(), ]); } } /** * 지각/결근 사유서 승인 시 기존 Attendance에 사유 기록 */ private function addReasonToAttendance(Leave $leave, int $tenantId): void { $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('base_date', $leave->start_date->toDateString()) ->first(); if ($attendance) { $typeName = $leave->leave_type === 'late_reason' ? '지각사유' : '결근사유'; $reason = $leave->reason ?? ''; $remarks = $attendance->remarks; $attendance->update([ 'remarks' => trim(($remarks ? $remarks.' / ' : '')."[{$typeName}] {$reason}"), 'updated_by' => auth()->id(), ]); } } /** * 결재 반려에 의한 휴가 자동 반려 */ public function rejectByApproval(Leave $leave, string $comment, int $rejecterId): Leave { $leave->update([ 'status' => 'rejected', 'reject_reason' => $comment, 'approved_by' => $rejecterId, 'approved_at' => now(), 'updated_by' => auth()->id(), ]); return $leave->fresh(['user', 'approver']); } /** * 전체 사원 잔여연차 요약 * * 사원관리의 모든 재직/휴직 직원을 표시하며, * balance 레코드가 없는 직원은 자동 생성한다. */ public function getBalanceSummary(?int $year = null, ?string $sort = null, ?string $direction = null, ?string $empStatus = null): Collection { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; // (1) 테넌트 연차 정책 조회 $policy = LeavePolicy::forTenant($tenantId)->first(); // (2) 재직상태 필터에 따른 직원 조회 $statusFilter = match ($empStatus) { 'active' => ['active', 'leave'], 'resigned' => ['resigned'], default => ['active', 'leave', 'resigned'], }; $employees = Employee::query() ->with(['user:id,name', 'department:id,name']) ->forTenant($tenantId) ->whereIn('employee_status', $statusFilter) ->get(); // (3) 기존 balance 일괄 조회 $existingBalances = LeaveBalance::query() ->forTenant($tenantId) ->forYear($year) ->get() ->keyBy('user_id'); // (4) balance 없는 직원 → insertOrIgnore로 자동 생성 $newRecords = []; foreach ($employees as $employee) { if (! $existingBalances->has($employee->user_id)) { $newRecords[] = [ 'tenant_id' => $tenantId, 'user_id' => $employee->user_id, 'year' => $year, 'total_days' => $this->calculateAnnualLeaveDays($employee, $year, $policy), 'used_days' => 0, 'created_at' => now(), 'updated_at' => now(), ]; } } if (! empty($newRecords)) { LeaveBalance::insertOrIgnore($newRecords); // 새로 생성된 레코드 포함하여 다시 조회 $existingBalances = LeaveBalance::query() ->forTenant($tenantId) ->forYear($year) ->get() ->keyBy('user_id'); } // (5) Employee 정보를 balance에 연결 $employeesByUserId = $employees->keyBy('user_id'); // (6) 현재연도 + 1년 미만 직원은 매번 재계산 (월별 발생 방식) // 퇴사자는 퇴사일 기준으로 확정되므로 재계산 대상에서 제외 if ($year === (int) now()->year) { foreach ($existingBalances as $balance) { $employee = $employeesByUserId->get($balance->user_id); if (! $employee || ! $employee->hire_date) { continue; } if ($employee->employee_status === 'resigned') { continue; } $hire = Carbon::parse($employee->hire_date); if ($hire->diffInMonths(today()) < 12) { $recalculated = $this->calculateAnnualLeaveDays($employee, $year, $policy); if (abs($balance->total_days - $recalculated) > 0.001) { $balance->update(['total_days' => $recalculated]); } } } } $result = $existingBalances ->filter(fn ($balance) => $employeesByUserId->has($balance->user_id)) ->map(function ($balance) use ($employeesByUserId) { $employee = $employeesByUserId->get($balance->user_id); $balance->employee = $employee; return $balance; }); // 정렬 (기본: 입사일 오름차순) $sortField = $sort ?? 'hire_date'; $isDesc = ($direction ?? 'asc') === 'desc'; $sortCallback = match ($sortField) { 'name' => fn ($b) => $b->employee?->display_name ?? '', 'department' => fn ($b) => $b->employee?->department?->name ?? '', 'hire_date' => fn ($b) => $b->employee?->hire_date ?? '9999-12-31', 'status' => fn ($b) => match ($b->employee?->employee_status) { 'active' => 1, 'leave' => 2, 'resigned' => 3, default => 9, }, 'resign_date' => fn ($b) => $b->employee?->resign_date ?? '9999-12-31', 'total_days' => fn ($b) => $b->total_days, 'used_days' => fn ($b) => $b->used_days, 'remaining' => fn ($b) => $b->total_days - $b->used_days, 'rate' => fn ($b) => $b->total_days > 0 ? $b->used_days / $b->total_days : 0, default => fn ($b) => $b->employee?->hire_date ?? '9999-12-31', }; return ($isDesc ? $result->sortByDesc($sortCallback) : $result->sortBy($sortCallback))->values(); } /** * 입사일 기반 연차일수 자동 산출 (근로기준법 제60조) * * - 입사일 없음 → default_annual_leave (기본 15일) * - 1년 미만 → 입사일~기준일 완료 월수 (최대 11일, 월별 발생) * - 1년 이상 → 15일 + 매 2년마다 +1일 (최대 max_annual_leave) */ private function calculateAnnualLeaveDays(Employee $employee, int $year, ?LeavePolicy $policy): float { $defaultDays = $policy->default_annual_leave ?? 15; $maxDays = $policy->max_annual_leave ?? 25; $hireDate = $employee->hire_date; if (! $hireDate) { return (float) $defaultDays; } $hire = Carbon::parse($hireDate); // 기준일: 현재연도면 오늘, 과거연도면 연말 $referenceDate = $year === (int) now()->year ? today() : Carbon::create($year, 12, 31); // 퇴사자: 기준일을 퇴사일로 제한 $resignDate = $employee->resign_date; if ($resignDate) { $resign = Carbon::parse($resignDate); if ($resign->lessThan($referenceDate)) { $referenceDate = $resign; } } // 아직 입사 전이면 0일 if ($hire->greaterThan($referenceDate)) { return 0; } // 입사일~기준일 완료 월수 $totalMonthsWorked = (int) $hire->diffInMonths($referenceDate); // 1년 미만: 완료된 월수 × 1일 (최대 11일) if ($totalMonthsWorked < 12) { return (float) min($totalMonthsWorked, 11); } // 1년 이상: 15일 + 매 2년마다 +1일 $yearsWorked = (int) $hire->diffInYears($referenceDate); $additionalDays = (int) floor(($yearsWorked - 1) / 2); $totalDays = $defaultDays + $additionalDays; return (float) min($totalDays, $maxDays); } /** * 개별 사원 잔여연차 */ public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; return LeaveBalance::query() ->forTenant($tenantId) ->forUser($userId) ->forYear($year) ->first(); } /** * 유형별/사원별 사용 통계 */ public function getUsageStats(?int $year = null): array { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; // 유형별 집계 $byType = Leave::query() ->forTenant($tenantId) ->forYear($year) ->withStatus('approved') ->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days')) ->groupBy('leave_type') ->get() ->keyBy('leave_type'); // 사원별 유형 크로스 테이블 $byUser = Leave::query() ->forTenant($tenantId) ->forYear($year) ->withStatus('approved') ->with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', ]) ->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days')) ->groupBy('user_id', 'leave_type') ->get() ->groupBy('user_id'); return [ 'by_type' => $byType, 'by_user' => $byUser, 'year' => $year, ]; } /** * 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수) */ public function calculateDays(string $type, string $start, string $end): float { if (in_array($type, ['half_am', 'half_pm'])) { return 0.5; } $period = CarbonPeriod::create($start, $end); $businessDays = 0; foreach ($period as $date) { if (! $date->isWeekend()) { $businessDays++; } } return (float) $businessDays; } /** * CSV 내보내기용 데이터 */ public function getExportData(array $filters = []): Collection { $tenantId = session('selected_tenant_id'); $query = Leave::query() ->with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver', ]) ->forTenant($tenantId); if (! empty($filters['status'])) { $query->withStatus($filters['status']); } if (! empty($filters['leave_type'])) { $query->where('leave_type', $filters['leave_type']); } if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { $query->betweenDates($filters['date_from'], $filters['date_to']); } return $query->orderBy('start_date', 'desc')->get(); } /** * 부서 목록 */ public function getDepartments(): \Illuminate\Database\Eloquent\Collection { $tenantId = session('selected_tenant_id'); return Department::query() ->where('is_active', true) ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->orderBy('sort_order') ->orderBy('name') ->get(['id', 'name', 'code']); } /** * 활성 사원 목록 (드롭다운용) */ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection { $tenantId = session('selected_tenant_id'); return \App\Models\HR\Employee::query() ->with('user:id,name') ->forTenant($tenantId) ->activeEmployees() ->orderBy('display_name') ->get(['id', 'user_id', 'display_name', 'department_id']); } // ========================================================================= // Private // ========================================================================= /** * 휴가/근태신청/사유서 결재 자동 생성 + 상신 */ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval { $approvalService = app(ApprovalService::class); // 1. 유형에 맞는 결재양식 조회 (FORM_CODE_MAP으로 동적 결정) $formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave'; $form = ApprovalForm::where('code', $formCode) ->where('tenant_id', $tenantId) ->where('is_active', true) ->first(); if (! $form) { $formNames = ['leave' => '휴가신청', 'attendance_request' => '근태신청', 'reason_report' => '사유서']; $formName = $formNames[$formCode] ?? $formCode; throw new \RuntimeException("{$formName} 결재 양식이 등록되지 않았습니다."); } // 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선 $line = null; if ($approvalLineId) { $line = ApprovalLine::where('tenant_id', $tenantId)->find($approvalLineId); } if (! $line) { $line = ApprovalLine::where('tenant_id', $tenantId) ->where('is_default', true) ->first(); } if (! $line) { throw new \RuntimeException('결재선을 찾을 수 없습니다. 기본결재선을 설정하거나 결재선을 선택해주세요.'); } // 3. 결재 본문 생성 $body = $this->buildLeaveApprovalBody($leave, $tenantId); // 4. steps 변환 $steps = collect($line->steps)->map(fn ($s) => [ 'user_id' => $s['user_id'], 'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval', ])->toArray(); // 5. 결재 제목 생성 (유형별 차별화) $typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type; $userName = $leave->user->name ?? ''; $period = $leave->start_date->format('n/j').'~'.$leave->end_date->format('n/j'); $titlePrefix = match ($formCode) { 'attendance_request' => '근태신청', 'reason_report' => '사유서', default => '휴가신청', }; $approval = $approvalService->createApproval([ 'form_id' => $form->id, 'line_id' => $line->id, 'title' => "{$titlePrefix} - {$userName} ({$typeName} {$period})", 'body' => $body, 'content' => [ 'leave_id' => $leave->id, 'user_name' => $userName, 'leave_type' => $typeName, 'start_date' => $leave->start_date->toDateString(), 'end_date' => $leave->end_date->toDateString(), 'days' => $leave->days, 'reason' => $leave->reason, ], 'is_urgent' => false, 'steps' => $steps, ]); // 6. 자동 상신 $approvalService->submit($approval->id); return $approval->fresh(); } /** * 결재 본문 HTML 생성 (유형별 차별화) */ private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string { $user = $leave->user; $typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type; $formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave'; // 부서 정보 $profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first(); $deptName = $profile?->department?->name ?? ''; // 공통 행 $rows = [['신청자', e($user->name ?? '')]]; if ($deptName) { $rows[] = ['부서', e($deptName)]; } $rows[] = ['유형', $typeName]; // 사유서: 대상일 + 사유 if ($formCode === 'reason_report') { $rows[] = ['대상일', $leave->start_date->format('Y-m-d')]; if ($leave->reason) { $rows[] = ['사유', e($leave->reason)]; } $intro = '아래와 같이 사유서를 제출합니다.'; } else { // 휴가/근태신청: 기간 + 일수 $period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d'); $daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일'; $rows[] = ['기간', $period.' ('.$daysStr.')']; if ($leave->reason) { $rows[] = ['사유', e($leave->reason)]; } // 잔여연차 (연차 차감 대상만) if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) { $balance = LeaveBalance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('year', now()->year) ->first(); if ($balance) { $rows[] = ['잔여연차', $balance->remaining.'일 (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')']; } } $intro = match ($formCode) { 'attendance_request' => '아래와 같이 근태를 신청합니다.', default => '아래와 같이 휴가를 신청합니다.', }; } // HTML 테이블 생성 $html = "
{$intro}
"; $html .= '| {$label} | {$value} |
|---|