Implement dark mode, bug report page, and schema dump

This commit is contained in:
2025-11-27 15:06:45 +08:00
parent 13bc6db529
commit 83602b1ed1
91 changed files with 1078 additions and 2291 deletions

View File

@@ -10,6 +10,15 @@ class BankReconciliation extends Model
{
use HasFactory;
protected static function booted()
{
static::creating(function (BankReconciliation $model) {
$model->adjusted_balance = $model->adjusted_balance ?? (float) $model->calculateAdjustedBalance();
$model->discrepancy_amount = $model->discrepancy_amount ?? (float) $model->calculateDiscrepancy();
$model->reconciliation_status = $model->reconciliation_status ?? self::STATUS_PENDING;
});
}
protected $fillable = [
'reconciliation_month',
'bank_statement_balance',
@@ -113,7 +122,9 @@ class BankReconciliation extends Model
*/
public function calculateDiscrepancy(): float
{
return abs($this->adjusted_balance - $this->bank_statement_balance);
$adjusted = $this->adjusted_balance ?? $this->calculateAdjustedBalance();
return abs($adjusted - floatval($this->bank_statement_balance));
}
/**
@@ -145,7 +156,8 @@ class BankReconciliation extends Model
*/
public function hasUnresolvedDiscrepancy(): bool
{
return $this->reconciliation_status === self::STATUS_DISCREPANCY;
return $this->reconciliation_status === self::STATUS_DISCREPANCY
|| $this->discrepancy_amount > 0.01;
}
/**
@@ -153,7 +165,7 @@ class BankReconciliation extends Model
*/
public function canBeReviewed(): bool
{
return $this->isPending() && $this->prepared_at !== null;
return $this->isPending() && $this->reviewed_at === null;
}
/**
@@ -183,30 +195,39 @@ class BankReconciliation extends Model
public function getOutstandingItemsSummary(): array
{
$checksTotal = 0;
$checksCount = 0;
if ($this->outstanding_checks) {
foreach ($this->outstanding_checks as $check) {
$checksTotal += floatval($check['amount'] ?? 0);
$checksCount++;
}
}
$depositsTotal = 0;
$depositsCount = 0;
if ($this->deposits_in_transit) {
foreach ($this->deposits_in_transit as $deposit) {
$depositsTotal += floatval($deposit['amount'] ?? 0);
$depositsCount++;
}
}
$chargesTotal = 0;
$chargesCount = 0;
if ($this->bank_charges) {
foreach ($this->bank_charges as $charge) {
$chargesTotal += floatval($charge['amount'] ?? 0);
$chargesCount++;
}
}
return [
'outstanding_checks_total' => $checksTotal,
'deposits_in_transit_total' => $depositsTotal,
'bank_charges_total' => $chargesTotal,
'total_outstanding_checks' => $checksTotal,
'outstanding_checks_count' => $checksCount,
'total_deposits_in_transit' => $depositsTotal,
'deposits_in_transit_count' => $depositsCount,
'total_bank_charges' => $chargesTotal,
'bank_charges_count' => $chargesCount,
'net_adjustment' => $depositsTotal - $checksTotal - $chargesTotal,
];
}

View File

@@ -11,6 +11,12 @@ class ChartOfAccount extends Model
{
use HasFactory;
public const TYPE_INCOME = 'income';
public const TYPE_EXPENSE = 'expense';
public const TYPE_ASSET = 'asset';
public const TYPE_LIABILITY = 'liability';
public const TYPE_NET_ASSET = 'net_asset';
protected $fillable = [
'account_code',
'account_name_zh',

View File

@@ -43,6 +43,7 @@ class FinanceDocument extends Model
protected $fillable = [
'member_id',
'submitted_by_user_id',
'submitted_by_id',
'title',
'amount',
'status',
@@ -50,10 +51,13 @@ class FinanceDocument extends Model
'attachment_path',
'submitted_at',
'approved_by_cashier_id',
'cashier_approved_by_id',
'cashier_approved_at',
'approved_by_accountant_id',
'accountant_approved_by_id',
'accountant_approved_at',
'approved_by_chair_id',
'chair_approved_by_id',
'chair_approved_at',
'rejected_by_user_id',
'rejected_at',
@@ -65,6 +69,7 @@ class FinanceDocument extends Model
'budget_item_id',
'requires_board_meeting',
'approved_by_board_meeting_id',
'board_meeting_approved_by_id',
'board_meeting_approved_at',
'payment_order_created_by_accountant_id',
'payment_order_created_at',
@@ -81,6 +86,7 @@ class FinanceDocument extends Model
'actual_payment_amount',
'cashier_ledger_entry_id',
'accounting_transaction_id',
'bank_reconciliation_id',
'reconciliation_status',
'reconciled_at',
];
@@ -183,9 +189,17 @@ class FinanceDocument extends Model
/**
* Check if document can be approved by cashier
*/
public function canBeApprovedByCashier(): bool
public function canBeApprovedByCashier(?User $user = null): bool
{
return $this->status === self::STATUS_PENDING;
if ($this->status !== self::STATUS_PENDING) {
return false;
}
if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) {
return false;
}
return true;
}
/**
@@ -258,7 +272,8 @@ class FinanceDocument extends Model
*/
public function needsBoardMeetingApproval(): bool
{
return $this->amount_tier === self::AMOUNT_TIER_LARGE;
$tier = $this->amount_tier ?? $this->determineAmountTier();
return $tier === self::AMOUNT_TIER_LARGE;
}
/**
@@ -266,18 +281,20 @@ class FinanceDocument extends Model
*/
public function isApprovalStageComplete(): bool
{
$tier = $this->amount_tier ?? $this->determineAmountTier();
// For small amounts: cashier + accountant
if ($this->amount_tier === self::AMOUNT_TIER_SMALL) {
if ($tier === self::AMOUNT_TIER_SMALL) {
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
}
// For medium amounts: cashier + accountant + chair
if ($this->amount_tier === self::AMOUNT_TIER_MEDIUM) {
if ($tier === self::AMOUNT_TIER_MEDIUM) {
return $this->status === self::STATUS_APPROVED_CHAIR;
}
// For large amounts: cashier + accountant + chair + board meeting
if ($this->amount_tier === self::AMOUNT_TIER_LARGE) {
if ($tier === self::AMOUNT_TIER_LARGE) {
return $this->status === self::STATUS_APPROVED_CHAIR &&
$this->board_meeting_approved_at !== null;
}
@@ -321,9 +338,7 @@ class FinanceDocument extends Model
*/
public function isPaymentCompleted(): bool
{
return $this->payment_executed_at !== null &&
$this->paymentOrder !== null &&
$this->paymentOrder->isExecuted();
return $this->payment_executed_at !== null;
}
/**
@@ -331,8 +346,7 @@ class FinanceDocument extends Model
*/
public function isRecordingComplete(): bool
{
return $this->cashier_ledger_entry_id !== null &&
$this->accounting_transaction_id !== null;
return $this->cashier_recorded_at !== null;
}
/**
@@ -350,8 +364,65 @@ class FinanceDocument extends Model
*/
public function isReconciled(): bool
{
return $this->reconciliation_status === self::RECONCILIATION_MATCHED ||
$this->reconciliation_status === self::RECONCILIATION_RESOLVED;
return $this->bank_reconciliation_id !== null;
}
// ============== Attribute aliases for backward compatibility ==============
public function getSubmittedByIdAttribute(): ?int
{
return $this->attributes['submitted_by_id'] ?? $this->attributes['submitted_by_user_id'] ?? null;
}
public function setSubmittedByIdAttribute($value): void
{
$this->attributes['submitted_by_user_id'] = $value;
$this->attributes['submitted_by_id'] = $value;
}
public function setSubmittedByUserIdAttribute($value): void
{
$this->attributes['submitted_by_user_id'] = $value;
$this->attributes['submitted_by_id'] = $value;
}
public function getCashierApprovedByIdAttribute(): ?int
{
return $this->approved_by_cashier_id ?? $this->attributes['approved_by_cashier_id'] ?? null;
}
public function setCashierApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_cashier_id'] = $value;
}
public function getAccountantApprovedByIdAttribute(): ?int
{
return $this->approved_by_accountant_id ?? $this->attributes['approved_by_accountant_id'] ?? null;
}
public function setAccountantApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_accountant_id'] = $value;
}
public function getChairApprovedByIdAttribute(): ?int
{
return $this->approved_by_chair_id ?? $this->attributes['approved_by_chair_id'] ?? null;
}
public function setChairApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_chair_id'] = $value;
}
public function getBoardMeetingApprovedByIdAttribute(): ?int
{
return $this->approved_by_board_meeting_id ?? $this->attributes['approved_by_board_meeting_id'] ?? null;
}
public function setBoardMeetingApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_board_meeting_id'] = $value;
}
/**
@@ -360,10 +431,10 @@ class FinanceDocument extends Model
public function getRequestTypeText(): string
{
return match ($this->request_type) {
self::REQUEST_TYPE_EXPENSE_REIMBURSEMENT => '事後報銷',
self::REQUEST_TYPE_ADVANCE_PAYMENT => '預支/借款',
self::REQUEST_TYPE_EXPENSE_REIMBURSEMENT => '費用報銷',
self::REQUEST_TYPE_ADVANCE_PAYMENT => '預支款',
self::REQUEST_TYPE_PURCHASE_REQUEST => '採購申請',
self::REQUEST_TYPE_PETTY_CASH => '零用金領取',
self::REQUEST_TYPE_PETTY_CASH => '零用金',
default => '未知',
};
}
@@ -374,9 +445,9 @@ class FinanceDocument extends Model
public function getAmountTierText(): string
{
return match ($this->amount_tier) {
self::AMOUNT_TIER_SMALL => '小額 (< 5,000)',
self::AMOUNT_TIER_MEDIUM => '中額 (5,000-50,000)',
self::AMOUNT_TIER_LARGE => '大額 (> 50,000)',
self::AMOUNT_TIER_SMALL => '小額< 5000',
self::AMOUNT_TIER_MEDIUM => '中額5000-50000',
self::AMOUNT_TIER_LARGE => '大額> 50000',
default => '未知',
};
}
@@ -417,19 +488,22 @@ class FinanceDocument extends Model
return 'approval';
}
if (!$this->isPaymentCompleted()) {
if ($this->payment_order_created_at === null) {
return 'approval';
}
if ($this->cashier_recorded_at === null) {
return 'payment';
}
if (!$this->isRecordingComplete()) {
if ($this->bank_reconciliation_id !== null) {
return 'completed';
}
if (! $this->exists) {
return 'recording';
}
if (!$this->isReconciled()) {
return 'reconciliation';
}
return 'completed';
return 'reconciliation';
}
}

View File

@@ -267,7 +267,7 @@ class Issue extends Model
self::STATUS_ASSIGNED => 'purple',
self::STATUS_IN_PROGRESS => 'yellow',
self::STATUS_REVIEW => 'orange',
self::STATUS_CLOSED => 'green',
self::STATUS_CLOSED => 'gray',
default => 'gray',
};
}
@@ -276,9 +276,9 @@ class Issue extends Model
{
return match($this->status) {
self::STATUS_NEW => 0,
self::STATUS_ASSIGNED => 20,
self::STATUS_ASSIGNED => 25,
self::STATUS_IN_PROGRESS => 50,
self::STATUS_REVIEW => 80,
self::STATUS_REVIEW => 75,
self::STATUS_CLOSED => 100,
default => 0,
};

View File

@@ -141,13 +141,17 @@ class Member extends Model
*/
public function getMembershipStatusBadgeAttribute(): string
{
return match($this->membership_status) {
$label = $this->membership_status_label;
$class = match($this->membership_status) {
self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
self::STATUS_ACTIVE => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
self::STATUS_EXPIRED => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
self::STATUS_SUSPENDED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
return trim("{$label} {$class}");
}
/**

View File

@@ -144,7 +144,7 @@ class MembershipPayment extends Model
{
return match($this->payment_method) {
self::METHOD_BANK_TRANSFER => '銀行轉帳',
self::METHOD_CONVENIENCE_STORE => '便利商店繳費',
self::METHOD_CONVENIENCE_STORE => '超商繳費',
self::METHOD_CASH => '現金',
self::METHOD_CREDIT_CARD => '信用卡',
default => $this->payment_method ?? '未指定',
@@ -157,10 +157,9 @@ class MembershipPayment extends Model
parent::boot();
static::deleting(function ($payment) {
if ($payment->receipt_path && Storage::exists($payment->receipt_path)) {
Storage::delete($payment->receipt_path);
if ($payment->receipt_path && Storage::disk('private')->exists($payment->receipt_path)) {
Storage::disk('private')->delete($payment->receipt_path);
}
});
}
}

View File

@@ -10,6 +10,12 @@ class PaymentOrder extends Model
{
use HasFactory;
protected $attributes = [
'status' => self::STATUS_DRAFT,
'verification_status' => self::VERIFICATION_PENDING,
'execution_status' => self::EXECUTION_PENDING,
];
protected $fillable = [
'finance_document_id',
'payee_name',