feat: [hr] 근태현황 MNG 프론트엔드 구현
- Attendance 모델 (attendances 테이블, 상태/색상 매핑, check_in/check_out accessor) - AttendanceService (목록/월간통계/CRUD, 부서/사원 드롭다운) - API 컨트롤러 (HTMX+JSON 이중 응답, stats/index/store/update/destroy) - 페이지 컨트롤러 (index 페이지 렌더링) - 웹/API 라우트 등록 (hr/attendances, api/admin/hr/attendances) - index.blade.php (통계카드+필터+등록/수정 모달) - partials/table.blade.php (HTMX 부분 로드 테이블)
This commit is contained in:
168
app/Http/Controllers/Api/Admin/HR/AttendanceController.php
Normal file
168
app/Http/Controllers/Api/Admin/HR/AttendanceController.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
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')) {
|
||||
return response(view('hr.attendances.partials.table', compact('attendances')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $attendances->items(),
|
||||
'meta' => [
|
||||
'current_page' => $attendances->currentPage(),
|
||||
'last_page' => $attendances->lastPage(),
|
||||
'per_page' => $attendances->perPage(),
|
||||
'total' => $attendances->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$stats = $this->attendanceService->getMonthlyStats(
|
||||
$request->integer('year') ?: null,
|
||||
$request->integer('month') ?: null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 등록
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 수정
|
||||
*/
|
||||
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 {
|
||||
$result = $this->attendanceService->deleteAttendance($id);
|
||||
|
||||
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,
|
||||
'message' => '근태가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/HR/AttendanceController.php
Normal file
33
app/Http/Controllers/HR/AttendanceController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $attendanceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 근태현황 목록 페이지
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$stats = $this->attendanceService->getMonthlyStats();
|
||||
$departments = $this->attendanceService->getDepartments();
|
||||
$employees = $this->attendanceService->getActiveEmployees();
|
||||
$statusMap = Attendance::STATUS_MAP;
|
||||
|
||||
return view('hr.attendances.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
}
|
||||
147
app/Models/HR/Attendance.php
Normal file
147
app/Models/HR/Attendance.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Attendance extends Model
|
||||
{
|
||||
use ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'attendances';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'base_date',
|
||||
'status',
|
||||
'json_details',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'json_details' => 'array',
|
||||
'base_date' => 'date',
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'onTime',
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
'onTime' => '정시출근',
|
||||
'late' => '지각',
|
||||
'absent' => '결근',
|
||||
'vacation' => '휴가',
|
||||
'businessTrip' => '출장',
|
||||
'fieldWork' => '외근',
|
||||
'overtime' => '야근',
|
||||
'remote' => '재택',
|
||||
];
|
||||
|
||||
public const STATUS_COLORS = [
|
||||
'onTime' => 'emerald',
|
||||
'late' => 'amber',
|
||||
'absent' => 'red',
|
||||
'vacation' => 'blue',
|
||||
'businessTrip' => 'purple',
|
||||
'fieldWork' => 'indigo',
|
||||
'overtime' => 'orange',
|
||||
'remote' => 'teal',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessor
|
||||
// =========================================================================
|
||||
|
||||
public function getCheckInAttribute(): ?string
|
||||
{
|
||||
$checkIns = $this->json_details['check_ins'] ?? [];
|
||||
|
||||
if (! empty($checkIns)) {
|
||||
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkIns));
|
||||
if (! empty($times)) {
|
||||
sort($times);
|
||||
|
||||
return $times[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->json_details['check_in'] ?? null;
|
||||
}
|
||||
|
||||
public function getCheckOutAttribute(): ?string
|
||||
{
|
||||
$checkOuts = $this->json_details['check_outs'] ?? [];
|
||||
|
||||
if (! empty($checkOuts)) {
|
||||
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkOuts));
|
||||
if (! empty($times)) {
|
||||
rsort($times);
|
||||
|
||||
return $times[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->json_details['check_out'] ?? null;
|
||||
}
|
||||
|
||||
public function getWorkMinutesAttribute(): ?int
|
||||
{
|
||||
return isset($this->json_details['work_minutes'])
|
||||
? (int) $this->json_details['work_minutes']
|
||||
: null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function scopeOnDate($query, string $date)
|
||||
{
|
||||
return $query->whereDate('base_date', $date);
|
||||
}
|
||||
|
||||
public function scopeBetweenDates($query, string $startDate, string $endDate)
|
||||
{
|
||||
return $query->whereBetween('base_date', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
226
app/Services/HR/AttendanceService.php
Normal file
226
app/Services/HR/AttendanceService.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\Tenants\Department;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceService
|
||||
{
|
||||
/**
|
||||
* 근태 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Attendance::query()
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)])
|
||||
->forTenant($tenantId);
|
||||
|
||||
// 이름 검색
|
||||
if (! empty($filters['q'])) {
|
||||
$search = $filters['q'];
|
||||
$query->whereHas('user', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 부서 필터
|
||||
if (! empty($filters['department_id'])) {
|
||||
$deptId = $filters['department_id'];
|
||||
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
||||
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
|
||||
$query->betweenDates($filters['date_from'], $filters['date_to']);
|
||||
} elseif (! empty($filters['date_from'])) {
|
||||
$query->whereDate('base_date', '>=', $filters['date_from']);
|
||||
} elseif (! empty($filters['date_to'])) {
|
||||
$query->whereDate('base_date', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
$query->orderBy('base_date', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계 (상태별 카운트)
|
||||
*/
|
||||
public function getMonthlyStats(?int $year = null, ?int $month = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = $year ?? now()->year;
|
||||
$month = $month ?? now()->month;
|
||||
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = now()->year == $year && now()->month == $month
|
||||
? now()->toDateString()
|
||||
: sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
|
||||
|
||||
$counts = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->betweenDates($startDate, $endDate)
|
||||
->select('status', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('status')
|
||||
->pluck('cnt', 'status')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'onTime' => $counts['onTime'] ?? 0,
|
||||
'late' => $counts['late'] ?? 0,
|
||||
'absent' => $counts['absent'] ?? 0,
|
||||
'vacation' => $counts['vacation'] ?? 0,
|
||||
'etc' => ($counts['businessTrip'] ?? 0) + ($counts['fieldWork'] ?? 0) + ($counts['overtime'] ?? 0) + ($counts['remote'] ?? 0),
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 등록 (Upsert: tenant_id + user_id + base_date)
|
||||
*/
|
||||
public function storeAttendance(array $data): Attendance
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
$jsonDetails = [];
|
||||
if (! empty($data['check_in'])) {
|
||||
$jsonDetails['check_in'] = $data['check_in'];
|
||||
}
|
||||
if (! empty($data['check_out'])) {
|
||||
$jsonDetails['check_out'] = $data['check_out'];
|
||||
}
|
||||
|
||||
$attendance = Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'base_date' => $data['base_date'],
|
||||
],
|
||||
[
|
||||
'status' => $data['status'] ?? 'onTime',
|
||||
'json_details' => ! empty($jsonDetails) ? $jsonDetails : null,
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
return $attendance->load('user');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 수정
|
||||
*/
|
||||
public function updateAttendance(int $id, array $data): ?Attendance
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$attendance = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $attendance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
|
||||
if (array_key_exists('status', $data)) {
|
||||
$updateData['status'] = $data['status'];
|
||||
}
|
||||
if (array_key_exists('remarks', $data)) {
|
||||
$updateData['remarks'] = $data['remarks'];
|
||||
}
|
||||
|
||||
// json_details 업데이트
|
||||
$jsonDetails = $attendance->json_details ?? [];
|
||||
if (array_key_exists('check_in', $data)) {
|
||||
if ($data['check_in']) {
|
||||
$jsonDetails['check_in'] = $data['check_in'];
|
||||
} else {
|
||||
unset($jsonDetails['check_in']);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('check_out', $data)) {
|
||||
if ($data['check_out']) {
|
||||
$jsonDetails['check_out'] = $data['check_out'];
|
||||
} else {
|
||||
unset($jsonDetails['check_out']);
|
||||
}
|
||||
}
|
||||
$updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null;
|
||||
$updateData['updated_by'] = auth()->id();
|
||||
|
||||
$attendance->update($updateData);
|
||||
|
||||
return $attendance->fresh('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 삭제
|
||||
*/
|
||||
public function deleteAttendance(int $id): bool
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$attendance = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $attendance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$attendance->update(['deleted_by' => auth()->id()]);
|
||||
$attendance->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
public function getDepartments(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return Department::query()
|
||||
->where('is_active', true)
|
||||
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 사원 목록 (드롭다운용)
|
||||
*/
|
||||
public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return Employee::query()
|
||||
->with('user:id,name')
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->orderBy('display_name')
|
||||
->get(['id', 'user_id', 'display_name', 'department_id']);
|
||||
}
|
||||
}
|
||||
332
resources/views/hr/attendances/index.blade.php
Normal file
332
resources/views/hr/attendances/index.blade.php
Normal file
@@ -0,0 +1,332 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '근태현황')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-6">
|
||||
{{-- 페이지 헤더 --}}
|
||||
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">근태현황</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $stats['year'] }}년 {{ $stats['month'] }}월 현재</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="openAttendanceModal()"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 --}}
|
||||
<div class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">정시출근</div>
|
||||
<div class="text-2xl font-bold text-emerald-600">{{ $stats['onTime'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">지각</div>
|
||||
<div class="text-2xl font-bold text-amber-600">{{ $stats['late'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">결근</div>
|
||||
<div class="text-2xl font-bold text-red-600">{{ $stats['absent'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">휴가</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $stats['vacation'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">기타</div>
|
||||
<div class="text-2xl font-bold text-gray-600">{{ $stats['etc'] }}건</div>
|
||||
</div>
|
||||
</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>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 근태 등록/수정 모달 --}}
|
||||
<div id="attendanceModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeAttendanceModal()"></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="attendanceModalTitle" class="text-lg font-semibold text-gray-800">근태 등록</h3>
|
||||
<button type="button" onclick="closeAttendanceModal()" 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="attendanceModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||
|
||||
<input type="hidden" id="att_id" value="">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사원</label>
|
||||
<select id="att_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>
|
||||
<input type="date" id="att_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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select id="att_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 class="flex gap-3">
|
||||
<div style="flex: 1;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
||||
<input type="time" id="att_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: 1;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
||||
<input type="time" id="att_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="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>
|
||||
|
||||
{{-- 푸터 --}}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closeAttendanceModal()"
|
||||
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="submitAttendance()"
|
||||
id="attendanceSubmitBtn"
|
||||
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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 필터 폼 submit 이벤트 가로채기
|
||||
document.getElementById('attendanceFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#attendances-table', 'htmx:trigger');
|
||||
});
|
||||
|
||||
// 모달 열기 (등록)
|
||||
function openAttendanceModal() {
|
||||
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_status').value = 'onTime';
|
||||
document.getElementById('att_check_in').value = '09:00';
|
||||
document.getElementById('att_check_out').value = '18:00';
|
||||
document.getElementById('att_remarks').value = '';
|
||||
document.getElementById('attendanceModalTitle').textContent = '근태 등록';
|
||||
document.getElementById('attendanceSubmitBtn').textContent = '등록';
|
||||
hideModalMessage();
|
||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 모달 열기 (수정)
|
||||
function openEditAttendanceModal(id, userId, baseDate, status, checkIn, checkOut, remarks) {
|
||||
document.getElementById('att_id').value = id;
|
||||
document.getElementById('att_user_id').value = userId;
|
||||
document.getElementById('att_user_id').disabled = true;
|
||||
document.getElementById('att_base_date').value = baseDate;
|
||||
document.getElementById('att_status').value = status;
|
||||
document.getElementById('att_check_in').value = checkIn || '';
|
||||
document.getElementById('att_check_out').value = checkOut || '';
|
||||
document.getElementById('att_remarks').value = remarks || '';
|
||||
document.getElementById('attendanceModalTitle').textContent = '근태 수정';
|
||||
document.getElementById('attendanceSubmitBtn').textContent = '수정';
|
||||
hideModalMessage();
|
||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeAttendanceModal() {
|
||||
document.getElementById('attendanceModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 모달 메시지 표시
|
||||
function showModalMessage(message, isError) {
|
||||
const el = document.getElementById('attendanceModalMessage');
|
||||
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 hideModalMessage() {
|
||||
document.getElementById('attendanceModalMessage').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 등록/수정 제출
|
||||
async function submitAttendance() {
|
||||
const id = document.getElementById('att_id').value;
|
||||
const isEdit = !!id;
|
||||
|
||||
const body = {
|
||||
status: document.getElementById('att_status').value,
|
||||
check_in: document.getElementById('att_check_in').value || null,
|
||||
check_out: document.getElementById('att_check_out').value || null,
|
||||
remarks: document.getElementById('att_remarks').value || null,
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
body.user_id = document.getElementById('att_user_id').value;
|
||||
body.base_date = document.getElementById('att_base_date').value;
|
||||
|
||||
if (!body.user_id) {
|
||||
showModalMessage('사원을 선택해주세요.', true);
|
||||
return;
|
||||
}
|
||||
if (!body.base_date) {
|
||||
showModalMessage('날짜를 선택해주세요.', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const url = isEdit
|
||||
? '{{ url("/api/admin/hr/attendances") }}/' + id
|
||||
: '{{ route("api.admin.hr.attendances.store") }}';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : '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) {
|
||||
showModalMessage(data.message, false);
|
||||
// 테이블 리로드
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.index") }}', {
|
||||
target: '#attendances-table',
|
||||
swap: 'innerHTML',
|
||||
values: getFilterValues(),
|
||||
});
|
||||
setTimeout(() => closeAttendanceModal(), 800);
|
||||
} else {
|
||||
showModalMessage(data.message || '오류가 발생했습니다.', true);
|
||||
}
|
||||
} catch (e) {
|
||||
showModalMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 필터 값 가져오기
|
||||
function getFilterValues() {
|
||||
const form = document.getElementById('attendanceFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const values = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
123
resources/views/hr/attendances/partials/table.blade.php
Normal file
123
resources/views/hr/attendances/partials/table.blade.php
Normal file
@@ -0,0 +1,123 @@
|
||||
{{-- 근태현황 테이블 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\Attendance;
|
||||
@endphp
|
||||
|
||||
<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-6 py-3 text-left 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-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-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@forelse($attendances as $attendance)
|
||||
@php
|
||||
$profile = $attendance->user?->tenantProfiles?->first();
|
||||
$department = $profile?->department;
|
||||
$displayName = $profile?->display_name ?? $attendance->user?->name ?? '-';
|
||||
$color = Attendance::STATUS_COLORS[$attendance->status] ?? 'gray';
|
||||
$label = Attendance::STATUS_MAP[$attendance->status] ?? $attendance->status;
|
||||
$checkIn = $attendance->check_in ? substr($attendance->check_in, 0, 5) : '-';
|
||||
$checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-';
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
{{-- 날짜 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $attendance->base_date->format('m-d') }}
|
||||
<span class="text-xs text-gray-400 ml-1">{{ ['일','월','화','수','목','금','토'][$attendance->base_date->dayOfWeek] }}</span>
|
||||
</td>
|
||||
|
||||
{{-- 사원 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-8 h-8 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-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 상태 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $color }}-100 text-{{ $color }}-700">
|
||||
{{ $label }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- 출근 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $checkIn }}
|
||||
</td>
|
||||
|
||||
{{-- 퇴근 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $checkOut }}
|
||||
</td>
|
||||
|
||||
{{-- 비고 --}}
|
||||
<td class="px-6 py-4 text-sm text-gray-500" style="max-width: 200px;">
|
||||
<span class="truncate block">{{ $attendance->remarks ?? '' }}</span>
|
||||
</td>
|
||||
|
||||
{{-- 작업 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{{-- 수정 --}}
|
||||
<button type="button"
|
||||
onclick="openEditAttendanceModal({{ $attendance->id }}, {{ $attendance->user_id }}, '{{ $attendance->base_date->toDateString() }}', '{{ $attendance->status }}', '{{ $attendance->check_in ? substr($attendance->check_in, 0, 5) : '' }}', '{{ $attendance->check_out ? substr($attendance->check_out, 0, 5) : '' }}', '{{ addslashes($attendance->remarks ?? '') }}')"
|
||||
class="text-blue-600 hover:text-blue-800" title="수정">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 삭제 --}}
|
||||
<button type="button"
|
||||
hx-delete="{{ route('api.admin.hr.attendances.destroy', $attendance->id) }}"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
hx-target="#attendances-table"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="{{ $displayName }}님의 {{ $attendance->base_date->format('m/d') }} 근태를 삭제하시겠습니까?"
|
||||
class="text-red-600 hover:text-red-800" title="삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" 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"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">근태 기록이 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($attendances->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $attendances->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -1060,3 +1060,12 @@
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'storePosition'])->name('store');
|
||||
});
|
||||
|
||||
// 근태현황 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('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
|
||||
@@ -896,6 +896,11 @@
|
||||
Route::get('/{id}', [\App\Http\Controllers\HR\EmployeeController::class, 'show'])->name('show');
|
||||
Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 근태현황
|
||||
Route::prefix('attendances')->name('attendances.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user