Changes to holidays and the overlap between this year and the previous year
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Morilog\Jalali\Jalalian;
|
||||
|
||||
class RemindHolidayCalendarUpdate extends Command
|
||||
{
|
||||
protected $signature = 'holidays:remind';
|
||||
|
||||
protected $description = 'یادآوری ثبت تعطیلات رسمی سالهای بعد از اتمام پوشش تقویم پروژه';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$coveredUntil = SeedIranianHolidays::coveredUntilYear();
|
||||
if ($coveredUntil < 1300) {
|
||||
$this->warn('فایل تعطیلات خالی یا نامعتبر است.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = Jalalian::now();
|
||||
$currentYear = $now->getYear();
|
||||
|
||||
// از ۲۰ اسفند سال پوششدادهشده، یا هر زمان بعد از آن سال
|
||||
$shouldRemind = $currentYear > $coveredUntil
|
||||
|| ($currentYear === $coveredUntil && $now->getMonth() === 12 && $now->getDay() >= 20);
|
||||
|
||||
if (!$shouldRemind) {
|
||||
$this->info("هنوز نیازی به یادآوری نیست (پوشش تا {$coveredUntil}، الان {$currentYear}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$yearsToRequest = [];
|
||||
for ($y = $coveredUntil + 1; $y <= max($currentYear + 1, $coveredUntil + 1); $y++) {
|
||||
$yearsToRequest[] = $y;
|
||||
}
|
||||
$yearsToRequest = array_values(array_unique($yearsToRequest));
|
||||
$yearsLabel = implode(' و ', $yearsToRequest);
|
||||
|
||||
$cacheKey = 'holidays_remind_' . implode('_', $yearsToRequest);
|
||||
if (Cache::has($cacheKey)) {
|
||||
$this->info('یادآوری اخیراً ارسال شده؛ این ماه دوباره ارسال نمیشود.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$message = "📅 یادآوری تعطیلات رسمی\n\n"
|
||||
. "پوشش تعطیلات در سیستم تا سال {$coveredUntil} است.\n"
|
||||
. "لطفاً تعطیلات رسمی سال {$yearsLabel} را طبق تقویم رسمی ایران به فایل\n"
|
||||
. "database/data/iranian_official_holidays.php\n"
|
||||
. "اضافه کنید و سپس اجرا کنید:\n"
|
||||
. "php artisan holidays:seed";
|
||||
|
||||
$this->notifyAdmins($message);
|
||||
Cache::put($cacheKey, true, now()->addDays(30));
|
||||
|
||||
$this->info("یادآوری برای سال(های) {$yearsLabel} به ادمینها ارسال شد.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function notifyAdmins(string $message): void
|
||||
{
|
||||
$admins = User::query()
|
||||
->whereNotNull('is_admin')
|
||||
->whereNotNull('bale')
|
||||
->select('bale')
|
||||
->get();
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
try {
|
||||
Http::withoutVerifying()->post(
|
||||
'https://tapi.bale.ai/1482838710:sYbLzby0tuLRzHvP4d3LsbcNwg2sjSDLTAU/sendMessage',
|
||||
[
|
||||
'chat_id' => $admin->bale,
|
||||
'text' => $message,
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holiday;
|
||||
use Illuminate\Console\Command;
|
||||
use Morilog\Jalali\Jalalian;
|
||||
|
||||
class SeedIranianHolidays extends Command
|
||||
{
|
||||
protected $signature = 'holidays:seed {--year= : فقط یک سال شمسی مشخص}';
|
||||
|
||||
protected $description = 'ثبت/بهروزرسانی تعطیلات رسمی ایران از فایل تقویم پروژه';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = database_path('data/iranian_official_holidays.php');
|
||||
if (!is_file($path)) {
|
||||
$this->error('فایل تعطیلات یافت نشد: ' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/** @var array<int, list<array{month:int, day:int, title:string}>> $years */
|
||||
$years = require $path;
|
||||
$onlyYear = $this->option('year') !== null ? (int) $this->option('year') : null;
|
||||
|
||||
$count = 0;
|
||||
foreach ($years as $jalaliYear => $days) {
|
||||
if ($onlyYear !== null && (int) $jalaliYear !== $onlyYear) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($days as $day) {
|
||||
try {
|
||||
$gregorian = (new Jalalian((int) $jalaliYear, (int) $day['month'], (int) $day['day']))
|
||||
->toCarbon()
|
||||
->format('Y-m-d');
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("رد شد: {$jalaliYear}/{$day['month']}/{$day['day']} — {$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
Holiday::updateOrCreate(
|
||||
['date' => $gregorian],
|
||||
['title' => $day['title']]
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("سال {$jalaliYear}: " . count($days) . ' روز');
|
||||
}
|
||||
|
||||
$this->info("جمع: {$count} تعطیل رسمی ثبت/بهروز شد.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** آخرین سال شمسی پوششدادهشده در فایل داده */
|
||||
public static function coveredUntilYear(): int
|
||||
{
|
||||
$path = database_path('data/iranian_official_holidays.php');
|
||||
if (!is_file($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$years = array_keys(require $path);
|
||||
|
||||
return $years ? (int) max($years) : 0;
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,17 @@ class AdminController extends Controller
|
||||
foreach ($pendingForUser->values() as $index => $attendance) {
|
||||
$raw = $rawValues[$index] ?? null;
|
||||
if (!$raw) {
|
||||
// Redis موجود است ولی کلید نیست → درخواست قبلاً تایید/رد شده؛ پرچم کهنه را پاک کن
|
||||
$attendance->requested = null;
|
||||
$attendance->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw);
|
||||
if (($decoded->function ?? null) === 'rejected') {
|
||||
Redis::del("att-tempreq:{$user->user_name}:{$attendance->id}");
|
||||
$attendance->requested = null;
|
||||
$attendance->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,12 +230,15 @@ class AdminController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pendingForUser as $attendance) {
|
||||
if (isset($loadedIds[(string) $attendance->id])) {
|
||||
continue;
|
||||
}
|
||||
// فقط وقتی Redis در دسترس نیست از fallback استفاده کن
|
||||
if (!$redisAvailable) {
|
||||
foreach ($pendingForUser as $attendance) {
|
||||
if (isset($loadedIds[(string) $attendance->id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rec['attendances'][] = $this->buildFallbackRequestFromAttendance($attendance);
|
||||
$rec['attendances'][] = $this->buildFallbackRequestFromAttendance($attendance);
|
||||
}
|
||||
}
|
||||
|
||||
$pendingById = $pendingForUser->keyBy('id');
|
||||
@@ -239,6 +253,46 @@ class AdminController extends Controller
|
||||
return $records;
|
||||
}
|
||||
|
||||
private function removeCreateRequest(string $userid, object $data): void
|
||||
{
|
||||
$key = "att-tempreq:creates:{$userid}";
|
||||
try {
|
||||
$items = Redis::lrange($key, 0, -1);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$decoded = json_decode($item);
|
||||
if (!$decoded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sameUser = ($decoded->userid ?? null) === ($data->userid ?? null);
|
||||
$sameEnter = (string) ($decoded->oldenter ?? '') === (string) ($data->oldenter ?? '');
|
||||
$sameExit = (string) ($decoded->oldexit ?? '') === (string) ($data->oldexit ?? '');
|
||||
|
||||
if ($sameUser && $sameEnter && $sameExit) {
|
||||
Redis::lrem($key, 1, $item);
|
||||
}
|
||||
}
|
||||
|
||||
Redis::del("att-tempreq:{$userid}:new");
|
||||
}
|
||||
|
||||
private function clearAttendanceRequestFlag($attendanceId): void
|
||||
{
|
||||
if (!$attendanceId || $attendanceId === 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
$att = Attendance::find($attendanceId);
|
||||
if ($att) {
|
||||
$att->requested = null;
|
||||
$att->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function enrichUsersForDashboard($users): void
|
||||
{
|
||||
$userNames = $users->pluck('user_name');
|
||||
@@ -579,7 +633,7 @@ class AdminController extends Controller
|
||||
}
|
||||
|
||||
$record->duration = $duration ?? $record->duration;
|
||||
$record->requested = '';
|
||||
$record->requested = null;
|
||||
$record->save();
|
||||
app(LatePenaltyService::class)->syncAttendance($record);
|
||||
|
||||
@@ -617,8 +671,7 @@ class AdminController extends Controller
|
||||
: null;
|
||||
$attendance->save();
|
||||
app(LatePenaltyService::class)->syncAttendance($attendance);
|
||||
$create_requests_key = "att-tempreq:creates:{$data->userid}";
|
||||
Redis::connection()->lrem($create_requests_key, 1, json_encode($data));
|
||||
$this->removeCreateRequest($data->userid, $data);
|
||||
$this->decrementUserRequestCount($data->userid);
|
||||
|
||||
tel_notif($data->userid, "تایید");
|
||||
@@ -633,18 +686,11 @@ class AdminController extends Controller
|
||||
if ($request->update == 'reject') {
|
||||
if ($data->function == 'delete' || $data->function == 'edit') {
|
||||
Redis::connection()->del("att-tempreq:{$data->userid}:{$data->id}");
|
||||
$this->clearAttendanceRequestFlag($data->id ?? null);
|
||||
} elseif ($data->function == 'new') {
|
||||
$create_requests_key = "att-tempreq:creates:{$data->userid}";
|
||||
Redis::connection()->lrem($create_requests_key, 1, json_encode($data));
|
||||
$this->removeCreateRequest($data->userid, $data);
|
||||
}
|
||||
|
||||
// dd($data->id);
|
||||
if ($data->id !== "new") {
|
||||
$att = Attendance::find($data->id);
|
||||
// dd($att);
|
||||
$att->requested = null;
|
||||
$att->save();
|
||||
}
|
||||
tel_notif($data->userid, "رد");
|
||||
$this->decrementUserRequestCount($data->userid);
|
||||
return redirect()->back()->with('message', [
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Holiday extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'title',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
];
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -111,16 +111,17 @@ class User extends Authenticatable
|
||||
if (!$date) {
|
||||
continue;
|
||||
}
|
||||
$month = $date->format('%B');
|
||||
$key = sprintf('%04d-%02d', $date->getYear(), $date->getMonth());
|
||||
|
||||
if (!isset($result[$month])) {
|
||||
$result[$month] = [];
|
||||
if (!isset($result[$key])) {
|
||||
$result[$key] = [];
|
||||
}
|
||||
|
||||
$result[$month][] = $att;
|
||||
$result[$key][] = $att;
|
||||
}
|
||||
}
|
||||
|
||||
krsort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Holiday;
|
||||
use App\Models\LatePeriodFine;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\User;
|
||||
@@ -18,6 +19,22 @@ class LatePenaltyService
|
||||
public const WEEKLY_ALLOWANCE_MINUTES = 15;
|
||||
public const FINE_PER_MINUTE = 2000;
|
||||
|
||||
/** جمعه و تعطیلات رسمی روز کاری نیستند؛ ورود در این روزها اضافهکاری است. */
|
||||
public function isWorkDay(Carbon $date): bool
|
||||
{
|
||||
if ($date->isFriday()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !Holiday::query()
|
||||
->whereDate('date', $date->toDateString())
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public function calculateFromEnteredAt(?string $enteredAt, ?string $userIdentity = null): array
|
||||
{
|
||||
if (!$enteredAt) {
|
||||
@@ -31,6 +48,17 @@ class LatePenaltyService
|
||||
|
||||
// فقط تا دقیقه؛ ثانیه نادیده گرفته میشود
|
||||
$entered = Carbon::parse($enteredAt)->startOfMinute();
|
||||
|
||||
// جمعه / تعطیل → بدون تأخیر و جریمه
|
||||
if (!$this->isWorkDay($entered)) {
|
||||
return [
|
||||
'late_minutes' => 0,
|
||||
'grace_late_minutes' => 0,
|
||||
'daily_fine_minutes' => 0,
|
||||
'daily_fine_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$threshold = $entered->copy()->setTimeFromTimeString(self::WORK_START)->startOfMinute();
|
||||
|
||||
if ($userIdentity) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* تعطیلات رسمی ایران بر اساس تقویم رسمی (مناسبتهای ملی و مذهبی).
|
||||
* جمعهها جداگانه در LatePenaltyService محاسبه میشوند و اینجا نیستند.
|
||||
*
|
||||
* منابع تقریبی: تقویم رسمی / فهرستهای منتشرشده برای ۱۴۰۵ و ۱۴۰۶.
|
||||
* تاریخهای قمری ممکن است با اعلام رؤیت هلال یک روز جابهجا شوند.
|
||||
*
|
||||
* @return array<int, list<array{month:int, day:int, title:string}>>
|
||||
*/
|
||||
return [
|
||||
1405 => [
|
||||
['month' => 1, 'day' => 1, 'title' => 'آغاز نوروز / عید سعید فطر'],
|
||||
['month' => 1, 'day' => 2, 'title' => 'عید نوروز / تعطیل عید فطر'],
|
||||
['month' => 1, 'day' => 3, 'title' => 'عید نوروز'],
|
||||
['month' => 1, 'day' => 4, 'title' => 'عید نوروز'],
|
||||
['month' => 1, 'day' => 12, 'title' => 'روز جمهوری اسلامی ایران'],
|
||||
['month' => 1, 'day' => 13, 'title' => 'روز طبیعت (سیزدهبدر)'],
|
||||
['month' => 1, 'day' => 24, 'title' => 'شهادت امام جعفر صادق (ع)'],
|
||||
|
||||
['month' => 3, 'day' => 3, 'title' => 'شهادت امام محمد باقر (ع)'],
|
||||
['month' => 3, 'day' => 6, 'title' => 'عید سعید قربان'],
|
||||
['month' => 3, 'day' => 14, 'title' => 'عید سعید غدیر خم / رحلت امام خمینی (ره)'],
|
||||
['month' => 3, 'day' => 15, 'title' => 'قیام ۱۵ خرداد'],
|
||||
|
||||
['month' => 4, 'day' => 3, 'title' => 'تاسوعای حسینی'],
|
||||
['month' => 4, 'day' => 4, 'title' => 'عاشورای حسینی'],
|
||||
|
||||
['month' => 5, 'day' => 13, 'title' => 'اربعین حسینی'],
|
||||
['month' => 5, 'day' => 21, 'title' => 'رحلت رسول اکرم (ص) و شهادت امام حسن مجتبی (ع)'],
|
||||
['month' => 5, 'day' => 22, 'title' => 'شهادت امام رضا (ع)'],
|
||||
['month' => 5, 'day' => 30, 'title' => 'شهادت امام حسن عسکری (ع)'],
|
||||
|
||||
['month' => 6, 'day' => 8, 'title' => 'میلاد رسول اکرم (ص) و امام جعفر صادق (ع)'],
|
||||
|
||||
['month' => 8, 'day' => 22, 'title' => 'شهادت حضرت فاطمه زهرا (س)'],
|
||||
|
||||
['month' => 10, 'day' => 2, 'title' => 'ولادت امام علی (ع) و روز پدر'],
|
||||
['month' => 10, 'day' => 16, 'title' => 'مبعث حضرت رسول اکرم (ص)'],
|
||||
|
||||
['month' => 11, 'day' => 4, 'title' => 'ولادت حضرت قائم (عج) / نیمه شعبان'],
|
||||
['month' => 11, 'day' => 22, 'title' => 'پیروزی انقلاب اسلامی ایران'],
|
||||
|
||||
['month' => 12, 'day' => 9, 'title' => 'شهادت امام علی (ع)'],
|
||||
['month' => 12, 'day' => 19, 'title' => 'عید سعید فطر'],
|
||||
['month' => 12, 'day' => 20, 'title' => 'تعطیل به مناسبت عید فطر'],
|
||||
['month' => 12, 'day' => 29, 'title' => 'روز ملی شدن صنعت نفت ایران'],
|
||||
],
|
||||
|
||||
1406 => [
|
||||
['month' => 1, 'day' => 1, 'title' => 'آغاز نوروز'],
|
||||
['month' => 1, 'day' => 2, 'title' => 'عید نوروز'],
|
||||
['month' => 1, 'day' => 3, 'title' => 'عید نوروز'],
|
||||
['month' => 1, 'day' => 4, 'title' => 'عید نوروز'],
|
||||
['month' => 1, 'day' => 12, 'title' => 'روز جمهوری اسلامی ایران'],
|
||||
['month' => 1, 'day' => 13, 'title' => 'روز طبیعت (سیزدهبدر)'],
|
||||
['month' => 1, 'day' => 14, 'title' => 'شهادت امام جعفر صادق (ع)'],
|
||||
|
||||
['month' => 2, 'day' => 24, 'title' => 'شهادت امام محمد باقر (ع)'],
|
||||
['month' => 2, 'day' => 27, 'title' => 'عید سعید قربان'],
|
||||
|
||||
['month' => 3, 'day' => 4, 'title' => 'عید سعید غدیر خم'],
|
||||
['month' => 3, 'day' => 14, 'title' => 'رحلت امام خمینی (ره)'],
|
||||
['month' => 3, 'day' => 15, 'title' => 'قیام ۱۵ خرداد'],
|
||||
['month' => 3, 'day' => 24, 'title' => 'تاسوعای حسینی'],
|
||||
['month' => 3, 'day' => 25, 'title' => 'عاشورای حسینی'],
|
||||
|
||||
['month' => 5, 'day' => 3, 'title' => 'اربعین حسینی'],
|
||||
['month' => 5, 'day' => 11, 'title' => 'رحلت رسول اکرم (ص) و شهادت امام حسن مجتبی (ع)'],
|
||||
['month' => 5, 'day' => 12, 'title' => 'شهادت امام رضا (ع)'],
|
||||
['month' => 5, 'day' => 20, 'title' => 'شهادت امام حسن عسکری (ع)'],
|
||||
['month' => 5, 'day' => 29, 'title' => 'میلاد رسول اکرم (ص) و امام جعفر صادق (ع)'],
|
||||
|
||||
['month' => 8, 'day' => 12, 'title' => 'شهادت حضرت فاطمه زهرا (س)'],
|
||||
|
||||
['month' => 9, 'day' => 21, 'title' => 'ولادت امام علی (ع) و روز پدر'],
|
||||
|
||||
['month' => 10, 'day' => 5, 'title' => 'مبعث حضرت رسول اکرم (ص)'],
|
||||
['month' => 10, 'day' => 23, 'title' => 'ولادت حضرت قائم (عج) / نیمه شعبان'],
|
||||
|
||||
['month' => 11, 'day' => 22, 'title' => 'پیروزی انقلاب اسلامی ایران'],
|
||||
['month' => 11, 'day' => 28, 'title' => 'شهادت امام علی (ع)'],
|
||||
|
||||
['month' => 12, 'day' => 8, 'title' => 'عید سعید فطر'],
|
||||
['month' => 12, 'day' => 9, 'title' => 'تعطیل به مناسبت عید فطر'],
|
||||
['month' => 12, 'day' => 29, 'title' => 'روز ملی شدن صنعت نفت ایران'],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('holidays', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->date('date')->unique();
|
||||
$table->string('title')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('holidays');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('holidays')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Artisan::call('holidays:seed');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// تعطیلات seedشده را عمداً حذف نمیکنیم
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class HolidaySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
Artisan::call('holidays:seed');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
use Morilog\Jalali\Jalalian;
|
||||
use Carbon\Carbon;
|
||||
|
||||
$previousMonthName = Jalalian::now()->subMonths(1)->format('%B');
|
||||
$previous = Jalalian::now()->subMonths(1);
|
||||
$previousMonthKey = sprintf('%04d-%02d', $previous->getYear(), $previous->getMonth());
|
||||
$previousMonthName = $previous->format('%B');
|
||||
$currentJalaliYear = Jalalian::now()->getYear();
|
||||
|
||||
$employeeTotals = [];
|
||||
|
||||
@@ -267,7 +270,7 @@ foreach ($data as $employee) {
|
||||
<h2>{{ $employee->name }}</h2>
|
||||
<div class="employee-stat">
|
||||
<span>کارکرد ماه قبل ({{ $previousMonthName }}):</span>
|
||||
<strong>{{ $employeeTotals[$employee->id][$previousMonthName] ?? '00:00' }}</strong>
|
||||
<strong>{{ $employeeTotals[$employee->id][$previousMonthKey] ?? '00:00' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,10 +285,23 @@ foreach ($data as $employee) {
|
||||
@else
|
||||
<div class="month-tabs">
|
||||
@foreach(array_keys($recordsGrouped) as $month)
|
||||
@php
|
||||
$monthLabel = $month;
|
||||
if (preg_match('/^(\d{4})-(\d{2})$/', (string) $month, $mm)) {
|
||||
try {
|
||||
$jj = new Jalalian((int) $mm[1], (int) $mm[2], 1);
|
||||
$monthLabel = ((int) $mm[1] === (int) $currentJalaliYear)
|
||||
? $jj->format('%B')
|
||||
: $jj->format('%B') . ' ' . $mm[1];
|
||||
} catch (\Throwable $e) {
|
||||
$monthLabel = $month;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<button type="button"
|
||||
class="month-tab tablinks-{{ $employee->id }}"
|
||||
onclick="openMonth(event, 'month_{{ $employee->id }}_{{ $month }}', '{{ $employee->id }}')">
|
||||
{{ $month }}
|
||||
{{ $monthLabel }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -307,7 +323,7 @@ foreach ($data as $employee) {
|
||||
<tbody>
|
||||
@foreach($records as $row)
|
||||
<tr>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, 'm/d') }}</td>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, 'Y/m/d') }}</td>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, '%A') }}</td>
|
||||
<td>{{ Carbon::parse($row->entered_at)->format('H:i') ?? '-' }}</td>
|
||||
<td>{{ $row->exited_at ? Carbon::parse($row->exited_at)->format('H:i') : '-' }}</td>
|
||||
|
||||
@@ -947,6 +947,39 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-month-more {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-month-more-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 9rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-panel, #fff);
|
||||
border: 1px solid var(--border-glass, #e5e7eb);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.ui-month-more-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-month-more-item {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ui-month-tab,
|
||||
.tablinks {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -155,18 +155,59 @@ if (!empty($user->tasks)) {
|
||||
<div class="w-[350px]">
|
||||
|
||||
@if(isset($_COOKIE['user_identity']))
|
||||
{{-- قسمت پردازش دادهها: گروهبندی لیست حضور و غیاب بر اساس نام ماه شمسی --}}
|
||||
{{-- قسمت پردازش دادهها: گروهبندی لیست حضور و غیاب بر اساس ماه+سال شمسی --}}
|
||||
@php
|
||||
// بررسی وجود داده
|
||||
$attendances = $user->attendances ?? collect();
|
||||
$currentJalaliYear = \Morilog\Jalali\Jalalian::now()->getYear();
|
||||
|
||||
// گروهبندی دادهها بر اساس نام ماه شمسی
|
||||
$groupedData = $attendances
|
||||
->filter(fn ($item) => !empty($item->entered_at))
|
||||
->groupBy(function ($item) {
|
||||
$month = \App\Support\JalaliHelper::format($item->entered_at, '%B', '');
|
||||
return $month !== '' ? $month : 'نامعتبر';
|
||||
});
|
||||
$j = \App\Support\JalaliHelper::fromDateTime($item->entered_at);
|
||||
if (!$j) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d', $j->getYear(), $j->getMonth());
|
||||
})
|
||||
->reject(fn ($_, $key) => $key === 'invalid')
|
||||
->sortKeysDesc();
|
||||
|
||||
$monthLabels = [];
|
||||
foreach ($groupedData->keys() as $monthKey) {
|
||||
[$y, $m] = array_map('intval', explode('-', $monthKey));
|
||||
try {
|
||||
$j = new \Morilog\Jalali\Jalalian($y, $m, 1);
|
||||
$name = $j->format('%B');
|
||||
$monthLabels[$monthKey] = ((int) $y === (int) $currentJalaliYear)
|
||||
? $name
|
||||
: $name . ' ' . $y;
|
||||
} catch (\Throwable $e) {
|
||||
$monthLabels[$monthKey] = $monthKey;
|
||||
}
|
||||
}
|
||||
|
||||
$recentMonthKeys = $groupedData->keys()->take(3);
|
||||
$olderMonthKeys = $groupedData->keys()->slice(3);
|
||||
|
||||
$attendanceDates = $attendances
|
||||
->map(fn ($a) => $a->entered_at ? \Carbon\Carbon::parse($a->entered_at)->toDateString() : null)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$holidayDates = collect();
|
||||
try {
|
||||
$holidayDates = $attendanceDates->isEmpty()
|
||||
? collect()
|
||||
: \App\Models\Holiday::query()
|
||||
->whereIn('date', $attendanceDates)
|
||||
->pluck('date')
|
||||
->map(fn ($d) => \Carbon\Carbon::parse($d)->toDateString())
|
||||
->flip();
|
||||
} catch (\Throwable $e) {
|
||||
$holidayDates = collect();
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="container inline-flex flex-col items-center justify-center">
|
||||
@@ -175,18 +216,36 @@ if (!empty($user->tasks)) {
|
||||
<h1>اطلاعاتی موجود نمیباشد</h1>
|
||||
</div>
|
||||
@else
|
||||
{{-- دکمههای تب (نام ماهها) --}}
|
||||
{{-- سه ماه اخیر + منوی بقیه --}}
|
||||
<div class="ui-month-tabs">
|
||||
@foreach($groupedData->keys() as $month)
|
||||
@foreach($recentMonthKeys as $monthKey)
|
||||
<button type="button" class="tablinks"
|
||||
onclick="openCity(event, '{{$month}}')">
|
||||
{{$month}}
|
||||
data-month-id="m-{{ $monthKey }}"
|
||||
onclick="openCity(event, 'm-{{ $monthKey }}')">
|
||||
{{ $monthLabels[$monthKey] ?? $monthKey }}
|
||||
</button>
|
||||
@endforeach
|
||||
|
||||
@if($olderMonthKeys->isNotEmpty())
|
||||
<div class="ui-month-more">
|
||||
<button type="button" class="tablinks ui-month-more-btn"
|
||||
onclick="toggleMonthMore(event)"
|
||||
title="ماههای دیگر">⋯</button>
|
||||
<div id="month-more-menu" class="ui-month-more-menu hidden">
|
||||
@foreach($olderMonthKeys as $monthKey)
|
||||
<button type="button" class="tablinks ui-month-more-item"
|
||||
data-month-id="m-{{ $monthKey }}"
|
||||
onclick="openCity(event, 'm-{{ $monthKey }}'); closeMonthMore();">
|
||||
{{ $monthLabels[$monthKey] ?? $monthKey }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@foreach($groupedData as $month => $records)
|
||||
<div id="{{$month}}" class="tabcontent ui-attendance-card">
|
||||
@foreach($groupedData as $monthKey => $records)
|
||||
<div id="m-{{ $monthKey }}" class="tabcontent ui-attendance-card">
|
||||
<table class="ui-attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -205,11 +264,11 @@ if (!empty($user->tasks)) {
|
||||
@php
|
||||
$monthLateMinutes = 0;
|
||||
$monthDailyFine = 0;
|
||||
$monthKey = null;
|
||||
$monthKeyCarbon = null;
|
||||
$firstEntered = $records->first()?->entered_at;
|
||||
if ($firstEntered && \App\Support\JalaliHelper::fromDateTime($firstEntered)) {
|
||||
$monthKey = \Carbon\Carbon::parse($firstEntered);
|
||||
[$mStart, $mEnd] = app(\App\Services\LatePenaltyService::class)->jalaliMonthBounds($monthKey);
|
||||
$monthKeyCarbon = \Carbon\Carbon::parse($firstEntered);
|
||||
[$mStart, $mEnd] = app(\App\Services\LatePenaltyService::class)->jalaliMonthBounds($monthKeyCarbon);
|
||||
$monthWeeklyFine = (int) \App\Models\LatePeriodFine::query()
|
||||
->where('user_identity', $user->user_name)
|
||||
->where('period_type', 'weekly')
|
||||
@@ -223,6 +282,11 @@ if (!empty($user->tasks)) {
|
||||
@php
|
||||
$monthLateMinutes += (int) ($item->late_minutes ?? 0);
|
||||
$monthDailyFine += (int) ($item->daily_fine_amount ?? 0);
|
||||
$enteredCarbon = $item->entered_at ? \Carbon\Carbon::parse($item->entered_at) : null;
|
||||
$isNonWorkDay = $enteredCarbon && (
|
||||
$enteredCarbon->isFriday()
|
||||
|| isset($holidayDates[$enteredCarbon->toDateString()])
|
||||
);
|
||||
@endphp
|
||||
<tr class="hover:bg-cyan-500 cursor-pointer transition duration-150"
|
||||
style="border-bottom: 1px solid #ddd; background-color: {{$item->requested ? '#bfdbfe' : '#ffffff' }}"
|
||||
@@ -237,9 +301,12 @@ if (!empty($user->tasks)) {
|
||||
|
||||
{{-- تاریخ --}}
|
||||
<td style="border-right: 1px solid #ddd; padding: 4px; background-color: {{"#e6e6e6" }};">
|
||||
{{ \App\Support\JalaliHelper::format($item->entered_at, 'm/d') }}
|
||||
{{ \App\Support\JalaliHelper::format($item->entered_at, 'Y/m/d') }}
|
||||
<span class="text-xs text-gray-500 block">
|
||||
{{ \App\Support\JalaliHelper::format($item->entered_at, '%A', '') }}
|
||||
@if($isNonWorkDay)
|
||||
· اضافهکاری
|
||||
@endif
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -260,7 +327,9 @@ if (!empty($user->tasks)) {
|
||||
|
||||
{{-- تأخیر --}}
|
||||
<td style="border-right: 1px solid #ddd; padding: 4px; {{ ($item->daily_fine_minutes ?? 0) > 0 ? 'color:#dc2626;font-weight:600;' : (($item->late_minutes ?? 0) > 0 ? 'color:#d97706;' : '') }}">
|
||||
@if(($item->late_minutes ?? 0) > 0)
|
||||
@if($isNonWorkDay)
|
||||
<span class="text-[10px] text-emerald-600">اضافهکاری</span>
|
||||
@elseif(($item->late_minutes ?? 0) > 0)
|
||||
{{ $item->late_minutes }}د
|
||||
@if(($item->daily_fine_amount ?? 0) > 0)
|
||||
<span class="text-[10px] block">{{ number_format($item->daily_fine_amount) }}ت</span>
|
||||
@@ -707,30 +776,52 @@ if (!empty($user->tasks)) {
|
||||
|
||||
function openCity(evt, Month) {
|
||||
var i, tabcontent, tablinks;
|
||||
// مخفی کردن تمام محتواها
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
|
||||
// غیرفعال کردن تمام تبها
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active bg-gray-300 font-bold", "");
|
||||
// اضافه کردن استایل دیفالت اگر کلاس اکتیو حذف شد
|
||||
if (!tablinks[i].className.includes("active")) {
|
||||
tablinks[i].style.backgroundColor = "";
|
||||
}
|
||||
}
|
||||
|
||||
// نمایش تب انتخاب شده
|
||||
document.getElementById(Month).style.display = "block";
|
||||
evt.currentTarget.className += " active bg-gray-300 font-bold";
|
||||
const panel = document.getElementById(Month);
|
||||
if (panel) {
|
||||
panel.style.display = "block";
|
||||
}
|
||||
if (evt && evt.currentTarget) {
|
||||
evt.currentTarget.className += " active bg-gray-300 font-bold";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMonthMore(evt) {
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
}
|
||||
const menu = document.getElementById('month-more-menu');
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeMonthMore() {
|
||||
const menu = document.getElementById('month-more-menu');
|
||||
if (menu) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function () {
|
||||
closeMonthMore();
|
||||
});
|
||||
|
||||
// باز کردن اولین تب به صورت خودکار هنگام لود صفحه
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let firstTab = document.querySelector(".tablinks");
|
||||
let firstTab = document.querySelector(".ui-month-tabs > .tablinks");
|
||||
if (firstTab) {
|
||||
firstTab.click();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ use Morilog\Jalali\Jalalian;
|
||||
use Carbon\Carbon;
|
||||
|
||||
$monthlyTotals = [];
|
||||
$currentJalaliYear = Jalalian::now()->getYear();
|
||||
$monthLabels = [];
|
||||
|
||||
foreach ($data as $month => $attendances) {
|
||||
foreach ($data as $monthKey => $attendances) {
|
||||
$tsec = 0;
|
||||
|
||||
foreach ($attendances as $attendance) {
|
||||
@@ -18,9 +20,27 @@ foreach ($data as $month => $attendances) {
|
||||
$hours = floor($tsec / 3600);
|
||||
$minutes = floor(($tsec % 3600) / 60);
|
||||
|
||||
$monthlyTotals[$month] = sprintf('%02d:%02d', $hours, $minutes);
|
||||
$monthlyTotals[$monthKey] = sprintf('%02d:%02d', $hours, $minutes);
|
||||
|
||||
if (preg_match('/^(\d{4})-(\d{2})$/', (string) $monthKey, $m)) {
|
||||
try {
|
||||
$j = new Jalalian((int) $m[1], (int) $m[2], 1);
|
||||
$name = $j->format('%B');
|
||||
$monthLabels[$monthKey] = ((int) $m[1] === (int) $currentJalaliYear)
|
||||
? $name
|
||||
: $name . ' ' . $m[1];
|
||||
} catch (\Throwable $e) {
|
||||
$monthLabels[$monthKey] = $monthKey;
|
||||
}
|
||||
} else {
|
||||
$monthLabels[$monthKey] = $monthKey;
|
||||
}
|
||||
}
|
||||
|
||||
$monthKeys = array_keys($data);
|
||||
$recentMonthKeys = array_slice($monthKeys, 0, 3);
|
||||
$olderMonthKeys = array_slice($monthKeys, 3);
|
||||
|
||||
?>
|
||||
@extends('app')
|
||||
|
||||
@@ -32,13 +52,27 @@ foreach ($data as $month => $attendances) {
|
||||
</div>
|
||||
@else
|
||||
<div class="ui-month-tabs" style="margin-top: 5rem;">
|
||||
@foreach(array_keys($data) as $month)
|
||||
<button type="button" class="tablinks" onclick="openCity(event, '{{ $month }}')">{{ $month }}</button>
|
||||
@foreach($recentMonthKeys as $monthKey)
|
||||
<button type="button" class="tablinks"
|
||||
onclick="openCity(event, 'm-{{ $monthKey }}')">{{ $monthLabels[$monthKey] ?? $monthKey }}</button>
|
||||
@endforeach
|
||||
@if(!empty($olderMonthKeys))
|
||||
<div class="ui-month-more">
|
||||
<button type="button" class="tablinks ui-month-more-btn" onclick="toggleMonthMore(event)">⋯</button>
|
||||
<div id="month-more-menu" class="ui-month-more-menu hidden">
|
||||
@foreach($olderMonthKeys as $monthKey)
|
||||
<button type="button" class="tablinks ui-month-more-item"
|
||||
onclick="openCity(event, 'm-{{ $monthKey }}'); closeMonthMore();">
|
||||
{{ $monthLabels[$monthKey] ?? $monthKey }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@foreach($data as $month => $records)
|
||||
<div id="{{ $month }}" class="tabcontent ui-attendance-card">
|
||||
@foreach($data as $monthKey => $records)
|
||||
<div id="m-{{ $monthKey }}" class="tabcontent ui-attendance-card">
|
||||
<table class="ui-attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -52,7 +86,7 @@ foreach ($data as $month => $attendances) {
|
||||
<tbody>
|
||||
@foreach($records as $row)
|
||||
<tr>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, 'm/d') }}</td>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, 'Y/m/d') }}</td>
|
||||
<td>{{ \App\Support\JalaliHelper::format($row->entered_at, '%A') }}</td>
|
||||
<td>{{ Carbon::parse($row->entered_at)->format('H:i') ?? '-' }}</td>
|
||||
<td>{{ $row->exited_at ? Carbon::parse($row->exited_at)->format('H:i') : '-' }}</td>
|
||||
@@ -61,7 +95,7 @@ foreach ($data as $month => $attendances) {
|
||||
@endforeach
|
||||
<tr>
|
||||
<td colspan="5" class="table-summary">
|
||||
مجموع کارکرد این ماه: {{ $monthlyTotals[$month] }}
|
||||
مجموع کارکرد این ماه: {{ $monthlyTotals[$monthKey] }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -78,13 +112,24 @@ foreach ($data as $month => $attendances) {
|
||||
|
||||
const panel = document.getElementById(Month);
|
||||
if (panel) panel.style.display = 'block';
|
||||
|
||||
if (evt.currentTarget) evt.currentTarget.classList.add('active');
|
||||
if (evt && evt.currentTarget) evt.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const firstTab = document.querySelector('.tablinks');
|
||||
if (firstTab) firstTab.click();
|
||||
function toggleMonthMore(evt) {
|
||||
if (evt) evt.stopPropagation();
|
||||
const menu = document.getElementById('month-more-menu');
|
||||
if (menu) menu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function closeMonthMore() {
|
||||
const menu = document.getElementById('month-more-menu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeMonthMore);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const first = document.querySelector('.ui-month-tabs > .tablinks');
|
||||
if (first) first.click();
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@@ -10,6 +10,9 @@ Artisan::command('inspire', function () {
|
||||
|
||||
Schedule::command('birthdays:notify')->dailyAt('09:00');
|
||||
|
||||
// یادآوری ثبت تعطیلات رسمی سالهای بعد (از پایان پوشش فعلی)
|
||||
Schedule::command('holidays:remind')->dailyAt('09:30');
|
||||
|
||||
// گزارش تأخیر روز قبل — هر روز ساعت ۲۲:۳۰
|
||||
Schedule::command('late:daily-report')->dailyAt('22:30');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user