diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index c1104b32..9032fbb4 100755 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\LeaveRequest; use App\Models\User; use Carbon\Carbon; use Illuminate\Http\Request; @@ -352,6 +353,11 @@ class AdminController extends Controller $this->enrichUsersForDashboard($users); $users = $users->sortByDesc('working'); $records = $this->loadPendingRequests($users); + $leaveRequests = LeaveRequest::query() + ->where('status', 'pending') + ->with('user:id,name,user_name') + ->orderByDesc('created_at') + ->get(); return view("admin..admin")->with(['message', [ 'color' => '#ff8800ff', @@ -359,6 +365,7 @@ class AdminController extends Controller ], "users" => $users, 'reqs' => $records, + 'leaveRequests' => $leaveRequests, "userType" => $provider ]); } diff --git a/app/Http/Controllers/LeaveController.php b/app/Http/Controllers/LeaveController.php new file mode 100644 index 00000000..fe01f5c0 --- /dev/null +++ b/app/Http/Controllers/LeaveController.php @@ -0,0 +1,238 @@ +with('message', [ + 'color' => '#ff8800ff', + 'text' => 'ابتدا باید وارد شوید!', + ]); + } + + $user = User::where('user_name', $_COOKIE['user_identity'])->first(); + if (!$user) { + return redirect('/')->with('message', [ + 'color' => '#ff8800ff', + 'text' => 'کاربر یافت نشد!', + ]); + } + + $type = $request->input('type'); + if (!in_array($type, ['daily', 'hourly'], true)) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'نوع مرخصی نامعتبر است.', + ]); + } + + if ($type === 'daily') { + $dateFrom = $this->parseJalaliDate($request->input('date_from')); + $dateTo = $this->parseJalaliDate($request->input('date_to')); + + if (!$dateFrom || !$dateTo) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'بازه تاریخ مرخصی روزانه الزامی است.', + ]); + } + if ($dateTo < $dateFrom) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'تاریخ پایان نمیتواند قبل از شروع باشد.', + ]); + } + $timeFrom = null; + $timeTo = null; + } else { + $dateFrom = $this->parseJalaliDate($request->input('date')); + $dateTo = $dateFrom; + $timeFrom = $this->normalizeTime($request->input('time_from')); + $timeTo = $this->normalizeTime($request->input('time_to')); + + if (!$dateFrom || !$timeFrom || !$timeTo) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'تاریخ و ساعت شروع/پایان مرخصی ساعتی الزامی است.', + ]); + } + if ($timeTo <= $timeFrom) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'ساعت پایان باید بعد از ساعت شروع باشد.', + ]); + } + } + + $leave = LeaveRequest::create([ + 'user_id' => $user->id, + 'user_identity' => $user->user_name, + 'type' => $type, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'time_from' => $timeFrom, + 'time_to' => $timeTo, + 'reason' => $request->input('reason'), + 'status' => 'pending', + ]); + + $user->req_counts = (int) $user->req_counts + 1; + $user->save(); + + $this->notifyAdminsNewLeave($leave, $user); + + return redirect('/')->with('message', [ + 'color' => '#00c300ff', + 'text' => 'درخواست مرخصی ثبت شد و برای تأیید ادمین ارسال گردید.', + ]); + } + + public function decide(Request $request) + { + if (!isset($_COOKIE['user_identity'])) { + return back()->with('message', [ + 'color' => '#ff8800ff', + 'text' => 'ابتدا باید وارد شوید!', + ]); + } + + $admin = User::where('user_name', $_COOKIE['user_identity'])->first(); + if (!$admin || !in_array((string) $admin->is_admin, ['1', '2'], true)) { + return back()->with('message', [ + 'color' => '#ff8800ff', + 'text' => 'دسترسی ندارید!', + ]); + } + + $leave = LeaveRequest::find($request->input('leave_id')); + if (!$leave || $leave->status !== 'pending') { + return back()->with('message', [ + 'color' => '#ff8800ff', + 'text' => 'درخواست مرخصی یافت نشد.', + ]); + } + + $action = $request->input('update'); + if (!in_array($action, ['approve', 'reject'], true)) { + return back()->with('message', [ + 'color' => '#ff4400ff', + 'text' => 'عملیات نامعتبر است.', + ]); + } + + $leave->status = $action === 'approve' ? 'approved' : 'rejected'; + $leave->reviewed_by = $admin->id; + $leave->reviewed_at = now(); + $leave->save(); + + $owner = User::where('user_name', $leave->user_identity)->first(); + if ($owner && (int) $owner->req_counts > 0) { + $owner->req_counts -= 1; + $owner->save(); + } + + if ($leave->status === 'approved') { + $this->recalculateLateForLeavePeriod($leave); + } + + $statusFa = $leave->status === 'approved' ? 'تایید' : 'رد'; + if ($owner) { + tel_notif($leave->user_identity, $statusFa); + } + + return back()->with('message', [ + 'color' => '#6aff00ff', + 'text' => "درخواست مرخصی با موفقیت {$statusFa} شد.", + ]); + } + + private function recalculateLateForLeavePeriod(LeaveRequest $leave): void + { + $service = app(LatePenaltyService::class); + $from = Carbon::parse($leave->date_from)->startOfDay(); + $to = Carbon::parse($leave->date_to)->endOfDay(); + + $attendances = Attendance::query() + ->where('user_identity', $leave->user_identity) + ->whereBetween('entered_at', [$from, $to]) + ->orderBy('entered_at') + ->get(); + + foreach ($attendances as $attendance) { + $service->syncAttendance($attendance); + } + } + + private function notifyAdminsNewLeave(LeaveRequest $leave, User $user): void + { + $typeFa = $leave->type === 'daily' ? 'روزانه' : 'ساعتی'; + $from = JalaliHelper::format($leave->date_from, 'Y/m/d'); + $to = JalaliHelper::format($leave->date_to, 'Y/m/d'); + + $message = "درخواست مرخصی {$typeFa}\n"; + $message .= "کارمند: {$user->name}\n"; + if ($leave->type === 'daily') { + $message .= "از {$from} تا {$to}\n"; + } else { + $timeFrom = substr((string) $leave->time_from, 0, 5); + $timeTo = substr((string) $leave->time_to, 0, 5); + $message .= "تاریخ: {$from}\nساعت: {$timeFrom} تا {$timeTo}\n"; + } + if ($leave->reason) { + $message .= "دلیل: {$leave->reason}"; + } + + $this->send_admins($message); + } + + private function parseJalaliDate(?string $value): ?string + { + if (!$value) { + return null; + } + + $value = \Morilog\Jalali\CalendarUtils::convertNumbers(trim($value), true); + $value = str_replace('/', '-', $value); + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + // اگر سال شمسی باشد (مثلاً 1404) با Jalalian تبدیل میشود + $year = (int) substr($value, 0, 4); + if ($year >= 1300 && $year <= 1500) { + return Jalalian::fromFormat('Y-m-d', $value)->toCarbon()->format('Y-m-d'); + } + + return $value; + } + + return Carbon::parse($value)->format('Y-m-d'); + } catch (\Throwable $e) { + return null; + } + } + + private function normalizeTime(?string $value): ?string + { + if (!$value) { + return null; + } + $value = trim(\Morilog\Jalali\CalendarUtils::convertNumbers($value, true)); + if (preg_match('/^(\d{1,2}):(\d{2})/', $value, $m)) { + return sprintf('%02d:%02d:00', (int) $m[1], (int) $m[2]); + } + + return null; + } +} diff --git a/app/Models/LeaveRequest.php b/app/Models/LeaveRequest.php new file mode 100644 index 00000000..3b375bd0 --- /dev/null +++ b/app/Models/LeaveRequest.php @@ -0,0 +1,56 @@ + 'date', + 'date_to' => 'date', + 'reviewed_at' => 'datetime', + ]; + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function reviewer() + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function isDaily(): bool + { + return $this->type === 'daily'; + } + + public function isHourly(): bool + { + return $this->type === 'hourly'; + } + + public function isApproved(): bool + { + return $this->status === 'approved'; + } +} diff --git a/app/Services/LatePenaltyService.php b/app/Services/LatePenaltyService.php index 03449eab..03a4cfb2 100644 --- a/app/Services/LatePenaltyService.php +++ b/app/Services/LatePenaltyService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Attendance; use App\Models\LatePeriodFine; +use App\Models\LeaveRequest; use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -17,7 +18,7 @@ class LatePenaltyService public const WEEKLY_ALLOWANCE_MINUTES = 15; public const FINE_PER_MINUTE = 2000; - public function calculateFromEnteredAt(?string $enteredAt): array + public function calculateFromEnteredAt(?string $enteredAt, ?string $userIdentity = null): array { if (!$enteredAt) { return [ @@ -32,6 +33,21 @@ class LatePenaltyService $entered = Carbon::parse($enteredAt)->startOfMinute(); $threshold = $entered->copy()->setTimeFromTimeString(self::WORK_START)->startOfMinute(); + if ($userIdentity) { + $leaveThreshold = $this->approvedLeaveWorkThreshold($userIdentity, $entered); + if ($leaveThreshold === false) { + return [ + 'late_minutes' => 0, + 'grace_late_minutes' => 0, + 'daily_fine_minutes' => 0, + 'daily_fine_amount' => 0, + ]; + } + if ($leaveThreshold instanceof Carbon) { + $threshold = $leaveThreshold->copy()->startOfMinute(); + } + } + if ($entered->lte($threshold)) { return [ 'late_minutes' => 0, @@ -54,6 +70,49 @@ class LatePenaltyService ]; } + /** + * مرخصی تأییدشده: false = معاف کامل، Carbon = شروع مؤثر کار، null = بدون تغییر. + */ + public function approvedLeaveWorkThreshold(string $userIdentity, Carbon $entered): Carbon|false|null + { + $day = $entered->toDateString(); + $workStart = $entered->copy()->setTimeFromTimeString(self::WORK_START)->startOfMinute(); + + $leaves = LeaveRequest::query() + ->where('user_identity', $userIdentity) + ->where('status', 'approved') + ->whereDate('date_from', '<=', $day) + ->whereDate('date_to', '>=', $day) + ->get(); + + $latestMorningLeaveEnd = null; + + foreach ($leaves as $leave) { + if ($leave->type === 'daily') { + return false; + } + + if ($leave->type !== 'hourly' || !$leave->time_from || !$leave->time_to) { + continue; + } + + $leaveStart = Carbon::parse($day . ' ' . $leave->time_from)->startOfMinute(); + $leaveEnd = Carbon::parse($day . ' ' . $leave->time_to)->startOfMinute(); + + // مرخصی صبح که شروع کار را پوشش میدهد + if ($leaveStart->lte($workStart) && $leaveEnd->gt($workStart)) { + if ($entered->lte($leaveEnd)) { + return false; + } + if ($latestMorningLeaveEnd === null || $leaveEnd->gt($latestMorningLeaveEnd)) { + $latestMorningLeaveEnd = $leaveEnd; + } + } + } + + return $latestMorningLeaveEnd; + } + /** دقایق تأخیر داخل بازه ۸:۳۰–۸:۴۵ برای یک رکورد */ public function graceLateMinutes(Attendance $att): int { @@ -94,7 +153,7 @@ class LatePenaltyService return $attendance; } - $calc = $this->calculateFromEnteredAt($attendance->entered_at); + $calc = $this->calculateFromEnteredAt($attendance->entered_at, $attendance->user_identity); $attendance->late_minutes = $calc['late_minutes']; $attendance->daily_fine_minutes = $calc['daily_fine_minutes']; $attendance->daily_fine_amount = $calc['daily_fine_amount']; diff --git a/database/migrations/2026_07_26_000001_create_leave_requests_table.php b/database/migrations/2026_07_26_000001_create_leave_requests_table.php new file mode 100644 index 00000000..a7b2ca79 --- /dev/null +++ b/database/migrations/2026_07_26_000001_create_leave_requests_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('user_identity'); + $table->string('type'); // daily | hourly + $table->date('date_from'); + $table->date('date_to'); + $table->time('time_from')->nullable(); + $table->time('time_to')->nullable(); + $table->text('reason')->nullable(); + $table->string('status')->default('pending'); // pending | approved | rejected + $table->unsignedBigInteger('reviewed_by')->nullable(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_identity', 'status']); + $table->index(['date_from', 'date_to']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_requests'); + } +}; diff --git a/resources/views/admin/admin.blade.php b/resources/views/admin/admin.blade.php index b42e73af..55fc80a7 100755 --- a/resources/views/admin/admin.blade.php +++ b/resources/views/admin/admin.blade.php @@ -11,6 +11,62 @@ $usersChunked = $users->chunk(3); @section('content')
درخواستهای مرخصی
+| کارمند | +نوع | +بازه | +وضعیت | +
|---|---|---|---|
| {{ $ownerName }} | +{{ $leave->type === 'daily' ? 'روزانه' : 'ساعتی' }} | +{!! $range !!} | +
+ @if($leave->reason)
+ {{ $leave->reason }} + @endif + + + |
+
درخواست ها
diff --git a/resources/views/attendance/index.blade.php b/resources/views/attendance/index.blade.php index ab34eb8e..52565e53 100755 --- a/resources/views/attendance/index.blade.php +++ b/resources/views/attendance/index.blade.php @@ -82,19 +82,25 @@ if (!empty($user->tasks)) { -