2026-02-26 19:34:07 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api\Admin\HR;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
2026-02-26 20:45:19 +09:00
|
|
|
use App\Models\HR\Attendance;
|
2026-02-26 19:34:07 +09:00
|
|
|
use App\Services\HR\AttendanceService;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Http\Response;
|
2026-02-26 20:45:19 +09:00
|
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
2026-02-26 19:34:07 +09:00
|
|
|
|
|
|
|
|
class AttendanceController extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
private AttendanceService $attendanceService
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 근태 목록 조회 (HTMX → HTML / 일반 → JSON)
|
|
|
|
|
*/
|
|
|
|
|
public function index(Request $request): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
$attendances = $this->attendanceService->getAttendances(
|
|
|
|
|
$request->all(),
|
|
|
|
|
$request->integer('per_page', 20)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
2026-02-26 22:20:48 +09:00
|
|
|
$viewName = $request->input('view') === 'manage'
|
|
|
|
|
? 'hr.attendances.partials.table-manage'
|
|
|
|
|
: 'hr.attendances.partials.table';
|
|
|
|
|
|
|
|
|
|
return response(view($viewName, compact('attendances')));
|
2026-02-26 19:34:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $attendances->items(),
|
|
|
|
|
'meta' => [
|
|
|
|
|
'current_page' => $attendances->currentPage(),
|
|
|
|
|
'last_page' => $attendances->lastPage(),
|
|
|
|
|
'per_page' => $attendances->perPage(),
|
|
|
|
|
'total' => $attendances->total(),
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-26 20:45:19 +09:00
|
|
|
* 월간 통계 (HTMX → HTML / 일반 → JSON)
|
2026-02-26 19:34:07 +09:00
|
|
|
*/
|
2026-02-26 20:45:19 +09:00
|
|
|
public function stats(Request $request): JsonResponse|Response
|
2026-02-26 19:34:07 +09:00
|
|
|
{
|
|
|
|
|
$stats = $this->attendanceService->getMonthlyStats(
|
|
|
|
|
$request->integer('year') ?: null,
|
|
|
|
|
$request->integer('month') ?: null
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-26 20:45:19 +09:00
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
return response(view('hr.attendances.partials.stats', compact('stats')));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:34:07 +09:00
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $stats,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 20:56:25 +09:00
|
|
|
/**
|
|
|
|
|
* 월간 캘린더 (HTMX → HTML)
|
|
|
|
|
*/
|
|
|
|
|
public function calendar(Request $request): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
$year = $request->integer('year') ?: now()->year;
|
|
|
|
|
$month = $request->integer('month') ?: now()->month;
|
|
|
|
|
$userId = $request->integer('user_id') ?: null;
|
|
|
|
|
|
|
|
|
|
$attendances = $this->attendanceService->getMonthlyCalendarData($year, $month, $userId);
|
|
|
|
|
$calendarData = $attendances->groupBy(fn ($att) => $att->base_date->format('Y-m-d'));
|
|
|
|
|
$employees = $this->attendanceService->getActiveEmployees();
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
return response(view('hr.attendances.partials.calendar', compact(
|
|
|
|
|
'year', 'month', 'calendarData', 'employees'
|
|
|
|
|
))->with('selectedUserId', $userId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $calendarData,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원별 월간 요약 (HTMX → HTML)
|
|
|
|
|
*/
|
|
|
|
|
public function summary(Request $request): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
$year = $request->integer('year') ?: now()->year;
|
|
|
|
|
$month = $request->integer('month') ?: now()->month;
|
|
|
|
|
|
|
|
|
|
$summary = $this->attendanceService->getEmployeeMonthlySummary($year, $month);
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
return response(view('hr.attendances.partials.summary', compact('summary', 'year', 'month')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $summary,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 초과근무 알림 (HTMX → HTML)
|
|
|
|
|
*/
|
|
|
|
|
public function overtimeAlerts(Request $request): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
$alerts = $this->attendanceService->getOvertimeAlerts();
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
return response(view('hr.attendances.partials.overtime-alerts', compact('alerts')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $alerts,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 잔여 연차 조회
|
|
|
|
|
*/
|
|
|
|
|
public function leaveBalance(Request $request, int $userId): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$balance = $this->attendanceService->getLeaveBalance($userId);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => [
|
|
|
|
|
'total' => $balance?->total ?? 0,
|
|
|
|
|
'used' => $balance?->used ?? 0,
|
|
|
|
|
'remaining' => $balance?->remaining ?? 0,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 20:45:19 +09:00
|
|
|
/**
|
|
|
|
|
* 엑셀(CSV) 내보내기
|
|
|
|
|
*/
|
|
|
|
|
public function export(Request $request): StreamedResponse
|
|
|
|
|
{
|
|
|
|
|
$attendances = $this->attendanceService->getExportData($request->all());
|
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
|
|
|
|
|
|
$filename = '근태현황_'.now()->format('Ymd').'.csv';
|
|
|
|
|
|
|
|
|
|
return response()->streamDownload(function () use ($attendances) {
|
|
|
|
|
$file = fopen('php://output', 'w');
|
|
|
|
|
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
|
|
|
|
|
|
|
|
|
fputcsv($file, ['날짜', '사원명', '부서', '상태', '출근', '퇴근', '비고']);
|
|
|
|
|
|
|
|
|
|
foreach ($attendances as $att) {
|
|
|
|
|
$profile = $att->user?->tenantProfiles?->first();
|
|
|
|
|
$displayName = $profile?->display_name ?? $att->user?->name ?? '-';
|
|
|
|
|
$department = $profile?->department?->name ?? '-';
|
|
|
|
|
$statusLabel = Attendance::STATUS_MAP[$att->status] ?? $att->status;
|
|
|
|
|
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
|
|
|
|
|
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
|
|
|
|
|
|
|
|
|
|
fputcsv($file, [
|
|
|
|
|
$att->base_date->format('Y-m-d'),
|
|
|
|
|
$displayName,
|
|
|
|
|
$department,
|
|
|
|
|
$statusLabel,
|
|
|
|
|
$checkIn,
|
|
|
|
|
$checkOut,
|
|
|
|
|
$att->remarks ?? '',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fclose($file);
|
|
|
|
|
}, $filename, [
|
|
|
|
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일괄 삭제
|
|
|
|
|
*/
|
|
|
|
|
public function bulkDestroy(Request $request): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
$validated = $request->validate([
|
|
|
|
|
'ids' => 'required|array|min:1',
|
|
|
|
|
'ids.*' => 'integer',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$count = $this->attendanceService->bulkDelete($validated['ids']);
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
$attendances = $this->attendanceService->getAttendances(
|
|
|
|
|
$request->except('ids'),
|
|
|
|
|
$request->integer('per_page', 20)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return response(view('hr.attendances.partials.table', compact('attendances')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'message' => "{$count}건의 근태가 삭제되었습니다.",
|
|
|
|
|
]);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
report($e);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '일괄 삭제 중 오류가 발생했습니다.',
|
|
|
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
|
|
|
], 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:34:07 +09:00
|
|
|
/**
|
|
|
|
|
* 근태 등록
|
|
|
|
|
*/
|
|
|
|
|
public function store(Request $request): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$validated = $request->validate([
|
|
|
|
|
'user_id' => 'required|integer|exists:users,id',
|
|
|
|
|
'base_date' => 'required|date',
|
|
|
|
|
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
|
|
|
|
'check_in' => 'nullable|date_format:H:i',
|
|
|
|
|
'check_out' => 'nullable|date_format:H:i',
|
|
|
|
|
'remarks' => 'nullable|string|max:500',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$attendance = $this->attendanceService->storeAttendance($validated);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'message' => '근태가 등록되었습니다.',
|
|
|
|
|
'data' => $attendance,
|
|
|
|
|
], 201);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
report($e);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '근태 등록 중 오류가 발생했습니다.',
|
|
|
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
|
|
|
], 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 20:56:25 +09:00
|
|
|
/**
|
|
|
|
|
* 일괄 등록
|
|
|
|
|
*/
|
|
|
|
|
public function bulkStore(Request $request): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$validated = $request->validate([
|
|
|
|
|
'user_ids' => 'required|array|min:1',
|
|
|
|
|
'user_ids.*' => 'integer|exists:users,id',
|
|
|
|
|
'base_date' => 'required|date',
|
|
|
|
|
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
|
|
|
|
'check_in' => 'nullable|date_format:H:i',
|
|
|
|
|
'check_out' => 'nullable|date_format:H:i',
|
|
|
|
|
'remarks' => 'nullable|string|max:500',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$result = $this->attendanceService->bulkStore($validated);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'message' => "신규 {$result['created']}건, 수정 {$result['updated']}건 처리되었습니다.",
|
|
|
|
|
'data' => $result,
|
|
|
|
|
], 201);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
report($e);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '일괄 등록 중 오류가 발생했습니다.',
|
|
|
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
|
|
|
], 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:34:07 +09:00
|
|
|
/**
|
|
|
|
|
* 근태 수정
|
|
|
|
|
*/
|
|
|
|
|
public function update(Request $request, int $id): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$validated = $request->validate([
|
|
|
|
|
'status' => 'sometimes|required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
|
|
|
|
'check_in' => 'nullable|date_format:H:i',
|
|
|
|
|
'check_out' => 'nullable|date_format:H:i',
|
|
|
|
|
'remarks' => 'nullable|string|max:500',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$attendance = $this->attendanceService->updateAttendance($id, $validated);
|
|
|
|
|
|
|
|
|
|
if (! $attendance) {
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '근태 정보를 찾을 수 없습니다.',
|
|
|
|
|
], 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'message' => '근태가 수정되었습니다.',
|
|
|
|
|
'data' => $attendance,
|
|
|
|
|
]);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
report($e);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '근태 수정 중 오류가 발생했습니다.',
|
|
|
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
|
|
|
], 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 근태 삭제
|
|
|
|
|
*/
|
|
|
|
|
public function destroy(Request $request, int $id): JsonResponse|Response
|
|
|
|
|
{
|
|
|
|
|
try {
|
2026-03-04 00:15:41 +09:00
|
|
|
$force = $request->boolean('force');
|
|
|
|
|
|
|
|
|
|
if ($force) {
|
|
|
|
|
if (! auth()->user()->isSuperAdmin()) {
|
|
|
|
|
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
|
|
|
|
|
}
|
|
|
|
|
$result = $this->attendanceService->forceDeleteAttendance($id);
|
|
|
|
|
$message = '근태가 영구 삭제되었습니다.';
|
|
|
|
|
} else {
|
|
|
|
|
$result = $this->attendanceService->deleteAttendance($id);
|
|
|
|
|
$message = '근태가 삭제되었습니다.';
|
|
|
|
|
}
|
2026-02-26 19:34:07 +09:00
|
|
|
|
|
|
|
|
if (! $result) {
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '근태 정보를 찾을 수 없습니다.',
|
|
|
|
|
], 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
$attendances = $this->attendanceService->getAttendances(
|
|
|
|
|
$request->all(),
|
|
|
|
|
$request->integer('per_page', 20)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return response(view('hr.attendances.partials.table', compact('attendances')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
2026-03-04 00:15:41 +09:00
|
|
|
'message' => $message,
|
2026-02-26 19:34:07 +09:00
|
|
|
]);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
report($e);
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => '근태 삭제 중 오류가 발생했습니다.',
|
|
|
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
|
|
|
], 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|