Commit c314a847 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add receipt template system, branch-scoped receptionist, and business logic fixes

- Receipt templates: configurable per-branch with field toggles, section ordering,
  and appearance settings (paper width, font size, currency symbol)
- ReceiptController for printing POS/payment receipts via thermal layout
- Receipt settings UI (Livewire) under Settings for branch managers
- Branch-scoped receptionist: all 4 wizards enforce user's branch_id
- Wallet freeze guard: deposit/withdraw now reject frozen wallets
- Overdue invoice job: daily command transitions sent→overdue past due_date
- Session generation scheduler: daily command creates upcoming training sessions
- Default receipt template created during setup wizard
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 69cb95b2
<?php
namespace App\Console\Commands;
use App\Domain\Training\Services\SessionGeneratorService;
use Illuminate\Console\Command;
class GenerateUpcomingSessions extends Command
{
protected $signature = 'sessions:generate-upcoming {--days=7 : Number of days ahead to generate}';
protected $description = 'Generate training sessions from group schedules for the upcoming period';
public function handle(SessionGeneratorService $service): int
{
$days = (int) $this->option('days');
$count = $service->generateUpcoming($days);
if ($count > 0) {
$this->info("Generated {$count} upcoming sessions for the next {$days} days.");
} else {
$this->info("No new sessions to generate.");
}
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Events\InvoiceOverdue;
use App\Domain\Financial\Models\Invoice;
use Illuminate\Console\Command;
class MarkOverdueInvoices extends Command
{
protected $signature = 'invoices:mark-overdue';
protected $description = 'Transition sent invoices to overdue when due_date has passed';
public function handle(): int
{
$count = 0;
Invoice::where('status', InvoiceStatus::Sent)
->whereNotNull('due_date')
->where('due_date', '<', now()->toDateString())
->chunkById(100, function ($invoices) use (&$count) {
foreach ($invoices as $invoice) {
$invoice->update(['status' => InvoiceStatus::Overdue]);
InvoiceOverdue::dispatch($invoice);
$count++;
}
});
if ($count > 0) {
$this->info("Marked {$count} invoices as overdue.");
}
return self::SUCCESS;
}
}
......@@ -31,6 +31,10 @@ public function deposit(Wallet $wallet, int $amount, string $description, ?Model
throw new InvalidArgumentException('Deposit amount must be positive');
}
if (!$wallet->is_active) {
throw new InvalidArgumentException('المحفظة مجمدة — لا يمكن الإيداع');
}
return DB::transaction(function () use ($wallet, $amount, $description, $reference, $creator) {
$wallet->lockForUpdate();
$balanceBefore = $wallet->balance;
......@@ -64,6 +68,10 @@ public function withdraw(Wallet $wallet, int $amount, string $description, ?Mode
throw new InvalidArgumentException('Withdrawal amount must be positive');
}
if (!$wallet->is_active) {
throw new InvalidArgumentException('المحفظة مجمدة — لا يمكن السحب');
}
return DB::transaction(function () use ($wallet, $amount, $description, $reference, $creator) {
$wallet->lockForUpdate();
......
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BranchSetting extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id',
'branch_id',
'key',
'value',
'group',
];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
}
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\BranchSetting;
class BranchSettingsService
{
/**
* Get a setting value for a branch. Falls back to academy-level system_settings.
*/
public function get(int $branchId, string $key, mixed $default = null): mixed
{
$setting = BranchSetting::where('branch_id', $branchId)
->where('key', $key)
->first();
if ($setting) {
return $setting->value;
}
// Fall back to academy-level setting
$academy = app('current_academy');
if ($academy) {
$systemSetting = \DB::table('system_settings')
->where('academy_id', $academy->id)
->where('key', $key)
->first();
if ($systemSetting) {
return $systemSetting->value;
}
}
return $default;
}
/**
* Set a branch-level setting.
*/
public function set(int $branchId, string $key, mixed $value, string $group = 'general'): void
{
$academy = app('current_academy');
BranchSetting::updateOrCreate(
['branch_id' => $branchId, 'key' => $key],
[
'academy_id' => $academy?->id,
'value' => is_array($value) ? json_encode($value) : (string) $value,
'group' => $group,
]
);
}
/**
* Get all settings for a branch (merged with academy defaults).
*/
public function allForBranch(int $branchId): array
{
$branchSettings = BranchSetting::where('branch_id', $branchId)
->pluck('value', 'key')
->toArray();
$academy = app('current_academy');
$academySettings = [];
if ($academy) {
$academySettings = \DB::table('system_settings')
->where('academy_id', $academy->id)
->pluck('value', 'key')
->toArray();
}
// Branch overrides academy
return array_merge($academySettings, $branchSettings);
}
/**
* Get receipt configuration for a branch.
*/
public function getReceiptConfig(int $branchId): array
{
$settings = BranchSetting::where('branch_id', $branchId)
->where('group', 'receipt')
->pluck('value', 'key')
->toArray();
return array_merge([
'receipt_header_show_logo' => 'true',
'receipt_header_show_branch_name' => 'true',
'receipt_header_show_branch_address' => 'true',
'receipt_header_show_branch_phone' => 'true',
'receipt_show_cashier' => 'true',
'receipt_show_participant' => 'true',
'receipt_show_tax' => 'true',
'receipt_show_discount_details' => 'true',
'receipt_show_payment_method' => 'true',
'receipt_show_invoice_number' => 'false',
'receipt_show_qr' => 'false',
'receipt_footer_text' => 'شكراً لزيارتكم',
'receipt_return_policy' => 'لا يمكن استرجاع المبلغ بعد 7 أيام',
'receipt_paper_width' => '80mm',
], $settings);
}
}
<?php
namespace App\Domain\POS\Models;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReceiptTemplate extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id',
'branch_id',
'name',
'name_ar',
'type',
'sections',
'visible_fields',
'settings',
'is_default',
'is_active',
'created_by',
];
protected function casts(): array
{
return [
'sections' => 'array',
'visible_fields' => 'array',
'settings' => 'array',
'is_default' => 'boolean',
'is_active' => 'boolean',
];
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeForBranch($query, int $branchId)
{
return $query->where(function ($q) use ($branchId) {
$q->where('branch_id', $branchId)
->orWhereNull('branch_id');
});
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Get the effective template for a branch + type.
* Branch-specific first, then academy-wide default.
*/
public static function resolveFor(int $branchId, string $type = 'pos'): ?self
{
// Branch-specific default
$template = static::where('branch_id', $branchId)
->where('type', $type)
->where('is_active', true)
->where('is_default', true)
->first();
if ($template) return $template;
// Academy-wide default (branch_id = null)
return static::whereNull('branch_id')
->where('type', $type)
->where('is_active', true)
->where('is_default', true)
->first();
}
/**
* Default field configuration for a new POS receipt template.
*/
public static function defaultPosFields(): array
{
return [
'header_logo' => true,
'header_academy_name' => true,
'header_branch_name' => true,
'header_branch_address' => true,
'header_branch_phone' => true,
'header_tax_number' => false,
'receipt_number' => true,
'receipt_date' => true,
'receipt_time' => true,
'cashier_name' => true,
'participant_name' => true,
'participant_code' => false,
'items_table' => true,
'item_description' => true,
'item_quantity' => true,
'item_unit_price' => true,
'item_discount' => true,
'item_total' => true,
'subtotal' => true,
'discount_total' => true,
'tax_amount' => true,
'grand_total' => true,
'payment_method' => true,
'payment_split_details' => true,
'coupon_code' => false,
'invoice_number' => false,
'footer_text' => true,
'footer_return_policy' => true,
'footer_thank_you' => true,
'qr_code' => false,
'barcode' => false,
];
}
/**
* Default section order for POS receipt.
*/
public static function defaultPosSections(): array
{
return [
['key' => 'header', 'label' => 'الترويسة', 'enabled' => true],
['key' => 'receipt_info', 'label' => 'معلومات الإيصال', 'enabled' => true],
['key' => 'customer_info', 'label' => 'معلومات العميل', 'enabled' => true],
['key' => 'items', 'label' => 'المنتجات/الخدمات', 'enabled' => true],
['key' => 'totals', 'label' => 'الإجماليات', 'enabled' => true],
['key' => 'payment', 'label' => 'طريقة الدفع', 'enabled' => true],
['key' => 'footer', 'label' => 'التذييل', 'enabled' => true],
];
}
/**
* Default settings for receipt appearance.
*/
public static function defaultSettings(): array
{
return [
'paper_width' => '80mm',
'font_size' => 'normal',
'show_borders' => true,
'logo_height' => '40px',
'footer_text' => 'شكراً لزيارتكم',
'return_policy' => 'لا يمكن استرجاع المبلغ بعد 7 أيام',
'currency_symbol' => 'ج.م',
'date_format' => 'Y/m/d',
'time_format' => 'H:i',
];
}
}
<?php
namespace App\Domain\POS\Services;
use App\Domain\Financial\Models\Payment;
use App\Domain\POS\Models\POSTransaction;
use App\Domain\POS\Models\ReceiptTemplate;
class ReceiptService
{
/**
* Build receipt data for a POS transaction.
*/
public function buildPosReceiptData(POSTransaction $transaction, ?ReceiptTemplate $template = null): array
{
$transaction->load(['items', 'participant.person', 'branch', 'processedBy', 'splitPayments', 'invoice']);
$template = $template ?? ReceiptTemplate::resolveFor($transaction->branch_id, 'pos');
$fields = $template?->visible_fields ?? ReceiptTemplate::defaultPosFields();
$settings = $template?->settings ?? ReceiptTemplate::defaultSettings();
$data = [
'template' => $template,
'fields' => $fields,
'settings' => $settings,
'sections' => $template?->sections ?? ReceiptTemplate::defaultPosSections(),
];
// Header
if ($fields['header_academy_name'] ?? true) {
$data['academy_name'] = app('current_academy')?->name_ar ?? '';
}
if ($fields['header_branch_name'] ?? true) {
$data['branch_name'] = $transaction->branch?->name_ar ?? '';
}
if ($fields['header_branch_address'] ?? true) {
$data['branch_address'] = $transaction->branch?->address ?? '';
}
if ($fields['header_branch_phone'] ?? true) {
$data['branch_phone'] = $transaction->branch?->phone ?? '';
}
// Receipt info
$data['receipt_number'] = $transaction->receipt_number;
$data['date'] = $transaction->processed_at?->format($settings['date_format'] ?? 'Y/m/d') ?? '';
$data['time'] = $transaction->processed_at?->format($settings['time_format'] ?? 'H:i') ?? '';
if ($fields['cashier_name'] ?? true) {
$data['cashier_name'] = $transaction->processedBy?->name_ar ?? $transaction->processedBy?->name ?? '';
}
// Customer
if ($fields['participant_name'] ?? true) {
$person = $transaction->participant?->person;
$data['participant_name'] = $person ? ($person->name_ar ?? $person->full_name) : __('عميل بدون بيانات');
}
if ($fields['participant_code'] ?? false) {
$data['participant_code'] = $transaction->participant?->code ?? '';
}
// Items
$data['items'] = $transaction->items->map(fn ($item) => [
'name' => $item->item_name_ar ?? $item->item_name,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'discount' => $item->discount_amount,
'total' => $item->line_total,
])->toArray();
// Totals
$data['subtotal'] = $transaction->subtotal;
$data['discount_amount'] = $transaction->discount_amount;
$data['tax_amount'] = $transaction->tax_amount;
$data['total_amount'] = $transaction->total_amount;
// Payment
$data['payment_method'] = $transaction->payment_method?->value ?? '';
$data['payment_method_label'] = $this->paymentMethodLabel($transaction->payment_method?->value);
if (($fields['payment_split_details'] ?? true) && $transaction->splitPayments->isNotEmpty()) {
$data['split_payments'] = $transaction->splitPayments->map(fn ($sp) => [
'method' => $this->paymentMethodLabel($sp->method),
'amount' => $sp->amount,
])->toArray();
}
if ($fields['coupon_code'] ?? false) {
$data['coupon_code'] = $transaction->coupon_code;
}
if ($fields['invoice_number'] ?? false) {
$data['invoice_number'] = $transaction->invoice?->invoice_number ?? '';
}
// Footer
$data['footer_text'] = $settings['footer_text'] ?? '';
$data['return_policy'] = $settings['return_policy'] ?? '';
$data['currency'] = $settings['currency_symbol'] ?? 'ج.م';
return $data;
}
/**
* Build receipt data for a standalone payment.
*/
public function buildPaymentReceiptData(Payment $payment, ?ReceiptTemplate $template = null): array
{
$payment->load(['invoice.participant.person', 'processedBy']);
$branchId = $payment->branch_id ?? $payment->invoice?->branch_id;
$template = $template ?? ReceiptTemplate::resolveFor($branchId ?? 0, 'payment');
$settings = $template?->settings ?? ReceiptTemplate::defaultSettings();
return [
'template' => $template,
'settings' => $settings,
'receipt_number' => $payment->reference ?? 'PAY-' . str_pad($payment->id, 6, '0', STR_PAD_LEFT),
'date' => $payment->paid_at?->format($settings['date_format'] ?? 'Y/m/d') ?? '',
'time' => $payment->paid_at?->format($settings['time_format'] ?? 'H:i') ?? '',
'participant_name' => $payment->invoice?->participant?->person?->name_ar ?? '',
'invoice_number' => $payment->invoice?->invoice_number ?? '',
'amount' => $payment->amount,
'method' => $this->paymentMethodLabel($payment->method?->value ?? $payment->method ?? ''),
'cashier_name' => $payment->processedBy?->name_ar ?? '',
'currency' => $settings['currency_symbol'] ?? 'ج.م',
'footer_text' => $settings['footer_text'] ?? '',
];
}
/**
* Create the default receipt template for an academy (called during setup).
*/
public function createDefaultTemplate(int $academyId, ?int $branchId = null): ReceiptTemplate
{
return ReceiptTemplate::create([
'academy_id' => $academyId,
'branch_id' => $branchId,
'name' => 'Default POS Receipt',
'name_ar' => 'إيصال نقطة البيع الافتراضي',
'type' => 'pos',
'sections' => ReceiptTemplate::defaultPosSections(),
'visible_fields' => ReceiptTemplate::defaultPosFields(),
'settings' => ReceiptTemplate::defaultSettings(),
'is_default' => true,
'is_active' => true,
]);
}
private function paymentMethodLabel(?string $method): string
{
return match ($method) {
'cash' => 'نقدي',
'card' => 'بطاقة',
'bank_transfer' => 'تحويل بنكي',
'wallet' => 'محفظة',
'cheque' => 'شيك',
'other' => 'أخرى',
default => $method ?? '',
};
}
}
<?php
namespace App\Http\Controllers;
use App\Domain\Financial\Models\Payment;
use App\Domain\POS\Models\POSTransaction;
use App\Domain\POS\Models\ReceiptTemplate;
use App\Domain\POS\Services\ReceiptService;
use Illuminate\Http\Request;
class ReceiptController extends Controller
{
public function __construct(
private ReceiptService $receiptService,
) {}
public function posPrint(POSTransaction $transaction)
{
$template = ReceiptTemplate::resolveFor(
$transaction->branch_id,
'pos'
);
$data = $this->receiptService->buildPosReceiptData($transaction, $template);
return view('receipts.pos', ['data' => $data]);
}
public function paymentPrint(Payment $payment)
{
$template = ReceiptTemplate::resolveFor(
$payment->branch_id ?? auth()->user()->branch_id,
'pos'
);
$data = $this->receiptService->buildPaymentReceiptData($payment, $template);
return view('receipts.pos', ['data' => $data]);
}
}
......@@ -15,6 +15,8 @@
#[Title('تحصيل مدفوعات')]
class CollectPaymentWizard extends Component
{
public ?int $branchId = null;
public int $currentStep = 1;
public int $totalSteps = 5;
......@@ -36,6 +38,18 @@ class CollectPaymentWizard extends Component
public ?string $receipt_number = null;
public int $paid_amount = 0;
public function mount(): void
{
$this->authorize('invoices.create');
$user = auth()->user();
$this->branchId = $user->branch_id;
if (!$this->branchId) {
abort(403, __('يجب تعيينك لفرع لتحصيل المدفوعات'));
}
}
public function rules(): array
{
return match ($this->currentStep) {
......@@ -160,6 +174,7 @@ public function render()
if (strlen($this->search) >= 2) {
$searchResults = Participant::query()
->with('person')
->where('branch_id', $this->branchId)
->where(function ($q) {
$search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%")
......
......@@ -15,6 +15,8 @@
#[Title('تسجيل في برنامج')]
class EnrollExistingWizard extends Component
{
public ?int $branchId = null;
public int $currentStep = 1;
public int $totalSteps = 4;
......@@ -34,6 +36,18 @@ class EnrollExistingWizard extends Component
// Result
public bool $completed = false;
public function mount(): void
{
$this->authorize('enrollments.create');
$user = auth()->user();
$this->branchId = $user->branch_id;
if (!$this->branchId) {
abort(403, __('يجب تعيينك لفرع لتسجيل المشتركين'));
}
}
public function rules(): array
{
return match ($this->currentStep) {
......@@ -130,6 +144,7 @@ public function render()
if (strlen($this->search) >= 2) {
$searchResults = Participant::query()
->with('person')
->where('branch_id', $this->branchId)
->where('status', 'active')
->where(function ($q) {
$search = $this->search;
......@@ -157,6 +172,7 @@ public function render()
$programs = TrainingProgram::where('activity_id', $this->selected_activity_id)
->where('status', 'active')
->whereNotIn('id', $enrolledProgramIds)
->whereHas('groups', fn ($q) => $q->where('branch_id', $this->branchId)->whereIn('status', ['active', 'forming']))
->orderBy('name_ar')
->get();
}
......
......@@ -15,6 +15,8 @@
#[Title('تسجيل جديد')]
class NewRegistrationWizard extends Component
{
public ?int $branchId = null;
public int $currentStep = 1;
public int $totalSteps = 6;
......@@ -46,6 +48,18 @@ class NewRegistrationWizard extends Component
public ?string $participant_number = null;
public ?string $enrollment_summary = null;
public function mount(): void
{
$this->authorize('participants.create');
$user = auth()->user();
$this->branchId = $user->branch_id;
if (!$this->branchId) {
abort(403, __('يجب تعيينك لفرع لتسجيل مشتركين جدد'));
}
}
public function rules(): array
{
return match ($this->currentStep) {
......@@ -135,6 +149,7 @@ public function confirm(ParticipantService $service): void
'date_of_birth' => $this->participant_date_of_birth,
'gender' => $this->participant_gender,
],
'branch_id' => $this->branchId,
'registration_source' => 'walk_in',
'medical_notes' => $this->participant_medical_notes ?: null,
'guardian' => [
......@@ -176,6 +191,7 @@ public function render()
if ($this->selected_activity_id) {
$programs = TrainingProgram::where('activity_id', $this->selected_activity_id)
->where('status', 'active')
->whereHas('groups', fn ($q) => $q->where('branch_id', $this->branchId)->whereIn('status', ['active', 'forming']))
->orderBy('name_ar')
->get();
}
......
......@@ -15,24 +15,49 @@
#[Title('مكتب الاستقبال')]
class ReceptionistDashboard extends Component
{
public ?int $branchId = null;
public string $branchName = '';
public function mount(): void
{
$this->authorize('participants.list');
$user = auth()->user();
$this->branchId = $user->branch_id;
if (!$this->branchId) {
// If user has no branch, they shouldn't use receptionist desk
abort(403, __('يجب تعيينك لفرع لاستخدام مكتب الاستقبال'));
}
$this->branchName = $user->branch?->name_ar ?? '';
}
public function render()
{
$today = now()->toDateString();
// Today's stats
// All queries scoped to this branch
$stats = [
'registrations_today' => Participant::whereDate('created_at', $today)->count(),
'payments_today' => Payment::where('status', 'confirmed')
'registrations_today' => Participant::where('branch_id', $this->branchId)
->whereDate('created_at', $today)->count(),
'payments_today' => Payment::where('branch_id', $this->branchId)
->where('status', 'confirmed')
->whereDate('created_at', $today)->sum('amount'),
'payments_count' => Payment::where('status', 'confirmed')
'payments_count' => Payment::where('branch_id', $this->branchId)
->where('status', 'confirmed')
->whereDate('created_at', $today)->count(),
'sessions_in_progress' => TrainingSession::where('session_date', $today)
'sessions_in_progress' => TrainingSession::whereHas('group', fn ($q) => $q->where('branch_id', $this->branchId))
->where('session_date', $today)
->where('status', 'in_progress')->count(),
'sessions_today' => TrainingSession::where('session_date', $today)->count(),
'sessions_today' => TrainingSession::whereHas('group', fn ($q) => $q->where('branch_id', $this->branchId))
->where('session_date', $today)->count(),
];
// Attendance summary for today
$todaySessions = TrainingSession::where('session_date', $today)->pluck('id');
// Attendance summary for today (branch-scoped)
$todaySessions = TrainingSession::whereHas('group', fn ($q) => $q->where('branch_id', $this->branchId))
->where('session_date', $today)->pluck('id');
$stats['checked_in'] = AttendanceRecord::whereIn('training_session_id', $todaySessions)
->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late])
->count();
......
<?php
namespace App\Livewire\Settings;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\POS\Models\ReceiptTemplate;
use App\Domain\POS\Services\ReceiptService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إعدادات الإيصال')]
class ReceiptSettings extends Component
{
public ?int $selectedBranchId = null;
public array $visibleFields = [];
public array $sections = [];
public array $settings = [];
public bool $saved = false;
public function mount(): void
{
$this->authorize('settings.manage');
$user = auth()->user();
$this->selectedBranchId = $user->branch_id;
$this->loadTemplate();
}
public function updatedSelectedBranchId(): void
{
$this->loadTemplate();
}
public function loadTemplate(): void
{
$template = $this->selectedBranchId
? ReceiptTemplate::resolveFor($this->selectedBranchId, 'pos')
: null;
$this->visibleFields = $template?->visible_fields ?? ReceiptTemplate::defaultPosFields();
$this->sections = $template?->sections ?? ReceiptTemplate::defaultPosSections();
$this->settings = $template?->settings ?? ReceiptTemplate::defaultSettings();
$this->saved = false;
}
public function toggleField(string $key): void
{
$this->visibleFields[$key] = !($this->visibleFields[$key] ?? true);
$this->saved = false;
}
public function toggleSection(int $index): void
{
if (isset($this->sections[$index])) {
$this->sections[$index]['enabled'] = !$this->sections[$index]['enabled'];
$this->saved = false;
}
}
public function moveSectionUp(int $index): void
{
if ($index > 0) {
$temp = $this->sections[$index - 1];
$this->sections[$index - 1] = $this->sections[$index];
$this->sections[$index] = $temp;
$this->saved = false;
}
}
public function moveSectionDown(int $index): void
{
if ($index < count($this->sections) - 1) {
$temp = $this->sections[$index + 1];
$this->sections[$index + 1] = $this->sections[$index];
$this->sections[$index] = $temp;
$this->saved = false;
}
}
public function save(): void
{
if (!$this->selectedBranchId) {
$this->addError('branch', __('يرجى اختيار الفرع'));
return;
}
$academy = app('current_academy');
if (!$academy) return;
$template = ReceiptTemplate::where('academy_id', $academy->id)
->where('branch_id', $this->selectedBranchId)
->where('type', 'pos')
->where('is_default', true)
->first();
if (!$template) {
$template = ReceiptTemplate::create([
'academy_id' => $academy->id,
'branch_id' => $this->selectedBranchId,
'name' => 'POS Receipt',
'name_ar' => 'إيصال نقطة البيع',
'type' => 'pos',
'is_default' => true,
'is_active' => true,
'created_by' => auth()->id(),
'sections' => $this->sections,
'visible_fields' => $this->visibleFields,
'settings' => $this->settings,
]);
} else {
$template->update([
'sections' => $this->sections,
'visible_fields' => $this->visibleFields,
'settings' => $this->settings,
]);
}
$this->saved = true;
session()->flash('success', __('تم حفظ إعدادات الإيصال'));
}
public function resetToDefaults(): void
{
$this->visibleFields = ReceiptTemplate::defaultPosFields();
$this->sections = ReceiptTemplate::defaultPosSections();
$this->settings = ReceiptTemplate::defaultSettings();
$this->saved = false;
}
public function render()
{
$branches = Branch::where('is_active', true)->orderBy('name_ar')->get();
$fieldLabels = [
'header_logo' => 'شعار المنشأة',
'header_academy_name' => 'اسم الأكاديمية',
'header_branch_name' => 'اسم الفرع',
'header_branch_address' => 'عنوان الفرع',
'header_branch_phone' => 'هاتف الفرع',
'header_tax_number' => 'الرقم الضريبي',
'receipt_number' => 'رقم الإيصال',
'receipt_date' => 'التاريخ',
'receipt_time' => 'الوقت',
'cashier_name' => 'اسم الكاشير',
'participant_name' => 'اسم العميل',
'participant_code' => 'كود المشترك',
'items_table' => 'جدول الأصناف',
'item_description' => 'وصف الصنف',
'item_quantity' => 'الكمية',
'item_unit_price' => 'سعر الوحدة',
'item_discount' => 'خصم الصنف',
'item_total' => 'إجمالي الصنف',
'subtotal' => 'المجموع الفرعي',
'discount_total' => 'إجمالي الخصم',
'tax_amount' => 'الضريبة',
'grand_total' => 'الإجمالي النهائي',
'payment_method' => 'طريقة الدفع',
'payment_split_details' => 'تفاصيل الدفع المقسم',
'coupon_code' => 'كود الكوبون',
'invoice_number' => 'رقم الفاتورة',
'footer_text' => 'نص التذييل',
'footer_return_policy' => 'سياسة الإرجاع',
'footer_thank_you' => 'رسالة الشكر',
'qr_code' => 'كود QR',
'barcode' => 'باركود',
];
return view('livewire.settings.receipt-settings', [
'branches' => $branches,
'fieldLabels' => $fieldLabels,
]);
}
}
......@@ -490,6 +490,10 @@ public function completeSetup(): void
$settingsService->set('grace_period_trainer', (string) $this->gracePeriodTrainer, 'attendance', 'integer');
$settingsService->set('max_discount_percent', (string) $this->maxDiscountPercent, 'pricing', 'integer');
// Create default receipt template
$receiptService = app(\App\Domain\POS\Services\ReceiptService::class);
$receiptService->createDefaultTemplate($academyId);
// Mark setup as complete
$settingsService->set('setup_completed', 'true', 'general', 'boolean');
});
......
<?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
{
// Branch-level settings (overrides academy settings per branch)
Schema::create('branch_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('organizations');
$table->foreignId('branch_id')->constrained('branches');
$table->string('key', 100);
$table->text('value')->nullable();
$table->string('group', 50)->default('general');
$table->timestamps();
$table->unique(['branch_id', 'key']);
$table->index(['academy_id', 'branch_id', 'group']);
});
// Receipt templates — configurable per branch
Schema::create('receipt_templates', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('organizations');
$table->foreignId('branch_id')->nullable()->constrained('branches');
$table->string('name', 100);
$table->string('name_ar', 100);
$table->string('type', 30)->default('pos');
$table->jsonb('sections')->default('[]');
$table->jsonb('visible_fields')->default('[]');
$table->jsonb('settings')->default('{}');
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->foreignId('created_by')->nullable()->constrained('users');
$table->timestamps();
$table->index(['academy_id', 'branch_id', 'type']);
});
DB::statement("ALTER TABLE receipt_templates ADD CONSTRAINT receipt_templates_type_check CHECK (type IN ('pos', 'invoice', 'enrollment', 'refund', 'payment'))");
}
public function down(): void
{
Schema::dropIfExists('receipt_templates');
Schema::dropIfExists('branch_settings');
}
};
......@@ -67,6 +67,7 @@
['label' => 'إعدادات الأكاديمية', 'route' => 'settings.academy', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'],
['label' => 'الهوية البصرية', 'route' => 'settings.branding', 'icon' => 'swatch', 'permission' => 'settings.manage'],
['label' => 'إعدادات النظام', 'route' => 'settings.system', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'],
['label' => 'إعدادات الإيصال', 'route' => 'settings.receipts', 'icon' => 'receipt-percent', 'permission' => 'settings.manage'],
['label' => 'معالج الإعداد', 'route' => 'setup.wizard', 'icon' => 'bolt', 'permission' => 'settings.manage'],
['label' => 'لوحة المشرف', 'route' => 'admin.panel', 'icon' => 'shield-check', 'permission' => 'settings.manage'],
]],
......
<div class="space-y-6">
{{-- Page Header --}}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">{{ __('إعدادات الإيصال') }}</h1>
<div class="flex items-center gap-3">
<button wire:click="resetToDefaults" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
{{ __('استعادة الافتراضي') }}
</button>
<button wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
<span wire:loading.remove wire:target="save">{{ __('حفظ الإعدادات') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ...') }}</span>
</button>
</div>
</div>
@if($saved)
<div class="p-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg">
{{ __('تم حفظ إعدادات الإيصال بنجاح') }}
</div>
@endif
@error('branch')
<div class="p-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ $message }}</div>
@enderror
{{-- Branch Selector --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('الفرع') }}</label>
<select wire:model.live="selectedBranchId" class="w-full max-w-sm rounded-lg border-gray-300 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('-- اختر الفرع --') }}</option>
@foreach($branches as $branch)
<option value="{{ $branch->id }}">{{ $branch->name_ar }}</option>
@endforeach
</select>
</div>
@if($selectedBranchId)
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Sections Order --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('ترتيب الأقسام') }}</h2>
<div class="space-y-2">
@foreach($sections as $index => $section)
<div class="flex items-center gap-3 p-3 rounded-lg border {{ $section['enabled'] ? 'border-blue-200 bg-blue-50' : 'border-gray-200 bg-gray-50' }}">
<div class="flex flex-col gap-0.5">
<button wire:click="moveSectionUp({{ $index }})" @if($index === 0) disabled @endif
class="text-gray-400 hover:text-gray-700 disabled:opacity-30 disabled:cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
</button>
<button wire:click="moveSectionDown({{ $index }})" @if($index === count($sections) - 1) disabled @endif
class="text-gray-400 hover:text-gray-700 disabled:opacity-30 disabled:cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
</div>
<label class="flex items-center flex-1 cursor-pointer">
<input type="checkbox" wire:click="toggleSection({{ $index }})" {{ $section['enabled'] ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 ms-2">
<span class="text-sm font-medium {{ $section['enabled'] ? 'text-gray-900' : 'text-gray-400' }}">
{{ $section['label'] }}
</span>
</label>
</div>
@endforeach
</div>
</div>
{{-- Field Toggles --}}
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('الحقول المعروضة') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach($fieldLabels as $key => $label)
<label class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors
{{ ($visibleFields[$key] ?? true) ? 'border-green-200 bg-green-50 hover:bg-green-100' : 'border-gray-200 bg-gray-50 hover:bg-gray-100' }}">
<input type="checkbox" wire:click="toggleField('{{ $key }}')" {{ ($visibleFields[$key] ?? true) ? 'checked' : '' }}
class="rounded border-gray-300 text-green-600 focus:ring-green-500">
<span class="text-sm {{ ($visibleFields[$key] ?? true) ? 'text-gray-900' : 'text-gray-400' }}">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
</div>
{{-- Receipt Settings --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('إعدادات المظهر') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('عرض الورق') }}</label>
<select wire:model="settings.paper_width" class="w-full rounded-lg border-gray-300 focus:ring-blue-500">
<option value="58mm">58mm (حراري صغير)</option>
<option value="80mm">80mm (حراري عادي)</option>
<option value="110mm">110mm (عريض)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('حجم الخط') }}</label>
<select wire:model="settings.font_size" class="w-full rounded-lg border-gray-300 focus:ring-blue-500">
<option value="small">{{ __('صغير') }}</option>
<option value="normal">{{ __('عادي') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ارتفاع الشعار') }}</label>
<select wire:model="settings.logo_height" class="w-full rounded-lg border-gray-300 focus:ring-blue-500">
<option value="30px">30px</option>
<option value="40px">40px</option>
<option value="50px">50px</option>
<option value="60px">60px</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('رمز العملة') }}</label>
<input type="text" wire:model="settings.currency_symbol" class="w-full rounded-lg border-gray-300 focus:ring-blue-500" placeholder="ج.م">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نص التذييل') }}</label>
<input type="text" wire:model="settings.footer_text" class="w-full rounded-lg border-gray-300 focus:ring-blue-500" placeholder="شكراً لزيارتكم">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سياسة الإرجاع') }}</label>
<input type="text" wire:model="settings.return_policy" class="w-full rounded-lg border-gray-300 focus:ring-blue-500" placeholder="لا يمكن استرجاع المبلغ بعد 7 أيام">
</div>
</div>
</div>
@endif
</div>
@php
$fields = $data['fields'] ?? [];
$settings = $data['settings'] ?? [];
$sections = $data['sections'] ?? [];
$currency = $settings['currency_symbol'] ?? 'ج.م';
$sectionEnabled = fn(string $key) => collect($sections)->firstWhere('key', $key)['enabled'] ?? true;
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="ar">
<head>
<meta charset="UTF-8">
<title>{{ __('إيصال') }} #{{ $data['receipt_number'] ?? '' }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Cairo', 'Noto Sans Arabic', sans-serif;
font-size: {{ ($settings['font_size'] ?? 'normal') === 'small' ? '11px' : '13px' }};
width: {{ $settings['paper_width'] ?? '80mm' }};
padding: 8px;
color: #000;
}
.text-center { text-align: center; }
.text-end { text-align: left; }
.text-start { text-align: right; }
.font-bold { font-weight: bold; }
.mt-2 { margin-top: 8px; }
.mt-1 { margin-top: 4px; }
.mb-2 { margin-bottom: 8px; }
.border-top { border-top: 1px dashed #000; padding-top: 6px; margin-top: 6px; }
.border-bottom { border-bottom: 1px dashed #000; padding-bottom: 6px; margin-bottom: 6px; }
.flex { display: flex; justify-content: space-between; }
.text-sm { font-size: 11px; }
.text-xs { font-size: 10px; }
.text-lg { font-size: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 3px 2px; text-align: right; font-size: 11px; }
th { border-bottom: 1px solid #000; font-weight: bold; }
td.num { text-align: left; direction: ltr; }
.total-row { font-weight: bold; font-size: 14px; }
@media print {
body { width: {{ $settings['paper_width'] ?? '80mm' }}; }
@page { size: {{ $settings['paper_width'] ?? '80mm' }} auto; margin: 0; }
}
</style>
</head>
<body>
{{-- Header --}}
@if($sectionEnabled('header'))
<div class="text-center mb-2 border-bottom">
@if(($fields['header_logo'] ?? true) && isset($data['logo_url']))
<img src="{{ $data['logo_url'] }}" style="height: {{ $settings['logo_height'] ?? '40px' }}; margin: 0 auto 6px;">
@endif
@if($fields['header_academy_name'] ?? true)
<div class="font-bold text-lg">{{ $data['academy_name'] ?? '' }}</div>
@endif
@if($fields['header_branch_name'] ?? true)
<div>{{ $data['branch_name'] ?? '' }}</div>
@endif
@if(($fields['header_branch_address'] ?? true) && !empty($data['branch_address']))
<div class="text-xs">{{ $data['branch_address'] }}</div>
@endif
@if(($fields['header_branch_phone'] ?? true) && !empty($data['branch_phone']))
<div class="text-xs" dir="ltr">{{ $data['branch_phone'] }}</div>
@endif
</div>
@endif
{{-- Receipt info --}}
@if($sectionEnabled('receipt_info'))
<div class="border-bottom">
<div class="flex">
<span>{{ __('رقم الإيصال') }}:</span>
<span class="font-bold" dir="ltr">{{ $data['receipt_number'] ?? '' }}</span>
</div>
@if($fields['receipt_date'] ?? true)
<div class="flex">
<span>{{ __('التاريخ') }}:</span>
<span dir="ltr">{{ $data['date'] ?? '' }}</span>
</div>
@endif
@if($fields['receipt_time'] ?? true)
<div class="flex">
<span>{{ __('الوقت') }}:</span>
<span dir="ltr">{{ $data['time'] ?? '' }}</span>
</div>
@endif
@if(($fields['cashier_name'] ?? true) && !empty($data['cashier_name']))
<div class="flex">
<span>{{ __('الكاشير') }}:</span>
<span>{{ $data['cashier_name'] }}</span>
</div>
@endif
</div>
@endif
{{-- Customer info --}}
@if($sectionEnabled('customer_info') && ($fields['participant_name'] ?? true) && !empty($data['participant_name']))
<div class="border-bottom">
<div class="flex">
<span>{{ __('العميل') }}:</span>
<span>{{ $data['participant_name'] }}</span>
</div>
@if(($fields['participant_code'] ?? false) && !empty($data['participant_code']))
<div class="flex text-xs">
<span>{{ __('الكود') }}:</span>
<span dir="ltr">{{ $data['participant_code'] }}</span>
</div>
@endif
</div>
@endif
{{-- Items --}}
@if($sectionEnabled('items') && ($fields['items_table'] ?? true))
<table class="mt-1 mb-2">
<thead>
<tr>
<th>{{ __('الصنف') }}</th>
@if($fields['item_quantity'] ?? true)<th class="num">{{ __('كمية') }}</th>@endif
@if($fields['item_unit_price'] ?? true)<th class="num">{{ __('سعر') }}</th>@endif
@if($fields['item_discount'] ?? true)<th class="num">{{ __('خصم') }}</th>@endif
@if($fields['item_total'] ?? true)<th class="num">{{ __('إجمالي') }}</th>@endif
</tr>
</thead>
<tbody>
@foreach($data['items'] ?? [] as $item)
<tr>
<td>{{ $item['name'] }}</td>
@if($fields['item_quantity'] ?? true)<td class="num">{{ $item['quantity'] }}</td>@endif
@if($fields['item_unit_price'] ?? true)<td class="num">{{ number_format($item['unit_price'] / 100, 2) }}</td>@endif
@if($fields['item_discount'] ?? true)<td class="num">{{ $item['discount'] > 0 ? number_format($item['discount'] / 100, 2) : '-' }}</td>@endif
@if($fields['item_total'] ?? true)<td class="num">{{ number_format($item['total'] / 100, 2) }}</td>@endif
</tr>
@endforeach
</tbody>
</table>
@endif
{{-- Totals --}}
@if($sectionEnabled('totals'))
<div class="border-top">
@if($fields['subtotal'] ?? true)
<div class="flex">
<span>{{ __('المجموع') }}:</span>
<span dir="ltr">{{ number_format(($data['subtotal'] ?? 0) / 100, 2) }} {{ $currency }}</span>
</div>
@endif
@if(($fields['discount_total'] ?? true) && ($data['discount_amount'] ?? 0) > 0)
<div class="flex">
<span>{{ __('الخصم') }}:</span>
<span dir="ltr">-{{ number_format($data['discount_amount'] / 100, 2) }} {{ $currency }}</span>
</div>
@endif
@if(($fields['tax_amount'] ?? true) && ($data['tax_amount'] ?? 0) > 0)
<div class="flex">
<span>{{ __('الضريبة') }}:</span>
<span dir="ltr">{{ number_format($data['tax_amount'] / 100, 2) }} {{ $currency }}</span>
</div>
@endif
<div class="flex total-row mt-1 border-top">
<span>{{ __('الإجمالي') }}:</span>
<span dir="ltr">{{ number_format(($data['total_amount'] ?? 0) / 100, 2) }} {{ $currency }}</span>
</div>
</div>
@endif
{{-- Payment method --}}
@if($sectionEnabled('payment') && ($fields['payment_method'] ?? true))
<div class="mt-2 border-top">
<div class="flex">
<span>{{ __('طريقة الدفع') }}:</span>
<span>{{ $data['payment_method_label'] ?? '' }}</span>
</div>
@if(($fields['payment_split_details'] ?? true) && !empty($data['split_payments']))
@foreach($data['split_payments'] as $sp)
<div class="flex text-xs">
<span>{{ $sp['method'] }}:</span>
<span dir="ltr">{{ number_format($sp['amount'] / 100, 2) }} {{ $currency }}</span>
</div>
@endforeach
@endif
@if(($fields['invoice_number'] ?? false) && !empty($data['invoice_number']))
<div class="flex text-xs mt-1">
<span>{{ __('رقم الفاتورة') }}:</span>
<span dir="ltr">{{ $data['invoice_number'] }}</span>
</div>
@endif
</div>
@endif
{{-- Footer --}}
@if($sectionEnabled('footer'))
<div class="mt-2 border-top text-center text-xs">
@if(($fields['footer_text'] ?? true) && !empty($data['footer_text']))
<p class="mt-1 font-bold">{{ $data['footer_text'] }}</p>
@endif
@if(($fields['footer_return_policy'] ?? true) && !empty($data['return_policy']))
<p class="mt-1">{{ $data['return_policy'] }}</p>
@endif
@if(($fields['footer_thank_you'] ?? true))
<p class="mt-2 font-bold">{{ __('شكراً لزيارتكم') }}</p>
@endif
</div>
@endif
<script>window.onload = function() { window.print(); }</script>
</body>
</html>
......@@ -4,4 +4,6 @@
Schedule::command('attendance:mark-absent')->hourly();
Schedule::command('attendance:enforce-thresholds')->dailyAt('06:00');
Schedule::command('invoices:mark-overdue')->dailyAt('01:00');
Schedule::command('sessions:generate-upcoming')->dailyAt('02:00');
Schedule::command('audit:cleanup --days=365')->weekly();
......@@ -52,6 +52,7 @@
use App\Livewire\Pricing\PromotionList;
use App\Livewire\Programs\ProgramForm;
use App\Livewire\Programs\ProgramList;
use App\Livewire\Settings\ReceiptSettings;
use App\Livewire\Settings\SystemSettingsForm;
use App\Livewire\Inventory\MovementList;
use App\Livewire\Inventory\ProductForm as InventoryProductForm;
......@@ -302,6 +303,16 @@
->middleware('permission:settings.manage');
Route::get('/settings/branding', \App\Livewire\Settings\BrandingSettings::class)->name('settings.branding')
->middleware('permission:settings.manage');
Route::get('/settings/receipts', ReceiptSettings::class)->name('settings.receipts')
->middleware('permission:settings.manage');
// Receipt Print
Route::get('/receipts/pos/{transaction}', [\App\Http\Controllers\ReceiptController::class, 'posPrint'])
->name('receipts.pos.print')
->middleware('permission:pos.sell');
Route::get('/receipts/payment/{payment}', [\App\Http\Controllers\ReceiptController::class, 'paymentPrint'])
->name('receipts.payment.print')
->middleware('permission:invoices.view');
// SuperAdmin
Route::get('/admin', SuperAdminPanel::class)->name('admin.panel')
......
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