Commit 8cf88598 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(fines): add proof file upload + send to cashier queue

- Add file upload field on violation creation (image/PDF/doc, max 5MB)
- Store proof_file path in violations table (new column)
- Show proof file link (📎) in violations index table
- Auto-send monetary fines to cashier queue on imposition
- Add manual "طابور الدفع" button on fines index for unpaid fines
- Add sendToQueue controller method + route
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8afeedf0
...@@ -13,6 +13,7 @@ use App\Modules\Fines\Models\Fine; ...@@ -13,6 +13,7 @@ use App\Modules\Fines\Models\Fine;
use App\Modules\Fines\Models\Violation; use App\Modules\Fines\Models\Violation;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService; use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Workflow\Services\WorkflowEngine; use App\Modules\Workflow\Services\WorkflowEngine;
class FineController extends Controller class FineController extends Controller
...@@ -111,6 +112,18 @@ class FineController extends Controller ...@@ -111,6 +112,18 @@ class FineController extends Controller
'amount' => $fineAmount, 'amount' => $fineAmount,
]); ]);
// Auto-send monetary fines to cashier queue
if ($penaltyType === 'fine' && bccomp($fineAmount, '0', 2) > 0) {
PaymentRequestService::createRequest([
'member_id' => (int) $violation['member_id'],
'payment_type' => 'fine',
'amount' => $fineAmount,
'description_ar' => 'غرامة مخالفة #' . $violationId,
'related_entity_type' => 'fines',
'related_entity_id' => (int) $fine->id,
]);
}
return $this->redirect('/fines')->withSuccess('تم فرض العقوبة: ' . Fine::getPenaltyTypeLabel($penaltyType)); return $this->redirect('/fines')->withSuccess('تم فرض العقوبة: ' . Fine::getPenaltyTypeLabel($penaltyType));
} }
...@@ -246,6 +259,33 @@ class FineController extends Controller ...@@ -246,6 +259,33 @@ class FineController extends Controller
return $this->redirect('/fines')->withSuccess('تم البت في التظلم: ' . match($decision) { 'upheld' => 'تأييد العقوبة', 'modified' => 'تعديل العقوبة', 'cancelled' => 'إلغاء العقوبة' }); return $this->redirect('/fines')->withSuccess('تم البت في التظلم: ' . match($decision) { 'upheld' => 'تأييد العقوبة', 'modified' => 'تعديل العقوبة', 'cancelled' => 'إلغاء العقوبة' });
} }
public function sendToQueue(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('الغرامة غير موجودة');
if ($fine['penalty_type'] !== 'fine') return $this->redirect('/fines')->withError('هذه العقوبة ليست غرامة مالية');
if ($fine['status'] === 'paid') return $this->redirect('/fines')->withError('الغرامة مسددة بالفعل');
$remaining = bcsub($fine['amount'], $fine['paid_amount'], 2);
if (bccomp($remaining, '0', 2) <= 0) return $this->redirect('/fines')->withError('الغرامة مسددة بالفعل');
$result = PaymentRequestService::createRequest([
'member_id' => (int) $fine['member_id'],
'payment_type' => 'fine',
'amount' => $remaining,
'description_ar' => 'غرامة مخالفة #' . ($fine['violation_id'] ?? $id),
'related_entity_type' => 'fines',
'related_entity_id' => (int) $id,
]);
if (!$result['success']) {
return $this->redirect('/fines')->withError($result['error']);
}
return $this->redirect('/fines')->withSuccess('تم إرسال الغرامة لطابور الدفع');
}
public function waive(Request $request, string $id): Response public function waive(Request $request, string $id): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -53,12 +53,35 @@ class ViolationController extends Controller ...@@ -53,12 +53,35 @@ class ViolationController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$proofFilePath = null;
if (!empty($_FILES['proof_file']['tmp_name']) && $_FILES['proof_file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['proof_file'];
$maxSize = 5 * 1024 * 1024;
if ($file['size'] > $maxSize) {
return $this->redirect("/violations/create/{$memberId}")->withError('حجم الملف يتجاوز الحد الأقصى (5 ميجا)');
}
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
if (!in_array($file['type'], $allowed)) {
return $this->redirect("/violations/create/{$memberId}")->withError('نوع الملف غير مسموح');
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$storedName = 'violation_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$destDir = App::getInstance()->basePath() . '/public/storage/uploads/violations';
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
move_uploaded_file($file['tmp_name'], $destDir . '/' . $storedName);
$proofFilePath = 'storage/uploads/violations/' . $storedName;
}
$violation = Violation::create([ $violation = Violation::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'violation_date' => $violationDate, 'violation_date' => $violationDate,
'description' => $description, 'description' => $description,
'reported_by' => $employee ? (int) $employee->id : null, 'reported_by' => $employee ? (int) $employee->id : null,
'evidence_notes' => $evidence ?: null, 'evidence_notes' => $evidence ?: null,
'proof_file' => $proofFilePath,
'status' => 'reported', 'status' => 'reported',
]); ]);
......
...@@ -16,7 +16,7 @@ class Violation extends Model ...@@ -16,7 +16,7 @@ class Violation extends Model
protected static bool $dispatchEvents = true; protected static bool $dispatchEvents = true;
protected static array $fillable = [ protected static array $fillable = [
'member_id', 'violation_date', 'description', 'reported_by', 'evidence_notes', 'status', 'member_id', 'violation_date', 'description', 'reported_by', 'evidence_notes', 'proof_file', 'status',
]; ];
public static function search(array $filters, int $perPage = 25, int $page = 1): array public static function search(array $filters, int $perPage = 25, int $page = 1): array
......
...@@ -8,6 +8,7 @@ return [ ...@@ -8,6 +8,7 @@ return [
['GET', '/fines', 'Fines\Controllers\FineController@index', ['auth'], 'fine.view'], ['GET', '/fines', 'Fines\Controllers\FineController@index', ['auth'], 'fine.view'],
['POST', '/fines/impose/{violationId}', 'Fines\Controllers\FineController@impose', ['auth', 'csrf'], 'fine.impose'], ['POST', '/fines/impose/{violationId}', 'Fines\Controllers\FineController@impose', ['auth', 'csrf'], 'fine.impose'],
['POST', '/fines/{id}/pay', 'Fines\Controllers\FineController@pay', ['auth', 'csrf'], 'fine.collect'], ['POST', '/fines/{id}/pay', 'Fines\Controllers\FineController@pay', ['auth', 'csrf'], 'fine.collect'],
['POST', '/fines/{id}/send-to-queue', 'Fines\Controllers\FineController@sendToQueue', ['auth', 'csrf'], 'fine.collect'],
['POST', '/fines/{id}/appeal', 'Fines\Controllers\FineController@submitAppeal', ['auth', 'csrf'], 'fine.view'], ['POST', '/fines/{id}/appeal', 'Fines\Controllers\FineController@submitAppeal', ['auth', 'csrf'], 'fine.view'],
['POST', '/fines/{id}/appeal-decide', 'Fines\Controllers\FineController@decideAppeal', ['auth', 'csrf'], 'fine.impose'], ['POST', '/fines/{id}/appeal-decide', 'Fines\Controllers\FineController@decideAppeal', ['auth', 'csrf'], 'fine.impose'],
['POST', '/fines/{id}/waive', 'Fines\Controllers\FineController@waive', ['auth', 'csrf'], 'fine.waive'], ['POST', '/fines/{id}/waive', 'Fines\Controllers\FineController@waive', ['auth', 'csrf'], 'fine.waive'],
......
...@@ -20,10 +20,9 @@ ...@@ -20,10 +20,9 @@
<td> <td>
<div style="display:flex;gap:5px;flex-wrap:wrap;"> <div style="display:flex;gap:5px;flex-wrap:wrap;">
<?php if (in_array($r['status'], ['imposed', 'appeal_upheld']) && $r['penalty_type'] === 'fine' && bccomp($r['amount'], $r['paid_amount'] ?? '0', 2) > 0 && can('fine.collect')): ?> <?php if (in_array($r['status'], ['imposed', 'appeal_upheld']) && $r['penalty_type'] === 'fine' && bccomp($r['amount'], $r['paid_amount'] ?? '0', 2) > 0 && can('fine.collect')): ?>
<form method="POST" action="/fines/<?= (int) $r['id'] ?>/pay" style="display:flex;gap:5px;"> <form method="POST" action="/fines/<?= (int) $r['id'] ?>/send-to-queue" style="display:inline;">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="payment_method" value="cash"> <button type="submit" class="btn btn-sm" style="background:#7C3AED;color:#fff;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;" title="إرسال لطابور الدفع">🏦 طابور الدفع</button>
<button type="submit" class="btn btn-sm btn-primary" onclick="return confirm('دفع غرامة <?= money(bcsub($r['amount'], $r['paid_amount'] ?? '0', 2)) ?>؟')">💰 ادفع <?= money(bcsub($r['amount'], $r['paid_amount'] ?? '0', 2)) ?></button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if ($r['status'] === 'imposed' && !$r['appeal_submitted']): ?> <?php if ($r['status'] === 'imposed' && !$r['appeal_submitted']): ?>
......
...@@ -2,13 +2,18 @@ ...@@ -2,13 +2,18 @@
<?php $__template->section('title'); ?>تسجيل مخالفة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?> <?php $__template->section('title'); ?>تسجيل مخالفة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php if (can('fine.impose')): ?> <?php if (can('fine.impose')): ?>
<form method="POST" action="/violations/store/<?= (int) $member['id'] ?>"> <form method="POST" action="/violations/store/<?= (int) $member['id'] ?>" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="card" style="padding:20px;"> <div class="card" style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"><label class="form-label">تاريخ المخالفة <span style="color:#DC2626;">*</span></label><input type="date" name="violation_date" value="<?= e(date('Y-m-d')) ?>" class="form-input" required></div> <div class="form-group"><label class="form-label">تاريخ المخالفة <span style="color:#DC2626;">*</span></label><input type="date" name="violation_date" value="<?= e(date('Y-m-d')) ?>" class="form-input" required></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">وصف المخالفة <span style="color:#DC2626;">*</span></label><textarea name="description" class="form-textarea" rows="4" required placeholder="وصف تفصيلي للمخالفة..."></textarea></div> <div class="form-group" style="grid-column:1/-1;"><label class="form-label">وصف المخالفة <span style="color:#DC2626;">*</span></label><textarea name="description" class="form-textarea" rows="4" required placeholder="وصف تفصيلي للمخالفة..."></textarea></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">أدلة / ملاحظات</label><textarea name="evidence_notes" class="form-textarea" rows="2"></textarea></div> <div class="form-group" style="grid-column:1/-1;"><label class="form-label">أدلة / ملاحظات</label><textarea name="evidence_notes" class="form-textarea" rows="2"></textarea></div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">ملف إثبات (صورة / مستند)</label>
<input type="file" name="proof_file" class="form-input" accept="image/*,.pdf,.doc,.docx" style="padding:8px;">
<small style="color:#6B7280;font-size:11px;">يقبل: صور، PDF، مستندات — الحد الأقصى 5 ميجا</small>
</div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary" style="margin-top:15px;">تسجيل المخالفة</button> <button type="submit" class="btn btn-primary" style="margin-top:15px;">تسجيل المخالفة</button>
......
...@@ -13,12 +13,13 @@ ...@@ -13,12 +13,13 @@
<button type="submit" class="btn btn-outline">بحث</button> <button type="submit" class="btn btn-outline">بحث</button>
</form> </form>
</div> </div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>التاريخ</th><th>العضو</th><th>الوصف</th><th>بواسطة</th><th>الحالة</th><th>الإجراءات</th></tr></thead><tbody> <div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>التاريخ</th><th>العضو</th><th>الوصف</th><th>إثبات</th><th>بواسطة</th><th>الحالة</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?> <?php foreach ($rows as $r): ?>
<tr> <tr>
<td style="font-size:13px;"><?= e($r['violation_date']) ?></td> <td style="font-size:13px;"><?= e($r['violation_date']) ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a></td> <td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a></td>
<td style="font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['description'], 0, 100)) ?></td> <td style="font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['description'], 0, 100)) ?></td>
<td style="text-align:center;"><?php if (!empty($r['proof_file'])): ?><a href="/<?= e($r['proof_file']) ?>" target="_blank" style="color:#2563EB;font-size:18px;" title="عرض ملف الإثبات">📎</a><?php else: ?><span style="color:#D1D5DB;"></span><?php endif; ?></td>
<td style="font-size:13px;"><?= e($r['reported_by_name'] ?? '—') ?></td> <td style="font-size:13px;"><?= e($r['reported_by_name'] ?? '—') ?></td>
<td><span style="color:<?= $r['status'] === 'reported' ? '#D97706' : '#059669' ?>;font-weight:600;"><?= $r['status'] === 'reported' ? 'مُبلّغ' : 'تم فرض عقوبة' ?></span></td> <td><span style="color:<?= $r['status'] === 'reported' ? '#D97706' : '#059669' ?>;font-weight:600;"><?= $r['status'] === 'reported' ? 'مُبلّغ' : 'تم فرض عقوبة' ?></span></td>
<td> <td>
...@@ -33,6 +34,6 @@ ...@@ -33,6 +34,6 @@
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="6" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مخالفات</td></tr><?php endif; ?> <?php if (empty($rows)): ?><tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مخالفات</td></tr><?php endif; ?>
</tbody></table></div></div> </tbody></table></div></div>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE violations ADD COLUMN proof_file VARCHAR(255) NULL AFTER evidence_notes",
'down' => "ALTER TABLE violations DROP COLUMN proof_file",
];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment