senarai nama hadir

This commit is contained in:
Saufi
2026-06-30 15:56:23 +08:00
parent 51ed4b59e8
commit f1c7d930bd
6 changed files with 380 additions and 2 deletions

View File

@@ -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 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)", "PowerShell(php artisan serve --port=8000)",
"Bash(docker version *)", "Bash(docker version *)",
"Bash(docker run *)" "Bash(docker run *)",
"PowerShell(git config --global --get credential.helper)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"c:\\Users\\User\\.claude\\projects\\c--Users-User-Aplikasi-spr2026-taklimat\\memory" "c:\\Users\\User\\.claude\\projects\\c--Users-User-Aplikasi-spr2026-taklimat\\memory"

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Models\KehadiranTaklimat;
use App\Models\SesiTaklimat;
use App\Models\Sppm\PetugasPermohonan;
use App\Support\SimpleXlsx;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EksportKehadiranController extends Controller
{
/**
* Susunan jawatan dalam laporan: PPM dahulu, kemudian mengikut Saluran
* (KTM sebelum KP), dan akhir sekali PPAPM.
*/
private const JAWATAN_KUMPULAN = [
'PPM' => 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<string,mixed>
*/
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<int,array<string,mixed>> $baris
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $baris
* @return array<int,array<int,string>>
*/
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;
}
}

191
app/Support/SimpleXlsx.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
namespace App\Support;
use ZipArchive;
/**
* Penjana .xlsx ringan tanpa kebergantungan luar. Setiap nilai sel ditulis
* sebagai "inline string" supaya nilai seperti No. KP kekal sebagai teks
* (mengekalkan sengkang / sifar di hadapan). Menyokong berbilang worksheet.
*/
class SimpleXlsx
{
/** @var array<int,array{name:string,rows:array<int,array<int,scalar|null>>}> */
private array $sheets = [];
/**
* Tambah satu worksheet. Baris pertama dianggap pengepala (huruf tebal).
*
* @param array<int,array<int,scalar|null>> $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 '<c r="'.$ref.'"'.$s.' t="inlineStr"><is><t xml:space="preserve">'.$v.'</t></is></c>';
}
/**
* @param array<int,array<int,scalar|null>> $rows
*/
private function sheetXml(array $rows): string
{
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>';
foreach ($rows as $r => $cells) {
$rowNum = $r + 1;
$style = $r === 0 ? 1 : 0;
$xml .= '<row r="'.$rowNum.'">';
foreach (array_values($cells) as $c => $val) {
$xml .= $this->cell($this->col($c).$rowNum, (string) $val, $style);
}
$xml .= '</row>';
}
return $xml.'</sheetData></worksheet>';
}
private function contentTypes(): string
{
$overrides = '';
foreach ($this->sheets as $i => $s) {
$overrides .= '<Override PartName="/xl/worksheets/sheet'.($i + 1).'.xml" '
.'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
.'<Default Extension="xml" ContentType="application/xml"/>'
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
.'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
.$overrides
.'</Types>';
}
private function rootRels(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
.'</Relationships>';
}
private function workbook(): string
{
$sheetsXml = '';
foreach ($this->sheets as $i => $s) {
$name = htmlspecialchars($s['name'], ENT_QUOTES | ENT_XML1, 'UTF-8');
$sheetsXml .= '<sheet name="'.$name.'" sheetId="'.($i + 1).'" r:id="rId'.($i + 1).'"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
.'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
.'<sheets>'.$sheetsXml.'</sheets></workbook>';
}
private function workbookRels(): string
{
$rels = '';
$count = count($this->sheets);
foreach ($this->sheets as $i => $s) {
$rels .= '<Relationship Id="rId'.($i + 1).'" '
.'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
.'Target="worksheets/sheet'.($i + 1).'.xml"/>';
}
$rels .= '<Relationship Id="rId'.($count + 1).'" '
.'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
.'Target="styles.xml"/>';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.$rels.'</Relationships>';
}
private function styles(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
.'<fonts count="2">'
.'<font><sz val="11"/><name val="Calibri"/></font>'
.'<font><b/><sz val="11"/><name val="Calibri"/></font>'
.'</fonts>'
.'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
.'<borders count="1"><border/></borders>'
.'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
.'<cellXfs count="2">'
.'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
.'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
.'</cellXfs>'
.'</styleSheet>';
}
}

View File

@@ -31,6 +31,8 @@ services:
<<: *app-common <<: *app-common
environment: environment:
RUN_MIGRATIONS: "true" RUN_MIGRATIONS: "true"
TZ: Asia/Kuala_Lumpur
APP_TIMEZONE: Asia/Kuala_Lumpur
web: web:
build: build:
@@ -39,6 +41,8 @@ services:
target: web target: web
image: taklimatspr-web:latest image: taklimatspr-web:latest
restart: unless-stopped restart: unless-stopped
environment:
TZ: Asia/Kuala_Lumpur
depends_on: depends_on:
- app - app
ports: ports:
@@ -49,6 +53,8 @@ services:
<<: *app-common <<: *app-common
environment: environment:
RUN_MIGRATIONS: "false" RUN_MIGRATIONS: "false"
TZ: Asia/Kuala_Lumpur
APP_TIMEZONE: Asia/Kuala_Lumpur
command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600 command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
depends_on: depends_on:
- app - app
@@ -57,6 +63,8 @@ services:
<<: *app-common <<: *app-common
environment: environment:
RUN_MIGRATIONS: "false" RUN_MIGRATIONS: "false"
TZ: Asia/Kuala_Lumpur
APP_TIMEZONE: Asia/Kuala_Lumpur
command: php artisan schedule:work command: php artisan schedule:work
depends_on: depends_on:
- app - app

View File

@@ -6,7 +6,10 @@
<div class="kad"> <div class="kad">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;"> <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<h1 class="mb-0">Sesi Taklimat</h1> <h1 class="mb-0">Sesi Taklimat</h1>
<a href="{{ route('sesi.create') }}" class="btn">+ Cipta Sesi</a> <div class="baris-tindakan">
<a href="{{ route('sesi.eksport') }}" class="btn btn-luar"> Export Kehadiran</a>
<a href="{{ route('sesi.create') }}" class="btn">+ Cipta Sesi</a>
</div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\CheckinController; use App\Http\Controllers\CheckinController;
use App\Http\Controllers\EksportKehadiranController;
use App\Http\Controllers\KaunterController; use App\Http\Controllers\KaunterController;
use App\Http\Controllers\PengurusController; use App\Http\Controllers\PengurusController;
use App\Http\Controllers\SesiTaklimatController; use App\Http\Controllers\SesiTaklimatController;
@@ -21,6 +22,7 @@ Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
// Pengurusan sesi taklimat (admin & pengurus dun) // Pengurusan sesi taklimat (admin & pengurus dun)
Route::get('/sesi', [SesiTaklimatController::class, 'index'])->name('sesi.index'); 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::get('/sesi/cipta', [SesiTaklimatController::class, 'create'])->name('sesi.create');
Route::post('/sesi', [SesiTaklimatController::class, 'store'])->name('sesi.store'); Route::post('/sesi', [SesiTaklimatController::class, 'store'])->name('sesi.store');
Route::get('/sesi/{sesi}', [SesiTaklimatController::class, 'show'])->name('sesi.show'); Route::get('/sesi/{sesi}', [SesiTaklimatController::class, 'show'])->name('sesi.show');