This commit is contained in:
Saufi
2026-06-03 08:51:22 +08:00
commit a14d43fe34
347 changed files with 38197 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\Admin;
use App\Models\Election;
use App\Models\ElectionSetting;
use App\Models\User;
class ElectionSettingsService
{
public function create(Election $election, array $data, User $actingUser): ElectionSetting
{
$settings = $election->settings()->create([
'registration_start_date' => $data['registration_start_date'],
'registration_end_date' => $data['registration_end_date'],
'polling_date' => $data['polling_date'],
'is_attendance_active' => $data['is_attendance_active'],
'is_registration_open_override' => $data['is_registration_open_override'],
]);
activity('admin_settings')
->performedOn($settings)
->causedBy($actingUser)
->withProperties(['election_id' => $election->id, 'after' => $data])
->log('election_settings_created');
return $settings;
}
public function update(Election $election, array $data, User $actingUser): void
{
/** @var ElectionSetting $settings */
$settings = $election->settings;
$before = $settings->only([
'registration_start_date',
'registration_end_date',
'polling_date',
'is_attendance_active',
'is_registration_open_override',
]);
$settings->update([
'registration_start_date' => $data['registration_start_date'],
'registration_end_date' => $data['registration_end_date'],
'polling_date' => $data['polling_date'],
'is_attendance_active' => $data['is_attendance_active'],
'is_registration_open_override' => $data['is_registration_open_override'],
]);
activity('admin_settings')
->performedOn($settings)
->causedBy($actingUser)
->withProperties([
'election_id' => $election->id,
'before' => $before,
'after' => $settings->only([
'registration_start_date',
'registration_end_date',
'polling_date',
'is_attendance_active',
'is_registration_open_override',
]),
])
->log('election_settings_updated');
}
}

View File

@@ -0,0 +1,289 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Application;
use App\Models\BankVerification;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\OperationalAccountService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AdminApplicationManagementService
{
public function __construct(
private readonly AdminPostCloseNoteService $postCloseNoteService,
private readonly OperationalAccountService $operationalAccountService,
) {}
/**
* @param array<string, mixed> $data
*/
public function create(User $actor, array $data): Application
{
return DB::transaction(function () use ($actor, $data): Application {
$election = Election::query()->findOrFail($data['election_id']);
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
/** @var PusatMengundi $pusat */
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
if ((int) $pusat->election_id !== (int) $election->id) {
throw ValidationException::withMessages([
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
]);
}
$application = Application::query()->create([
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null,
'public_uuid' => (string) Str::uuid(),
'source' => 'admin_manual',
'status' => 'submitted',
'name' => $data['name'],
'ic_number' => $this->normalizeIc($data['ic_number']),
'phone_number' => $data['phone_number'],
'email' => $data['email'] ?? null,
'address' => $data['address'] ?? null,
'requested_position_id' => $data['requested_position_id'],
'bank_name' => $data['bank_name'] ?? null,
'bank_account_number' => $data['bank_account_number'] ?? null,
'created_by_user_id' => $actor->id,
]);
$application->statusHistories()->create([
'from_status' => null,
'to_status' => 'submitted',
'changed_by_user_id' => $actor->id,
'note' => $data['note'] ?? 'Manual entry by Admin.',
]);
BankVerification::query()->firstOrCreate(
['application_id' => $application->id],
['status' => 'pending'],
);
$this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_manual_entry_after_close');
activity('admin_management')
->performedOn($application)
->causedBy($actor)
->withProperties(['source' => 'admin_manual'])
->log('application_created_by_admin');
return $application;
});
}
/**
* @param array<string, mixed> $data
*/
public function update(Application $application, User $actor, array $data): Application
{
return DB::transaction(function () use ($application, $actor, $data): Application {
$application->loadMissing('election');
$election = $application->election;
if (! $election instanceof Election) {
throw ValidationException::withMessages([
'election_id' => 'Pilihanraya permohonan tidak sah.',
]);
}
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
$before = $application->only([
'pusat_mengundi_id',
'selected_ktm_assignment_id',
'name',
'ic_number',
'phone_number',
'email',
'address',
'requested_position_id',
'approved_position_id',
'bank_name',
'bank_account_number',
'status',
]);
$application->forceFill([
'pusat_mengundi_id' => $data['pusat_mengundi_id'],
'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null,
'name' => $data['name'],
'ic_number' => $this->normalizeIc($data['ic_number']),
'phone_number' => $data['phone_number'],
'email' => $data['email'] ?? null,
'address' => $data['address'] ?? null,
'requested_position_id' => $data['requested_position_id'],
'approved_position_id' => $data['approved_position_id'] ?? null,
'bank_name' => $data['bank_name'] ?? null,
'bank_account_number' => $data['bank_account_number'] ?? null,
'status' => $data['status'],
])->save();
$after = $application->only(array_keys($before));
$application->statusHistories()->create([
'from_status' => $before['status'],
'to_status' => $application->status,
'changed_by_user_id' => $actor->id,
'note' => $data['note'] ?? 'Application updated by Admin.',
'metadata' => [
'before' => $before,
'after' => $after,
],
]);
$this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_application_edit_after_close');
activity('admin_management')
->performedOn($application)
->causedBy($actor)
->withProperties(['before' => $before, 'after' => $after])
->log('application_updated_by_admin');
return $application;
});
}
/**
* @param array<string, mixed> $data
*/
public function assign(Application $application, User $actor, array $data): StaffAssignment
{
return DB::transaction(function () use ($application, $actor, $data): StaffAssignment {
$application->loadMissing('election');
$election = $application->election;
if (! $election instanceof Election) {
throw ValidationException::withMessages([
'election_id' => 'Pilihanraya permohonan tidak sah.',
]);
}
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
/** @var Position $position */
$position = Position::query()->findOrFail($data['position_id']);
/** @var PusatMengundi $pusat */
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
if ((int) $pusat->election_id !== (int) $election->id) {
throw ValidationException::withMessages([
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
]);
}
$reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null;
$saluranMengundiId = $data['saluran_mengundi_id'] ?? null;
if ($saluranMengundiId) {
/** @var SaluranMengundi $saluran */
$saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId);
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
throw ValidationException::withMessages([
'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.',
]);
}
}
if ($reportsToAssignmentId) {
/** @var StaffAssignment $ktmAssignment */
$ktmAssignment = StaffAssignment::query()
->with('position')
->whereKey($reportsToAssignmentId)
->where('election_id', $election->id)
->where('pusat_mengundi_id', $pusat->id)
->where('status', 'active')
->firstOrFail();
$ktmPosition = $ktmAssignment->position;
if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') {
throw ValidationException::withMessages([
'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.',
]);
}
$saluranMengundiId = $ktmAssignment->saluran_mengundi_id;
}
$previousStatus = $application->status;
$previousPositionId = $application->approved_position_id ?: $application->requested_position_id;
$application->forceFill([
'pusat_mengundi_id' => $pusat->id,
'selected_ktm_assignment_id' => $reportsToAssignmentId,
'approved_position_id' => $position->id,
'approved_by_user_id' => $actor->id,
'approved_at' => now(),
'status' => 'assigned',
])->save();
$assignment = StaffAssignment::query()->updateOrCreate(
['application_id' => $application->id],
[
'election_id' => $election->id,
'user_id' => $application->user_id,
'position_id' => $position->id,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => $saluranMengundiId,
'reports_to_assignment_id' => $reportsToAssignmentId,
'status' => $data['status'] ?? 'active',
'assigned_by_user_id' => $actor->id,
'assigned_at' => now(),
'source' => 'admin_direct',
],
);
$application->statusHistories()->create([
'from_status' => $previousStatus,
'to_status' => 'assigned',
'changed_by_user_id' => $actor->id,
'note' => $data['note'] ?? 'Direct assignment by Admin.',
'metadata' => [
'approved_position_id' => $position->id,
'previous_position_id' => $previousPositionId,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => $saluranMengundiId,
'reports_to_assignment_id' => $reportsToAssignmentId,
],
]);
$assignment->histories()->create([
'from_position_id' => $previousPositionId,
'to_position_id' => $position->id,
'to_pusat_mengundi_id' => $pusat->id,
'to_saluran_mengundi_id' => $saluranMengundiId,
'to_reports_to_assignment_id' => $reportsToAssignmentId,
'changed_by_user_id' => $actor->id,
'note' => $data['note'] ?? 'Direct assignment by Admin.',
]);
$this->operationalAccountService->provisionForApplication($application, $position);
$this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_after_close');
activity('admin_management')
->performedOn($assignment)
->causedBy($actor)
->withProperties([
'application_id' => $application->id,
'position_code' => $position->code,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => $saluranMengundiId,
'reports_to_assignment_id' => $reportsToAssignmentId,
])
->log('staff_assigned_by_admin');
return $assignment;
});
}
private function normalizeIc(mixed $icNumber): string
{
return preg_replace('/\D+/', '', (string) $icNumber) ?? '';
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Application;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\OperationalAccountService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AdminAssignmentManagementService
{
public function __construct(
private readonly AdminPostCloseNoteService $postCloseNoteService,
private readonly OperationalAccountService $operationalAccountService,
) {}
/**
* @param array<string, mixed> $data
*/
public function update(StaffAssignment $assignment, User $actor, array $data): StaffAssignment
{
return DB::transaction(function () use ($assignment, $actor, $data): StaffAssignment {
$assignment->loadMissing(['election', 'position']);
$election = $assignment->election;
if (! $election instanceof Election) {
throw ValidationException::withMessages([
'election_id' => 'Pilihanraya tugasan tidak sah.',
]);
}
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
/** @var Position $position */
$position = Position::query()->findOrFail($data['position_id']);
/** @var PusatMengundi $pusat */
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
if ((int) $pusat->election_id !== (int) $election->id) {
throw ValidationException::withMessages([
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
]);
}
$before = [
'position_id' => $assignment->position_id,
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
'saluran_mengundi_id' => $assignment->saluran_mengundi_id,
'reports_to_assignment_id' => $assignment->reports_to_assignment_id,
'status' => $assignment->status,
];
$reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null;
$saluranMengundiId = $data['saluran_mengundi_id'] ?? null;
if ($saluranMengundiId) {
/** @var SaluranMengundi $saluran */
$saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId);
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
throw ValidationException::withMessages([
'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.',
]);
}
}
if ($reportsToAssignmentId) {
/** @var StaffAssignment $ktmAssignment */
$ktmAssignment = StaffAssignment::query()
->with('position')
->whereKey($reportsToAssignmentId)
->where('election_id', $election->id)
->where('pusat_mengundi_id', $pusat->id)
->where('status', 'active')
->firstOrFail();
$ktmPosition = $ktmAssignment->position;
if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') {
throw ValidationException::withMessages([
'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.',
]);
}
if ((int) $ktmAssignment->id === (int) $assignment->id) {
throw ValidationException::withMessages([
'reports_to_assignment_id' => 'KTM tidak boleh melapor kepada tugasan sendiri.',
]);
}
$saluranMengundiId = $ktmAssignment->saluran_mengundi_id;
}
$assignment->forceFill([
'position_id' => $position->id,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => $saluranMengundiId,
'reports_to_assignment_id' => $reportsToAssignmentId,
'status' => $data['status'],
'assigned_by_user_id' => $actor->id,
'assigned_at' => now(),
'source' => 'admin_update',
])->save();
$assignment->histories()->create([
'from_position_id' => $before['position_id'],
'to_position_id' => $position->id,
'from_pusat_mengundi_id' => $before['pusat_mengundi_id'],
'to_pusat_mengundi_id' => $pusat->id,
'from_saluran_mengundi_id' => $before['saluran_mengundi_id'],
'to_saluran_mengundi_id' => $saluranMengundiId,
'from_reports_to_assignment_id' => $before['reports_to_assignment_id'],
'to_reports_to_assignment_id' => $reportsToAssignmentId,
'changed_by_user_id' => $actor->id,
'note' => $data['note'] ?? 'Assignment updated by Admin.',
]);
$application = $assignment->application;
if ($application instanceof Application) {
$application->forceFill([
'pusat_mengundi_id' => $pusat->id,
'approved_position_id' => $position->id,
'selected_ktm_assignment_id' => $reportsToAssignmentId,
'status' => $data['status'] === 'active' ? 'assigned' : $application->status,
])->save();
}
$this->operationalAccountService->provisionForAssignment($assignment, $position);
$this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_edit_after_close');
activity('admin_management')
->performedOn($assignment)
->causedBy($actor)
->withProperties([
'before' => $before,
'after' => [
'position_id' => $assignment->position_id,
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
'saluran_mengundi_id' => $assignment->saluran_mengundi_id,
'reports_to_assignment_id' => $assignment->reports_to_assignment_id,
'status' => $assignment->status,
],
])
->log('staff_assignment_updated_by_admin');
return $assignment;
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Election;
use App\Models\SystemNote;
use App\Models\User;
use App\Services\RegistrationPeriodService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class AdminPostCloseNoteService
{
public function __construct(
private readonly RegistrationPeriodService $registrationPeriodService,
) {}
public function requireIfClosed(Election $election, ?string $note): void
{
if ($this->registrationPeriodService->isOpen($election)) {
return;
}
if (blank($note)) {
throw ValidationException::withMessages([
'note' => 'Catatan wajib diisi untuk perubahan selepas tempoh pendaftaran ditutup.',
]);
}
}
public function recordIfClosed(Election $election, Model $noteable, User $actor, ?string $note, string $type): ?SystemNote
{
if ($this->registrationPeriodService->isOpen($election) || blank($note)) {
return null;
}
return SystemNote::query()->create([
'election_id' => $election->id,
'noteable_type' => $noteable::class,
'noteable_id' => $noteable->getKey(),
'note_type' => $type,
'note' => $note,
'created_by_user_id' => $actor->id,
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Election;
use App\Models\JkmRepresentative;
use App\Models\KkmRepresentative;
use App\Models\PoliceEscort;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class AdminRepresentativeService
{
public function __construct(
private readonly AdminPostCloseNoteService $postCloseNoteService,
) {}
/**
* @param array<string, mixed> $data
*/
public function store(User $actor, array $data): Model
{
return DB::transaction(function () use ($actor, $data): Model {
$election = Election::query()->findOrFail($data['election_id']);
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
/** @var PusatMengundi $pusat */
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
if ((int) $pusat->election_id !== (int) $election->id) {
throw new \InvalidArgumentException('Pusat Mengundi tidak berada dalam pilihanraya yang sama.');
}
if (! empty($data['saluran_mengundi_id'])) {
/** @var SaluranMengundi $saluran */
$saluran = SaluranMengundi::query()->findOrFail($data['saluran_mengundi_id']);
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
throw new \InvalidArgumentException('Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.');
}
}
$payload = [
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
'name' => $data['name'],
'ic_number' => $data['ic_number'] ?? null,
'phone_number' => $data['phone_number'] ?? null,
'notes' => $data['notes'] ?? null,
];
$type = (string) $data['type'];
$representative = match ($type) {
'police' => PoliceEscort::query()->create([
...$payload,
'saluran_mengundi_id' => $data['saluran_mengundi_id'] ?? null,
'rank' => $data['rank'] ?? null,
'station' => $data['station'] ?? null,
]),
'kkm' => KkmRepresentative::query()->create([
...$payload,
'agency' => $data['agency'] ?? null,
]),
'jkm' => JkmRepresentative::query()->create([
...$payload,
'agency' => $data['agency'] ?? null,
]),
default => throw new \InvalidArgumentException('Jenis wakil agensi tidak sah.'),
};
$this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $data['note'] ?? null, 'admin_representative_after_close');
activity('admin_management')
->performedOn($representative)
->causedBy($actor)
->withProperties(['type' => $type])
->log('representative_created_by_admin');
return $representative;
});
}
public function delete(Model $representative, User $actor, ?string $note = null): void
{
DB::transaction(function () use ($representative, $actor, $note): void {
$election = match (true) {
$representative instanceof PoliceEscort => $representative->election,
$representative instanceof KkmRepresentative => $representative->election,
$representative instanceof JkmRepresentative => $representative->election,
default => null,
};
if (! $election instanceof Election) {
throw new \InvalidArgumentException('Pilihanraya wakil agensi tidak sah.');
}
$this->postCloseNoteService->requireIfClosed($election, $note);
$this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $note, 'admin_representative_delete_after_close');
activity('admin_management')
->performedOn($representative)
->causedBy($actor)
->withProperties(['note' => $note])
->log('representative_deleted_by_admin');
$representative->delete();
});
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;
class PennempatanService
{
/**
* Summary per pusat for the dashboard vacancy table.
*
* @return Collection<int, PusatMengundi>
*/
public function pusatsWithVacancySummary(): Collection
{
return PusatMengundi::query()
->with([
'dun',
'election',
'saluranMengundis',
'positionQuotas',
])
->withCount(['saluranMengundis'])
->get()
->each(function (PusatMengundi $pusat): void {
$totalQuota = $pusat->positionQuotas->sum('quota');
$filled = StaffAssignment::query()
->where('pusat_mengundi_id', $pusat->id)
->where('status', 'active')
->count();
$pusat->setAttribute('total_quota', $totalQuota);
$pusat->setAttribute('filled', $filled);
$pusat->setAttribute('vacant', max(0, $totalQuota - $filled));
// PPM name
$ppmPosition = Position::query()->where('code', 'PPM')->first();
$ppmAssignment = $ppmPosition
? StaffAssignment::query()
->with('application')
->where('pusat_mengundi_id', $pusat->id)
->where('position_id', $ppmPosition->id)
->where('status', 'active')
->whereNull('saluran_mengundi_id')
->first()
: null;
$pusat->setAttribute('ppm_name', $ppmAssignment?->application?->name ?? '-');
})
->filter(fn (PusatMengundi $p): bool => $p->vacant > 0)
->sortByDesc('vacant')
->values();
}
/**
* Structured breakdown of quotas + assignments for a specific pusat.
*
* @return BaseCollection<int, array{saluran: SaluranMengundi|null, label: string, entries: BaseCollection}>
*/
public function detailForPusat(PusatMengundi $pusat): BaseCollection
{
$quotas = PositionQuota::query()
->with('position')
->where('pusat_mengundi_id', $pusat->id)
->get()
->groupBy(fn (PositionQuota $q): string => $q->saluran_mengundi_id ?? 'pusat');
$assignments = StaffAssignment::query()
->with(['application', 'position'])
->where('pusat_mengundi_id', $pusat->id)
->where('status', 'active')
->get()
->groupBy(fn (StaffAssignment $a): string => ($a->saluran_mengundi_id ?? 'pusat').'_'.$a->position_id);
$salurans = SaluranMengundi::query()
->where('pusat_mengundi_id', $pusat->id)
->orderBy('number')
->get()
->keyBy('id');
$sections = collect();
// Pusat-level section first
if ($quotas->has('pusat')) {
$sections->push($this->buildSection(null, 'Peringkat Pusat (Saluran 0)', $quotas->get('pusat'), $assignments));
}
// Per-saluran sections
foreach ($salurans as $saluran) {
$saluranQuotas = $quotas->get((string) $saluran->id, collect());
$sections->push($this->buildSection($saluran, 'Saluran '.$saluran->number, $saluranQuotas, $assignments));
}
return $sections;
}
/**
* @param Collection<int, PositionQuota> $quotas
* @param BaseCollection<string, Collection> $assignments
* @return array{saluran: SaluranMengundi|null, label: string, entries: BaseCollection}
*/
private function buildSection(?SaluranMengundi $saluran, string $label, $quotas, BaseCollection $assignments): array
{
$scopeKey = $saluran ? (string) $saluran->id : 'pusat';
$entries = $quotas->map(function (PositionQuota $quota) use ($scopeKey, $assignments) {
$key = $scopeKey.'_'.$quota->position_id;
$filled = $assignments->get($key, collect());
$vacant = max(0, $quota->quota - $filled->count());
return [
'quota' => $quota,
'position' => $quota->position,
'assignments' => $filled,
'vacant' => $vacant,
];
})->values();
return [
'saluran' => $saluran,
'label' => $label,
'entries' => $entries,
];
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Services\Admin\Management;
use App\Models\Election;
use App\Models\PusatMengundi;
use App\Models\User;
use App\Models\WheelchairAllocation;
use App\Models\WheelchairTransaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WheelchairManagementService
{
/**
* @param array{election_id: int, pusat_mengundi_id: int, allocated_quantity: int, notes?: string|null} $data
*/
public function upsertAllocation(User $actor, array $data): WheelchairAllocation
{
return DB::transaction(function () use ($actor, $data): WheelchairAllocation {
/** @var Election $election */
$election = Election::query()->findOrFail($data['election_id']);
/** @var PusatMengundi $pusat */
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
if ((int) $pusat->election_id !== (int) $election->id) {
throw ValidationException::withMessages([
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
]);
}
$allocation = WheelchairAllocation::query()->firstOrNew([
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
]);
$outstanding = $allocation->exists ? $this->outstandingQuantity($allocation) : 0;
if ($data['allocated_quantity'] < $outstanding) {
throw ValidationException::withMessages([
'allocated_quantity' => 'Peruntukan tidak boleh kurang daripada jumlah kerusi roda yang masih belum dipulang.',
]);
}
$before = $allocation->exists ? $allocation->only(['allocated_quantity', 'notes']) : null;
$allocation->forceFill([
'allocated_quantity' => $data['allocated_quantity'],
'notes' => $data['notes'] ?? null,
])->save();
activity('wheelchairs')
->performedOn($allocation)
->causedBy($actor)
->withProperties([
'before' => $before,
'after' => $allocation->only(['allocated_quantity', 'notes']),
])
->log('wheelchair_allocation_upserted');
return $allocation;
});
}
/**
* @param array{transaction_type: string, quantity: int, taken_at?: string|null, taken_by_name?: string|null, returned_at?: string|null, return_condition?: string|null, notes?: string|null} $data
*/
public function recordTransaction(WheelchairAllocation $allocation, User $actor, array $data): WheelchairTransaction
{
return DB::transaction(function () use ($allocation, $actor, $data): WheelchairTransaction {
$allocation->refresh();
$type = $data['transaction_type'];
$quantity = $data['quantity'];
if ($type === 'taken' && $quantity > $this->availableQuantity($allocation)) {
throw ValidationException::withMessages([
'quantity' => 'Jumlah diambil melebihi baki peruntukan kerusi roda.',
]);
}
if ($type === 'returned' && $quantity > $this->outstandingQuantity($allocation)) {
throw ValidationException::withMessages([
'quantity' => 'Jumlah dipulang melebihi jumlah kerusi roda yang masih belum dipulang.',
]);
}
$transaction = WheelchairTransaction::query()->create([
'wheelchair_allocation_id' => $allocation->id,
'transaction_type' => $type,
'quantity' => $quantity,
'taken_at' => $type === 'taken' ? ($data['taken_at'] ?? now()) : null,
'taken_by_name' => $type === 'taken' ? ($data['taken_by_name'] ?? null) : null,
'returned_at' => $type === 'returned' ? ($data['returned_at'] ?? now()) : null,
'return_condition' => $type === 'returned' ? ($data['return_condition'] ?? null) : null,
'returned_by_name' => $type === 'returned' ? ($data['returned_by_name'] ?? null) : null,
'recorded_by_user_id' => $actor->id,
'notes' => $data['notes'] ?? null,
]);
activity('wheelchairs')
->performedOn($transaction)
->causedBy($actor)
->withProperties([
'allocation_id' => $allocation->id,
'transaction_type' => $type,
'quantity' => $quantity,
'available_after' => $this->availableQuantity($allocation->fresh()),
'outstanding_after' => $this->outstandingQuantity($allocation->fresh()),
])
->log('wheelchair_transaction_recorded');
return $transaction;
});
}
public function takenQuantity(WheelchairAllocation $allocation): int
{
return (int) $allocation->transactions()
->where('transaction_type', 'taken')
->sum('quantity');
}
public function returnedQuantity(WheelchairAllocation $allocation): int
{
return (int) $allocation->transactions()
->where('transaction_type', 'returned')
->sum('quantity');
}
public function outstandingQuantity(WheelchairAllocation $allocation): int
{
return max(0, $this->takenQuantity($allocation) - $this->returnedQuantity($allocation));
}
public function availableQuantity(WheelchairAllocation $allocation): int
{
return max(0, (int) $allocation->allocated_quantity - $this->outstandingQuantity($allocation));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Services\Admin;
use App\Mail\OperationalAccountCreatedMail;
use App\Models\Application;
use App\Models\Position;
use App\Models\StaffAssignment;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class OperationalAccountService
{
private const ROLES_NEEDING_ACCOUNT = ['PPM', 'KTM'];
public function provisionForApplication(Application $application, Position $position): void
{
if (! in_array($position->code, self::ROLES_NEEDING_ACCOUNT, true)) {
return;
}
if (! $application->email) {
return;
}
[$user, $tempPassword] = $this->findOrCreateUser($application->name, $application->email);
// Link user to application if not already linked
if ($application->user_id !== $user->id) {
$application->forceFill(['user_id' => $user->id])->save();
}
$user->assignRole($position->code);
if ($tempPassword !== null) {
Mail::to($user->email)->queue(
new OperationalAccountCreatedMail($user, $position->code, $tempPassword)
);
}
}
public function provisionForAssignment(StaffAssignment $assignment, Position $position): void
{
if (! in_array($position->code, self::ROLES_NEEDING_ACCOUNT, true)) {
return;
}
$application = $assignment->application;
if ($application) {
$this->provisionForApplication($application, $position);
// Re-link user_id on assignment after provisioning
$assignment->loadMissing('application');
if ($assignment->application?->user_id && $assignment->user_id !== $assignment->application->user_id) {
$assignment->forceFill(['user_id' => $assignment->application->user_id])->save();
}
return;
}
// Assignment without application (direct assignment) — skip provisioning
}
/**
* @return array{User, string|null} [$user, $tempPassword|null]
*/
private function findOrCreateUser(string $name, string $email): array
{
$existing = User::query()->where('email', $email)->first();
if ($existing) {
return [$existing, null];
}
$tempPassword = Str::random(10);
$user = User::query()->create([
'name' => $name,
'email' => $email,
'password' => Hash::make($tempPassword),
'email_verified_at' => now(),
]);
return [$user, $tempPassword];
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\Admin;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\StaffAssignment;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class PpmAssignmentService
{
public function assign(PusatMengundi $pusatMengundi, User $user): StaffAssignment
{
return DB::transaction(function () use ($pusatMengundi, $user): StaffAssignment {
$position = Position::query()->where('code', 'PPM')->firstOrFail();
StaffAssignment::query()
->where('election_id', $pusatMengundi->election_id)
->where('pusat_mengundi_id', $pusatMengundi->id)
->where('position_id', $position->id)
->whereNull('saluran_mengundi_id')
->where('status', 'active')
->where('user_id', '!=', $user->id)
->update(['status' => 'replaced']);
$assignment = StaffAssignment::query()->updateOrCreate(
[
'election_id' => $pusatMengundi->election_id,
'pusat_mengundi_id' => $pusatMengundi->id,
'position_id' => $position->id,
'saluran_mengundi_id' => null,
'user_id' => $user->id,
],
[
'status' => 'active',
'assigned_by_user_id' => auth()->id(),
'assigned_at' => now(),
'source' => 'admin_setup',
],
);
$assignment->histories()->create([
'to_position_id' => $position->id,
'to_pusat_mengundi_id' => $pusatMengundi->id,
'changed_by_user_id' => auth()->id(),
'note' => 'PPM assigned from Admin setup module.',
]);
$user->assignRole('PPM');
activity('admin_setup')
->performedOn($assignment)
->causedBy(auth()->user())
->withProperties([
'pusat_mengundi_id' => $pusatMengundi->id,
'user_id' => $user->id,
'position_code' => 'PPM',
])
->log('ppm_assigned');
return $assignment;
});
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services\Admin;
use App\Models\Dun;
use App\Models\Election;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use OpenSpout\Reader\XLSX\Reader;
class PusatImportService
{
// Column indexes (0-based) in the XLSX
private const COL_NAMA_PUSAT = 0;
private const COL_NAMA_PPM = 1;
private const COL_SALURAN = 2;
// 3 KTM, 4 POLIS, 5 KP — formula columns, computed from saluran count
private const COL_PPM_COUNT = 6;
// 7 KPDP — computed from saluran count
private const COL_PAPM = 8;
private const COL_JKM = 9;
private const COL_KKM = 10;
// 11 TAMBAHAN — computed from saluran count
private const COL_PPM_PHONE = 12;
/**
* @return array{imported: int, salurans: int, errors: list<string>}
*/
public function import(Election $election, Dun $dun, UploadedFile $file): array
{
$rows = $this->parseXlsx($file->getRealPath());
$errors = [];
$imported = 0;
$saluranTotal = 0;
DB::transaction(function () use ($election, $dun, $rows, &$imported, &$saluranTotal, &$errors): void {
// Replace: delete all existing pusat for this DUN (cascade to saluran + quotas).
PusatMengundi::query()
->where('dun_id', $dun->id)
->each(function (PusatMengundi $pusat): void {
$pusat->saluranMengundis()->delete();
$pusat->positionQuotas()->delete();
$pusat->delete();
});
$positions = Position::query()->get()->keyBy('code');
foreach ($rows as $index => $row) {
$rowNum = $index + 2; // +2: 1-based + skip header
$namaPusat = trim((string) ($row[self::COL_NAMA_PUSAT] ?? ''));
if ($namaPusat === '') {
continue; // skip empty rows
}
$saluranCount = (int) ($row[self::COL_SALURAN] ?? 0);
if ($saluranCount < 1) {
$errors[] = "Baris {$rowNum}: Bilangan saluran mesti sekurang-kurangnya 1 untuk '{$namaPusat}'.";
continue;
}
$code = sprintf('PM%03d', $imported + 1);
$pusat = PusatMengundi::query()->create([
'election_id' => $election->id,
'dun_id' => $dun->id,
'public_uuid' => (string) Str::uuid(),
'code' => $code,
'name' => $namaPusat,
'address' => null,
'ppm_name' => trim((string) ($row[self::COL_NAMA_PPM] ?? '')),
'ppm_phone' => trim((string) ($row[self::COL_PPM_PHONE] ?? '')),
'registration_url' => null,
'qr_code_path' => null,
]);
// Generate QR code for this pusat
app(PusatQrCodeService::class)->generate($pusat);
// Create numbered salurans
$salurans = [];
for ($n = 1; $n <= $saluranCount; $n++) {
$salurans[] = SaluranMengundi::query()->create([
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
'number' => (string) $n,
'name' => null,
'voter_count' => null,
]);
}
$saluranTotal += $saluranCount;
// Pusat-level quotas
$pusatQuotas = [
'PPM' => (int) ($row[self::COL_PPM_COUNT] ?? 1),
'PAPM' => (int) ($row[self::COL_PAPM] ?? 0),
'JKM' => (int) ($row[self::COL_JKM] ?? 1),
'KKM' => (int) ($row[self::COL_KKM] ?? 2),
];
foreach ($pusatQuotas as $code => $quota) {
$position = $positions->get($code);
if ($position) {
PositionQuota::query()->create([
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => null,
'position_id' => $position->id,
'quota' => $quota,
]);
}
}
// Saluran-level quotas (standard per saluran)
$saluranQuotaCodes = [
'KTM' => 1,
'POLIS' => 1,
'KP' => 4,
'KPDP' => 1,
'CALON_TAMBAHAN' => 1,
];
foreach ($salurans as $saluran) {
foreach ($saluranQuotaCodes as $posCode => $quota) {
$position = $positions->get($posCode);
if ($position) {
PositionQuota::query()->create([
'election_id' => $election->id,
'pusat_mengundi_id' => $pusat->id,
'saluran_mengundi_id' => $saluran->id,
'position_id' => $position->id,
'quota' => $quota,
]);
}
}
}
$imported++;
}
});
return [
'imported' => $imported,
'salurans' => $saluranTotal,
'errors' => $errors,
];
}
/**
* @return list<list<mixed>>
*/
private function parseXlsx(string $path): array
{
$reader = new Reader();
$reader->open($path);
$rows = [];
$isHeader = true;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
if ($isHeader) {
$isHeader = false;
continue;
}
$rows[] = $row->toArray();
}
break; // only first sheet
}
$reader->close();
return $rows;
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Services\Admin;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PusatOperationalSetupService
{
/**
* @param array<string, mixed> $data
*/
public function configure(PusatMengundi $pusatMengundi, array $data): void
{
DB::transaction(function () use ($pusatMengundi, $data): void {
$salurans = collect();
foreach (range(1, (int) $data['saluran_count']) as $number) {
$salurans->push(SaluranMengundi::query()->updateOrCreate(
[
'pusat_mengundi_id' => $pusatMengundi->id,
'number' => (string) $number,
],
[
'election_id' => $pusatMengundi->election_id,
'name' => null,
],
));
}
$this->upsertPusatQuotas($pusatMengundi, $data);
foreach ($salurans as $saluran) {
$this->upsertSaluranQuotas($pusatMengundi, $saluran, $data);
}
});
}
/**
* @param array<string, mixed> $data
*/
private function upsertPusatQuotas(PusatMengundi $pusatMengundi, array $data): void
{
foreach ([
'PPM' => 'ppm_count',
'PAPM' => 'papm_count',
'JKM' => 'jkm_count',
'KKM' => 'kkm_count',
] as $code => $field) {
$this->upsertQuota($pusatMengundi, null, $code, (int) $data[$field]);
}
}
/**
* @param array<string, mixed> $data
*/
private function upsertSaluranQuotas(PusatMengundi $pusatMengundi, SaluranMengundi $saluran, array $data): void
{
foreach ([
'KTM' => 'ktm_count',
'POLIS' => 'polis_iring_count',
'KP' => 'kp_count',
'KPDP' => 'kpdp_count',
'CALON_TAMBAHAN' => 'calon_tambahan_count',
] as $code => $field) {
$this->upsertQuota($pusatMengundi, $saluran, $code, (int) $data[$field]);
}
}
private function upsertQuota(PusatMengundi $pusatMengundi, ?SaluranMengundi $saluran, string $positionCode, int $quota): void
{
$position = Position::query()->where('code', $positionCode)->first();
if (! $position instanceof Position) {
throw ValidationException::withMessages([
'positions' => 'Jawatan '.$positionCode.' belum dikonfigurasi.',
]);
}
PositionQuota::query()->updateOrCreate(
[
'election_id' => $pusatMengundi->election_id,
'pusat_mengundi_id' => $pusatMengundi->id,
'saluran_mengundi_id' => $saluran?->id,
'position_id' => $position->id,
],
[
'quota' => $quota,
],
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Admin;
use App\Models\PusatMengundi;
use Illuminate\Support\Facades\Storage;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class PusatQrCodeService
{
public function generate(PusatMengundi $pusatMengundi): PusatMengundi
{
$url = route('public.applications.create', [
'pusatMengundi' => $pusatMengundi->public_uuid,
]);
$path = 'qrcodes/pusat-mengundi/'.$pusatMengundi->public_uuid.'.svg';
Storage::disk('local')->put($path, QrCode::format('svg')
->size(320)
->margin(2)
->generate($url));
$pusatMengundi->forceFill([
'registration_url' => $url,
'qr_code_path' => $path,
])->save();
activity('admin_setup')
->performedOn($pusatMengundi)
->causedBy(auth()->user())
->withProperties([
'registration_url' => $url,
'qr_code_path' => $path,
])
->log('pusat_qr_generated');
return $pusatMengundi;
}
}