Commit 04dcc7e4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add branch persistence + expense cancellation with reversing entries

Branch: saves preferred_branch_id to users table on switch, restores
on next login/session so you stay on the branch you last picked.

Expenses: adds status column (active/cancelled) with cancel button that
creates a reversing double-entry transaction. Cancelled expenses remain
visible but excluded from totals.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ea923df7
......@@ -26,6 +26,10 @@ class Expense extends Model
'receipt_reference',
'expense_date',
'notes',
'status',
'cancelled_by',
'cancelled_at',
'cancellation_reason',
'created_by',
];
......@@ -36,6 +40,7 @@ protected function casts(): array
'amount' => 'integer',
'payment_method' => PaymentMethod::class,
'expense_date' => 'date',
'cancelled_at' => 'datetime',
];
}
......@@ -48,4 +53,9 @@ public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function canceller(): BelongsTo
{
return $this->belongsTo(User::class, 'cancelled_by');
}
}
......@@ -143,6 +143,44 @@ public function recordExternalRevenue(array $data, User $actor): Expense
});
}
public function cancelExpense(Expense $expense, string $reason, User $actor): Expense
{
if ($expense->status === 'cancelled') {
throw new DomainException('هذا المصروف ملغى بالفعل');
}
return DB::transaction(function () use ($expense, $reason, $actor) {
$expense->update([
'status' => 'cancelled',
'cancelled_by' => $actor->id,
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$originalTransaction = Transaction::where('reference_type', get_class($expense))
->where('reference_id', $expense->id)
->first();
if ($originalTransaction) {
Transaction::create([
'academy_id' => $expense->academy_id,
'debit_account_id' => $originalTransaction->credit_account_id,
'credit_account_id' => $originalTransaction->debit_account_id,
'reference_type' => get_class($expense),
'reference_id' => $expense->id,
'amount' => $expense->amount,
'currency' => 'EGP',
'type' => TransactionType::Adjustment,
'description' => "إلغاء مصروف: {$expense->description}",
'transaction_date' => now()->toDateString(),
'created_by' => $actor->id,
]);
}
return $expense;
});
}
private function createExpenseTransaction(
int $academyId,
string $expenseAccountCode,
......
......@@ -15,8 +15,8 @@ public function mount(): void
$value = session('active_branch_id');
$this->selectedBranch = $value === null ? 'all' : (string) $value;
} else {
// First visit — default to user's assigned branch or first active
$branchId = auth()->user()->branch_id;
$user = auth()->user();
$branchId = $user->preferred_branch_id ?? $user->branch_id;
if (!$branchId) {
$first = Branch::where('is_active', true)->first();
$branchId = $first?->id;
......@@ -30,8 +30,11 @@ public function updatedSelectedBranch($value): void
{
if ($value === 'all') {
session(['active_branch_id' => null]);
auth()->user()->update(['preferred_branch_id' => null]);
} else {
session(['active_branch_id' => (int) $value]);
$branchId = (int) $value;
session(['active_branch_id' => $branchId]);
auth()->user()->update(['preferred_branch_id' => $branchId]);
}
$this->dispatch('branch-switched');
......
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Enums\ExpenseCategory;
use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -29,6 +30,10 @@ class ExpenseList extends Component
#[Url]
public ?string $to_date = null;
public bool $showCancelModal = false;
public ?string $cancellingExpenseUuid = null;
public string $cancellationReason = '';
public function updatedSearch(): void
{
$this->resetPage();
......@@ -39,6 +44,36 @@ public function updatedCategoryFilter(): void
$this->resetPage();
}
public function openCancelModal(string $uuid): void
{
$this->cancellingExpenseUuid = $uuid;
$this->cancellationReason = '';
$this->showCancelModal = true;
}
public function confirmCancel(ExpenseService $service): void
{
$this->validate([
'cancellationReason' => 'required|min:3|max:500',
], [
'cancellationReason.required' => 'يرجى كتابة سبب الإلغاء',
'cancellationReason.min' => 'السبب قصير جداً',
]);
$expense = Expense::where('uuid', $this->cancellingExpenseUuid)->firstOrFail();
try {
$service->cancelExpense($expense, $this->cancellationReason, auth()->user());
session()->flash('success', __('تم إلغاء المصروف بنجاح'));
} catch (\App\Domain\Shared\Exceptions\DomainException $e) {
session()->flash('error', $e->getMessage());
}
$this->showCancelModal = false;
$this->cancellingExpenseUuid = null;
$this->cancellationReason = '';
}
public function render()
{
$branchId = $this->getActiveBranchId();
......@@ -56,6 +91,7 @@ public function render()
->paginate(20);
$totalAmount = Expense::query()
->where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->category_filter, fn ($q) => $q->where('category', $this->category_filter))
->when($this->from_date, fn ($q) => $q->where('expense_date', '>=', $this->from_date))
......
......@@ -38,6 +38,7 @@ class User extends Authenticatable
'is_super_admin',
'preferred_locale',
'preferred_timezone',
'preferred_branch_id',
'last_login_at',
'last_login_ip',
'login_count',
......
<?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::table('users', function (Blueprint $table) {
$table->foreignId('preferred_branch_id')->nullable()->after('preferred_timezone')->constrained('branches')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('preferred_branch_id');
});
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('expenses', function (Blueprint $table) {
$table->string('status', 20)->default('active')->after('notes');
$table->foreignId('cancelled_by')->nullable()->after('status')->constrained('users');
$table->timestamp('cancelled_at')->nullable()->after('cancelled_by');
$table->string('cancellation_reason', 500)->nullable()->after('cancelled_at');
});
DB::statement("ALTER TABLE expenses ADD CONSTRAINT expenses_status_check CHECK (status IN ('active', 'cancelled'))");
}
public function down(): void
{
DB::statement("ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_status_check");
Schema::table('expenses', function (Blueprint $table) {
$table->dropConstrainedForeignId('cancelled_by');
$table->dropColumn(['status', 'cancelled_at', 'cancellation_reason']);
});
}
};
......@@ -13,6 +13,9 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounde
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ session('error') }}</div>
@endif
{{-- Summary Card --}}
<div class="mb-4 p-4 bg-gradient-to-l from-red-50 to-white border border-red-200 rounded-xl">
......@@ -57,12 +60,14 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('المبلغ') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('المستلم') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('طريقة الدفع') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('بواسطة') }}</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500">{{ __('إجراءات') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($expenses as $expense)
<tr class="hover:bg-gray-50">
<tr class="hover:bg-gray-50 {{ $expense->status === 'cancelled' ? 'opacity-60' : '' }}">
<td class="px-4 py-3 text-gray-600" dir="ltr">{{ $expense->expense_date?->format('Y-m-d') }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
......@@ -84,11 +89,28 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
@endphp
{{ $methodLabels[$expense->payment_method->value ?? $expense->payment_method] ?? $expense->payment_method }}
</td>
<td class="px-4 py-3">
@if($expense->status === 'cancelled')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="{{ $expense->cancellation_reason }}">{{ __('ملغى') }}</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{{ __('نشط') }}</span>
@endif
</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ $expense->creator?->name }}</td>
<td class="px-4 py-3 text-center">
@if($expense->status !== 'cancelled')
<button wire:click="openCancelModal('{{ $expense->uuid }}')"
class="text-red-600 hover:text-red-800 text-xs font-medium">
{{ __('إلغاء') }}
</button>
@else
<span class="text-gray-400 text-xs"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">{{ __('لا توجد مصروفات مسجلة') }}</td>
<td colspan="9" class="px-4 py-8 text-center text-gray-400">{{ __('لا توجد مصروفات مسجلة') }}</td>
</tr>
@endforelse
</tbody>
......@@ -99,4 +121,35 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
<div class="px-4 py-3 border-t">{{ $expenses->links() }}</div>
@endif
</div>
{{-- Cancel Modal --}}
@if($showCancelModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" wire:click.self="$set('showCancelModal', false)">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ __('إلغاء المصروف') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('سيتم عكس القيد المالي لهذا المصروف. المصروف سيبقى مرئياً بحالة "ملغى".') }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سبب الإلغاء') }} <span class="text-red-500">*</span></label>
<textarea wire:model="cancellationReason" rows="3"
class="w-full rounded-lg border-gray-300 text-sm focus:ring-red-500 focus:border-red-500"
placeholder="{{ __('مثال: تم تسجيل المصروف بالغلط في فرع آخر') }}"></textarea>
@error('cancellationReason')
<p class="text-xs text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-3 justify-end">
<button wire:click="$set('showCancelModal', false)"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 rounded-lg border border-gray-300">
{{ __('تراجع') }}
</button>
<button wire:click="confirmCancel"
wire:loading.attr="disabled"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
<span wire:loading.remove wire:target="confirmCancel">{{ __('تأكيد الإلغاء') }}</span>
<span wire:loading wire:target="confirmCancel">{{ __('جارٍ الإلغاء...') }}</span>
</button>
</div>
</div>
</div>
@endif
</div>
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