diff --git a/.claude/settings.json b/.claude/settings.json index 3fec581..1d4dae6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "PowerShell(php artisan tinker --execute=\"`$s = App\\\\Models\\\\SesiTaklimat::create\\(['dun_id'=>1,'tarikh'=>now\\(\\)->toDateString\\(\\),'nama'=>'Taklimat Petugas Sesi 1','lokasi'=>'Dewan Serbaguna Gelang Patah','masa'=>'9:00 pagi - 12:00 tengah hari','created_by_user_id'=>1]\\); echo 'SESI ID: '.`$s->id.PHP_EOL.'TOKEN: '.`$s->qr_token.PHP_EOL; `$p = App\\\\Models\\\\Sppm\\\\PetugasPermohonan::assignedUntukDun\\(1\\)->get\\(['id','nama_ic','no_kp']\\); echo `$p->toJson\\(JSON_PRETTY_PRINT\\);\")", "PowerShell(php artisan serve --port=8000)", "Bash(docker version *)", - "Bash(docker run *)" + "Bash(docker run *)", + "PowerShell(git config --global --get credential.helper)" ], "additionalDirectories": [ "c:\\Users\\User\\.claude\\projects\\c--Users-User-Aplikasi-spr2026-taklimat\\memory" diff --git a/app/Http/Controllers/EksportKehadiranController.php b/app/Http/Controllers/EksportKehadiranController.php new file mode 100644 index 0000000..37de3ba --- /dev/null +++ b/app/Http/Controllers/EksportKehadiranController.php @@ -0,0 +1,173 @@ + 0, + 'KTM' => 1, + 'KP_KMU' => 1, + 'PPAPM_MANUAL' => 2, + 'PPAPM_KOMPUTER' => 2, + 'PPAPM_BERGERAK' => 2, + ]; + + private const PENGEPALA = [ + 'Bil', 'DUN', 'Pusat Mengundi', 'Saluran', 'Jawatan', 'Nama', 'No. KP', 'Tarikh Hadir', + ]; + + /** + * Eksport kehadiran semua petugas merentas semua sesi taklimat yang + * dilihat pengguna, dikumpul kepada satu fail Excel: worksheet "Hadir" + * dan "Tidak Hadir". + */ + public function __invoke(Request $request): Response + { + $user = $request->user(); + + $sesiSemua = SesiTaklimat::with('dun') + ->when($user->isPengurus(), fn ($q) => $q->where('dun_id', $user->dun_id)) + ->get(); + + $hadir = []; + $tidakHadir = []; + + foreach ($sesiSemua->groupBy('dun_id') as $sesiDun) { + $dun = $sesiDun->first()->dun; + $dunLabel = trim(($dun->code ?? '').' '.($dun->nama ?? '')); + $sesiIds = $sesiDun->pluck('id'); + + // Tarikh hadir paling awal bagi setiap petugas merentas sesi DUN ini. + $tarikhHadir = []; + $rekodKehadiran = KehadiranTaklimat::whereIn('sesi_taklimat_id', $sesiIds) + ->orderBy('hadir_at') + ->get(['permohonan_id', 'sesi_taklimat_id']); + + foreach ($rekodKehadiran as $rekod) { + if (! array_key_exists($rekod->permohonan_id, $tarikhHadir)) { + $tarikhHadir[$rekod->permohonan_id] = optional( + $sesiDun->firstWhere('id', $rekod->sesi_taklimat_id)?->tarikh + )->format('d/m/Y') ?? ''; + } + } + + $petugas = PetugasPermohonan::assignedUntukDun((int) $sesiDun->first()->dun_id) + ->with(['assignments' => fn ($q) => $q->orderBy('urutan')->with(['pusatMengundi', 'saluran', 'jawatan'])]) + ->get(); + + foreach ($petugas as $p) { + $baris = $this->barisPetugas($p, $dunLabel); + + if (array_key_exists($p->id, $tarikhHadir)) { + $baris['tarikh_hadir'] = $tarikhHadir[$p->id]; + $hadir[] = $baris; + } else { + $tidakHadir[] = $baris; + } + } + } + + $hadir = $this->susun($hadir); + $tidakHadir = $this->susun($tidakHadir); + + $xlsx = (new SimpleXlsx) + ->addSheet('Hadir', $this->keBaris($hadir)) + ->addSheet('Tidak Hadir', $this->keBaris($tidakHadir)); + + $namaFail = 'Kehadiran-Taklimat-'.now()->format('Ymd-His').'.xlsx'; + + return response($xlsx->toString(), 200, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => 'attachment; filename="'.$namaFail.'"', + ]); + } + + /** + * Hasilkan satu baris data petugas berserta kunci susunan. + * + * @return array + */ + private function barisPetugas(PetugasPermohonan $p, string $dunLabel): array + { + $assignment = $p->assignmentUtama(); + + $pusatNama = $assignment?->pusatMengundi?->nama ?? ''; + $saluranNo = $assignment?->saluran?->nombor_saluran; + $kod = $assignment?->jawatan?->kod ?? ''; + + $kumpulan = self::JAWATAN_KUMPULAN[$kod] ?? 9; + // Dalam kumpulan Saluran, KTM (0) didahulukan sebelum KP (1). + $subJawatan = $kod === 'KTM' ? 0 : 1; + + return [ + 'dun' => $dunLabel, + 'pusat' => $pusatNama, + 'saluran' => $saluranNo !== null ? 'Saluran '.$saluranNo : '', + 'jawatan' => $assignment?->jawatan?->nama ?? '-', + 'nama' => $p->nama_ic ?? '', + 'no_kp' => $p->no_kp ?? '', + 'tarikh_hadir' => '', + '_sort' => [ + $dunLabel, + $pusatNama, + $kumpulan, + $kumpulan === 1 ? (int) ($saluranNo ?? 0) : 0, + $subJawatan, + $p->nama_ic ?? '', + ], + ]; + } + + /** + * Susun mengikut pusat mengundi > PPM > Saluran (KTM > KP) > PPAPM. + * + * @param array> $baris + * @return array> + */ + private function susun(array $baris): array + { + usort($baris, function ($a, $b) { + return $a['_sort'] <=> $b['_sort']; + }); + + return $baris; + } + + /** + * Tukar baris data kepada matriks (pengepala + sel) untuk worksheet. + * + * @param array> $baris + * @return array> + */ + private function keBaris(array $baris): array + { + $rows = [self::PENGEPALA]; + + foreach ($baris as $i => $b) { + $rows[] = [ + $i + 1, + $b['dun'], + $b['pusat'], + $b['saluran'], + $b['jawatan'], + $b['nama'], + $b['no_kp'], + $b['tarikh_hadir'], + ]; + } + + return $rows; + } +} diff --git a/app/Support/SimpleXlsx.php b/app/Support/SimpleXlsx.php new file mode 100644 index 0000000..9e5cdad --- /dev/null +++ b/app/Support/SimpleXlsx.php @@ -0,0 +1,191 @@ +>}> */ + private array $sheets = []; + + /** + * Tambah satu worksheet. Baris pertama dianggap pengepala (huruf tebal). + * + * @param array> $rows + */ + public function addSheet(string $name, array $rows): static + { + $this->sheets[] = ['name' => $this->namaSah($name), 'rows' => $rows]; + + return $this; + } + + /** + * Hasilkan kandungan binari fail .xlsx. + */ + public function toString(): string + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx'); + + $zip = new ZipArchive; + $zip->open($tmp, ZipArchive::OVERWRITE); + + $zip->addFromString('[Content_Types].xml', $this->contentTypes()); + $zip->addFromString('_rels/.rels', $this->rootRels()); + $zip->addFromString('xl/workbook.xml', $this->workbook()); + $zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRels()); + $zip->addFromString('xl/styles.xml', $this->styles()); + + foreach ($this->sheets as $i => $sheet) { + $zip->addFromString('xl/worksheets/sheet'.($i + 1).'.xml', $this->sheetXml($sheet['rows'])); + } + + $zip->close(); + + $data = file_get_contents($tmp); + @unlink($tmp); + + return $data; + } + + private function namaSah(string $name): string + { + $name = preg_replace('/[\\\\\/\*\?\[\]:]/', ' ', $name); + + return mb_substr(trim($name), 0, 31) ?: 'Sheet'; + } + + /** + * Tukar indeks lajur (0-asas) kepada huruf A, B, … AA. + */ + private function col(int $index): string + { + $letters = ''; + $index++; + + while ($index > 0) { + $mod = ($index - 1) % 26; + $letters = chr(65 + $mod).$letters; + $index = intdiv($index - 1, 26); + } + + return $letters; + } + + private function cell(string $ref, string $value, int $style): string + { + $v = htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + $s = $style > 0 ? ' s="'.$style.'"' : ''; + + return ''.$v.''; + } + + /** + * @param array> $rows + */ + private function sheetXml(array $rows): string + { + $xml = '' + .''; + + foreach ($rows as $r => $cells) { + $rowNum = $r + 1; + $style = $r === 0 ? 1 : 0; + $xml .= ''; + + foreach (array_values($cells) as $c => $val) { + $xml .= $this->cell($this->col($c).$rowNum, (string) $val, $style); + } + + $xml .= ''; + } + + return $xml.''; + } + + private function contentTypes(): string + { + $overrides = ''; + + foreach ($this->sheets as $i => $s) { + $overrides .= ''; + } + + return '' + .'' + .'' + .'' + .'' + .'' + .$overrides + .''; + } + + private function rootRels(): string + { + return '' + .'' + .'' + .''; + } + + private function workbook(): string + { + $sheetsXml = ''; + + foreach ($this->sheets as $i => $s) { + $name = htmlspecialchars($s['name'], ENT_QUOTES | ENT_XML1, 'UTF-8'); + $sheetsXml .= ''; + } + + return '' + .'' + .''.$sheetsXml.''; + } + + private function workbookRels(): string + { + $rels = ''; + $count = count($this->sheets); + + foreach ($this->sheets as $i => $s) { + $rels .= ''; + } + + $rels .= ''; + + return '' + .'' + .$rels.''; + } + + private function styles(): string + { + return '' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .'' + .''; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index bd1053c..ea50b25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: <<: *app-common environment: RUN_MIGRATIONS: "true" + TZ: Asia/Kuala_Lumpur + APP_TIMEZONE: Asia/Kuala_Lumpur web: build: @@ -39,6 +41,8 @@ services: target: web image: taklimatspr-web:latest restart: unless-stopped + environment: + TZ: Asia/Kuala_Lumpur depends_on: - app ports: @@ -49,6 +53,8 @@ services: <<: *app-common environment: RUN_MIGRATIONS: "false" + TZ: Asia/Kuala_Lumpur + APP_TIMEZONE: Asia/Kuala_Lumpur command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600 depends_on: - app @@ -57,6 +63,8 @@ services: <<: *app-common environment: RUN_MIGRATIONS: "false" + TZ: Asia/Kuala_Lumpur + APP_TIMEZONE: Asia/Kuala_Lumpur command: php artisan schedule:work depends_on: - app diff --git a/resources/views/sesi/index.blade.php b/resources/views/sesi/index.blade.php index 8a81a9b..690748b 100644 --- a/resources/views/sesi/index.blade.php +++ b/resources/views/sesi/index.blade.php @@ -6,7 +6,10 @@ diff --git a/routes/web.php b/routes/web.php index 931f7e8..a43e44a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\CheckinController; +use App\Http\Controllers\EksportKehadiranController; use App\Http\Controllers\KaunterController; use App\Http\Controllers\PengurusController; use App\Http\Controllers\SesiTaklimatController; @@ -21,6 +22,7 @@ Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); Route::middleware(['auth'])->group(function () { // Pengurusan sesi taklimat (admin & pengurus dun) Route::get('/sesi', [SesiTaklimatController::class, 'index'])->name('sesi.index'); + Route::get('/sesi/eksport-kehadiran', EksportKehadiranController::class)->name('sesi.eksport'); Route::get('/sesi/cipta', [SesiTaklimatController::class, 'create'])->name('sesi.create'); Route::post('/sesi', [SesiTaklimatController::class, 'store'])->name('sesi.store'); Route::get('/sesi/{sesi}', [SesiTaklimatController::class, 'show'])->name('sesi.show');