feat: [attendance] 근태관리 2차 고도화 8개 기능 구현
- 월간 캘린더 뷰 (사원별 필터, 날짜 클릭 등록, HTMX 월 이동) - 일괄 등록 (다수 사원 체크박스 선택 후 일괄 등록, upsert 처리) - 사원별 월간 요약 (상태별 카운트 + 총 근무시간 집계 테이블) - 초과근무 알림 (주 48h 경고 / 52h 위험 배너) - 근태 승인 워크플로우 (신청→승인→근태 레코드 자동 생성) - 자동 결근 처리 (매일 23:50 스케줄러, 주말 제외) - 연차 관리 연동 (휴가 등록 시 leave_balances 자동 차감) - GPS 출퇴근 UI (테이블 GPS 아이콘 + 상세 모달) - 탭 네비게이션 (목록/캘린더/요약/승인) HTMX 기반 전환
This commit is contained in:
30
app/Console/Commands/MarkAbsentEmployees.php
Normal file
30
app/Console/Commands/MarkAbsentEmployees.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MarkAbsentEmployees extends Command
|
||||
{
|
||||
protected $signature = 'attendance:mark-absent {--date= : 대상 날짜 (YYYY-MM-DD), 기본값: 오늘}';
|
||||
|
||||
protected $description = '영업일에 출근 기록이 없는 사원을 자동 결근 처리';
|
||||
|
||||
public function handle(AttendanceService $service): int
|
||||
{
|
||||
$date = $this->option('date') ?: now()->toDateString();
|
||||
|
||||
$this->info("자동 결근 처리 시작: {$date}");
|
||||
|
||||
$count = $service->markAbsentees($date);
|
||||
|
||||
if ($count > 0) {
|
||||
$this->info("{$count}명 결근 처리 완료");
|
||||
} else {
|
||||
$this->info('결근 처리 대상이 없습니다 (주말이거나 모든 사원에 기록이 있음)');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,85 @@ public function stats(Request $request): JsonResponse|Response
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 캘린더 (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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀(CSV) 내보내기
|
||||
*/
|
||||
@@ -173,6 +252,40 @@ public function store(Request $request): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 등록
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 수정
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\AttendanceRequestService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AttendanceRequestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceRequestService $requestService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 신청 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$requests = $this->requestService->getRequests(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.requests', compact('requests')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $requests->items(),
|
||||
'meta' => [
|
||||
'current_page' => $requests->currentPage(),
|
||||
'last_page' => $requests->lastPage(),
|
||||
'per_page' => $requests->perPage(),
|
||||
'total' => $requests->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'request_type' => 'required|string|in:vacation,businessTrip,remote,fieldWork',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
'json_details' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->storeRequest($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '근태 신청이 등록되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '신청 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->approve($id);
|
||||
|
||||
if (! $attendanceRequest) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인 처리되었습니다. 근태 레코드가 자동 생성되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'reject_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->reject($id, $validated['reject_reason'] ?? null);
|
||||
|
||||
if (! $attendanceRequest) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려 처리되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '반려 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/Models/HR/AttendanceRequest.php
Normal file
93
app/Models/HR/AttendanceRequest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AttendanceRequest extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'attendance_requests';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'request_type',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'reason',
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'reject_reason',
|
||||
'json_details',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'approved_by' => 'int',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
'json_details' => 'array',
|
||||
];
|
||||
|
||||
public const TYPE_MAP = [
|
||||
'vacation' => '휴가',
|
||||
'businessTrip' => '출장',
|
||||
'remote' => '재택',
|
||||
'fieldWork' => '외근',
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
'pending' => '대기',
|
||||
'approved' => '승인',
|
||||
'rejected' => '반려',
|
||||
];
|
||||
|
||||
public const STATUS_COLORS = [
|
||||
'pending' => 'amber',
|
||||
'approved' => 'emerald',
|
||||
'rejected' => 'red',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TYPE_MAP[$this->request_type] ?? $this->request_type;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::STATUS_MAP[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return self::STATUS_COLORS[$this->status] ?? 'gray';
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
38
app/Models/HR/LeaveBalance.php
Normal file
38
app/Models/HR/LeaveBalance.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LeaveBalance extends Model
|
||||
{
|
||||
protected $table = 'leave_balances';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'year',
|
||||
'total_days',
|
||||
'used_days',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'year' => 'int',
|
||||
'total_days' => 'float',
|
||||
'used_days' => 'float',
|
||||
'remaining_days' => 'float',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function getRemainingAttribute(): float
|
||||
{
|
||||
return $this->remaining_days ?? ($this->total_days - $this->used_days);
|
||||
}
|
||||
}
|
||||
148
app/Services/HR/AttendanceRequestService.php
Normal file
148
app/Services/HR/AttendanceRequestService.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\AttendanceRequest;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceRequestService
|
||||
{
|
||||
/**
|
||||
* 신청 목록 조회
|
||||
*/
|
||||
public function getRequests(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = AttendanceRequest::query()
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'approver'])
|
||||
->forTenant($tenantId)
|
||||
->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected')")
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['user_id'])) {
|
||||
$query->where('user_id', $filters['user_id']);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 등록
|
||||
*/
|
||||
public function storeRequest(array $data): AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return AttendanceRequest::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'request_type' => $data['request_type'],
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'status' => 'pending',
|
||||
'json_details' => $data['json_details'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $id): ?AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$request = AttendanceRequest::query()
|
||||
->forTenant($tenantId)
|
||||
->where('status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($request, $tenantId) {
|
||||
$request->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
// 승인 시 해당 기간의 근태 레코드 자동 생성
|
||||
$this->createAttendanceRecords($request, $tenantId);
|
||||
|
||||
return $request->fresh(['user', 'approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 처리
|
||||
*/
|
||||
public function reject(int $id, ?string $reason = null): ?AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$request = AttendanceRequest::query()
|
||||
->forTenant($tenantId)
|
||||
->where('status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$request->update([
|
||||
'status' => 'rejected',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
'reject_reason' => $reason,
|
||||
]);
|
||||
|
||||
return $request->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 후 근태 레코드 자동 생성
|
||||
*/
|
||||
private function createAttendanceRecords(AttendanceRequest $request, int $tenantId): void
|
||||
{
|
||||
$statusMap = [
|
||||
'vacation' => 'vacation',
|
||||
'businessTrip' => 'businessTrip',
|
||||
'remote' => 'remote',
|
||||
'fieldWork' => 'fieldWork',
|
||||
];
|
||||
|
||||
$status = $statusMap[$request->request_type] ?? $request->request_type;
|
||||
|
||||
$period = CarbonPeriod::create($request->start_date, $request->end_date);
|
||||
|
||||
foreach ($period as $date) {
|
||||
// 주말 제외
|
||||
if ($date->isWeekend()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $request->user_id,
|
||||
'base_date' => $date->toDateString(),
|
||||
],
|
||||
[
|
||||
'status' => $status,
|
||||
'remarks' => $request->reason ? mb_substr($request->reason, 0, 100) : null,
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\HR\LeaveBalance;
|
||||
use App\Models\Tenants\Department;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -116,6 +118,15 @@ public function storeAttendance(array $data): Attendance
|
||||
$jsonDetails['check_out'] = $data['check_out'];
|
||||
}
|
||||
|
||||
// 근무 시간 자동 계산
|
||||
if (! empty($data['check_in']) && ! empty($data['check_out'])) {
|
||||
$in = Carbon::createFromFormat('H:i', $data['check_in']);
|
||||
$out = Carbon::createFromFormat('H:i', $data['check_out']);
|
||||
if ($out->gt($in)) {
|
||||
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
|
||||
}
|
||||
}
|
||||
|
||||
$attendance = Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -134,10 +145,72 @@ public function storeAttendance(array $data): Attendance
|
||||
$attendance->update(['created_by' => auth()->id()]);
|
||||
}
|
||||
|
||||
// 휴가 상태이면 연차 차감
|
||||
if (($data['status'] ?? '') === 'vacation') {
|
||||
$this->deductLeaveBalance($tenantId, $data['user_id']);
|
||||
}
|
||||
|
||||
return $attendance->load('user');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 등록
|
||||
*/
|
||||
public function bulkStore(array $data): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
DB::transaction(function () use ($data, $tenantId, &$created, &$updated) {
|
||||
$jsonDetails = [];
|
||||
if (! empty($data['check_in'])) {
|
||||
$jsonDetails['check_in'] = $data['check_in'];
|
||||
}
|
||||
if (! empty($data['check_out'])) {
|
||||
$jsonDetails['check_out'] = $data['check_out'];
|
||||
}
|
||||
|
||||
if (! empty($data['check_in']) && ! empty($data['check_out'])) {
|
||||
$in = Carbon::createFromFormat('H:i', $data['check_in']);
|
||||
$out = Carbon::createFromFormat('H:i', $data['check_out']);
|
||||
if ($out->gt($in)) {
|
||||
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['user_ids'] as $userId) {
|
||||
$attendance = Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'base_date' => $data['base_date'],
|
||||
],
|
||||
[
|
||||
'status' => $data['status'] ?? 'onTime',
|
||||
'json_details' => ! empty($jsonDetails) ? $jsonDetails : null,
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($attendance->wasRecentlyCreated) {
|
||||
$attendance->update(['created_by' => auth()->id()]);
|
||||
$created++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
|
||||
if (($data['status'] ?? '') === 'vacation') {
|
||||
$this->deductLeaveBalance($tenantId, $userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'updated' => $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 수정
|
||||
*/
|
||||
@@ -178,6 +251,20 @@ public function updateAttendance(int $id, array $data): ?Attendance
|
||||
unset($jsonDetails['check_out']);
|
||||
}
|
||||
}
|
||||
|
||||
// 근무 시간 재계산
|
||||
$checkIn = $jsonDetails['check_in'] ?? null;
|
||||
$checkOut = $jsonDetails['check_out'] ?? null;
|
||||
if ($checkIn && $checkOut) {
|
||||
$in = Carbon::createFromFormat('H:i', $checkIn);
|
||||
$out = Carbon::createFromFormat('H:i', $checkOut);
|
||||
if ($out->gt($in)) {
|
||||
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
|
||||
}
|
||||
} else {
|
||||
unset($jsonDetails['work_minutes']);
|
||||
}
|
||||
|
||||
$updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null;
|
||||
$updateData['updated_by'] = auth()->id();
|
||||
|
||||
@@ -229,6 +316,218 @@ public function bulkDelete(array $ids): int
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 캘린더 데이터 (base_date 기준 그룹화)
|
||||
*/
|
||||
public function getMonthlyCalendarData(int $year, int $month, ?int $userId = null): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
|
||||
|
||||
$query = Attendance::query()
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)])
|
||||
->forTenant($tenantId)
|
||||
->betweenDates($startDate, $endDate)
|
||||
->orderBy('base_date')
|
||||
->orderBy('user_id');
|
||||
|
||||
if ($userId) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원별 월간 요약
|
||||
*/
|
||||
public function getEmployeeMonthlySummary(int $year, int $month): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
|
||||
|
||||
$raw = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->betweenDates($startDate, $endDate)
|
||||
->select(
|
||||
'user_id',
|
||||
'status',
|
||||
DB::raw('COUNT(*) as cnt'),
|
||||
DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as total_minutes")
|
||||
)
|
||||
->groupBy('user_id', 'status')
|
||||
->get();
|
||||
|
||||
$summary = [];
|
||||
foreach ($raw as $row) {
|
||||
if (! isset($summary[$row->user_id])) {
|
||||
$summary[$row->user_id] = [
|
||||
'user_id' => $row->user_id,
|
||||
'total_days' => 0,
|
||||
'total_minutes' => 0,
|
||||
'statuses' => [],
|
||||
];
|
||||
}
|
||||
$summary[$row->user_id]['total_days'] += $row->cnt;
|
||||
$summary[$row->user_id]['total_minutes'] += (int) $row->total_minutes;
|
||||
$summary[$row->user_id]['statuses'][$row->status] = $row->cnt;
|
||||
}
|
||||
|
||||
// 사원 정보 가져오기
|
||||
$employees = Employee::query()
|
||||
->with(['user:id,name', 'department:id,name'])
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
foreach ($summary as &$item) {
|
||||
$emp = $employees[$item['user_id']] ?? null;
|
||||
$item['name'] = $emp?->display_name ?? $emp?->user?->name ?? '-';
|
||||
$item['department'] = $emp?->department?->name ?? '-';
|
||||
}
|
||||
|
||||
return array_values($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 초과근무 알림 (이번 주 기준)
|
||||
*/
|
||||
public function getOvertimeAlerts(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$weekStart = now()->startOfWeek(Carbon::MONDAY)->toDateString();
|
||||
$weekEnd = now()->endOfWeek(Carbon::SUNDAY)->toDateString();
|
||||
|
||||
$results = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->betweenDates($weekStart, $weekEnd)
|
||||
->select(
|
||||
'user_id',
|
||||
DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as week_minutes")
|
||||
)
|
||||
->groupBy('user_id')
|
||||
->having('week_minutes', '>=', 2880) // 48시간 = 2880분
|
||||
->get();
|
||||
|
||||
$alerts = [];
|
||||
if ($results->isNotEmpty()) {
|
||||
$employees = Employee::query()
|
||||
->with(['user:id,name'])
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
foreach ($results as $row) {
|
||||
$emp = $employees[$row->user_id] ?? null;
|
||||
$hours = round($row->week_minutes / 60, 1);
|
||||
$alerts[] = [
|
||||
'user_id' => $row->user_id,
|
||||
'name' => $emp?->display_name ?? $emp?->user?->name ?? '-',
|
||||
'hours' => $hours,
|
||||
'level' => $row->week_minutes >= 3120 ? 'danger' : 'warning', // 52h = 3120분
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 결근 처리 (영업일에 출근 기록 없는 사원)
|
||||
*/
|
||||
public function markAbsentees(?string $date = null): int
|
||||
{
|
||||
$date = $date ?? now()->toDateString();
|
||||
$carbonDate = Carbon::parse($date);
|
||||
|
||||
// 주말이면 스킵
|
||||
if ($carbonDate->isWeekend()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
// 모든 테넌트의 활성 사원 조회
|
||||
$tenantIds = DB::table('tenants')->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$activeUserIds = Employee::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->activeEmployees()
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
|
||||
if (empty($activeUserIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 기록이 있는 사원 제외
|
||||
$existingUserIds = Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('base_date', $date)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
|
||||
$absentUserIds = array_diff($activeUserIds, $existingUserIds);
|
||||
|
||||
foreach ($absentUserIds as $userId) {
|
||||
Attendance::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'base_date' => $date,
|
||||
'status' => 'absent',
|
||||
'remarks' => '자동 결근 처리',
|
||||
'created_by' => null,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차 잔여 조회
|
||||
*/
|
||||
public function getLeaveBalance(int $userId): ?LeaveBalance
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = now()->year;
|
||||
|
||||
return LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $userId)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차 차감 (remaining_days는 stored generated이므로 used_days만 업데이트)
|
||||
*/
|
||||
private function deductLeaveBalance(int $tenantId, int $userId): void
|
||||
{
|
||||
$year = now()->year;
|
||||
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $userId)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
|
||||
if ($balance && $balance->remaining_days > 0) {
|
||||
$balance->update([
|
||||
'used_days' => $balance->used_days + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="openBulkModal()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
일괄 등록
|
||||
</button>
|
||||
<button type="button" onclick="exportAttendances()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -39,82 +46,147 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 te
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 탭 네비게이션 --}}
|
||||
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
|
||||
<button type="button" onclick="switchTab('list')" id="tab-list"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600">
|
||||
목록
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('calendar')" id="tab-calendar"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
캘린더
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('summary')" id="tab-summary"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
요약
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('requests')" id="tab-requests"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
승인
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
||||
<div id="stats-container" class="mb-6">
|
||||
<div id="stats-container" class="mb-4">
|
||||
@include('hr.attendances.partials.stats', ['stats' => $stats])
|
||||
</div>
|
||||
|
||||
{{-- 테이블 컨테이너 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{{-- 필터 + 일괄 삭제 --}}
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<x-filter-collapsible id="attendanceFilter">
|
||||
<form id="attendanceFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 180px; max-width: 260px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="q" placeholder="사원 이름..."
|
||||
value="{{ request('q') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{{-- 초과근무 알림 영역 --}}
|
||||
<div id="overtime-alerts-container" class="mb-4"
|
||||
hx-get="{{ route('api.admin.hr.attendances.overtime-alerts') }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
{{-- 탭 콘텐츠 영역 --}}
|
||||
<div id="attendances-content">
|
||||
{{-- 목록 탭 --}}
|
||||
<div id="content-list">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{{-- 필터 + 일괄 삭제 --}}
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<x-filter-collapsible id="attendanceFilter">
|
||||
<form id="attendanceFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 180px; max-width: 260px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="q" placeholder="사원 이름..."
|
||||
value="{{ request('q') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 1 160px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">부서</label>
|
||||
<select name="department_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 부서</option>
|
||||
@foreach($departments as $dept)
|
||||
<option value="{{ $dept->id }}" {{ request('department_id') == $dept->id ? 'selected' : '' }}>
|
||||
{{ $dept->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 140px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">상태</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 상태</option>
|
||||
@foreach($statusMap as $key => $label)
|
||||
<option value="{{ $key }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">시작일</label>
|
||||
<input type="date" name="date_from"
|
||||
value="{{ request('date_from', now()->startOfMonth()->toDateString()) }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">종료일</label>
|
||||
<input type="date" name="date_to"
|
||||
value="{{ request('date_to', now()->toDateString()) }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button type="submit"
|
||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||
hx-target="#attendances-table"
|
||||
hx-include="#attendanceFilterForm"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||
검색
|
||||
</button>
|
||||
<button type="button" id="bulkDeleteBtn" onclick="bulkDeleteAttendances()" class="hidden px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors">
|
||||
선택 삭제 (<span id="bulkDeleteCount">0</span>건)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
</div>
|
||||
|
||||
{{-- HTMX 테이블 영역 --}}
|
||||
<div id="attendances-table"
|
||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||
hx-vals='{"date_from": "{{ now()->startOfMonth()->toDateString() }}", "date_to": "{{ now()->toDateString() }}"}'
|
||||
hx-trigger="load"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="min-h-[200px]">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
<div style="flex: 0 1 160px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">부서</label>
|
||||
<select name="department_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 부서</option>
|
||||
@foreach($departments as $dept)
|
||||
<option value="{{ $dept->id }}" {{ request('department_id') == $dept->id ? 'selected' : '' }}>
|
||||
{{ $dept->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 140px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">상태</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 상태</option>
|
||||
@foreach($statusMap as $key => $label)
|
||||
<option value="{{ $key }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">시작일</label>
|
||||
<input type="date" name="date_from"
|
||||
value="{{ request('date_from', now()->startOfMonth()->toDateString()) }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">종료일</label>
|
||||
<input type="date" name="date_to"
|
||||
value="{{ request('date_to', now()->toDateString()) }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button type="submit"
|
||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||
hx-target="#attendances-table"
|
||||
hx-include="#attendanceFilterForm"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||
검색
|
||||
</button>
|
||||
<button type="button" id="bulkDeleteBtn" onclick="bulkDeleteAttendances()" class="hidden px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors">
|
||||
선택 삭제 (<span id="bulkDeleteCount">0</span>건)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- HTMX 테이블 영역 --}}
|
||||
<div id="attendances-table"
|
||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||
hx-vals='{"date_from": "{{ now()->startOfMonth()->toDateString() }}", "date_to": "{{ now()->toDateString() }}"}'
|
||||
hx-trigger="load"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="min-h-[200px]">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
{{-- 캘린더 탭 --}}
|
||||
<div id="content-calendar" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div id="attendance-calendar-container">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 요약 탭 --}}
|
||||
<div id="content-summary" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div id="attendance-summary-container">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 승인 탭 --}}
|
||||
<div id="content-requests" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div id="attendance-requests-container">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +256,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
|
||||
<input type="text" id="att_remarks" placeholder="비고 사항 입력..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 잔여 연차 표시 --}}
|
||||
<div id="leaveBalanceInfo" class="hidden rounded-lg px-4 py-3 text-sm bg-blue-50 text-blue-700">
|
||||
잔여 연차: <span id="leaveBalanceCount" class="font-bold">-</span>일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 푸터 --}}
|
||||
@@ -201,10 +278,237 @@ class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg tra
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 일괄 등록 모달 --}}
|
||||
<div id="bulkModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeBulkModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl relative" style="max-height: 90vh; display: flex; flex-direction: column;">
|
||||
{{-- 헤더 --}}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||
<h3 class="text-lg font-semibold text-gray-800">일괄 근태 등록</h3>
|
||||
<button type="button" onclick="closeBulkModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 바디 --}}
|
||||
<div class="px-6 py-4 space-y-4 overflow-y-auto" style="flex: 1;">
|
||||
<div id="bulkModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
|
||||
<input type="date" id="bulk_base_date" value="{{ now()->toDateString() }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 130px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select id="bulk_status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
@foreach($statusMap as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 110px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
||||
<input type="time" id="bulk_check_in" value="09:00"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 1 110px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
||||
<input type="time" id="bulk_check_out" value="18:00"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||
<input type="text" id="bulk_remarks" placeholder="비고 사항..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 사원 목록 --}}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">사원 선택</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" id="bulk_select_all" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
전체 선택
|
||||
</label>
|
||||
</div>
|
||||
<div class="border border-gray-200 rounded-lg overflow-y-auto" style="max-height: 280px;">
|
||||
@foreach($employees as $emp)
|
||||
<label class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0">
|
||||
<input type="checkbox" class="bulk-emp-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
value="{{ $emp->user_id }}" data-name="{{ $emp->display_name ?? $emp->user?->name }}">
|
||||
<span class="text-sm text-gray-700">{{ $emp->display_name ?? $emp->user?->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">선택: <span id="bulkSelectedCount">0</span>명</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 푸터 --}}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 shrink-0">
|
||||
<button type="button" onclick="closeBulkModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="submitBulkAttendance()"
|
||||
class="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors">
|
||||
일괄 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 승인 신청 모달 --}}
|
||||
<div id="requestModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeRequestModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md relative">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 id="requestModalTitle" class="text-lg font-semibold text-gray-800">근태 신청</h3>
|
||||
<button type="button" onclick="closeRequestModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div id="requestModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||
<input type="hidden" id="req_id" value="">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">신청자</label>
|
||||
<select id="req_user_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">사원 선택</option>
|
||||
@foreach($employees as $emp)
|
||||
<option value="{{ $emp->user_id }}">{{ $emp->display_name ?? $emp->user?->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">신청 유형</label>
|
||||
<select id="req_type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="vacation">휴가</option>
|
||||
<option value="businessTrip">출장</option>
|
||||
<option value="remote">재택</option>
|
||||
<option value="fieldWork">외근</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div style="flex: 1;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">시작일</label>
|
||||
<input type="date" id="req_start_date" value="{{ now()->toDateString() }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">종료일</label>
|
||||
<input type="date" id="req_end_date" value="{{ now()->toDateString() }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||
<textarea id="req_reason" rows="3" placeholder="신청 사유를 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closeRequestModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="submitRequest()"
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
신청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- GPS 설정 모달 --}}
|
||||
<div id="gpsModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeGpsModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm relative">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">GPS 출퇴근 정보</h3>
|
||||
<button type="button" onclick="closeGpsModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-3">
|
||||
<div id="gpsDetailContent" class="text-sm text-gray-700">
|
||||
GPS 데이터가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closeGpsModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// ===== 현재 탭 상태 =====
|
||||
let currentTab = 'list';
|
||||
const tabLoaded = { list: true, calendar: false, summary: false, requests: false };
|
||||
|
||||
function switchTab(tab) {
|
||||
// 이전 탭 비활성화
|
||||
document.getElementById('tab-' + currentTab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700';
|
||||
document.getElementById('content-' + currentTab).classList.add('hidden');
|
||||
|
||||
// 새 탭 활성화
|
||||
currentTab = tab;
|
||||
document.getElementById('tab-' + tab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600';
|
||||
document.getElementById('content-' + tab).classList.remove('hidden');
|
||||
|
||||
// 최초 로드 시 데이터 가져오기
|
||||
if (!tabLoaded[tab]) {
|
||||
tabLoaded[tab] = true;
|
||||
loadTabData(tab);
|
||||
}
|
||||
}
|
||||
|
||||
function loadTabData(tab) {
|
||||
const year = document.getElementById('statsYear').value;
|
||||
const month = document.getElementById('statsMonth').value;
|
||||
|
||||
if (tab === 'calendar') {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.calendar") }}', {
|
||||
target: '#attendance-calendar-container',
|
||||
swap: 'innerHTML',
|
||||
values: { year: year, month: month },
|
||||
});
|
||||
} else if (tab === 'summary') {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.summary") }}', {
|
||||
target: '#attendance-summary-container',
|
||||
swap: 'innerHTML',
|
||||
values: { year: year, month: month },
|
||||
});
|
||||
} else if (tab === 'requests') {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.requests.index") }}', {
|
||||
target: '#attendance-requests-container',
|
||||
swap: 'innerHTML',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 일괄 선택 관리 =====
|
||||
const selectedAttendanceIds = new Set();
|
||||
|
||||
@@ -276,8 +580,27 @@ function updateBulkUI() {
|
||||
}
|
||||
|
||||
// ===== 통계 기간 선택 =====
|
||||
document.getElementById('statsYear').addEventListener('change', refreshStats);
|
||||
document.getElementById('statsMonth').addEventListener('change', refreshStats);
|
||||
document.getElementById('statsYear').addEventListener('change', onPeriodChange);
|
||||
document.getElementById('statsMonth').addEventListener('change', onPeriodChange);
|
||||
|
||||
function onPeriodChange() {
|
||||
refreshStats();
|
||||
// 캘린더/요약 탭이 로드된 경우 갱신
|
||||
if (tabLoaded.calendar) {
|
||||
tabLoaded.calendar = false;
|
||||
if (currentTab === 'calendar') {
|
||||
tabLoaded.calendar = true;
|
||||
loadTabData('calendar');
|
||||
}
|
||||
}
|
||||
if (tabLoaded.summary) {
|
||||
tabLoaded.summary = false;
|
||||
if (currentTab === 'summary') {
|
||||
tabLoaded.summary = true;
|
||||
loadTabData('summary');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStats() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.stats") }}', {
|
||||
@@ -320,12 +643,12 @@ function getFilterValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
// ===== 모달 =====
|
||||
function openAttendanceModal() {
|
||||
// ===== 근태 등록/수정 모달 =====
|
||||
function openAttendanceModal(date) {
|
||||
document.getElementById('att_id').value = '';
|
||||
document.getElementById('att_user_id').value = '';
|
||||
document.getElementById('att_user_id').disabled = false;
|
||||
document.getElementById('att_base_date').value = '{{ now()->toDateString() }}';
|
||||
document.getElementById('att_base_date').value = date || '{{ now()->toDateString() }}';
|
||||
document.getElementById('att_base_date').disabled = false;
|
||||
document.getElementById('att_status').value = 'onTime';
|
||||
document.getElementById('att_check_in').value = '09:00';
|
||||
@@ -333,6 +656,7 @@ function openAttendanceModal() {
|
||||
document.getElementById('att_remarks').value = '';
|
||||
document.getElementById('attendanceModalTitle').textContent = '근태 등록';
|
||||
document.getElementById('attendanceSubmitBtn').textContent = '등록';
|
||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||
hideModalMessage();
|
||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||
}
|
||||
@@ -349,6 +673,7 @@ function openEditAttendanceModal(id, userId, baseDate, status, checkIn, checkOut
|
||||
document.getElementById('att_remarks').value = remarks || '';
|
||||
document.getElementById('attendanceModalTitle').textContent = '근태 수정';
|
||||
document.getElementById('attendanceSubmitBtn').textContent = '수정';
|
||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||
hideModalMessage();
|
||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||
}
|
||||
@@ -368,6 +693,35 @@ function hideModalMessage() {
|
||||
document.getElementById('attendanceModalMessage').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 상태 변경 시 휴가이면 잔여 연차 표시
|
||||
document.getElementById('att_status').addEventListener('change', function() {
|
||||
if (this.value === 'vacation') {
|
||||
const userId = document.getElementById('att_user_id').value;
|
||||
if (userId) fetchLeaveBalance(userId);
|
||||
} else {
|
||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('att_user_id').addEventListener('change', function() {
|
||||
if (document.getElementById('att_status').value === 'vacation' && this.value) {
|
||||
fetchLeaveBalance(this.value);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchLeaveBalance(userId) {
|
||||
try {
|
||||
const res = await fetch('{{ url("/api/admin/hr/attendances/leave-balance") }}/' + userId, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('leaveBalanceCount').textContent = data.data.remaining;
|
||||
document.getElementById('leaveBalanceInfo').classList.remove('hidden');
|
||||
}
|
||||
} catch(e) { /* 조회 실패 시 표시하지 않음 */ }
|
||||
}
|
||||
|
||||
async function submitAttendance() {
|
||||
const id = document.getElementById('att_id').value;
|
||||
const isEdit = !!id;
|
||||
@@ -421,5 +775,209 @@ function hideModalMessage() {
|
||||
showModalMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 일괄 등록 모달 =====
|
||||
function openBulkModal() {
|
||||
document.getElementById('bulk_base_date').value = '{{ now()->toDateString() }}';
|
||||
document.getElementById('bulk_status').value = 'onTime';
|
||||
document.getElementById('bulk_check_in').value = '09:00';
|
||||
document.getElementById('bulk_check_out').value = '18:00';
|
||||
document.getElementById('bulk_remarks').value = '';
|
||||
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = false);
|
||||
document.getElementById('bulk_select_all').checked = false;
|
||||
document.getElementById('bulkSelectedCount').textContent = '0';
|
||||
document.getElementById('bulkModalMessage').classList.add('hidden');
|
||||
document.getElementById('bulkModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeBulkModal() {
|
||||
document.getElementById('bulkModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 전체 선택
|
||||
document.getElementById('bulk_select_all').addEventListener('change', function() {
|
||||
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = this.checked);
|
||||
updateBulkSelectedCount();
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('bulk-emp-checkbox')) {
|
||||
updateBulkSelectedCount();
|
||||
}
|
||||
});
|
||||
|
||||
function updateBulkSelectedCount() {
|
||||
const count = document.querySelectorAll('.bulk-emp-checkbox:checked').length;
|
||||
document.getElementById('bulkSelectedCount').textContent = count;
|
||||
}
|
||||
|
||||
async function submitBulkAttendance() {
|
||||
const checkedBoxes = document.querySelectorAll('.bulk-emp-checkbox:checked');
|
||||
if (checkedBoxes.length === 0) {
|
||||
showBulkMessage('사원을 1명 이상 선택해주세요.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userIds = Array.from(checkedBoxes).map(cb => parseInt(cb.value));
|
||||
const body = {
|
||||
user_ids: userIds,
|
||||
base_date: document.getElementById('bulk_base_date').value,
|
||||
status: document.getElementById('bulk_status').value,
|
||||
check_in: document.getElementById('bulk_check_in').value || null,
|
||||
check_out: document.getElementById('bulk_check_out').value || null,
|
||||
remarks: document.getElementById('bulk_remarks').value || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("api.admin.hr.attendances.bulk-store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showBulkMessage(data.message, false);
|
||||
refreshTable();
|
||||
setTimeout(() => closeBulkModal(), 1000);
|
||||
} else {
|
||||
showBulkMessage(data.message || '오류가 발생했습니다.', true);
|
||||
}
|
||||
} catch (e) {
|
||||
showBulkMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function showBulkMessage(message, isError) {
|
||||
const el = document.getElementById('bulkModalMessage');
|
||||
el.textContent = message;
|
||||
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===== 승인 신청 모달 =====
|
||||
function openRequestModal() {
|
||||
document.getElementById('req_id').value = '';
|
||||
document.getElementById('req_user_id').value = '';
|
||||
document.getElementById('req_type').value = 'vacation';
|
||||
document.getElementById('req_start_date').value = '{{ now()->toDateString() }}';
|
||||
document.getElementById('req_end_date').value = '{{ now()->toDateString() }}';
|
||||
document.getElementById('req_reason').value = '';
|
||||
document.getElementById('requestModalTitle').textContent = '근태 신청';
|
||||
document.getElementById('requestModalMessage').classList.add('hidden');
|
||||
document.getElementById('requestModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRequestModal() {
|
||||
document.getElementById('requestModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitRequest() {
|
||||
const userId = document.getElementById('req_user_id').value;
|
||||
if (!userId) { showRequestMessage('사원을 선택해주세요.', true); return; }
|
||||
|
||||
const body = {
|
||||
user_id: parseInt(userId),
|
||||
request_type: document.getElementById('req_type').value,
|
||||
start_date: document.getElementById('req_start_date').value,
|
||||
end_date: document.getElementById('req_end_date').value,
|
||||
reason: document.getElementById('req_reason').value || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("api.admin.hr.attendances.requests.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showRequestMessage(data.message, false);
|
||||
if (tabLoaded.requests) loadTabData('requests');
|
||||
setTimeout(() => closeRequestModal(), 800);
|
||||
} else {
|
||||
showRequestMessage(data.message || '오류가 발생했습니다.', true);
|
||||
}
|
||||
} catch(e) {
|
||||
showRequestMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function showRequestMessage(message, isError) {
|
||||
const el = document.getElementById('requestModalMessage');
|
||||
el.textContent = message;
|
||||
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===== GPS 모달 =====
|
||||
function openGpsModal(gpsData) {
|
||||
const content = document.getElementById('gpsDetailContent');
|
||||
if (gpsData && gpsData.check_in_location) {
|
||||
let html = '<div class="space-y-2">';
|
||||
if (gpsData.check_in_location) {
|
||||
html += '<div><span class="font-medium">출근 위치:</span> ' + (gpsData.check_in_location.address || gpsData.check_in_location.lat + ', ' + gpsData.check_in_location.lng) + '</div>';
|
||||
}
|
||||
if (gpsData.check_out_location) {
|
||||
html += '<div><span class="font-medium">퇴근 위치:</span> ' + (gpsData.check_out_location.address || gpsData.check_out_location.lat + ', ' + gpsData.check_out_location.lng) + '</div>';
|
||||
}
|
||||
if (gpsData.is_auto_checked) {
|
||||
html += '<div class="text-xs text-emerald-600">자동 출퇴근 처리됨</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
} else {
|
||||
content.textContent = 'GPS 데이터가 없습니다.';
|
||||
}
|
||||
document.getElementById('gpsModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeGpsModal() {
|
||||
document.getElementById('gpsModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ===== 승인/반려 처리 =====
|
||||
async function approveRequest(id) {
|
||||
if (!confirm('승인하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadTabData('requests');
|
||||
refreshTable();
|
||||
} else {
|
||||
alert(data.message || '승인 처리 중 오류');
|
||||
}
|
||||
} catch(e) { alert('서버 통신 중 오류'); }
|
||||
}
|
||||
|
||||
async function rejectRequest(id) {
|
||||
const reason = prompt('반려 사유를 입력하세요:');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ reject_reason: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadTabData('requests');
|
||||
} else {
|
||||
alert(data.message || '반려 처리 중 오류');
|
||||
}
|
||||
} catch(e) { alert('서버 통신 중 오류'); }
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
184
resources/views/hr/attendances/partials/calendar.blade.php
Normal file
184
resources/views/hr/attendances/partials/calendar.blade.php
Normal file
@@ -0,0 +1,184 @@
|
||||
{{-- 근태 월간 캘린더 (HTMX로 로드) --}}
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use App\Models\HR\Attendance;
|
||||
|
||||
$year = $year ?? now()->year;
|
||||
$month = $month ?? now()->month;
|
||||
|
||||
$firstDay = Carbon::create($year, $month, 1);
|
||||
$lastDay = $firstDay->copy()->endOfMonth();
|
||||
$startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY);
|
||||
$endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY);
|
||||
|
||||
$today = Carbon::today();
|
||||
$prevMonth = $firstDay->copy()->subMonth();
|
||||
$nextMonth = $firstDay->copy()->addMonth();
|
||||
@endphp
|
||||
|
||||
{{-- 캘린더 헤더 --}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button"
|
||||
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $prevMonth->year, 'month' => $prevMonth->month]) }}"
|
||||
hx-target="#attendance-calendar-container"
|
||||
hx-swap="innerHTML"
|
||||
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="text-xl font-bold text-gray-800">{{ $year }}년 {{ $month }}월</h2>
|
||||
|
||||
<button type="button"
|
||||
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $nextMonth->year, 'month' => $nextMonth->month]) }}"
|
||||
hx-target="#attendance-calendar-container"
|
||||
hx-swap="innerHTML"
|
||||
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if(!$today->isSameMonth($firstDay))
|
||||
<button type="button"
|
||||
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $today->year, 'month' => $today->month]) }}"
|
||||
hx-target="#attendance-calendar-container"
|
||||
hx-swap="innerHTML"
|
||||
class="ml-2 px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-600 rounded-lg transition-colors">
|
||||
오늘
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 사원 필터 --}}
|
||||
<div style="flex: 0 1 200px;">
|
||||
<select id="calendarUserFilter"
|
||||
onchange="filterCalendar()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 사원</option>
|
||||
@foreach($employees ?? [] as $emp)
|
||||
<option value="{{ $emp->user_id }}" {{ ($selectedUserId ?? '') == $emp->user_id ? 'selected' : '' }}>
|
||||
{{ $emp->display_name ?? $emp->user?->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterCalendar() {
|
||||
const userId = document.getElementById('calendarUserFilter').value;
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.calendar") }}', {
|
||||
target: '#attendance-calendar-container',
|
||||
swap: 'innerHTML',
|
||||
values: { year: {{ $year }}, month: {{ $month }}, user_id: userId },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{{-- 캘린더 그리드 --}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-red-500 border-b w-[14.28%]">일</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">월</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">화</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">수</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">목</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">금</th>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-blue-500 border-b w-[14.28%]">토</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@php
|
||||
$currentDate = $startOfWeek->copy();
|
||||
@endphp
|
||||
|
||||
@while($currentDate <= $endOfWeek)
|
||||
<tr>
|
||||
@for($i = 0; $i < 7; $i++)
|
||||
@php
|
||||
$dateKey = $currentDate->format('Y-m-d');
|
||||
$isCurrentMonth = $currentDate->month === (int)$month;
|
||||
$isToday = $currentDate->isSameDay($today);
|
||||
$isSunday = $currentDate->dayOfWeek === Carbon::SUNDAY;
|
||||
$isSaturday = $currentDate->dayOfWeek === Carbon::SATURDAY;
|
||||
$dayAttendances = $calendarData[$dateKey] ?? collect();
|
||||
@endphp
|
||||
|
||||
<td class="border border-gray-100 align-top {{ !$isCurrentMonth ? 'bg-gray-50/50' : '' }}">
|
||||
<div class="p-1" style="min-height: 6rem;">
|
||||
{{-- 날짜 헤더 --}}
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="{{ !$isCurrentMonth ? 'text-gray-300' : '' }}
|
||||
{{ $isCurrentMonth && $isSunday ? 'text-red-500' : '' }}
|
||||
{{ $isCurrentMonth && $isSaturday ? 'text-blue-500' : '' }}
|
||||
{{ $isCurrentMonth && !$isSunday && !$isSaturday ? 'text-gray-700' : '' }}
|
||||
{{ $isToday ? 'bg-emerald-500 !text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold' : 'text-sm font-medium' }}
|
||||
">
|
||||
{{ $currentDate->day }}
|
||||
</span>
|
||||
|
||||
@if($isCurrentMonth)
|
||||
<button type="button" onclick="openAttendanceModal('{{ $dateKey }}')"
|
||||
class="text-gray-300 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors p-0.5"
|
||||
title="{{ $currentDate->format('m/d') }} 근태 등록">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 근태 목록 --}}
|
||||
@if($dayAttendances->isNotEmpty())
|
||||
<div class="space-y-0.5">
|
||||
@foreach($dayAttendances as $att)
|
||||
@php
|
||||
$color = Attendance::STATUS_COLORS[$att->status] ?? 'gray';
|
||||
$label = Attendance::STATUS_MAP[$att->status] ?? $att->status;
|
||||
$profile = $att->user?->tenantProfiles?->first();
|
||||
$name = $profile?->display_name ?? $att->user?->name ?? '?';
|
||||
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
|
||||
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
|
||||
@endphp
|
||||
<button type="button"
|
||||
onclick="openEditAttendanceModal({{ $att->id }}, {{ $att->user_id }}, '{{ $att->base_date->toDateString() }}', '{{ $att->status }}', '{{ $checkIn }}', '{{ $checkOut }}', '{{ addslashes($att->remarks ?? '') }}')"
|
||||
class="w-full text-left px-1.5 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity truncate"
|
||||
style="background-color: var(--{{ $color }}-50, #f0fdf4); color: var(--{{ $color }}-700, #15803d); border: 1px solid var(--{{ $color }}-200, #bbf7d0);"
|
||||
title="{{ $name }} - {{ $label }} {{ $checkIn ? '('.$checkIn.'~'.$checkOut.')' : '' }}">
|
||||
<span class="inline-flex items-center px-1 py-0 rounded text-[10px] font-bold bg-{{ $color }}-100 text-{{ $color }}-700">{{ $label }}</span>
|
||||
<span class="text-gray-600">{{ mb_substr($name, 0, 3) }}</span>
|
||||
@if($checkIn)
|
||||
<span class="text-gray-400">{{ $checkIn }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@php
|
||||
$currentDate->addDay();
|
||||
@endphp
|
||||
@endfor
|
||||
</tr>
|
||||
@endwhile
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- 범례 --}}
|
||||
<div class="flex flex-wrap gap-3 mt-4 px-2">
|
||||
@foreach(Attendance::STATUS_MAP as $key => $label)
|
||||
@php $color = Attendance::STATUS_COLORS[$key] ?? 'gray'; @endphp
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-{{ $color }}-400"></span>
|
||||
<span class="text-xs text-gray-600">{{ $label }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
{{-- 초과근무 알림 배너 (HTMX로 로드) --}}
|
||||
@if(!empty($alerts))
|
||||
<div class="space-y-2">
|
||||
@foreach($alerts as $alert)
|
||||
<div class="flex items-center gap-3 px-4 py-3 rounded-lg {{ $alert['level'] === 'danger' ? 'bg-red-50 border border-red-200' : 'bg-amber-50 border border-amber-200' }}">
|
||||
<div class="shrink-0">
|
||||
@if($alert['level'] === 'danger')
|
||||
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium {{ $alert['level'] === 'danger' ? 'text-red-700' : 'text-amber-700' }}">
|
||||
{{ $alert['name'] }}
|
||||
</span>
|
||||
<span class="text-sm {{ $alert['level'] === 'danger' ? 'text-red-600' : 'text-amber-600' }}">
|
||||
— 이번 주 {{ $alert['hours'] }}시간 근무
|
||||
@if($alert['level'] === 'danger')
|
||||
(주 52시간 초과!)
|
||||
@else
|
||||
(주 48시간 초과 경고)
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
110
resources/views/hr/attendances/partials/requests.blade.php
Normal file
110
resources/views/hr/attendances/partials/requests.blade.php
Normal file
@@ -0,0 +1,110 @@
|
||||
{{-- 근태 신청/승인 목록 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\AttendanceRequest;
|
||||
@endphp
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">근태 신청/승인</h3>
|
||||
<button type="button" onclick="openRequestModal()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
신청
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($requests->isEmpty())
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">근태 신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
@else
|
||||
<x-table-swipe>
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">신청자</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">유형</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">기간</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사유</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">처리자</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@foreach($requests as $req)
|
||||
@php
|
||||
$profile = $req->user?->tenantProfiles?->first();
|
||||
$displayName = $profile?->display_name ?? $req->user?->name ?? '-';
|
||||
$statusColor = AttendanceRequest::STATUS_COLORS[$req->status] ?? 'gray';
|
||||
$typeLabel = AttendanceRequest::TYPE_MAP[$req->request_type] ?? $req->request_type;
|
||||
$statusLabel = AttendanceRequest::STATUS_MAP[$req->status] ?? $req->status;
|
||||
$dateRange = $req->start_date->format('m/d') . ($req->start_date->ne($req->end_date) ? ' ~ ' . $req->end_date->format('m/d') : '');
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-7 h-7 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($displayName, 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||||
{{ $typeLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-700 whitespace-nowrap">{{ $dateRange }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500" style="max-width: 200px;">
|
||||
<span class="truncate block">{{ $req->reason ?? '-' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $statusColor }}-100 text-{{ $statusColor }}-700">
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
||||
@if($req->approved_by)
|
||||
{{ $req->approver?->name ?? '-' }}
|
||||
<div class="text-xs text-gray-400">{{ $req->approved_at?->format('m/d H:i') }}</div>
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
@if($req->status === 'pending')
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button type="button" onclick="approveRequest({{ $req->id }})"
|
||||
class="px-3 py-1 text-xs bg-emerald-600 hover:bg-emerald-700 text-white rounded transition-colors">
|
||||
승인
|
||||
</button>
|
||||
<button type="button" onclick="rejectRequest({{ $req->id }})"
|
||||
class="px-3 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded transition-colors">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
@elseif($req->status === 'rejected' && $req->reject_reason)
|
||||
<span class="text-xs text-red-500" title="{{ $req->reject_reason }}">
|
||||
사유: {{ mb_substr($req->reject_reason, 0, 20) }}{{ mb_strlen($req->reject_reason) > 20 ? '...' : '' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
@if($requests->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $requests->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
58
resources/views/hr/attendances/partials/summary.blade.php
Normal file
58
resources/views/hr/attendances/partials/summary.blade.php
Normal file
@@ -0,0 +1,58 @@
|
||||
{{-- 사원별 월간 요약 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\Attendance;
|
||||
@endphp
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{ $year }}년 {{ $month }}월 사원별 요약</h3>
|
||||
</div>
|
||||
|
||||
@if(empty($summary))
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">해당 월의 근태 데이터가 없습니다.</p>
|
||||
</div>
|
||||
@else
|
||||
<x-table-swipe>
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">근무일</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">총근무(h)</th>
|
||||
@foreach(Attendance::STATUS_MAP as $key => $label)
|
||||
<th class="px-3 py-3 text-center text-sm font-semibold text-gray-600">{{ $label }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@foreach($summary as $item)
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-7 h-7 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($item['name'], 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $item['name'] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-sm text-gray-700">{{ $item['department'] }}</td>
|
||||
<td class="px-4 py-3 text-center text-sm font-medium text-gray-700">{{ $item['total_days'] }}일</td>
|
||||
<td class="px-4 py-3 text-center text-sm font-medium text-gray-700">
|
||||
{{ $item['total_minutes'] > 0 ? round($item['total_minutes'] / 60, 1) : '-' }}
|
||||
</td>
|
||||
@foreach(Attendance::STATUS_MAP as $key => $label)
|
||||
@php $cnt = $item['statuses'][$key] ?? 0; @endphp
|
||||
<td class="px-3 py-3 text-center text-sm {{ $cnt > 0 ? 'font-medium text-gray-800' : 'text-gray-300' }}">
|
||||
{{ $cnt ?: '-' }}
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
@endif
|
||||
@@ -17,6 +17,7 @@
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">출근</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">퇴근</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">비고</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">GPS</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -81,6 +82,27 @@
|
||||
<span class="truncate block">{{ $attendance->remarks ?? '' }}</span>
|
||||
</td>
|
||||
|
||||
{{-- GPS --}}
|
||||
<td class="px-4 py-4 whitespace-nowrap text-center">
|
||||
@php $gpsData = $attendance->json_details['gps_data'] ?? null; @endphp
|
||||
@if($gpsData)
|
||||
<button type="button" onclick='openGpsModal(@json($gpsData))'
|
||||
class="text-emerald-600 hover:text-emerald-800" title="GPS 정보 보기">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<span class="text-gray-300">
|
||||
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 작업 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@@ -110,7 +132,7 @@ class="text-red-600 hover:text-red-800" title="삭제">
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="px-6 py-12 text-center">
|
||||
<td colspan="10" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
|
||||
@@ -1063,7 +1063,12 @@
|
||||
// 근태현황 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendances')->name('api.admin.hr.attendances.')->group(function () {
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'stats'])->name('stats');
|
||||
Route::get('/calendar', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'calendar'])->name('calendar');
|
||||
Route::get('/summary', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'summary'])->name('summary');
|
||||
Route::get('/overtime-alerts', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'overtimeAlerts'])->name('overtime-alerts');
|
||||
Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'export'])->name('export');
|
||||
Route::get('/leave-balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'leaveBalance'])->name('leave-balance');
|
||||
Route::post('/bulk-store', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkStore'])->name('bulk-store');
|
||||
Route::post('/bulk-delete', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkDestroy'])->name('bulk-delete');
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'store'])->name('store');
|
||||
@@ -1071,3 +1076,10 @@
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 근태 신청/승인 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendance-requests')->name('api.admin.hr.attendances.requests.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'store'])->name('store');
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'reject'])->name('reject');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// 매일 23:50 자동 결근 처리
|
||||
Schedule::command('attendance:mark-absent')->dailyAt('23:50');
|
||||
|
||||
Reference in New Issue
Block a user