Initial commit

This commit is contained in:
2025-11-20 23:21:05 +08:00
commit 13bc6db529
378 changed files with 54527 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\Document;
use Illuminate\Console\Command;
class ArchiveExpiredDocuments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'documents:archive-expired
{--dry-run : Preview which documents would be archived}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically archive documents that have passed their expiration date';
/**
* Execute the console command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
// Check if auto-archive is enabled in system settings
$settings = app(\App\Services\SettingsService::class);
if (!$settings->isAutoArchiveEnabled()) {
$this->info('Auto-archive is disabled in system settings.');
return 0;
}
// Find expired documents that should be auto-archived
$expiredDocuments = Document::where('status', 'active')
->where('auto_archive_on_expiry', true)
->whereNotNull('expires_at')
->whereDate('expires_at', '<', now())
->get();
if ($expiredDocuments->isEmpty()) {
$this->info('No expired documents found.');
return 0;
}
$this->info("Found {$expiredDocuments->count()} expired document(s)");
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made');
$this->newLine();
}
foreach ($expiredDocuments as $document) {
$this->line("- {$document->title} (expired: {$document->expires_at->format('Y-m-d')})");
if (!$dryRun) {
$document->archive();
AuditLog::create([
'user_id' => null,
'action' => 'document.auto_archived',
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'old_values' => ['status' => 'active'],
'new_values' => ['status' => 'archived'],
'description' => "Document auto-archived due to expiration on {$document->expires_at->format('Y-m-d')}",
'ip_address' => '127.0.0.1',
'user_agent' => 'CLI Auto-Archive',
]);
$this->info(" ✓ Archived");
}
}
$this->newLine();
if (!$dryRun) {
$this->info("Successfully archived {$expiredDocuments->count()} document(s)");
}
return 0;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Spatie\Permission\Models\Role;
class AssignRole extends Command
{
protected $signature = 'roles:assign {email} {role}';
protected $description = 'Assign a role to a user by email';
public function handle(): int
{
$email = $this->argument('email');
$roleName = $this->argument('role');
$user = User::where('email', $email)->first();
if (! $user) {
$this->error("User not found for email {$email}");
return static::FAILURE;
}
$role = Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
$user->assignRole($role);
$this->info("Assigned role {$roleName} to {$email}");
return static::SUCCESS;
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class ImportDocuments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'documents:import
{path : Path to directory containing documents and manifest.json}
{--user-id=1 : User ID to attribute uploads to}
{--dry-run : Preview import without making changes}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Bulk import documents from a directory with manifest.json';
/**
* Execute the console command.
*/
public function handle()
{
$path = $this->argument('path');
$userId = $this->option('user-id');
$dryRun = $this->option('dry-run');
// Validate path
if (!File::isDirectory($path)) {
$this->error("Directory not found: {$path}");
return 1;
}
// Check for manifest.json
$manifestPath = $path . '/manifest.json';
if (!File::exists($manifestPath)) {
$this->error("manifest.json not found in {$path}");
$this->info("Expected format:");
$this->line($this->getManifestExample());
return 1;
}
// Load manifest
$manifest = json_decode(File::get($manifestPath), true);
if (!$manifest || !isset($manifest['documents'])) {
$this->error("Invalid manifest.json format");
return 1;
}
// Validate user
$user = User::find($userId);
if (!$user) {
$this->error("User not found: {$userId}");
return 1;
}
$this->info("Importing documents from: {$path}");
$this->info("Attributed to: {$user->name}");
if ($dryRun) {
$this->warn("DRY RUN - No changes will be made");
}
$this->newLine();
$successCount = 0;
$errorCount = 0;
foreach ($manifest['documents'] as $item) {
try {
$this->processDocument($path, $item, $user, $dryRun);
$successCount++;
} catch (\Exception $e) {
$this->error("Error processing {$item['file']}: {$e->getMessage()}");
$errorCount++;
}
}
$this->newLine();
$this->info("Import complete!");
$this->info("Success: {$successCount}");
if ($errorCount > 0) {
$this->error("Errors: {$errorCount}");
}
return 0;
}
protected function processDocument(string $basePath, array $item, User $user, bool $dryRun): void
{
$filePath = $basePath . '/' . $item['file'];
// Validate file exists
if (!File::exists($filePath)) {
throw new \Exception("File not found: {$filePath}");
}
// Find or create category
$category = DocumentCategory::where('slug', $item['category'])->first();
if (!$category) {
throw new \Exception("Category not found: {$item['category']}");
}
$this->line("Processing: {$item['title']}");
$this->line(" Category: {$category->name}");
$this->line(" File: {$item['file']}");
if ($dryRun) {
$this->line(" [DRY RUN] Would create document");
return;
}
// Copy file to storage
$fileInfo = pathinfo($filePath);
$storagePath = 'documents/' . uniqid() . '.' . $fileInfo['extension'];
Storage::disk('private')->put($storagePath, File::get($filePath));
// Create document
$document = Document::create([
'document_category_id' => $category->id,
'title' => $item['title'],
'document_number' => $item['document_number'] ?? null,
'description' => $item['description'] ?? null,
'access_level' => $item['access_level'] ?? $category->default_access_level,
'status' => 'active',
'created_by' => $user->id,
'updated_by' => $user->id,
]);
// Add first version
$document->addVersion(
filePath: $storagePath,
originalFilename: $fileInfo['basename'],
mimeType: File::mimeType($filePath),
fileSize: File::size($filePath),
uploadedBy: $user,
versionNotes: $item['version_notes'] ?? 'Initial import'
);
AuditLog::create([
'user_id' => $user->id,
'action' => 'document.imported',
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'old_values' => null,
'new_values' => ['title' => $item['title']],
'ip_address' => '127.0.0.1',
'user_agent' => 'CLI Import',
]);
$this->info(" ✓ Created document ID: {$document->id}");
}
protected function getManifestExample(): string
{
return <<<'JSON'
{
"documents": [
{
"file": "bylaws.pdf",
"title": "協會章程",
"category": "association-bylaws",
"document_number": "2024-001",
"description": "協會章程修正版",
"access_level": "members",
"version_notes": "Initial import"
},
{
"file": "meeting-2024-01.pdf",
"title": "2024年1月會議記錄",
"category": "meeting-minutes",
"access_level": "members"
}
]
}
JSON;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Mail\MemberActivationMail;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
class ImportMembers extends Command
{
protected $signature = 'members:import {path : CSV file path}';
protected $description = 'Import members from a CSV file';
public function handle(): int
{
$path = $this->argument('path');
if (! is_file($path)) {
$this->error("File not found: {$path}");
return static::FAILURE;
}
$handle = fopen($path, 'r');
if (! $handle) {
$this->error("Unable to open file: {$path}");
return static::FAILURE;
}
$header = fgetcsv($handle);
if (! $header) {
$this->error('CSV file is empty.');
fclose($handle);
return static::FAILURE;
}
$header = array_map('trim', $header);
$expected = [
'full_name',
'email',
'phone',
'address_line_1',
'address_line_2',
'city',
'postal_code',
'emergency_contact_name',
'emergency_contact_phone',
'membership_started_at',
'membership_expires_at',
];
foreach ($expected as $column) {
if (! in_array($column, $header, true)) {
$this->error("Missing required column: {$column}");
fclose($handle);
return static::FAILURE;
}
}
$indexes = array_flip($header);
$createdUsers = 0;
$updatedMembers = 0;
while (($row = fgetcsv($handle)) !== false) {
$email = trim($row[$indexes['email']] ?? '');
if ($email === '') {
continue;
}
$fullName = trim($row[$indexes['full_name']] ?? '');
$nationalId = trim($row[$indexes['national_id']] ?? '');
$phone = trim($row[$indexes['phone']] ?? '');
$started = trim($row[$indexes['membership_started_at']] ?? '');
$expires = trim($row[$indexes['membership_expires_at']] ?? '');
$address1 = trim($row[$indexes['address_line_1']] ?? '');
$address2 = trim($row[$indexes['address_line_2']] ?? '');
$city = trim($row[$indexes['city']] ?? '');
$postal = trim($row[$indexes['postal_code']] ?? '');
$emergencyName = trim($row[$indexes['emergency_contact_name']] ?? '');
$emergencyPhone = trim($row[$indexes['emergency_contact_phone']] ?? '');
$user = User::where('email', $email)->first();
$isNewUser = false;
if (! $user) {
$user = User::create([
'name' => $fullName !== '' ? $fullName : $email,
'email' => $email,
'password' => Str::random(32),
]);
$isNewUser = true;
$createdUsers++;
}
$member = Member::updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $fullName !== '' ? $fullName : $user->name,
'email' => $email,
'national_id' => $nationalId !== '' ? $nationalId : null,
'phone' => $phone !== '' ? $phone : null,
'address_line_1' => $address1 ?: null,
'address_line_2' => $address2 ?: null,
'city' => $city ?: null,
'postal_code' => $postal ?: null,
'emergency_contact_name' => $emergencyName ?: null,
'emergency_contact_phone' => $emergencyPhone ?: null,
'membership_started_at' => $started !== '' ? $started : null,
'membership_expires_at' => $expires !== '' ? $expires : null,
],
);
$updatedMembers++;
if ($isNewUser) {
$token = Password::createToken($user);
Mail::to($user)->queue(new MemberActivationMail($user, $token));
AuditLogger::log('user.activation_link_sent', $user, [
'email' => $user->email,
]);
}
}
fclose($handle);
$this->info("Users created: {$createdUsers}");
$this->info("Members imported/updated: {$updatedMembers}");
return static::SUCCESS;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Mail\MembershipExpiryReminderMail;
use App\Models\Member;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendMembershipExpiryReminders extends Command
{
protected $signature = 'members:send-expiry-reminders {--days=30 : Number of days before expiry to send reminders}';
protected $description = 'Send membership expiry reminder emails';
public function handle(): int
{
$days = (int) $this->option('days');
$targetDate = now()->addDays($days)->toDateString();
$members = Member::whereDate('membership_expires_at', $targetDate)
->where(function ($q) {
$q->whereNull('last_expiry_reminder_sent_at')
->orWhere('last_expiry_reminder_sent_at', '<', now()->subDays(1));
})
->get();
if ($members->isEmpty()) {
$this->info('No members to remind.');
return static::SUCCESS;
}
foreach ($members as $member) {
if (! $member->email) {
continue;
}
Mail::to($member->email)->queue(new MembershipExpiryReminderMail($member));
$member->last_expiry_reminder_sent_at = now();
$member->save();
}
$this->info('Reminders sent to '.$members->count().' member(s).');
return static::SUCCESS;
}
}

27
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
$schedule->command('members:send-expiry-reminders --days=30')->daily();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DocumentCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class DocumentCategoryController extends Controller
{
/**
* Display a listing of document categories
*/
public function index()
{
$categories = DocumentCategory::withCount('activeDocuments')
->orderBy('sort_order')
->get();
return view('admin.document-categories.index', compact('categories'));
}
/**
* Show the form for creating a new category
*/
public function create()
{
return view('admin.document-categories.create');
}
/**
* Store a newly created category
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:document_categories,slug',
'description' => 'nullable|string',
'icon' => 'nullable|string|max:10',
'sort_order' => 'nullable|integer',
'default_access_level' => 'required|in:public,members,admin,board',
]);
// Auto-generate slug if not provided
if (empty($validated['slug'])) {
$validated['slug'] = Str::slug($validated['name']);
}
$category = DocumentCategory::create($validated);
return redirect()
->route('admin.document-categories.index')
->with('status', '文件類別已成功建立');
}
/**
* Show the form for editing a category
*/
public function edit(DocumentCategory $documentCategory)
{
return view('admin.document-categories.edit', compact('documentCategory'));
}
/**
* Update the specified category
*/
public function update(Request $request, DocumentCategory $documentCategory)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:document_categories,slug,' . $documentCategory->id,
'description' => 'nullable|string',
'icon' => 'nullable|string|max:10',
'sort_order' => 'nullable|integer',
'default_access_level' => 'required|in:public,members,admin,board',
]);
$documentCategory->update($validated);
return redirect()
->route('admin.document-categories.index')
->with('status', '文件類別已成功更新');
}
/**
* Remove the specified category
*/
public function destroy(DocumentCategory $documentCategory)
{
// Check if category has documents
if ($documentCategory->documents()->count() > 0) {
return back()->with('error', '此類別下有文件,無法刪除');
}
$documentCategory->delete();
return redirect()
->route('admin.document-categories.index')
->with('status', '文件類別已成功刪除');
}
}

View File

@@ -0,0 +1,385 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\DocumentVersion;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
/**
* Display a listing of documents
*/
public function index(Request $request)
{
$query = Document::with(['category', 'currentVersion', 'createdBy'])
->orderBy('created_at', 'desc');
// Filter by category
if ($request->filled('category')) {
$query->where('document_category_id', $request->category);
}
// Filter by access level
if ($request->filled('access_level')) {
$query->where('access_level', $request->access_level);
}
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('document_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
$documents = $query->paginate(20);
$categories = DocumentCategory::orderBy('sort_order')->get();
return view('admin.documents.index', compact('documents', 'categories'));
}
/**
* Show the form for creating a new document
*/
public function create()
{
$categories = DocumentCategory::orderBy('sort_order')->get();
return view('admin.documents.create', compact('categories'));
}
/**
* Store a newly created document with initial version
*/
public function store(Request $request)
{
$validated = $request->validate([
'document_category_id' => 'required|exists:document_categories,id',
'title' => 'required|string|max:255',
'document_number' => 'nullable|string|max:255|unique:documents,document_number',
'description' => 'nullable|string',
'access_level' => 'required|in:public,members,admin,board',
'file' => 'required|file|max:10240', // 10MB max
'version_notes' => 'nullable|string',
]);
// Upload file
$file = $request->file('file');
$path = $file->store('documents', 'private');
// Create document
$document = Document::create([
'document_category_id' => $validated['document_category_id'],
'title' => $validated['title'],
'document_number' => $validated['document_number'],
'description' => $validated['description'],
'access_level' => $validated['access_level'],
'status' => 'active',
'created_by_user_id' => auth()->id(),
'version_count' => 0,
]);
// Add first version
$document->addVersion(
filePath: $path,
originalFilename: $file->getClientOriginalName(),
mimeType: $file->getMimeType(),
fileSize: $file->getSize(),
uploadedBy: auth()->user(),
versionNotes: $validated['version_notes'] ?? '初始版本'
);
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.created',
'description' => "建立文件:{$document->title}",
'ip_address' => request()->ip(),
]);
return redirect()
->route('admin.documents.show', $document)
->with('status', '文件已成功建立');
}
/**
* Display the specified document
*/
public function show(Document $document)
{
$document->load(['category', 'versions.uploadedBy', 'createdBy', 'lastUpdatedBy', 'accessLogs.user']);
$versionHistory = $document->getVersionHistory();
return view('admin.documents.show', compact('document', 'versionHistory'));
}
/**
* Show the form for editing the document metadata
*/
public function edit(Document $document)
{
$categories = DocumentCategory::orderBy('sort_order')->get();
return view('admin.documents.edit', compact('document', 'categories'));
}
/**
* Update the document metadata (not the file)
*/
public function update(Request $request, Document $document)
{
$validated = $request->validate([
'document_category_id' => 'required|exists:document_categories,id',
'title' => 'required|string|max:255',
'document_number' => 'nullable|string|max:255|unique:documents,document_number,' . $document->id,
'description' => 'nullable|string',
'access_level' => 'required|in:public,members,admin,board',
]);
$document->update([
...$validated,
'last_updated_by_user_id' => auth()->id(),
]);
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.updated',
'description' => "更新文件資訊:{$document->title}",
'ip_address' => request()->ip(),
]);
return redirect()
->route('admin.documents.show', $document)
->with('status', '文件資訊已成功更新');
}
/**
* Upload a new version of the document
*/
public function uploadNewVersion(Request $request, Document $document)
{
$validated = $request->validate([
'file' => 'required|file|max:10240', // 10MB max
'version_notes' => 'required|string',
]);
// Upload file
$file = $request->file('file');
$path = $file->store('documents', 'private');
// Add new version
$version = $document->addVersion(
filePath: $path,
originalFilename: $file->getClientOriginalName(),
mimeType: $file->getMimeType(),
fileSize: $file->getSize(),
uploadedBy: auth()->user(),
versionNotes: $validated['version_notes']
);
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.version_uploaded',
'description' => "上傳新版本:{$document->title} (版本 {$version->version_number})",
'ip_address' => request()->ip(),
]);
return back()->with('status', "新版本 {$version->version_number} 已成功上傳");
}
/**
* Promote an old version to current
*/
public function promoteVersion(Document $document, DocumentVersion $version)
{
if ($version->document_id !== $document->id) {
return back()->with('error', '版本不符合');
}
$document->promoteVersion($version, auth()->user());
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.version_promoted',
'description' => "提升版本為當前版本:{$document->title} (版本 {$version->version_number})",
'ip_address' => request()->ip(),
]);
return back()->with('status', "版本 {$version->version_number} 已設為當前版本");
}
/**
* Download a specific version
*/
public function downloadVersion(Document $document, DocumentVersion $version)
{
if ($version->document_id !== $document->id) {
abort(404);
}
if (!$version->fileExists()) {
abort(404, '檔案不存在');
}
// Log access
$document->logAccess('download', auth()->user());
return Storage::disk('private')->download(
$version->file_path,
$version->original_filename
);
}
/**
* Archive a document
*/
public function archive(Document $document)
{
$document->archive();
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.archived',
'description' => "封存文件:{$document->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '文件已封存');
}
/**
* Restore an archived document
*/
public function restore(Document $document)
{
$document->unarchive();
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.restored',
'description' => "恢復文件:{$document->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '文件已恢復');
}
/**
* Delete a document permanently
*/
public function destroy(Document $document)
{
$title = $document->title;
// Delete all version files
foreach ($document->versions as $version) {
if ($version->fileExists()) {
Storage::disk('private')->delete($version->file_path);
}
}
$document->delete();
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'document.deleted',
'description' => "刪除文件:{$title}",
'ip_address' => request()->ip(),
]);
return redirect()
->route('admin.documents.index')
->with('status', '文件已永久刪除');
}
/**
* Display document statistics dashboard
*/
public function statistics()
{
// Check if statistics feature is enabled
$settings = app(\App\Services\SettingsService::class);
if (!$settings->isFeatureEnabled('statistics')) {
abort(404, '統計功能未啟用');
}
// Check user permission
if (!auth()->user()->can('view_document_statistics')) {
abort(403, '您沒有檢視文件統計的權限');
}
$stats = [
'total_documents' => Document::where('status', 'active')->count(),
'total_versions' => \App\Models\DocumentVersion::count(),
'total_downloads' => Document::sum('download_count'),
'total_views' => Document::sum('view_count'),
'archived_documents' => Document::where('status', 'archived')->count(),
];
// Documents by category
$documentsByCategory = DocumentCategory::withCount(['activeDocuments'])
->orderBy('active_documents_count', 'desc')
->get();
// Most viewed documents
$mostViewed = Document::with(['category', 'currentVersion'])
->where('status', 'active')
->orderBy('view_count', 'desc')
->limit(10)
->get();
// Most downloaded documents
$mostDownloaded = Document::with(['category', 'currentVersion'])
->where('status', 'active')
->orderBy('download_count', 'desc')
->limit(10)
->get();
// Recent activity (last 30 days)
$recentActivity = \App\Models\DocumentAccessLog::with(['user', 'document'])
->where('accessed_at', '>=', now()->subDays(30))
->latest('accessed_at')
->limit(50)
->get();
// Monthly upload trends (last 6 months)
$uploadTrends = Document::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, COUNT(*) as count')
->where('created_at', '>=', now()->subMonths(6))
->groupBy('month')
->orderBy('month', 'desc')
->get();
// Access level distribution
$accessLevelStats = Document::selectRaw('access_level, COUNT(*) as count')
->where('status', 'active')
->groupBy('access_level')
->get();
return view('admin.documents.statistics', compact(
'stats',
'documentsByCategory',
'mostViewed',
'mostDownloaded',
'recentActivity',
'uploadTrends',
'accessLevelStats'
));
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\SystemSetting;
use App\Services\SettingsService;
use Illuminate\Http\Request;
class SystemSettingsController extends Controller
{
protected $settings;
public function __construct(SettingsService $settings)
{
$this->settings = $settings;
}
/**
* Redirect to general settings
*/
public function index()
{
return redirect()->route('admin.settings.general');
}
/**
* Show general settings page
*/
public function general()
{
$settings = [
'system_name' => $this->settings->get('general.system_name', 'Usher Management System'),
'timezone' => $this->settings->get('general.timezone', 'Asia/Taipei'),
];
return view('admin.settings.general', compact('settings'));
}
/**
* Update general settings
*/
public function updateGeneral(Request $request)
{
$validated = $request->validate([
'system_name' => 'required|string|max:255',
'timezone' => 'required|string|max:255',
]);
SystemSetting::set('general.system_name', $validated['system_name'], 'string', 'general');
SystemSetting::set('general.timezone', $validated['timezone'], 'string', 'general');
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.general.updated',
'description' => 'Updated general settings',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.general')->with('status', '一般設定已更新');
}
/**
* Show document features settings page
*/
public function features()
{
$settings = [
'qr_codes_enabled' => $this->settings->isFeatureEnabled('qr_codes'),
'tagging_enabled' => $this->settings->isFeatureEnabled('tagging'),
'expiration_enabled' => $this->settings->isFeatureEnabled('expiration'),
'bulk_import_enabled' => $this->settings->isFeatureEnabled('bulk_import'),
'statistics_enabled' => $this->settings->isFeatureEnabled('statistics'),
'version_history_enabled' => $this->settings->isFeatureEnabled('version_history'),
];
return view('admin.settings.features', compact('settings'));
}
/**
* Update features settings
*/
public function updateFeatures(Request $request)
{
$features = [
'qr_codes_enabled',
'tagging_enabled',
'expiration_enabled',
'bulk_import_enabled',
'statistics_enabled',
'version_history_enabled',
];
foreach ($features as $feature) {
$value = $request->has($feature) ? true : false;
SystemSetting::set("features.{$feature}", $value, 'boolean', 'features');
}
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.features.updated',
'description' => 'Updated document features settings',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.features')->with('status', '功能設定已更新');
}
/**
* Show security & limits settings page
*/
public function security()
{
$settings = [
'rate_limit_authenticated' => $this->settings->getDownloadRateLimit(true),
'rate_limit_guest' => $this->settings->getDownloadRateLimit(false),
'max_file_size_mb' => $this->settings->getMaxFileSize(),
'allowed_file_types' => $this->settings->getAllowedFileTypes(),
];
return view('admin.settings.security', compact('settings'));
}
/**
* Update security settings
*/
public function updateSecurity(Request $request)
{
$validated = $request->validate([
'rate_limit_authenticated' => 'required|integer|min:1|max:1000',
'rate_limit_guest' => 'required|integer|min:1|max:1000',
'max_file_size_mb' => 'required|integer|min:1|max:100',
'allowed_file_types' => 'nullable|string',
]);
SystemSetting::set('security.rate_limit_authenticated', $validated['rate_limit_authenticated'], 'integer', 'security');
SystemSetting::set('security.rate_limit_guest', $validated['rate_limit_guest'], 'integer', 'security');
SystemSetting::set('security.max_file_size_mb', $validated['max_file_size_mb'], 'integer', 'security');
// Process allowed file types
if ($request->filled('allowed_file_types')) {
$types = array_map('trim', explode(',', $validated['allowed_file_types']));
SystemSetting::set('security.allowed_file_types', $types, 'json', 'security');
}
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.security.updated',
'description' => 'Updated security and limits settings',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.security')->with('status', '安全性設定已更新');
}
/**
* Show notifications settings page
*/
public function notifications()
{
$settings = [
'enabled' => $this->settings->areNotificationsEnabled(),
'expiration_alerts_enabled' => $this->settings->get('notifications.expiration_alerts_enabled', true),
'expiration_recipients' => $this->settings->getExpirationNotificationRecipients(),
'archive_notifications_enabled' => $this->settings->get('notifications.archive_notifications_enabled', true),
'new_document_alerts_enabled' => $this->settings->get('notifications.new_document_alerts_enabled', false),
];
return view('admin.settings.notifications', compact('settings'));
}
/**
* Update notifications settings
*/
public function updateNotifications(Request $request)
{
$validated = $request->validate([
'enabled' => 'boolean',
'expiration_alerts_enabled' => 'boolean',
'expiration_recipients' => 'nullable|string',
'archive_notifications_enabled' => 'boolean',
'new_document_alerts_enabled' => 'boolean',
]);
SystemSetting::set('notifications.enabled', $request->has('enabled'), 'boolean', 'notifications');
SystemSetting::set('notifications.expiration_alerts_enabled', $request->has('expiration_alerts_enabled'), 'boolean', 'notifications');
SystemSetting::set('notifications.archive_notifications_enabled', $request->has('archive_notifications_enabled'), 'boolean', 'notifications');
SystemSetting::set('notifications.new_document_alerts_enabled', $request->has('new_document_alerts_enabled'), 'boolean', 'notifications');
// Process email recipients
if ($request->filled('expiration_recipients')) {
$emails = array_map('trim', explode(',', $validated['expiration_recipients']));
$emails = array_filter($emails, fn($email) => filter_var($email, FILTER_VALIDATE_EMAIL));
SystemSetting::set('notifications.expiration_recipients', $emails, 'json', 'notifications');
} else {
SystemSetting::set('notifications.expiration_recipients', [], 'json', 'notifications');
}
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.notifications.updated',
'description' => 'Updated notification settings',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.notifications')->with('status', '通知設定已更新');
}
/**
* Show advanced settings page
*/
public function advanced()
{
$settings = [
'qr_code_size' => $this->settings->getQRCodeSize(),
'qr_code_format' => $this->settings->getQRCodeFormat(),
'statistics_time_range' => $this->settings->getStatisticsTimeRange(),
'statistics_top_n' => $this->settings->getStatisticsTopN(),
'audit_log_retention_days' => $this->settings->getAuditLogRetentionDays(),
'max_versions_retain' => $this->settings->getMaxVersionsToRetain(),
'default_expiration_days' => $this->settings->getDefaultExpirationDays(),
'expiration_warning_days' => $this->settings->getExpirationWarningDays(),
'auto_archive_enabled' => $this->settings->isAutoArchiveEnabled(),
'max_tags_per_document' => $this->settings->get('documents.max_tags_per_document', 10),
'default_access_level' => $this->settings->getDefaultAccessLevel(),
];
return view('admin.settings.advanced', compact('settings'));
}
/**
* Update advanced settings
*/
public function updateAdvanced(Request $request)
{
$validated = $request->validate([
'qr_code_size' => 'required|integer|min:100|max:1000',
'qr_code_format' => 'required|in:png,svg',
'statistics_time_range' => 'required|integer|min:7|max:365',
'statistics_top_n' => 'required|integer|min:5|max:100',
'audit_log_retention_days' => 'required|integer|min:30|max:3650',
'max_versions_retain' => 'required|integer|min:0|max:100',
'default_expiration_days' => 'required|integer|min:0|max:3650',
'expiration_warning_days' => 'required|integer|min:1|max:365',
'auto_archive_enabled' => 'boolean',
'max_tags_per_document' => 'required|integer|min:1|max:50',
'default_access_level' => 'required|in:public,members,admin,board',
]);
SystemSetting::set('advanced.qr_code_size', $validated['qr_code_size'], 'integer', 'advanced');
SystemSetting::set('advanced.qr_code_format', $validated['qr_code_format'], 'string', 'advanced');
SystemSetting::set('advanced.statistics_time_range', $validated['statistics_time_range'], 'integer', 'advanced');
SystemSetting::set('advanced.statistics_top_n', $validated['statistics_top_n'], 'integer', 'advanced');
SystemSetting::set('advanced.audit_log_retention_days', $validated['audit_log_retention_days'], 'integer', 'advanced');
SystemSetting::set('advanced.max_versions_retain', $validated['max_versions_retain'], 'integer', 'advanced');
SystemSetting::set('documents.default_expiration_days', $validated['default_expiration_days'], 'integer', 'documents');
SystemSetting::set('documents.expiration_warning_days', $validated['expiration_warning_days'], 'integer', 'documents');
SystemSetting::set('documents.auto_archive_enabled', $request->has('auto_archive_enabled'), 'boolean', 'documents');
SystemSetting::set('documents.max_tags_per_document', $validated['max_tags_per_document'], 'integer', 'documents');
SystemSetting::set('documents.default_access_level', $validated['default_access_level'], 'string', 'documents');
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.advanced.updated',
'description' => 'Updated advanced settings',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.advanced')->with('status', '進階設定已更新');
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use Illuminate\Http\Request;
class AdminAuditLogController extends Controller
{
public function index(Request $request)
{
$query = AuditLog::query()->with('user');
$search = $request->string('search')->toString();
$action = $request->string('action')->toString();
$userId = $request->integer('user_id');
$start = $request->date('start_date');
$end = $request->date('end_date');
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
}
if ($action) {
$query->where('action', $action);
}
if ($userId) {
$query->where('user_id', $userId);
}
if ($start) {
$query->whereDate('created_at', '>=', $start);
}
if ($end) {
$query->whereDate('created_at', '<=', $end);
}
$logs = $query->orderByDesc('created_at')->paginate(25)->withQueryString();
$actions = AuditLog::select('action')->distinct()->orderBy('action')->pluck('action');
$users = AuditLog::with('user')->whereNotNull('user_id')->select('user_id')->distinct()->get()->map(function ($log) {
return $log->user;
})->filter();
return view('admin.audit.index', [
'logs' => $logs,
'search' => $search,
'actionFilter' => $action,
'userFilter' => $userId,
'startDate' => $start,
'endDate' => $end,
'actions' => $actions,
'users' => $users,
]);
}
public function export(Request $request)
{
$query = AuditLog::query()->with('user');
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
}
if ($action = $request->string('action')->toString()) {
$query->where('action', $action);
}
if ($userId = $request->integer('user_id')) {
$query->where('user_id', $userId);
}
if ($start = $request->date('start_date')) {
$query->whereDate('created_at', '>=', $start);
}
if ($end = $request->date('end_date')) {
$query->whereDate('created_at', '<=', $end);
}
return response()->stream(function () use ($query) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Timestamp', 'User', 'Action', 'Metadata']);
$query->orderByDesc('created_at')->chunk(500, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at,
$log->user?->email ?? 'System',
$log->action,
json_encode($log->metadata, JSON_UNESCAPED_UNICODE),
]);
}
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="audit-logs-'.now()->format('Ymd_His').'.csv"',
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\FinanceDocument;
use Illuminate\Http\Request;
class AdminDashboardController extends Controller
{
public function index()
{
// Member statistics
$totalMembers = Member::count();
$activeMembers = Member::whereDate('membership_expires_at', '>=', now()->toDateString())->count();
$expiredMembers = Member::where(function ($q) {
$q->whereNull('membership_expires_at')
->orWhereDate('membership_expires_at', '<', now()->toDateString());
})->count();
$expiringSoon = Member::whereBetween('membership_expires_at', [
now()->toDateString(),
now()->addDays(30)->toDateString()
])->count();
// Payment statistics
$totalPayments = MembershipPayment::count();
$totalRevenue = MembershipPayment::sum('amount') ?? 0;
$recentPayments = MembershipPayment::with('member')
->orderByDesc('paid_at')
->limit(5)
->get();
$paymentsThisMonth = MembershipPayment::whereYear('paid_at', now()->year)
->whereMonth('paid_at', now()->month)
->count();
$revenueThisMonth = MembershipPayment::whereYear('paid_at', now()->year)
->whereMonth('paid_at', now()->month)
->sum('amount') ?? 0;
// Finance document statistics
$pendingApprovals = FinanceDocument::where('status', '!=', FinanceDocument::STATUS_APPROVED_CHAIR)
->where('status', '!=', FinanceDocument::STATUS_REJECTED)
->count();
$fullyApprovedDocs = FinanceDocument::where('status', FinanceDocument::STATUS_APPROVED_CHAIR)->count();
$rejectedDocs = FinanceDocument::where('status', FinanceDocument::STATUS_REJECTED)->count();
// Documents pending user's approval
$user = auth()->user();
$myPendingApprovals = 0;
if ($user->hasRole('cashier')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_PENDING)->count();
}
if ($user->hasRole('accountant')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_APPROVED_CASHIER)->count();
}
if ($user->hasRole('chair')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_APPROVED_ACCOUNTANT)->count();
}
return view('admin.dashboard.index', compact(
'totalMembers',
'activeMembers',
'expiredMembers',
'expiringSoon',
'totalPayments',
'totalRevenue',
'recentPayments',
'paymentsThisMonth',
'revenueThisMonth',
'pendingApprovals',
'fullyApprovedDocs',
'rejectedDocs',
'myPendingApprovals'
));
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace App\Http\Controllers;
use App\Models\Member;
use App\Support\AuditLogger;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class AdminMemberController extends Controller
{
public function index(Request $request)
{
$query = Member::query()->with('user');
// Text search (name, email, phone, national ID)
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
// Search by national ID hash if provided
if (!empty($search)) {
$q->orWhere('national_id_hash', hash('sha256', $search));
}
});
}
// Membership status filter
if ($status = $request->string('status')->toString()) {
if ($status === 'active') {
$query->whereDate('membership_expires_at', '>=', now()->toDateString());
} elseif ($status === 'expired') {
$query->where(function ($q) {
$q->whereNull('membership_expires_at')
->orWhereDate('membership_expires_at', '<', now()->toDateString());
});
} elseif ($status === 'expiring_soon') {
$query->whereBetween('membership_expires_at', [
now()->toDateString(),
now()->addDays(30)->toDateString()
]);
}
}
// Date range filters
if ($startedFrom = $request->string('started_from')->toString()) {
$query->whereDate('membership_started_at', '>=', $startedFrom);
}
if ($startedTo = $request->string('started_to')->toString()) {
$query->whereDate('membership_started_at', '<=', $startedTo);
}
// Payment status filter
if ($paymentStatus = $request->string('payment_status')->toString()) {
if ($paymentStatus === 'has_payments') {
$query->whereHas('payments');
} elseif ($paymentStatus === 'no_payments') {
$query->whereDoesntHave('payments');
}
}
$members = $query->orderBy('full_name')->paginate(15)->withQueryString();
return view('admin.members.index', [
'members' => $members,
'filters' => $request->only(['search', 'status', 'started_from', 'started_to', 'payment_status']),
]);
}
public function show(Member $member)
{
$member->load('user.roles', 'payments');
$roles = Role::orderBy('name')->get();
return view('admin.members.show', [
'member' => $member,
'roles' => $roles,
]);
}
public function create()
{
return view('admin.members.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
]);
// Create user account
$user = \App\Models\User::create([
'name' => $validated['full_name'],
'email' => $validated['email'],
'password' => \Illuminate\Support\Str::random(32),
]);
// Create member record
$member = Member::create(array_merge($validated, [
'user_id' => $user->id,
]));
// Send activation email
$token = \Illuminate\Support\Facades\Password::createToken($user);
\Illuminate\Support\Facades\Mail::to($user)->queue(new \App\Mail\MemberActivationMail($user, $token));
// Log the action
AuditLogger::log('member.created', $member, $validated);
AuditLogger::log('user.activation_link_sent', $user, ['email' => $user->email]);
return redirect()
->route('admin.members.show', $member)
->with('status', __('Member created successfully. Activation email has been sent.'));
}
public function edit(Member $member)
{
return view('admin.members.edit', [
'member' => $member,
]);
}
public function update(Request $request, Member $member)
{
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
]);
$member->update($validated);
AuditLogger::log('member.updated', $member, $validated);
return redirect()
->route('admin.members.show', $member)
->with('status', __('Member updated successfully.'));
}
public function importForm()
{
return view('admin.members.import');
}
public function import(Request $request)
{
$validated = $request->validate([
'file' => ['required', 'file', 'mimes:csv,txt'],
]);
$path = $validated['file']->store('imports');
$fullPath = storage_path('app/'.$path);
Artisan::call('members:import', ['path' => $fullPath]);
$output = Artisan::output();
AuditLogger::log('members.imported', null, [
'path' => $fullPath,
'output' => $output,
]);
return redirect()
->route('admin.members.index')
->with('status', __('Import completed.')."\n".$output);
}
public function updateRoles(Request $request, Member $member)
{
$user = $member->user;
if (! $user) {
abort(400, 'Member is not linked to a user.');
}
$validated = $request->validate([
'roles' => ['nullable', 'array'],
'roles.*' => ['exists:roles,name'],
]);
$roleNames = $validated['roles'] ?? [];
$user->syncRoles($roleNames);
AuditLogger::log('member.roles_updated', $member, ['roles' => $roleNames]);
return redirect()->route('admin.members.show', $member)->with('status', __('Roles updated.'));
}
/**
* Show membership activation form
*/
public function showActivate(Member $member)
{
// Check if user has permission
if (!auth()->user()->can('activate_memberships') && !auth()->user()->is_admin) {
abort(403, 'You do not have permission to activate memberships.');
}
// Check if member has fully approved payment
$approvedPayment = $member->payments()
->where('status', \App\Models\MembershipPayment::STATUS_APPROVED_CHAIR)
->latest()
->first();
if (!$approvedPayment && !auth()->user()->is_admin) {
return redirect()->route('admin.members.show', $member)
->with('error', __('Member must have an approved payment before activation.'));
}
return view('admin.members.activate', compact('member', 'approvedPayment'));
}
/**
* Activate membership
*/
public function activate(Request $request, Member $member)
{
// Check if user has permission
if (!auth()->user()->can('activate_memberships') && !auth()->user()->is_admin) {
abort(403, 'You do not have permission to activate memberships.');
}
$validated = $request->validate([
'membership_started_at' => ['required', 'date'],
'membership_expires_at' => ['required', 'date', 'after:membership_started_at'],
'membership_type' => ['required', 'in:regular,honorary,lifetime,student'],
]);
// Update member
$member->update([
'membership_started_at' => $validated['membership_started_at'],
'membership_expires_at' => $validated['membership_expires_at'],
'membership_type' => $validated['membership_type'],
'membership_status' => Member::STATUS_ACTIVE,
]);
AuditLogger::log('member.activated', $member, [
'started_at' => $validated['membership_started_at'],
'expires_at' => $validated['membership_expires_at'],
'type' => $validated['membership_type'],
'activated_by' => auth()->id(),
]);
// Send activation confirmation email
\Illuminate\Support\Facades\Mail::to($member->email)
->queue(new \App\Mail\MembershipActivatedMail($member));
return redirect()->route('admin.members.show', $member)
->with('status', __('Membership activated successfully! Member has been notified.'));
}
public function export(Request $request): StreamedResponse
{
$query = Member::query()->with('user');
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($status = $request->string('status')->toString()) {
if ($status === 'active') {
$query->whereDate('membership_expires_at', '>=', now()->toDateString());
} elseif ($status === 'expired') {
$query->where(function ($q) {
$q->whereNull('membership_expires_at')
->orWhereDate('membership_expires_at', '<', now()->toDateString());
});
}
}
$headers = [
'ID',
'Full Name',
'Email',
'Phone',
'Address Line 1',
'Address Line 2',
'City',
'Postal Code',
'Emergency Contact Name',
'Emergency Contact Phone',
'Membership Start',
'Membership Expiry',
];
$response = new StreamedResponse(function () use ($query, $headers) {
$handle = fopen('php://output', 'w');
fputcsv($handle, $headers);
$query->chunk(500, function ($members) use ($handle) {
foreach ($members as $member) {
fputcsv($handle, [
$member->id,
$member->full_name,
$member->email,
$member->phone,
$member->address_line_1,
$member->address_line_2,
$member->city,
$member->postal_code,
$member->emergency_contact_name,
$member->emergency_contact_phone,
optional($member->membership_started_at)->toDateString(),
optional($member->membership_expires_at)->toDateString(),
]);
}
});
fclose($handle);
});
$filename = 'members-export-'.now()->format('Ymd_His').'.csv';
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', "attachment; filename=\"{$filename}\"");
return $response;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\MembershipPayment;
use Illuminate\Http\Request;
use App\Support\AuditLogger;
use Barryvdh\DomPDF\Facade\Pdf;
class AdminPaymentController extends Controller
{
public function create(Member $member)
{
return view('admin.payments.create', [
'member' => $member,
]);
}
public function store(Request $request, Member $member)
{
$validated = $request->validate([
'paid_at' => ['required', 'date'],
'amount' => ['required', 'numeric', 'min:0'],
'method' => ['nullable', 'string', 'max:255'],
'reference' => ['nullable', 'string', 'max:255'],
]);
$payment = $member->payments()->create($validated);
AuditLogger::log('payment.created', $payment, $validated);
return redirect()
->route('admin.members.show', $member)
->with('status', __('Payment recorded successfully.'));
}
public function edit(Member $member, MembershipPayment $payment)
{
return view('admin.payments.edit', [
'member' => $member,
'payment' => $payment,
]);
}
public function update(Request $request, Member $member, MembershipPayment $payment)
{
$validated = $request->validate([
'paid_at' => ['required', 'date'],
'amount' => ['required', 'numeric', 'min:0'],
'method' => ['nullable', 'string', 'max:255'],
'reference' => ['nullable', 'string', 'max:255'],
]);
$payment->update($validated);
AuditLogger::log('payment.updated', $payment, $validated);
return redirect()
->route('admin.members.show', $member)
->with('status', __('Payment updated successfully.'));
}
public function destroy(Member $member, MembershipPayment $payment)
{
$payment->delete();
AuditLogger::log('payment.deleted', $payment);
return redirect()
->route('admin.members.show', $member)
->with('status', __('Payment deleted.'));
}
public function receipt(Member $member, MembershipPayment $payment)
{
// Verify the payment belongs to the member
if ($payment->member_id !== $member->id) {
abort(404);
}
$pdf = Pdf::loadView('admin.payments.receipt', [
'member' => $member,
'payment' => $payment,
]);
$filename = 'receipt-' . $payment->id . '-' . now()->format('Ymd') . '.pdf';
return $pdf->download($filename);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
class AdminRoleController extends Controller
{
public function index()
{
$roles = Role::withCount('users')->orderBy('name')->paginate(15);
return view('admin.roles.index', [
'roles' => $roles,
]);
}
public function create()
{
return view('admin.roles.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')],
'description' => ['nullable', 'string', 'max:255'],
]);
Role::create([
'name' => $validated['name'],
'guard_name' => 'web',
'description' => $validated['description'] ?? null,
]);
return redirect()->route('admin.roles.index')->with('status', __('Role created.'));
}
public function show(Role $role, Request $request)
{
$search = $request->string('search')->toString();
$usersQuery = $role->users()->orderBy('name');
if ($search) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$users = $usersQuery->paginate(15)->withQueryString();
$availableUsers = User::orderBy('name')->select('id', 'name', 'email')->get();
return view('admin.roles.show', [
'role' => $role,
'users' => $users,
'availableUsers' => $availableUsers,
'search' => $search,
]);
}
public function edit(Role $role)
{
return view('admin.roles.edit', [
'role' => $role,
]);
}
public function update(Request $request, Role $role)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
'description' => ['nullable', 'string', 'max:255'],
]);
$role->update($validated);
return redirect()->route('admin.roles.show', $role)->with('status', __('Role updated.'));
}
public function assignUsers(Request $request, Role $role)
{
$validated = $request->validate([
'user_ids' => ['required', 'array'],
'user_ids.*' => ['exists:users,id'],
]);
$users = User::whereIn('id', $validated['user_ids'])->get();
foreach ($users as $user) {
$user->assignRole($role);
}
return redirect()->route('admin.roles.show', $role)->with('status', __('Users assigned to role.'));
}
public function removeUser(Role $role, User $user)
{
$user->removeRole($role);
return redirect()->route('admin.roles.show', $role)->with('status', __('Role removed from user.'));
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME);
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME)
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace App\Http\Controllers;
use App\Models\BankReconciliation;
use App\Models\CashierLedgerEntry;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class BankReconciliationController extends Controller
{
/**
* Display a listing of bank reconciliations
*/
public function index(Request $request)
{
$query = BankReconciliation::query()
->with([
'preparedByCashier',
'reviewedByAccountant',
'approvedByManager'
])
->orderByDesc('reconciliation_month');
// Filter by status
if ($request->filled('reconciliation_status')) {
$query->where('reconciliation_status', $request->reconciliation_status);
}
// Filter by month
if ($request->filled('month')) {
$query->whereYear('reconciliation_month', '=', substr($request->month, 0, 4))
->whereMonth('reconciliation_month', '=', substr($request->month, 5, 2));
}
$reconciliations = $query->paginate(15);
return view('admin.bank-reconciliations.index', [
'reconciliations' => $reconciliations,
]);
}
/**
* Show the form for creating a new bank reconciliation
*/
public function create(Request $request)
{
// Check authorization
$this->authorize('prepare_bank_reconciliation');
// Default to current month
$month = $request->input('month', now()->format('Y-m'));
// Get system book balance from cashier ledger
$systemBalance = CashierLedgerEntry::getLatestBalance();
return view('admin.bank-reconciliations.create', [
'month' => $month,
'systemBalance' => $systemBalance,
]);
}
/**
* Store a newly created bank reconciliation
*/
public function store(Request $request)
{
// Check authorization
$this->authorize('prepare_bank_reconciliation');
$validated = $request->validate([
'reconciliation_month' => ['required', 'date_format:Y-m'],
'bank_statement_balance' => ['required', 'numeric'],
'bank_statement_date' => ['required', 'date'],
'bank_statement_file' => ['nullable', 'file', 'max:10240'],
'system_book_balance' => ['required', 'numeric'],
'outstanding_checks' => ['nullable', 'array'],
'outstanding_checks.*.amount' => ['required', 'numeric', 'min:0'],
'outstanding_checks.*.check_number' => ['nullable', 'string'],
'outstanding_checks.*.description' => ['nullable', 'string'],
'deposits_in_transit' => ['nullable', 'array'],
'deposits_in_transit.*.amount' => ['required', 'numeric', 'min:0'],
'deposits_in_transit.*.date' => ['nullable', 'date'],
'deposits_in_transit.*.description' => ['nullable', 'string'],
'bank_charges' => ['nullable', 'array'],
'bank_charges.*.amount' => ['required', 'numeric', 'min:0'],
'bank_charges.*.description' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
// Handle bank statement file upload
$statementPath = null;
if ($request->hasFile('bank_statement_file')) {
$statementPath = $request->file('bank_statement_file')->store('bank-statements', 'local');
}
// Create reconciliation record
$reconciliation = new BankReconciliation([
'reconciliation_month' => $validated['reconciliation_month'] . '-01',
'bank_statement_balance' => $validated['bank_statement_balance'],
'bank_statement_date' => $validated['bank_statement_date'],
'bank_statement_file_path' => $statementPath,
'system_book_balance' => $validated['system_book_balance'],
'outstanding_checks' => $validated['outstanding_checks'] ?? [],
'deposits_in_transit' => $validated['deposits_in_transit'] ?? [],
'bank_charges' => $validated['bank_charges'] ?? [],
'prepared_by_cashier_id' => $request->user()->id,
'prepared_at' => now(),
'notes' => $validated['notes'] ?? null,
]);
// Calculate adjusted balance
$reconciliation->adjusted_balance = $reconciliation->calculateAdjustedBalance();
// Calculate discrepancy
$reconciliation->discrepancy_amount = $reconciliation->calculateDiscrepancy();
// Set status based on discrepancy
if ($reconciliation->hasDiscrepancy()) {
$reconciliation->reconciliation_status = BankReconciliation::STATUS_DISCREPANCY;
} else {
$reconciliation->reconciliation_status = BankReconciliation::STATUS_PENDING;
}
$reconciliation->save();
AuditLogger::log('bank_reconciliation.created', $reconciliation, $validated);
DB::commit();
$message = '銀行調節表已建立。';
if ($reconciliation->hasDiscrepancy()) {
$message .= ' 發現差異金額NT$ ' . number_format($reconciliation->discrepancy_amount, 2);
}
return redirect()
->route('admin.bank-reconciliations.show', $reconciliation)
->with('status', $message);
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->withInput()
->with('error', '建立銀行調節表時發生錯誤:' . $e->getMessage());
}
}
/**
* Display the specified bank reconciliation
*/
public function show(BankReconciliation $bankReconciliation)
{
$bankReconciliation->load([
'preparedByCashier',
'reviewedByAccountant',
'approvedByManager'
]);
// Get outstanding items summary
$summary = $bankReconciliation->getOutstandingItemsSummary();
return view('admin.bank-reconciliations.show', [
'reconciliation' => $bankReconciliation,
'summary' => $summary,
]);
}
/**
* Accountant reviews the bank reconciliation
*/
public function review(Request $request, BankReconciliation $bankReconciliation)
{
// Check authorization
$this->authorize('review_bank_reconciliation');
// Check if can be reviewed
if (!$bankReconciliation->canBeReviewed()) {
return redirect()
->route('admin.bank-reconciliations.show', $bankReconciliation)
->with('error', '此銀行調節表無法覆核。');
}
$validated = $request->validate([
'review_notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
$bankReconciliation->update([
'reviewed_by_accountant_id' => $request->user()->id,
'reviewed_at' => now(),
]);
AuditLogger::log('bank_reconciliation.reviewed', $bankReconciliation, $validated);
DB::commit();
return redirect()
->route('admin.bank-reconciliations.show', $bankReconciliation)
->with('status', '銀行調節表已完成會計覆核。');
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->with('error', '覆核銀行調節表時發生錯誤:' . $e->getMessage());
}
}
/**
* Manager approves the bank reconciliation
*/
public function approve(Request $request, BankReconciliation $bankReconciliation)
{
// Check authorization
$this->authorize('approve_bank_reconciliation');
// Check if can be approved
if (!$bankReconciliation->canBeApproved()) {
return redirect()
->route('admin.bank-reconciliations.show', $bankReconciliation)
->with('error', '此銀行調節表無法核准。');
}
DB::beginTransaction();
try {
// Determine final status
$finalStatus = $bankReconciliation->hasDiscrepancy()
? BankReconciliation::STATUS_DISCREPANCY
: BankReconciliation::STATUS_COMPLETED;
$bankReconciliation->update([
'approved_by_manager_id' => $request->user()->id,
'approved_at' => now(),
'reconciliation_status' => $finalStatus,
]);
AuditLogger::log('bank_reconciliation.approved', $bankReconciliation, [
'approved_by' => $request->user()->name,
'final_status' => $finalStatus,
]);
DB::commit();
$message = '銀行調節表已核准。';
if ($finalStatus === BankReconciliation::STATUS_DISCREPANCY) {
$message .= ' 請注意:仍有差異需要處理。';
}
return redirect()
->route('admin.bank-reconciliations.show', $bankReconciliation)
->with('status', $message);
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->with('error', '核准銀行調節表時發生錯誤:' . $e->getMessage());
}
}
/**
* Download bank statement file
*/
public function downloadStatement(BankReconciliation $bankReconciliation)
{
if (!$bankReconciliation->bank_statement_file_path) {
abort(404, '找不到銀行對帳單檔案');
}
if (!Storage::disk('local')->exists($bankReconciliation->bank_statement_file_path)) {
abort(404, '銀行對帳單檔案不存在');
}
return Storage::disk('local')->download($bankReconciliation->bank_statement_file_path);
}
/**
* Export reconciliation to PDF
*/
public function exportPdf(BankReconciliation $bankReconciliation)
{
// Check authorization
$this->authorize('view_cashier_ledger');
$bankReconciliation->load([
'preparedByCashier',
'reviewedByAccountant',
'approvedByManager'
]);
$summary = $bankReconciliation->getOutstandingItemsSummary();
// Generate PDF (you would need to implement PDF generation library like DomPDF or TCPDF)
// For now, return a view that can be printed
return view('admin.bank-reconciliations.pdf', [
'reconciliation' => $bankReconciliation,
'summary' => $summary,
]);
}
}

View File

@@ -0,0 +1,269 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\BudgetItem;
use App\Models\ChartOfAccount;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BudgetController extends Controller
{
public function index(Request $request)
{
$query = Budget::query()->with('createdBy', 'approvedBy');
// Filter by fiscal year
if ($fiscalYear = $request->integer('fiscal_year')) {
$query->where('fiscal_year', $fiscalYear);
}
// Filter by status
if ($status = $request->string('status')->toString()) {
$query->where('status', $status);
}
$budgets = $query->orderByDesc('fiscal_year')
->orderByDesc('created_at')
->paginate(15);
// Get unique fiscal years for filter dropdown
$fiscalYears = Budget::select('fiscal_year')
->distinct()
->orderByDesc('fiscal_year')
->pluck('fiscal_year');
return view('admin.budgets.index', [
'budgets' => $budgets,
'fiscalYears' => $fiscalYears,
]);
}
public function create()
{
return view('admin.budgets.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'fiscal_year' => ['required', 'integer', 'min:2000', 'max:2100'],
'name' => ['required', 'string', 'max:255'],
'period_type' => ['required', 'in:annual,quarterly,monthly'],
'period_start' => ['required', 'date'],
'period_end' => ['required', 'date', 'after:period_start'],
'notes' => ['nullable', 'string'],
]);
$budget = Budget::create([
...$validated,
'status' => Budget::STATUS_DRAFT,
'created_by_user_id' => $request->user()->id,
]);
AuditLogger::log('budget.created', $budget, $validated);
return redirect()
->route('admin.budgets.edit', $budget)
->with('status', __('Budget created successfully. Add budget items below.'));
}
public function show(Budget $budget)
{
$budget->load([
'createdBy',
'approvedBy',
'budgetItems.chartOfAccount',
'budgetItems' => fn($q) => $q->orderBy('chart_of_account_id'),
]);
// Group budget items by account type
$incomeItems = $budget->budgetItems->filter(fn($item) => $item->chartOfAccount->isIncome());
$expenseItems = $budget->budgetItems->filter(fn($item) => $item->chartOfAccount->isExpense());
return view('admin.budgets.show', [
'budget' => $budget,
'incomeItems' => $incomeItems,
'expenseItems' => $expenseItems,
]);
}
public function edit(Budget $budget)
{
if (!$budget->canBeEdited()) {
return redirect()
->route('admin.budgets.show', $budget)
->with('error', __('This budget cannot be edited.'));
}
$budget->load(['budgetItems.chartOfAccount']);
// Get all active income and expense accounts
$incomeAccounts = ChartOfAccount::where('account_type', 'income')
->where('is_active', true)
->orderBy('account_code')
->get();
$expenseAccounts = ChartOfAccount::where('account_type', 'expense')
->where('is_active', true)
->orderBy('account_code')
->get();
return view('admin.budgets.edit', [
'budget' => $budget,
'incomeAccounts' => $incomeAccounts,
'expenseAccounts' => $expenseAccounts,
]);
}
public function update(Request $request, Budget $budget)
{
if (!$budget->canBeEdited()) {
abort(403, 'This budget cannot be edited.');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'period_start' => ['required', 'date'],
'period_end' => ['required', 'date', 'after:period_start'],
'notes' => ['nullable', 'string'],
'budget_items' => ['nullable', 'array'],
'budget_items.*.chart_of_account_id' => ['required', 'exists:chart_of_accounts,id'],
'budget_items.*.budgeted_amount' => ['required', 'numeric', 'min:0'],
'budget_items.*.notes' => ['nullable', 'string'],
]);
DB::transaction(function () use ($budget, $validated, $request) {
// Update budget
$budget->update([
'name' => $validated['name'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'notes' => $validated['notes'] ?? null,
]);
// Delete existing budget items and recreate
$budget->budgetItems()->delete();
// Create new budget items
if (!empty($validated['budget_items'])) {
foreach ($validated['budget_items'] as $itemData) {
if ($itemData['budgeted_amount'] > 0) {
BudgetItem::create([
'budget_id' => $budget->id,
'chart_of_account_id' => $itemData['chart_of_account_id'],
'budgeted_amount' => $itemData['budgeted_amount'],
'notes' => $itemData['notes'] ?? null,
]);
}
}
}
AuditLogger::log('budget.updated', $budget, [
'user' => $request->user()->name,
'items_count' => count($validated['budget_items'] ?? []),
]);
});
return redirect()
->route('admin.budgets.show', $budget)
->with('status', __('Budget updated successfully.'));
}
public function submit(Request $request, Budget $budget)
{
if (!$budget->isDraft()) {
abort(403, 'Only draft budgets can be submitted.');
}
if ($budget->budgetItems()->count() === 0) {
return redirect()
->route('admin.budgets.edit', $budget)
->with('error', __('Cannot submit budget without budget items.'));
}
$budget->update(['status' => Budget::STATUS_SUBMITTED]);
AuditLogger::log('budget.submitted', $budget, ['submitted_by' => $request->user()->name]);
return redirect()
->route('admin.budgets.show', $budget)
->with('status', __('Budget submitted for approval.'));
}
public function approve(Request $request, Budget $budget)
{
if (!$budget->canBeApproved()) {
abort(403, 'This budget cannot be approved.');
}
// Check if user has permission (admin or chair)
$user = $request->user();
if (!$user->hasRole('chair') && !$user->is_admin && !$user->hasRole('admin')) {
abort(403, 'Only chair can approve budgets.');
}
$budget->update([
'status' => Budget::STATUS_APPROVED,
'approved_by_user_id' => $user->id,
'approved_at' => now(),
]);
AuditLogger::log('budget.approved', $budget, ['approved_by' => $user->name]);
return redirect()
->route('admin.budgets.show', $budget)
->with('status', __('Budget approved successfully.'));
}
public function activate(Request $request, Budget $budget)
{
if (!$budget->isApproved()) {
abort(403, 'Only approved budgets can be activated.');
}
$budget->update(['status' => Budget::STATUS_ACTIVE]);
AuditLogger::log('budget.activated', $budget, ['activated_by' => $request->user()->name]);
return redirect()
->route('admin.budgets.show', $budget)
->with('status', __('Budget activated successfully.'));
}
public function close(Request $request, Budget $budget)
{
if (!$budget->isActive()) {
abort(403, 'Only active budgets can be closed.');
}
$budget->update(['status' => Budget::STATUS_CLOSED]);
AuditLogger::log('budget.closed', $budget, ['closed_by' => $request->user()->name]);
return redirect()
->route('admin.budgets.show', $budget)
->with('status', __('Budget closed successfully.'));
}
public function destroy(Request $request, Budget $budget)
{
if (!$budget->isDraft()) {
abort(403, 'Only draft budgets can be deleted.');
}
$fiscalYear = $budget->fiscal_year;
$budget->delete();
AuditLogger::log('budget.deleted', null, [
'fiscal_year' => $fiscalYear,
'deleted_by' => $request->user()->name,
]);
return redirect()
->route('admin.budgets.index')
->with('status', __('Budget deleted successfully.'));
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Http\Controllers;
use App\Models\CashierLedgerEntry;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CashierLedgerController extends Controller
{
/**
* Display a listing of cashier ledger entries
*/
public function index(Request $request)
{
$query = CashierLedgerEntry::query()
->with(['financeDocument', 'recordedByCashier'])
->orderByDesc('entry_date')
->orderByDesc('id');
// Filter by entry type
if ($request->filled('entry_type')) {
$query->where('entry_type', $request->entry_type);
}
// Filter by payment method
if ($request->filled('payment_method')) {
$query->where('payment_method', $request->payment_method);
}
// Filter by bank account
if ($request->filled('bank_account')) {
$query->where('bank_account', $request->bank_account);
}
// Filter by date range
if ($request->filled('date_from')) {
$query->where('entry_date', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->where('entry_date', '<=', $request->date_to);
}
$entries = $query->paginate(20);
// Get latest balance for each bank account
$balances = DB::table('cashier_ledger_entries')
->select('bank_account', DB::raw('MAX(id) as latest_id'))
->groupBy('bank_account')
->get()
->mapWithKeys(function ($item) {
$latest = CashierLedgerEntry::find($item->latest_id);
return [$item->bank_account => $latest->balance_after ?? 0];
});
return view('admin.cashier-ledger.index', [
'entries' => $entries,
'balances' => $balances,
]);
}
/**
* Show the form for creating a new ledger entry
*/
public function create(Request $request)
{
// Check authorization
$this->authorize('record_cashier_ledger');
// Get finance document if specified
$financeDocument = null;
if ($request->filled('finance_document_id')) {
$financeDocument = FinanceDocument::with('paymentOrder')->findOrFail($request->finance_document_id);
}
return view('admin.cashier-ledger.create', [
'financeDocument' => $financeDocument,
]);
}
/**
* Store a newly created ledger entry
*/
public function store(Request $request)
{
// Check authorization
$this->authorize('record_cashier_ledger');
$validated = $request->validate([
'finance_document_id' => ['nullable', 'exists:finance_documents,id'],
'entry_date' => ['required', 'date'],
'entry_type' => ['required', 'in:receipt,payment'],
'payment_method' => ['required', 'in:bank_transfer,check,cash'],
'bank_account' => ['nullable', 'string', 'max:100'],
'amount' => ['required', 'numeric', 'min:0.01'],
'receipt_number' => ['nullable', 'string', 'max:50'],
'transaction_reference' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
// Get latest balance for the bank account
$bankAccount = $validated['bank_account'] ?? 'default';
$balanceBefore = CashierLedgerEntry::getLatestBalance($bankAccount);
// Create new entry
$entry = new CashierLedgerEntry([
'finance_document_id' => $validated['finance_document_id'] ?? null,
'entry_date' => $validated['entry_date'],
'entry_type' => $validated['entry_type'],
'payment_method' => $validated['payment_method'],
'bank_account' => $bankAccount,
'amount' => $validated['amount'],
'balance_before' => $balanceBefore,
'receipt_number' => $validated['receipt_number'] ?? null,
'transaction_reference' => $validated['transaction_reference'] ?? null,
'recorded_by_cashier_id' => $request->user()->id,
'recorded_at' => now(),
'notes' => $validated['notes'] ?? null,
]);
// Calculate balance after
$entry->balance_after = $entry->calculateBalanceAfter($balanceBefore);
$entry->save();
// Update finance document if linked
if ($validated['finance_document_id']) {
$financeDocument = FinanceDocument::find($validated['finance_document_id']);
$financeDocument->update([
'cashier_ledger_entry_id' => $entry->id,
]);
}
AuditLogger::log('cashier_ledger_entry.created', $entry, $validated);
DB::commit();
return redirect()
->route('admin.cashier-ledger.show', $entry)
->with('status', '現金簿記錄已建立。');
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->withInput()
->with('error', '建立現金簿記錄時發生錯誤:' . $e->getMessage());
}
}
/**
* Display the specified ledger entry
*/
public function show(CashierLedgerEntry $cashierLedgerEntry)
{
$cashierLedgerEntry->load([
'financeDocument.member',
'financeDocument.paymentOrder',
'recordedByCashier'
]);
return view('admin.cashier-ledger.show', [
'entry' => $cashierLedgerEntry,
]);
}
/**
* Show ledger balance report
*/
public function balanceReport(Request $request)
{
// Check authorization
$this->authorize('view_cashier_ledger');
// Get all bank accounts with their latest balances
$accounts = DB::table('cashier_ledger_entries')
->select('bank_account')
->distinct()
->get()
->map(function ($account) {
$latest = CashierLedgerEntry::where('bank_account', $account->bank_account)
->orderBy('entry_date', 'desc')
->orderBy('id', 'desc')
->first();
return [
'bank_account' => $account->bank_account,
'balance' => $latest->balance_after ?? 0,
'last_updated' => $latest->entry_date ?? null,
];
});
// Get transaction summary for current month
$startOfMonth = now()->startOfMonth();
$endOfMonth = now()->endOfMonth();
$monthlySummary = [
'receipts' => CashierLedgerEntry::where('entry_type', CashierLedgerEntry::ENTRY_TYPE_RECEIPT)
->whereBetween('entry_date', [$startOfMonth, $endOfMonth])
->sum('amount'),
'payments' => CashierLedgerEntry::where('entry_type', CashierLedgerEntry::ENTRY_TYPE_PAYMENT)
->whereBetween('entry_date', [$startOfMonth, $endOfMonth])
->sum('amount'),
];
return view('admin.cashier-ledger.balance-report', [
'accounts' => $accounts,
'monthlySummary' => $monthlySummary,
]);
}
/**
* Export ledger entries to CSV
*/
public function export(Request $request)
{
// Check authorization
$this->authorize('view_cashier_ledger');
$query = CashierLedgerEntry::query()
->with(['financeDocument', 'recordedByCashier'])
->orderBy('entry_date')
->orderBy('id');
// Apply filters
if ($request->filled('date_from')) {
$query->where('entry_date', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->where('entry_date', '<=', $request->date_to);
}
if ($request->filled('bank_account')) {
$query->where('bank_account', $request->bank_account);
}
$entries = $query->get();
$filename = 'cashier_ledger_' . now()->format('Ymd_His') . '.csv';
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function() use ($entries) {
$file = fopen('php://output', 'w');
// Add BOM for UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
// Header row
fputcsv($file, [
'記帳日期',
'類型',
'付款方式',
'銀行帳戶',
'金額',
'交易前餘額',
'交易後餘額',
'收據編號',
'交易參考號',
'記錄人',
'備註',
]);
// Data rows
foreach ($entries as $entry) {
fputcsv($file, [
$entry->entry_date->format('Y-m-d'),
$entry->getEntryTypeText(),
$entry->getPaymentMethodText(),
$entry->bank_account ?? '',
$entry->amount,
$entry->balance_before,
$entry->balance_after,
$entry->receipt_number ?? '',
$entry->transaction_reference ?? '',
$entry->recordedByCashier->name ?? '',
$entry->notes ?? '',
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}

View File

@@ -0,0 +1,315 @@
<?php
namespace App\Http\Controllers;
use App\Mail\FinanceDocumentApprovedByAccountant;
use App\Mail\FinanceDocumentApprovedByCashier;
use App\Mail\FinanceDocumentFullyApproved;
use App\Mail\FinanceDocumentRejected;
use App\Mail\FinanceDocumentSubmitted;
use App\Models\FinanceDocument;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class FinanceDocumentController extends Controller
{
public function index(Request $request)
{
$query = FinanceDocument::query()
->with(['member', 'submittedBy', 'paymentOrder']);
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by request type
if ($request->filled('request_type')) {
$query->where('request_type', $request->request_type);
}
// Filter by amount tier
if ($request->filled('amount_tier')) {
$query->where('amount_tier', $request->amount_tier);
}
// Filter by workflow stage
if ($request->filled('workflow_stage')) {
$stage = $request->workflow_stage;
if ($stage === 'approval') {
$query->whereNull('payment_order_created_at');
} elseif ($stage === 'payment') {
$query->whereNotNull('payment_order_created_at')
->whereNull('payment_executed_at');
} elseif ($stage === 'recording') {
$query->whereNotNull('payment_executed_at')
->where(function($q) {
$q->whereNull('cashier_ledger_entry_id')
->orWhereNull('accounting_transaction_id');
});
} elseif ($stage === 'completed') {
$query->whereNotNull('cashier_ledger_entry_id')
->whereNotNull('accounting_transaction_id');
}
}
$documents = $query->orderByDesc('created_at')->paginate(15);
return view('admin.finance.index', [
'documents' => $documents,
]);
}
public function create(Request $request)
{
$members = Member::orderBy('full_name')->get();
return view('admin.finance.create', [
'members' => $members,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'member_id' => ['nullable', 'exists:members,id'],
'title' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0'],
'request_type' => ['required', 'in:expense_reimbursement,advance_payment,purchase_request,petty_cash'],
'description' => ['nullable', 'string'],
'attachment' => ['nullable', 'file', 'max:10240'], // 10MB max
]);
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('finance-documents', 'local');
}
// Create document first to use its determineAmountTier method
$document = new FinanceDocument([
'member_id' => $validated['member_id'] ?? null,
'submitted_by_user_id' => $request->user()->id,
'title' => $validated['title'],
'amount' => $validated['amount'],
'request_type' => $validated['request_type'],
'description' => $validated['description'] ?? null,
'attachment_path' => $attachmentPath,
'status' => FinanceDocument::STATUS_PENDING,
'submitted_at' => now(),
]);
// Determine amount tier
$document->amount_tier = $document->determineAmountTier();
// Set if requires board meeting
$document->requires_board_meeting = $document->needsBoardMeetingApproval();
// Save the document
$document->save();
AuditLogger::log('finance_document.created', $document, $validated);
// Send email notification to finance cashiers
$cashiers = User::role('finance_cashier')->get();
if ($cashiers->isEmpty()) {
// Fallback to old cashier role for backward compatibility
$cashiers = User::role('cashier')->get();
}
foreach ($cashiers as $cashier) {
Mail::to($cashier->email)->queue(new FinanceDocumentSubmitted($document));
}
return redirect()
->route('admin.finance.index')
->with('status', '財務申請單已提交。申請類型:' . $document->getRequestTypeText() . ',金額級別:' . $document->getAmountTierText());
}
public function show(FinanceDocument $financeDocument)
{
$financeDocument->load([
'member',
'submittedBy',
'approvedByCashier',
'approvedByAccountant',
'approvedByChair',
'rejectedBy',
'chartOfAccount',
'budgetItem',
'approvedByBoardMeeting',
'paymentOrderCreatedByAccountant',
'paymentVerifiedByCashier',
'paymentExecutedByCashier',
'paymentOrder.createdByAccountant',
'paymentOrder.verifiedByCashier',
'paymentOrder.executedByCashier',
'cashierLedgerEntry.recordedByCashier',
'accountingTransaction',
]);
return view('admin.finance.show', [
'document' => $financeDocument,
]);
}
public function approve(Request $request, FinanceDocument $financeDocument)
{
$user = $request->user();
// Check if user has any finance approval permissions
$isCashier = $user->hasRole('finance_cashier') || $user->hasRole('cashier');
$isAccountant = $user->hasRole('finance_accountant') || $user->hasRole('accountant');
$isChair = $user->hasRole('finance_chair') || $user->hasRole('chair');
// Determine which level of approval based on current status and user role
if ($financeDocument->canBeApprovedByCashier() && $isCashier) {
$financeDocument->update([
'approved_by_cashier_id' => $user->id,
'cashier_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
]);
AuditLogger::log('finance_document.approved_by_cashier', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// Send email notification to accountants
$accountants = User::role('finance_accountant')->get();
if ($accountants->isEmpty()) {
$accountants = User::role('accountant')->get();
}
foreach ($accountants as $accountant) {
Mail::to($accountant->email)->queue(new FinanceDocumentApprovedByCashier($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出納已審核通過。已送交會計審核。');
}
if ($financeDocument->canBeApprovedByAccountant() && $isAccountant) {
$financeDocument->update([
'approved_by_accountant_id' => $user->id,
'accountant_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
]);
AuditLogger::log('finance_document.approved_by_accountant', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// For small amounts, approval is complete (no chair needed)
if ($financeDocument->amount_tier === FinanceDocument::AMOUNT_TIER_SMALL) {
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '會計已審核通過。小額申請審核完成,可以製作付款單。');
}
// For medium and large amounts, send to chair
$chairs = User::role('finance_chair')->get();
if ($chairs->isEmpty()) {
$chairs = User::role('chair')->get();
}
foreach ($chairs as $chair) {
Mail::to($chair->email)->queue(new FinanceDocumentApprovedByAccountant($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '會計已審核通過。已送交理事長審核。');
}
if ($financeDocument->canBeApprovedByChair() && $isChair) {
$financeDocument->update([
'approved_by_chair_id' => $user->id,
'chair_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
]);
AuditLogger::log('finance_document.approved_by_chair', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
'requires_board_meeting' => $financeDocument->requires_board_meeting,
]);
// For large amounts, notify that board meeting approval is still needed
if ($financeDocument->requires_board_meeting && !$financeDocument->board_meeting_approved_at) {
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '理事長已審核通過。大額申請仍需理事會核准。');
}
// For medium amounts or large amounts with board approval, complete
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentFullyApproved($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '審核流程完成。會計可以製作付款單。');
}
abort(403, 'You are not authorized to approve this document at this stage.');
}
public function reject(Request $request, FinanceDocument $financeDocument)
{
$validated = $request->validate([
'rejection_reason' => ['required', 'string', 'max:1000'],
]);
$user = $request->user();
// Can be rejected by cashier, accountant, or chair at any stage (except if already rejected or fully approved)
if ($financeDocument->isRejected() || $financeDocument->isFullyApproved()) {
abort(403, '此文件無法駁回。');
}
// Check if user has permission to reject
$canReject = $user->hasRole('finance_cashier') || $user->hasRole('cashier') ||
$user->hasRole('finance_accountant') || $user->hasRole('accountant') ||
$user->hasRole('finance_chair') || $user->hasRole('chair');
if (!$canReject) {
abort(403, '您無權駁回此文件。');
}
$financeDocument->update([
'rejected_by_user_id' => $user->id,
'rejected_at' => now(),
'rejection_reason' => $validated['rejection_reason'],
'status' => FinanceDocument::STATUS_REJECTED,
]);
AuditLogger::log('finance_document.rejected', $financeDocument, [
'rejected_by' => $user->name,
'reason' => $validated['rejection_reason'],
'amount_tier' => $financeDocument->amount_tier,
]);
// Send email notification to submitter (rejected)
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentRejected($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '財務申請單已駁回。');
}
public function download(FinanceDocument $financeDocument)
{
if (!$financeDocument->attachment_path) {
abort(404, 'No attachment found.');
}
$path = storage_path('app/' . $financeDocument->attachment_path);
if (!file_exists($path)) {
abort(404, 'Attachment file not found.');
}
return response()->download($path);
}
}

View File

@@ -0,0 +1,507 @@
<?php
namespace App\Http\Controllers;
use App\Models\Issue;
use App\Models\IssueAttachment;
use App\Models\IssueComment;
use App\Models\IssueLabel;
use App\Models\IssueTimeLog;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use App\Mail\IssueAssignedMail;
use App\Mail\IssueStatusChangedMail;
use App\Mail\IssueCommentedMail;
use App\Mail\IssueClosedMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class IssueController extends Controller
{
public function index(Request $request)
{
$query = Issue::with(['creator', 'assignee', 'labels'])
->latest();
// Filter by type
if ($type = $request->string('issue_type')->toString()) {
$query->where('issue_type', $type);
}
// Filter by status
if ($status = $request->string('status')->toString()) {
$query->where('status', $status);
}
// Filter by priority
if ($priority = $request->string('priority')->toString()) {
$query->where('priority', $priority);
}
// Filter by assignee
if ($assigneeId = $request->integer('assigned_to')) {
$query->where('assigned_to_user_id', $assigneeId);
}
// Filter by creator
if ($creatorId = $request->integer('created_by')) {
$query->where('created_by_user_id', $creatorId);
}
// Filter by label
if ($labelId = $request->integer('label')) {
$query->withLabel($labelId);
}
// Filter by due date range
if ($dueDateFrom = $request->string('due_date_from')->toString()) {
$query->whereDate('due_date', '>=', $dueDateFrom);
}
if ($dueDateTo = $request->string('due_date_to')->toString()) {
$query->whereDate('due_date', '<=', $dueDateTo);
}
// Text search
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('issue_number', 'like', "%{$search}%")
->orWhere('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// Show only open issues by default
if ($request->string('show_closed')->toString() !== '1') {
$query->open();
}
$issues = $query->paginate(20)->withQueryString();
// Get filters for dropdowns
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
// Get summary stats
$stats = [
'total_open' => Issue::open()->count(),
'assigned_to_me' => Issue::assignedTo(Auth::id())->open()->count(),
'overdue' => Issue::overdue()->count(),
'high_priority' => Issue::byPriority(Issue::PRIORITY_HIGH)->open()->count() +
Issue::byPriority(Issue::PRIORITY_URGENT)->open()->count(),
];
return view('admin.issues.index', compact('issues', 'users', 'labels', 'stats'));
}
public function create()
{
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
$members = Member::orderBy('full_name')->get();
$openIssues = Issue::open()->orderBy('issue_number')->get();
return view('admin.issues.create', compact('users', 'labels', 'members', 'openIssues'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
$issue = DB::transaction(function () use ($validated, $request) {
$issue = Issue::create([
...$validated,
'created_by_user_id' => Auth::id(),
'status' => $validated['assigned_to_user_id'] ? Issue::STATUS_ASSIGNED : Issue::STATUS_NEW,
]);
// Attach labels
if (!empty($validated['labels'])) {
$issue->labels()->attach($validated['labels']);
}
// Auto-watch: creator and assignee
$watchers = [Auth::id()];
if ($validated['assigned_to_user_id'] && $validated['assigned_to_user_id'] != Auth::id()) {
$watchers[] = $validated['assigned_to_user_id'];
}
$issue->watchers()->attach(array_unique($watchers));
AuditLogger::log('issue.created', $issue, [
'issue_number' => $issue->issue_number,
'title' => $issue->title,
'type' => $issue->issue_type,
]);
return $issue;
});
// Send email notification to assignee
if ($issue->assigned_to_user_id && $issue->assignedTo) {
Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue));
}
return redirect()->route('admin.issues.show', $issue)
->with('status', __('Issue created successfully.'));
}
public function show(Issue $issue)
{
$issue->load([
'creator',
'assignee',
'reviewer',
'member',
'parentIssue',
'subTasks',
'labels',
'watchers',
'comments.user',
'attachments.user',
'timeLogs.user',
'relatedIssues',
]);
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
return view('admin.issues.show', compact('issue', 'users', 'labels'));
}
public function edit(Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
$members = Member::orderBy('full_name')->get();
$openIssues = Issue::open()->where('id', '!=', $issue->id)->orderBy('issue_number')->get();
return view('admin.issues.edit', compact('issue', 'users', 'labels', 'members', 'openIssues'));
}
public function update(Request $request, Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'reviewer_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
$issue = DB::transaction(function () use ($issue, $validated) {
$issue->update($validated);
// Sync labels
if (isset($validated['labels'])) {
$issue->labels()->sync($validated['labels']);
}
AuditLogger::log('issue.updated', $issue, [
'issue_number' => $issue->issue_number,
]);
return $issue;
});
return redirect()->route('admin.issues.show', $issue)
->with('status', __('Issue updated successfully.'));
}
public function destroy(Issue $issue)
{
if (!Auth::user()->is_admin) {
abort(403, 'Only administrators can delete issues.');
}
AuditLogger::log('issue.deleted', $issue, [
'issue_number' => $issue->issue_number,
'title' => $issue->title,
]);
$issue->delete();
return redirect()->route('admin.issues.index')
->with('status', __('Issue deleted successfully.'));
}
// ==================== Workflow Actions ====================
public function assign(Request $request, Issue $issue)
{
$validated = $request->validate([
'assigned_to_user_id' => ['required', 'exists:users,id'],
]);
$issue->update([
'assigned_to_user_id' => $validated['assigned_to_user_id'],
'status' => Issue::STATUS_ASSIGNED,
]);
// Add assignee as watcher
if (!$issue->watchers->contains($validated['assigned_to_user_id'])) {
$issue->watchers()->attach($validated['assigned_to_user_id']);
}
AuditLogger::log('issue.assigned', $issue, [
'assigned_to' => $validated['assigned_to_user_id'],
]);
// Send email notification to assignee
if ($issue->assignedTo) {
Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue));
}
return back()->with('status', __('Issue assigned successfully.'));
}
public function updateStatus(Request $request, Issue $issue)
{
$validated = $request->validate([
'status' => ['required', Rule::in([
Issue::STATUS_NEW,
Issue::STATUS_ASSIGNED,
Issue::STATUS_IN_PROGRESS,
Issue::STATUS_REVIEW,
Issue::STATUS_CLOSED,
])],
'close_reason' => ['nullable', 'string', 'max:500'],
]);
$newStatus = $validated['status'];
$oldStatus = $issue->status;
// Validate status transition
if ($newStatus === Issue::STATUS_IN_PROGRESS && !$issue->canMoveToInProgress()) {
return back()->with('error', __('Issue must be assigned before moving to in progress.'));
}
if ($newStatus === Issue::STATUS_REVIEW && !$issue->canMoveToReview()) {
return back()->with('error', __('Issue must be in progress before moving to review.'));
}
if ($newStatus === Issue::STATUS_CLOSED && !$issue->canBeClosed()) {
return back()->with('error', __('Cannot close issue in current state.'));
}
// Update status
$updateData = ['status' => $newStatus];
if ($newStatus === Issue::STATUS_CLOSED) {
$updateData['closed_at'] = now();
} elseif ($oldStatus === Issue::STATUS_CLOSED) {
// Reopening
$updateData['closed_at'] = null;
}
$issue->update($updateData);
AuditLogger::log('issue.status_changed', $issue, [
'from_status' => $oldStatus,
'to_status' => $newStatus,
'close_reason' => $validated['close_reason'] ?? null,
]);
// Send email notifications to watchers
if ($newStatus === Issue::STATUS_CLOSED) {
// Send "closed" notification
foreach ($issue->watchers as $watcher) {
Mail::to($watcher->email)->queue(new IssueClosedMail($issue));
}
} else {
// Send "status changed" notification
foreach ($issue->watchers as $watcher) {
Mail::to($watcher->email)->queue(new IssueStatusChangedMail($issue, $oldStatus, $newStatus));
}
}
return back()->with('status', __('Issue status updated successfully.'));
}
public function addComment(Request $request, Issue $issue)
{
$validated = $request->validate([
'comment_text' => ['required', 'string'],
'is_internal' => ['boolean'],
]);
$comment = $issue->comments()->create([
'user_id' => Auth::id(),
'comment_text' => $validated['comment_text'],
'is_internal' => $validated['is_internal'] ?? false,
]);
AuditLogger::log('issue.commented', $issue, [
'comment_id' => $comment->id,
]);
// Notify watchers (except the comment author and skip internal comments for non-watchers)
foreach ($issue->watchers as $watcher) {
// Don't send notification to the person who added the comment
if ($watcher->id === Auth::id()) {
continue;
}
Mail::to($watcher->email)->queue(new IssueCommentedMail($issue, $comment));
}
return back()->with('status', __('Comment added successfully.'));
}
public function uploadAttachment(Request $request, Issue $issue)
{
$validated = $request->validate([
'file' => ['required', 'file', 'max:10240'], // 10MB max
]);
$file = $request->file('file');
$fileName = $file->getClientOriginalName();
$filePath = $file->store('issue-attachments', 'private');
$attachment = $issue->attachments()->create([
'user_id' => Auth::id(),
'file_name' => $fileName,
'file_path' => $filePath,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]);
AuditLogger::log('issue.file_attached', $issue, [
'file_name' => $fileName,
'attachment_id' => $attachment->id,
]);
return back()->with('status', __('File uploaded successfully.'));
}
public function downloadAttachment(IssueAttachment $attachment)
{
if (!Storage::exists($attachment->file_path)) {
abort(404, 'File not found.');
}
return Storage::download($attachment->file_path, $attachment->file_name);
}
public function deleteAttachment(IssueAttachment $attachment)
{
$issueId = $attachment->issue_id;
AuditLogger::log('issue.file_deleted', $attachment->issue, [
'file_name' => $attachment->file_name,
'attachment_id' => $attachment->id,
]);
$attachment->delete();
return redirect()->route('admin.issues.show', $issueId)
->with('status', __('Attachment deleted successfully.'));
}
public function logTime(Request $request, Issue $issue)
{
$validated = $request->validate([
'hours' => ['required', 'numeric', 'min:0.01', 'max:999.99'],
'description' => ['nullable', 'string', 'max:500'],
'logged_at' => ['required', 'date'],
]);
$timeLog = $issue->timeLogs()->create([
'user_id' => Auth::id(),
'hours' => $validated['hours'],
'description' => $validated['description'],
'logged_at' => $validated['logged_at'],
]);
AuditLogger::log('issue.time_logged', $issue, [
'hours' => $validated['hours'],
'time_log_id' => $timeLog->id,
]);
return back()->with('status', __('Time logged successfully.'));
}
public function addWatcher(Request $request, Issue $issue)
{
$validated = $request->validate([
'user_id' => ['required', 'exists:users,id'],
]);
if ($issue->watchers->contains($validated['user_id'])) {
return back()->with('error', __('User is already watching this issue.'));
}
$issue->watchers()->attach($validated['user_id']);
AuditLogger::log('issue.watcher_added', $issue, [
'watcher_id' => $validated['user_id'],
]);
return back()->with('status', __('Watcher added successfully.'));
}
public function removeWatcher(Request $request, Issue $issue)
{
$validated = $request->validate([
'user_id' => ['required', 'exists:users,id'],
]);
$issue->watchers()->detach($validated['user_id']);
AuditLogger::log('issue.watcher_removed', $issue, [
'watcher_id' => $validated['user_id'],
]);
return back()->with('status', __('Watcher removed successfully.'));
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Models\IssueLabel;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class IssueLabelController extends Controller
{
public function index()
{
$labels = IssueLabel::withCount('issues')->orderBy('name')->get();
return view('admin.issue-labels.index', compact('labels'));
}
public function create()
{
return view('admin.issue-labels.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:issue_labels,name'],
'color' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'description' => ['nullable', 'string', 'max:500'],
]);
$label = IssueLabel::create($validated);
AuditLogger::log('issue_label.created', $label, [
'name' => $label->name,
]);
return redirect()->route('admin.issue-labels.index')
->with('status', __('Label created successfully.'));
}
public function edit(IssueLabel $issueLabel)
{
return view('admin.issue-labels.edit', compact('issueLabel'));
}
public function update(Request $request, IssueLabel $issueLabel)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:issue_labels,name,' . $issueLabel->id],
'color' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'description' => ['nullable', 'string', 'max:500'],
]);
$issueLabel->update($validated);
AuditLogger::log('issue_label.updated', $issueLabel, [
'name' => $issueLabel->name,
]);
return redirect()->route('admin.issue-labels.index')
->with('status', __('Label updated successfully.'));
}
public function destroy(IssueLabel $issueLabel)
{
if (!Auth::user()->is_admin) {
abort(403, 'Only administrators can delete labels.');
}
AuditLogger::log('issue_label.deleted', $issueLabel, [
'name' => $issueLabel->name,
]);
$issueLabel->delete();
return redirect()->route('admin.issue-labels.index')
->with('status', __('Label deleted successfully.'));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers;
use App\Models\Issue;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class IssueReportsController extends Controller
{
public function index(Request $request)
{
// Date range filter (default: last 30 days)
$startDate = $request->date('start_date', now()->subDays(30));
$endDate = $request->date('end_date', now());
// Overview Statistics
$stats = [
'total_issues' => Issue::count(),
'open_issues' => Issue::open()->count(),
'closed_issues' => Issue::closed()->count(),
'overdue_issues' => Issue::overdue()->count(),
];
// Issues by Status
$issuesByStatus = Issue::select('status', DB::raw('count(*) as count'))
->groupBy('status')
->get()
->mapWithKeys(fn($item) => [$item->status => $item->count]);
// Issues by Priority
$issuesByPriority = Issue::select('priority', DB::raw('count(*) as count'))
->groupBy('priority')
->get()
->mapWithKeys(fn($item) => [$item->priority => $item->count]);
// Issues by Type
$issuesByType = Issue::select('issue_type', DB::raw('count(*) as count'))
->groupBy('issue_type')
->get()
->mapWithKeys(fn($item) => [$item->issue_type => $item->count]);
// Issues Created Over Time (last 30 days)
$issuesCreatedOverTime = Issue::select(
DB::raw('DATE(created_at) as date'),
DB::raw('count(*) as count')
)
->whereBetween('created_at', [$startDate, $endDate])
->groupBy('date')
->orderBy('date')
->get();
// Issues Closed Over Time (last 30 days)
$issuesClosedOverTime = Issue::select(
DB::raw('DATE(closed_at) as date'),
DB::raw('count(*) as count')
)
->whereNotNull('closed_at')
->whereBetween('closed_at', [$startDate, $endDate])
->groupBy('date')
->orderBy('date')
->get();
// Assignee Performance
$assigneePerformance = User::select('users.id', 'users.name')
->leftJoin('issues', 'users.id', '=', 'issues.assigned_to_user_id')
->selectRaw('count(issues.id) as total_assigned')
->selectRaw('sum(case when issues.status = ? then 1 else 0 end) as completed', [Issue::STATUS_CLOSED])
->selectRaw('sum(case when issues.due_date < NOW() and issues.status != ? then 1 else 0 end) as overdue', [Issue::STATUS_CLOSED])
->groupBy('users.id', 'users.name')
->having('total_assigned', '>', 0)
->orderByDesc('total_assigned')
->limit(10)
->get()
->map(function ($user) {
$user->completion_rate = $user->total_assigned > 0
? round(($user->completed / $user->total_assigned) * 100, 1)
: 0;
return $user;
});
// Time Tracking Metrics
$timeTrackingMetrics = Issue::selectRaw('
sum(estimated_hours) as total_estimated,
sum(actual_hours) as total_actual,
avg(estimated_hours) as avg_estimated,
avg(actual_hours) as avg_actual
')
->whereNotNull('estimated_hours')
->first();
// Top Labels Used
$topLabels = DB::table('issue_labels')
->select('issue_labels.id', 'issue_labels.name', 'issue_labels.color', DB::raw('count(issue_label_pivot.issue_id) as usage_count'))
->leftJoin('issue_label_pivot', 'issue_labels.id', '=', 'issue_label_pivot.issue_label_id')
->groupBy('issue_labels.id', 'issue_labels.name', 'issue_labels.color')
->having('usage_count', '>', 0)
->orderByDesc('usage_count')
->limit(10)
->get();
// Average Resolution Time (days)
$avgResolutionTime = Issue::whereNotNull('closed_at')
->selectRaw('avg(TIMESTAMPDIFF(DAY, created_at, closed_at)) as avg_days')
->value('avg_days');
// Recent Activity (last 10 issues)
$recentIssues = Issue::with(['creator', 'assignee'])
->latest()
->limit(10)
->get();
return view('admin.issue-reports.index', compact(
'stats',
'issuesByStatus',
'issuesByPriority',
'issuesByType',
'issuesCreatedOverTime',
'issuesClosedOverTime',
'assigneePerformance',
'timeTrackingMetrics',
'topLabels',
'avgResolutionTime',
'recentIssues',
'startDate',
'endDate'
));
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class MemberDashboardController extends Controller
{
public function show(Request $request)
{
$user = $request->user();
$member = $user->member;
if (! $member) {
abort(404);
}
$member->load([
'payments.submittedBy',
'payments.verifiedByCashier',
'payments.verifiedByAccountant',
'payments.verifiedByChair',
'payments.rejectedBy'
]);
$pendingPayment = $member->getPendingPayment();
return view('member.dashboard', [
'member' => $member,
'payments' => $member->payments()->latest('paid_at')->get(),
'pendingPayment' => $pendingPayment,
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers;
use App\Mail\PaymentSubmittedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rule;
class MemberPaymentController extends Controller
{
/**
* Show payment submission form
*/
public function create()
{
$member = Auth::user()->member;
if (!$member) {
return redirect()->route('member.dashboard')
->with('error', __('You must have a member account to submit payment.'));
}
// Check if member can submit payment
if (!$member->canSubmitPayment()) {
return redirect()->route('member.dashboard')
->with('error', __('You cannot submit payment at this time. You may already have a pending payment or your membership is already active.'));
}
return view('member.submit-payment', compact('member'));
}
/**
* Store payment submission
*/
public function store(Request $request)
{
$member = Auth::user()->member;
if (!$member || !$member->canSubmitPayment()) {
return redirect()->route('member.dashboard')
->with('error', __('You cannot submit payment at this time.'));
}
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:0'],
'paid_at' => ['required', 'date', 'before_or_equal:today'],
'payment_method' => ['required', Rule::in([
MembershipPayment::METHOD_BANK_TRANSFER,
MembershipPayment::METHOD_CONVENIENCE_STORE,
MembershipPayment::METHOD_CASH,
MembershipPayment::METHOD_CREDIT_CARD,
])],
'reference' => ['nullable', 'string', 'max:255'],
'receipt' => ['required', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:10240'], // 10MB max
'notes' => ['nullable', 'string', 'max:500'],
]);
// Store receipt file
$receiptFile = $request->file('receipt');
$receiptPath = $receiptFile->store('payment-receipts', 'private');
// Create payment record
$payment = MembershipPayment::create([
'member_id' => $member->id,
'amount' => $validated['amount'],
'paid_at' => $validated['paid_at'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'] ?? null,
'receipt_path' => $receiptPath,
'notes' => $validated['notes'] ?? null,
'submitted_by_user_id' => Auth::id(),
'status' => MembershipPayment::STATUS_PENDING,
]);
AuditLogger::log('payment.submitted', $payment, [
'member_id' => $member->id,
'amount' => $payment->amount,
'payment_method' => $payment->payment_method,
]);
// Send notification to member (confirmation)
Mail::to($member->email)->queue(new PaymentSubmittedMail($payment, 'member'));
// Send notification to cashiers (action needed)
$cashiers = User::permission('verify_payments_cashier')->get();
foreach ($cashiers as $cashier) {
Mail::to($cashier->email)->queue(new PaymentSubmittedMail($payment, 'cashier'));
}
return redirect()->route('member.dashboard')
->with('status', __('Payment submitted successfully! We will review your payment and notify you once verified.'));
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace App\Http\Controllers;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class PaymentOrderController extends Controller
{
/**
* Display a listing of payment orders
*/
public function index(Request $request)
{
$query = PaymentOrder::query()
->with([
'financeDocument',
'createdByAccountant',
'verifiedByCashier',
'executedByCashier'
])
->orderByDesc('created_at');
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by verification status
if ($request->filled('verification_status')) {
$query->where('verification_status', $request->verification_status);
}
// Filter by execution status
if ($request->filled('execution_status')) {
$query->where('execution_status', $request->execution_status);
}
$paymentOrders = $query->paginate(15);
return view('admin.payment-orders.index', [
'paymentOrders' => $paymentOrders,
]);
}
/**
* Show the form for creating a new payment order (accountant only)
*/
public function create(FinanceDocument $financeDocument)
{
// Check authorization
$this->authorize('create_payment_order');
// Check if document is ready for payment order creation
if (!$financeDocument->canCreatePaymentOrder()) {
return redirect()
->route('admin.finance.show', $financeDocument)
->with('error', '此財務申請單尚未完成審核流程,無法製作付款單。');
}
// Check if payment order already exists
if ($financeDocument->paymentOrder !== null) {
return redirect()
->route('admin.payment-orders.show', $financeDocument->paymentOrder)
->with('error', '此財務申請單已有付款單。');
}
$financeDocument->load(['member', 'submittedBy']);
return view('admin.payment-orders.create', [
'financeDocument' => $financeDocument,
]);
}
/**
* Store a newly created payment order (accountant creates)
*/
public function store(Request $request, FinanceDocument $financeDocument)
{
// Check authorization
$this->authorize('create_payment_order');
// Check if document is ready
if (!$financeDocument->canCreatePaymentOrder()) {
return redirect()
->route('admin.finance.show', $financeDocument)
->with('error', '此財務申請單尚未完成審核流程,無法製作付款單。');
}
$validated = $request->validate([
'payee_name' => ['required', 'string', 'max:100'],
'payee_bank_code' => ['nullable', 'string', 'max:10'],
'payee_account_number' => ['nullable', 'string', 'max:30'],
'payee_bank_name' => ['nullable', 'string', 'max:100'],
'payment_amount' => ['required', 'numeric', 'min:0'],
'payment_method' => ['required', 'in:bank_transfer,check,cash'],
'notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
// Generate payment order number
$paymentOrderNumber = PaymentOrder::generatePaymentOrderNumber();
// Create payment order
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $financeDocument->id,
'payee_name' => $validated['payee_name'],
'payee_bank_code' => $validated['payee_bank_code'] ?? null,
'payee_account_number' => $validated['payee_account_number'] ?? null,
'payee_bank_name' => $validated['payee_bank_name'] ?? null,
'payment_amount' => $validated['payment_amount'],
'payment_method' => $validated['payment_method'],
'created_by_accountant_id' => $request->user()->id,
'payment_order_number' => $paymentOrderNumber,
'notes' => $validated['notes'] ?? null,
'status' => PaymentOrder::STATUS_PENDING_VERIFICATION,
'verification_status' => PaymentOrder::VERIFICATION_PENDING,
'execution_status' => PaymentOrder::EXECUTION_PENDING,
]);
// Update finance document
$financeDocument->update([
'payment_order_created_by_accountant_id' => $request->user()->id,
'payment_order_created_at' => now(),
'payment_method' => $validated['payment_method'],
'payee_name' => $validated['payee_name'],
'payee_account_number' => $validated['payee_account_number'] ?? null,
'payee_bank_name' => $validated['payee_bank_name'] ?? null,
]);
AuditLogger::log('payment_order.created', $paymentOrder, $validated);
DB::commit();
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('status', "付款單 {$paymentOrderNumber} 已建立,等待出納覆核。");
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->withInput()
->with('error', '建立付款單時發生錯誤:' . $e->getMessage());
}
}
/**
* Display the specified payment order
*/
public function show(PaymentOrder $paymentOrder)
{
$paymentOrder->load([
'financeDocument.member',
'financeDocument.submittedBy',
'createdByAccountant',
'verifiedByCashier',
'executedByCashier'
]);
return view('admin.payment-orders.show', [
'paymentOrder' => $paymentOrder,
]);
}
/**
* Cashier verifies the payment order
*/
public function verify(Request $request, PaymentOrder $paymentOrder)
{
// Check authorization
$this->authorize('verify_payment_order');
// Check if can be verified
if (!$paymentOrder->canBeVerifiedByCashier()) {
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('error', '此付款單無法覆核。');
}
$validated = $request->validate([
'action' => ['required', 'in:approve,reject'],
'verification_notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
if ($validated['action'] === 'approve') {
// Approve
$paymentOrder->update([
'verified_by_cashier_id' => $request->user()->id,
'verified_at' => now(),
'verification_status' => PaymentOrder::VERIFICATION_APPROVED,
'verification_notes' => $validated['verification_notes'] ?? null,
'status' => PaymentOrder::STATUS_VERIFIED,
]);
// Update finance document
$paymentOrder->financeDocument->update([
'payment_verified_by_cashier_id' => $request->user()->id,
'payment_verified_at' => now(),
]);
AuditLogger::log('payment_order.verified_approved', $paymentOrder, $validated);
$message = '付款單已覆核通過,可以執行付款。';
} else {
// Reject
$paymentOrder->update([
'verified_by_cashier_id' => $request->user()->id,
'verified_at' => now(),
'verification_status' => PaymentOrder::VERIFICATION_REJECTED,
'verification_notes' => $validated['verification_notes'] ?? null,
'status' => PaymentOrder::STATUS_CANCELLED,
]);
AuditLogger::log('payment_order.verified_rejected', $paymentOrder, $validated);
$message = '付款單已駁回。';
}
DB::commit();
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('status', $message);
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->with('error', '覆核付款單時發生錯誤:' . $e->getMessage());
}
}
/**
* Cashier executes the payment
*/
public function execute(Request $request, PaymentOrder $paymentOrder)
{
// Check authorization
$this->authorize('execute_payment');
// Check if can be executed
if (!$paymentOrder->canBeExecuted()) {
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('error', '此付款單無法執行。');
}
$validated = $request->validate([
'transaction_reference' => ['required', 'string', 'max:100'],
'payment_receipt' => ['nullable', 'file', 'max:10240'], // 10MB max
'execution_notes' => ['nullable', 'string'],
]);
DB::beginTransaction();
try {
// Handle receipt upload
$receiptPath = null;
if ($request->hasFile('payment_receipt')) {
$receiptPath = $request->file('payment_receipt')->store('payment-receipts', 'local');
}
// Execute payment
$paymentOrder->update([
'executed_by_cashier_id' => $request->user()->id,
'executed_at' => now(),
'execution_status' => PaymentOrder::EXECUTION_COMPLETED,
'transaction_reference' => $validated['transaction_reference'],
'payment_receipt_path' => $receiptPath,
'status' => PaymentOrder::STATUS_EXECUTED,
]);
// Update finance document
$paymentOrder->financeDocument->update([
'payment_executed_by_cashier_id' => $request->user()->id,
'payment_executed_at' => now(),
'payment_transaction_id' => $validated['transaction_reference'],
'payment_receipt_path' => $receiptPath,
'actual_payment_amount' => $paymentOrder->payment_amount,
]);
AuditLogger::log('payment_order.executed', $paymentOrder, $validated);
DB::commit();
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('status', '付款已執行完成。');
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->with('error', '執行付款時發生錯誤:' . $e->getMessage());
}
}
/**
* Download payment receipt
*/
public function downloadReceipt(PaymentOrder $paymentOrder)
{
if (!$paymentOrder->payment_receipt_path) {
abort(404, '找不到付款憑證');
}
if (!Storage::disk('local')->exists($paymentOrder->payment_receipt_path)) {
abort(404, '付款憑證檔案不存在');
}
return Storage::disk('local')->download($paymentOrder->payment_receipt_path);
}
/**
* Cancel a payment order
*/
public function cancel(Request $request, PaymentOrder $paymentOrder)
{
// Check authorization
$this->authorize('create_payment_order'); // Only accountant can cancel
// Cannot cancel if already executed
if ($paymentOrder->isExecuted()) {
return redirect()
->route('admin.payment-orders.show', $paymentOrder)
->with('error', '已執行的付款單無法取消。');
}
DB::beginTransaction();
try {
$paymentOrder->update([
'status' => PaymentOrder::STATUS_CANCELLED,
]);
AuditLogger::log('payment_order.cancelled', $paymentOrder, [
'cancelled_by' => $request->user()->id,
]);
DB::commit();
return redirect()
->route('admin.payment-orders.index')
->with('status', '付款單已取消。');
} catch (\Exception $e) {
DB::rollBack();
return redirect()
->back()
->with('error', '取消付款單時發生錯誤:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers;
use App\Mail\PaymentApprovedByCashierMail;
use App\Mail\PaymentApprovedByAccountantMail;
use App\Mail\PaymentFullyApprovedMail;
use App\Mail\PaymentRejectedMail;
use App\Models\MembershipPayment;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
class PaymentVerificationController extends Controller
{
/**
* Show payment verification dashboard
*/
public function index(Request $request)
{
$user = Auth::user();
$tab = $request->query('tab', 'all');
// Base query with relationships
$query = MembershipPayment::with(['member', 'submittedBy', 'verifiedByCashier', 'verifiedByAccountant', 'verifiedByChair', 'rejectedBy'])
->latest();
// Filter by tab
if ($tab === 'cashier' && $user->can('verify_payments_cashier')) {
$query->where('status', MembershipPayment::STATUS_PENDING);
} elseif ($tab === 'accountant' && $user->can('verify_payments_accountant')) {
$query->where('status', MembershipPayment::STATUS_APPROVED_CASHIER);
} elseif ($tab === 'chair' && $user->can('verify_payments_chair')) {
$query->where('status', MembershipPayment::STATUS_APPROVED_ACCOUNTANT);
} elseif ($tab === 'rejected') {
$query->where('status', MembershipPayment::STATUS_REJECTED);
} elseif ($tab === 'approved') {
$query->where('status', MembershipPayment::STATUS_APPROVED_CHAIR);
}
// Filter by search
if ($search = $request->query('search')) {
$query->whereHas('member', function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
})->orWhere('reference', 'like', "%{$search}%");
}
$payments = $query->paginate(20)->withQueryString();
// Get counts for tabs
$counts = [
'pending' => MembershipPayment::where('status', MembershipPayment::STATUS_PENDING)->count(),
'cashier_approved' => MembershipPayment::where('status', MembershipPayment::STATUS_APPROVED_CASHIER)->count(),
'accountant_approved' => MembershipPayment::where('status', MembershipPayment::STATUS_APPROVED_ACCOUNTANT)->count(),
'approved' => MembershipPayment::where('status', MembershipPayment::STATUS_APPROVED_CHAIR)->count(),
'rejected' => MembershipPayment::where('status', MembershipPayment::STATUS_REJECTED)->count(),
];
return view('admin.payment-verifications.index', compact('payments', 'tab', 'counts'));
}
/**
* Show verification form for a payment
*/
public function show(MembershipPayment $payment)
{
$payment->load(['member', 'submittedBy', 'verifiedByCashier', 'verifiedByAccountant', 'verifiedByChair', 'rejectedBy']);
return view('admin.payment-verifications.show', compact('payment'));
}
/**
* Approve payment (cashier tier)
*/
public function approveByCashier(Request $request, MembershipPayment $payment)
{
if (!Auth::user()->can('verify_payments_cashier')) {
abort(403, 'You do not have permission to verify payments as cashier.');
}
if (!$payment->canBeApprovedByCashier()) {
return back()->with('error', __('This payment cannot be approved at this stage.'));
}
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
]);
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => Auth::id(),
'cashier_verified_at' => now(),
'notes' => $validated['notes'] ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_cashier', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => Auth::id(),
]);
// Send notification to member
Mail::to($payment->member->email)->queue(new PaymentApprovedByCashierMail($payment));
// Send notification to accountants
$accountants = User::permission('verify_payments_accountant')->get();
foreach ($accountants as $accountant) {
Mail::to($accountant->email)->queue(new PaymentApprovedByCashierMail($payment));
}
return redirect()->route('admin.payment-verifications.index')
->with('status', __('Payment approved by cashier. Forwarded to accountant for review.'));
}
/**
* Approve payment (accountant tier)
*/
public function approveByAccountant(Request $request, MembershipPayment $payment)
{
if (!Auth::user()->can('verify_payments_accountant')) {
abort(403, 'You do not have permission to verify payments as accountant.');
}
if (!$payment->canBeApprovedByAccountant()) {
return back()->with('error', __('This payment cannot be approved at this stage.'));
}
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
]);
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'verified_by_accountant_id' => Auth::id(),
'accountant_verified_at' => now(),
'notes' => $validated['notes'] ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_accountant', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => Auth::id(),
]);
// Send notification to member
Mail::to($payment->member->email)->queue(new PaymentApprovedByAccountantMail($payment));
// Send notification to chairs
$chairs = User::permission('verify_payments_chair')->get();
foreach ($chairs as $chair) {
Mail::to($chair->email)->queue(new PaymentApprovedByAccountantMail($payment));
}
return redirect()->route('admin.payment-verifications.index')
->with('status', __('Payment approved by accountant. Forwarded to chair for final approval.'));
}
/**
* Approve payment (chair tier - final approval)
*/
public function approveByChair(Request $request, MembershipPayment $payment)
{
if (!Auth::user()->can('verify_payments_chair')) {
abort(403, 'You do not have permission to verify payments as chair.');
}
if (!$payment->canBeApprovedByChair()) {
return back()->with('error', __('This payment cannot be approved at this stage.'));
}
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
]);
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'verified_by_chair_id' => Auth::id(),
'chair_verified_at' => now(),
'notes' => $validated['notes'] ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_chair', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => Auth::id(),
]);
// Send notification to member and admins
Mail::to($payment->member->email)->queue(new PaymentFullyApprovedMail($payment));
// Notify membership managers
$managers = User::permission('activate_memberships')->get();
foreach ($managers as $manager) {
Mail::to($manager->email)->queue(new PaymentFullyApprovedMail($payment));
}
return redirect()->route('admin.payment-verifications.index')
->with('status', __('Payment fully approved! Member can now be activated by membership manager.'));
}
/**
* Reject payment
*/
public function reject(Request $request, MembershipPayment $payment)
{
$user = Auth::user();
// Check if user has any verification permission
if (!$user->can('verify_payments_cashier')
&& !$user->can('verify_payments_accountant')
&& !$user->can('verify_payments_chair')) {
abort(403, 'You do not have permission to reject payments.');
}
if ($payment->isFullyApproved()) {
return back()->with('error', __('Cannot reject a fully approved payment.'));
}
$validated = $request->validate([
'rejection_reason' => ['required', 'string', 'max:1000'],
]);
$payment->update([
'status' => MembershipPayment::STATUS_REJECTED,
'rejected_by_user_id' => Auth::id(),
'rejected_at' => now(),
'rejection_reason' => $validated['rejection_reason'],
]);
AuditLogger::log('payment.rejected', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'rejected_by' => Auth::id(),
'reason' => $validated['rejection_reason'],
]);
// Send notification to member
Mail::to($payment->member->email)->queue(new PaymentRejectedMail($payment));
return redirect()->route('admin.payment-verifications.index')
->with('status', __('Payment rejected. Member has been notified.'));
}
/**
* Download payment receipt
*/
public function downloadReceipt(MembershipPayment $payment)
{
if (!$payment->receipt_path || !Storage::exists($payment->receipt_path)) {
abort(404, 'Receipt file not found.');
}
$fileName = 'payment_receipt_' . $payment->member->full_name . '_' . $payment->paid_at->format('Ymd') . '.' . pathinfo($payment->receipt_path, PATHINFO_EXTENSION);
return Storage::download($payment->receipt_path, $fileName);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use App\Models\Member;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
'member' => $request->user()->member,
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$validated = $request->validated();
$request->user()->fill($validated);
if ($request->hasFile('profile_photo')) {
$path = $request->file('profile_photo')->store('profile-photos', 'public');
if ($request->user()->profile_photo_path) {
Storage::disk('public')->delete($request->user()->profile_photo_path);
}
$request->user()->profile_photo_path = $path;
}
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
$memberFields = [
'phone',
'address_line_1',
'address_line_2',
'city',
'postal_code',
'emergency_contact_name',
'emergency_contact_phone',
];
$memberData = collect($validated)
->only($memberFields)
->filter(function ($value, $key) {
return true;
});
if ($memberData->isNotEmpty()) {
$member = $request->user()->member;
if ($member) {
$member->fill($memberData->all());
$member->save();
}
}
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use App\Models\DocumentCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PublicDocumentController extends Controller
{
/**
* Display the document library (public + member access)
*/
public function index(Request $request)
{
$query = Document::with(['category', 'currentVersion'])
->where('status', 'active');
// Filter based on user's access level
$user = auth()->user();
if (!$user) {
// Only public documents for guests
$query->where('access_level', 'public');
} elseif (!$user->is_admin && !$user->hasRole('admin')) {
// Members can see public + members-only
$query->whereIn('access_level', ['public', 'members']);
}
// Admins can see all documents
// Filter by category
if ($request->filled('category')) {
$query->where('document_category_id', $request->category);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
$documents = $query->orderBy('created_at', 'desc')->paginate(20);
// Get categories with document counts (based on user's access)
$categories = DocumentCategory::withCount([
'activeDocuments' => function($query) use ($user) {
if (!$user) {
$query->where('access_level', 'public');
} elseif (!$user->is_admin && !$user->hasRole('admin')) {
$query->whereIn('access_level', ['public', 'members']);
}
}
])->orderBy('sort_order')->get();
return view('documents.index', compact('documents', 'categories'));
}
/**
* Show a document via its public UUID
*/
public function show(string $uuid)
{
$document = Document::where('public_uuid', $uuid)
->where('status', 'active')
->with(['category', 'currentVersion', 'versions.uploadedBy'])
->firstOrFail();
// Check access permission
$user = auth()->user();
if (!$document->canBeViewedBy($user)) {
if (!$user) {
return redirect()->route('login')->with('error', '請先登入以檢視此文件');
}
abort(403, '您沒有權限檢視此文件');
}
// Log access
$document->logAccess('view', $user);
return view('documents.show', compact('document'));
}
/**
* Download the current version of a document
*/
public function download(string $uuid)
{
$document = Document::where('public_uuid', $uuid)
->where('status', 'active')
->firstOrFail();
// Check access permission
$user = auth()->user();
if (!$document->canBeViewedBy($user)) {
if (!$user) {
return redirect()->route('login')->with('error', '請先登入以下載此文件');
}
abort(403, '您沒有權限下載此文件');
}
$currentVersion = $document->currentVersion;
if (!$currentVersion || !$currentVersion->fileExists()) {
abort(404, '檔案不存在');
}
// Log access
$document->logAccess('download', $user);
return Storage::disk('private')->download(
$currentVersion->file_path,
$currentVersion->original_filename
);
}
/**
* Download a specific version (if user has access)
*/
public function downloadVersion(string $uuid, int $versionId)
{
$document = Document::where('public_uuid', $uuid)
->where('status', 'active')
->firstOrFail();
// Check access permission
$user = auth()->user();
if (!$document->canBeViewedBy($user)) {
abort(403, '您沒有權限下載此文件');
}
$version = $document->versions()->findOrFail($versionId);
if (!$version->fileExists()) {
abort(404, '檔案不存在');
}
// Log access
$document->logAccess('download', $user);
return Storage::disk('private')->download(
$version->file_path,
$version->original_filename
);
}
/**
* Generate and download QR code for document
*/
public function downloadQRCode(string $uuid)
{
// Check if QR code feature is enabled
$settings = app(\App\Services\SettingsService::class);
if (!$settings->isFeatureEnabled('qr_codes')) {
abort(404, 'QR Code 功能未啟用');
}
// Check user permission
$user = auth()->user();
if ($user && !$user->can('use_qr_codes')) {
abort(403, '您沒有使用 QR Code 功能的權限');
}
$document = Document::where('public_uuid', $uuid)->firstOrFail();
// Check document access
if (!$document->canBeViewedBy($user)) {
abort(403);
}
// Use default size from system settings
$qrCode = $document->generateQRCodePNG();
return response($qrCode, 200)
->header('Content-Type', 'image/png')
->header('Content-Disposition', 'attachment; filename="qrcode-' . $document->id . '.png"');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Mail\MemberRegistrationWelcomeMail;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password;
class PublicMemberRegistrationController extends Controller
{
/**
* Show the member registration form
*/
public function create()
{
return view('register.member');
}
/**
* Handle member registration
*/
public function store(Request $request)
{
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', 'unique:members,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'phone' => ['nullable', 'string', 'max:20'],
'national_id' => ['nullable', 'string', 'max:20'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:100'],
'postal_code' => ['nullable', 'string', 'max:10'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:20'],
'terms_accepted' => ['required', 'accepted'],
]);
// Create user and member in a transaction
$member = DB::transaction(function () use ($validated) {
// Create user account
$user = User::create([
'name' => $validated['full_name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
// Create member record with pending status
$member = Member::create([
'user_id' => $user->id,
'full_name' => $validated['full_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'national_id' => $validated['national_id'] ?? null,
'address_line_1' => $validated['address_line_1'] ?? null,
'address_line_2' => $validated['address_line_2'] ?? null,
'city' => $validated['city'] ?? null,
'postal_code' => $validated['postal_code'] ?? null,
'emergency_contact_name' => $validated['emergency_contact_name'] ?? null,
'emergency_contact_phone' => $validated['emergency_contact_phone'] ?? null,
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
]);
AuditLogger::log('member.self_registered', $member, [
'email' => $member->email,
'name' => $member->full_name,
]);
return $member;
});
// Send welcome email with payment instructions
Mail::to($member->email)->queue(new MemberRegistrationWelcomeMail($member));
// Log the user in
auth()->loginUsingId($member->user_id);
return redirect()->route('member.dashboard')
->with('status', __('Registration successful! Please submit your membership payment to complete your registration.'));
}
}

View File

@@ -0,0 +1,272 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\BudgetItem;
use App\Models\ChartOfAccount;
use App\Models\Transaction;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TransactionController extends Controller
{
public function index(Request $request)
{
$query = Transaction::query()
->with(['chartOfAccount', 'budgetItem.budget', 'createdBy']);
// Filter by transaction type
if ($type = $request->string('transaction_type')->toString()) {
$query->where('transaction_type', $type);
}
// Filter by account
if ($accountId = $request->integer('chart_of_account_id')) {
$query->where('chart_of_account_id', $accountId);
}
// Filter by budget
if ($budgetId = $request->integer('budget_id')) {
$query->whereHas('budgetItem', fn($q) => $q->where('budget_id', $budgetId));
}
// Filter by date range
if ($startDate = $request->date('start_date')) {
$query->where('transaction_date', '>=', $startDate);
}
if ($endDate = $request->date('end_date')) {
$query->where('transaction_date', '<=', $endDate);
}
// Search description
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('reference_number', 'like', "%{$search}%");
});
}
$transactions = $query->orderByDesc('transaction_date')
->orderByDesc('created_at')
->paginate(20);
// Get filter options
$accounts = ChartOfAccount::where('is_active', true)
->whereIn('account_type', ['income', 'expense'])
->orderBy('account_code')
->get();
$budgets = Budget::orderByDesc('fiscal_year')->get();
// Calculate totals
$totalIncome = (clone $query)->income()->sum('amount');
$totalExpense = (clone $query)->expense()->sum('amount');
return view('admin.transactions.index', [
'transactions' => $transactions,
'accounts' => $accounts,
'budgets' => $budgets,
'totalIncome' => $totalIncome,
'totalExpense' => $totalExpense,
]);
}
public function create(Request $request)
{
// Get active budgets
$budgets = Budget::whereIn('status', [Budget::STATUS_ACTIVE, Budget::STATUS_APPROVED])
->orderByDesc('fiscal_year')
->get();
// Get income and expense accounts
$incomeAccounts = ChartOfAccount::where('account_type', 'income')
->where('is_active', true)
->orderBy('account_code')
->get();
$expenseAccounts = ChartOfAccount::where('account_type', 'expense')
->where('is_active', true)
->orderBy('account_code')
->get();
// Pre-select budget if provided
$selectedBudgetId = $request->integer('budget_id');
return view('admin.transactions.create', [
'budgets' => $budgets,
'incomeAccounts' => $incomeAccounts,
'expenseAccounts' => $expenseAccounts,
'selectedBudgetId' => $selectedBudgetId,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'chart_of_account_id' => ['required', 'exists:chart_of_accounts,id'],
'transaction_date' => ['required', 'date'],
'amount' => ['required', 'numeric', 'min:0.01'],
'transaction_type' => ['required', 'in:income,expense'],
'description' => ['required', 'string', 'max:255'],
'reference_number' => ['nullable', 'string', 'max:255'],
'budget_item_id' => ['nullable', 'exists:budget_items,id'],
'notes' => ['nullable', 'string'],
]);
DB::transaction(function () use ($validated, $request) {
$transaction = Transaction::create([
...$validated,
'created_by_user_id' => $request->user()->id,
]);
// Update budget item actual amount if linked
if ($transaction->budget_item_id) {
$this->updateBudgetItemActual($transaction->budget_item_id);
}
AuditLogger::log('transaction.created', $transaction, [
'user' => $request->user()->name,
'amount' => $validated['amount'],
'type' => $validated['transaction_type'],
]);
});
return redirect()
->route('admin.transactions.index')
->with('status', __('Transaction recorded successfully.'));
}
public function show(Transaction $transaction)
{
$transaction->load([
'chartOfAccount',
'budgetItem.budget',
'budgetItem.chartOfAccount',
'financeDocument',
'membershipPayment',
'createdBy',
]);
return view('admin.transactions.show', [
'transaction' => $transaction,
]);
}
public function edit(Transaction $transaction)
{
// Only allow editing if not linked to finance document or payment
if ($transaction->finance_document_id || $transaction->membership_payment_id) {
return redirect()
->route('admin.transactions.show', $transaction)
->with('error', __('Cannot edit auto-generated transactions.'));
}
$budgets = Budget::whereIn('status', [Budget::STATUS_ACTIVE, Budget::STATUS_APPROVED])
->orderByDesc('fiscal_year')
->get();
$incomeAccounts = ChartOfAccount::where('account_type', 'income')
->where('is_active', true)
->orderBy('account_code')
->get();
$expenseAccounts = ChartOfAccount::where('account_type', 'expense')
->where('is_active', true)
->orderBy('account_code')
->get();
return view('admin.transactions.edit', [
'transaction' => $transaction,
'budgets' => $budgets,
'incomeAccounts' => $incomeAccounts,
'expenseAccounts' => $expenseAccounts,
]);
}
public function update(Request $request, Transaction $transaction)
{
// Only allow editing if not auto-generated
if ($transaction->finance_document_id || $transaction->membership_payment_id) {
abort(403, 'Cannot edit auto-generated transactions.');
}
$validated = $request->validate([
'chart_of_account_id' => ['required', 'exists:chart_of_accounts,id'],
'transaction_date' => ['required', 'date'],
'amount' => ['required', 'numeric', 'min:0.01'],
'description' => ['required', 'string', 'max:255'],
'reference_number' => ['nullable', 'string', 'max:255'],
'budget_item_id' => ['nullable', 'exists:budget_items,id'],
'notes' => ['nullable', 'string'],
]);
DB::transaction(function () use ($transaction, $validated, $request) {
$oldBudgetItemId = $transaction->budget_item_id;
$transaction->update($validated);
// Update budget item actuals
if ($oldBudgetItemId) {
$this->updateBudgetItemActual($oldBudgetItemId);
}
if ($transaction->budget_item_id && $transaction->budget_item_id != $oldBudgetItemId) {
$this->updateBudgetItemActual($transaction->budget_item_id);
}
AuditLogger::log('transaction.updated', $transaction, [
'user' => $request->user()->name,
]);
});
return redirect()
->route('admin.transactions.show', $transaction)
->with('status', __('Transaction updated successfully.'));
}
public function destroy(Request $request, Transaction $transaction)
{
// Only allow deleting if not auto-generated
if ($transaction->finance_document_id || $transaction->membership_payment_id) {
abort(403, 'Cannot delete auto-generated transactions.');
}
$budgetItemId = $transaction->budget_item_id;
DB::transaction(function () use ($transaction, $budgetItemId, $request) {
$transaction->delete();
// Update budget item actual
if ($budgetItemId) {
$this->updateBudgetItemActual($budgetItemId);
}
AuditLogger::log('transaction.deleted', null, [
'user' => $request->user()->name,
'description' => $transaction->description,
'amount' => $transaction->amount,
]);
});
return redirect()
->route('admin.transactions.index')
->with('status', __('Transaction deleted successfully.'));
}
/**
* Update budget item actual amount based on all transactions
*/
protected function updateBudgetItemActual(int $budgetItemId): void
{
$budgetItem = BudgetItem::find($budgetItemId);
if (!$budgetItem) {
return;
}
$actualAmount = Transaction::where('budget_item_id', $budgetItemId)
->sum('amount');
$budgetItem->update(['actual_amount' => $actualAmount]);
}
}

69
app/Http/Kernel.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckPaidMembership
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// If not authenticated, redirect to login
if (!$user) {
return redirect()->route('login');
}
// If user doesn't have a member record, redirect with error
if (!$user->member) {
return redirect()->route('member.dashboard')
->with('error', __('You must be a registered member to access this resource.'));
}
// Check if member has active paid membership
if (!$user->member->hasPaidMembership()) {
return redirect()->route('member.dashboard')
->with('error', __('This resource is only available to active paid members. Please complete your membership payment and activation.'));
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || (! $user->is_admin && ! $user->hasRole('admin'))) {
abort(403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'profile_photo' => ['nullable', 'image', 'max:2048'],
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FinanceDocumentApprovedByAccountant extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public \App\Models\FinanceDocument $document
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Finance Document Awaiting Chair Final Approval - ' . $this->document->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.finance.approved-by-accountant',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use App\Models\FinanceDocument;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FinanceDocumentApprovedByCashier extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public FinanceDocument $document
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Finance Document Awaiting Accountant Review - ' . $this->document->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.finance.approved-by-cashier',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FinanceDocumentFullyApproved extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public \App\Models\FinanceDocument $document
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Finance Document Fully Approved - ' . $this->document->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.finance.fully-approved',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FinanceDocumentRejected extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public \App\Models\FinanceDocument $document
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Finance Document Rejected - ' . $this->document->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.finance.rejected',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use App\Models\FinanceDocument;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FinanceDocumentSubmitted extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public FinanceDocument $document
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'New Finance Document Awaiting Cashier Review - ' . $this->document->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.finance.submitted',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueAssignedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Issue Assigned to You - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.assigned',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueClosedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Issue Closed - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.closed',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use App\Models\IssueComment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueCommentedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue,
public IssueComment $comment
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'New Comment on Issue - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.commented',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueDueSoonMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue,
public int $daysRemaining
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Issue Due Soon - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.due-soon',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueOverdueMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue,
public int $daysOverdue
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Issue Overdue - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.overdue',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Mail;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class IssueStatusChangedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Issue $issue,
public string $oldStatus,
public string $newStatus
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Issue Status Changed - ' . $this->issue->issue_number . ': ' . $this->issue->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.issues.status-changed',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class MemberActivationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public User $user;
public string $token;
public function __construct(User $user, string $token)
{
$this->user = $user;
$this->token = $token;
}
public function build(): self
{
$resetUrl = url(route('password.reset', [
'token' => $this->token,
'email' => $this->user->email,
], false));
return $this->subject(__('Activate your membership account'))
->text('emails.members.activation-text', [
'user' => $this->user,
'resetUrl' => $resetUrl,
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\Member;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class MemberRegistrationWelcomeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Member $member
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome! Please Submit Payment to Complete Your Membership - ' . $this->member->full_name,
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.members.registration-welcome',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\Member;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class MembershipActivatedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Member $member
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Membership Activated - Welcome to ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.members.activated',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Mail;
use App\Models\Member;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class MembershipExpiryReminderMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public Member $member;
public function __construct(Member $member)
{
$this->member = $member;
}
public function build(): self
{
return $this->subject(__('Your membership is expiring soon'))
->text('emails.members.expiry-reminder-text', [
'member' => $this->member,
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\MembershipPayment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentApprovedByAccountantMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MembershipPayment $payment
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Payment Verification Update - Accountant Approved',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payments.approved-accountant',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\MembershipPayment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentApprovedByCashierMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MembershipPayment $payment
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Payment Verification Update - Cashier Approved',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payments.approved-cashier',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\MembershipPayment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentFullyApprovedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MembershipPayment $payment
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Payment Fully Approved - Membership Activation Pending',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payments.fully-approved',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use App\Models\MembershipPayment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentRejectedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MembershipPayment $payment
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Payment Verification - Action Required',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payments.rejected',
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Models\MembershipPayment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentSubmittedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MembershipPayment $payment,
public string $recipient // 'member' or 'cashier'
) {
}
public function envelope(): Envelope
{
$subject = $this->recipient === 'member'
? 'Payment Submitted Successfully - Awaiting Verification'
: 'New Payment Submitted for Verification - ' . $this->payment->member->full_name;
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payments.submitted-' . $this->recipient,
);
}
public function attachments(): array
{
return [];
}
}

28
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'action',
'auditable_type',
'auditable_id',
'metadata',
];
protected $casts = [
'metadata' => 'array',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BankReconciliation extends Model
{
use HasFactory;
protected $fillable = [
'reconciliation_month',
'bank_statement_balance',
'bank_statement_date',
'bank_statement_file_path',
'system_book_balance',
'outstanding_checks',
'deposits_in_transit',
'bank_charges',
'adjusted_balance',
'discrepancy_amount',
'reconciliation_status',
'prepared_by_cashier_id',
'reviewed_by_accountant_id',
'approved_by_manager_id',
'prepared_at',
'reviewed_at',
'approved_at',
'notes',
];
protected $casts = [
'reconciliation_month' => 'date',
'bank_statement_balance' => 'decimal:2',
'bank_statement_date' => 'date',
'system_book_balance' => 'decimal:2',
'outstanding_checks' => 'array',
'deposits_in_transit' => 'array',
'bank_charges' => 'array',
'adjusted_balance' => 'decimal:2',
'discrepancy_amount' => 'decimal:2',
'prepared_at' => 'datetime',
'reviewed_at' => 'datetime',
'approved_at' => 'datetime',
];
/**
* 狀態常數
*/
const STATUS_PENDING = 'pending';
const STATUS_COMPLETED = 'completed';
const STATUS_DISCREPANCY = 'discrepancy';
/**
* 製作調節表的出納人員
*/
public function preparedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'prepared_by_cashier_id');
}
/**
* 覆核的會計人員
*/
public function reviewedByAccountant(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_accountant_id');
}
/**
* 核准的主管
*/
public function approvedByManager(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_manager_id');
}
/**
* 計算調整後餘額
*/
public function calculateAdjustedBalance(): float
{
$adjusted = $this->system_book_balance;
// 加上在途存款
if ($this->deposits_in_transit) {
foreach ($this->deposits_in_transit as $deposit) {
$adjusted += floatval($deposit['amount'] ?? 0);
}
}
// 減去未兌現支票
if ($this->outstanding_checks) {
foreach ($this->outstanding_checks as $check) {
$adjusted -= floatval($check['amount'] ?? 0);
}
}
// 減去銀行手續費
if ($this->bank_charges) {
foreach ($this->bank_charges as $charge) {
$adjusted -= floatval($charge['amount'] ?? 0);
}
}
return $adjusted;
}
/**
* 計算差異金額
*/
public function calculateDiscrepancy(): float
{
return abs($this->adjusted_balance - $this->bank_statement_balance);
}
/**
* 檢查是否有差異
*/
public function hasDiscrepancy(float $tolerance = 0.01): bool
{
return $this->calculateDiscrepancy() > $tolerance;
}
/**
* 是否待覆核
*/
public function isPending(): bool
{
return $this->reconciliation_status === self::STATUS_PENDING;
}
/**
* 是否已完成
*/
public function isCompleted(): bool
{
return $this->reconciliation_status === self::STATUS_COMPLETED;
}
/**
* 是否有差異待處理
*/
public function hasUnresolvedDiscrepancy(): bool
{
return $this->reconciliation_status === self::STATUS_DISCREPANCY;
}
/**
* 是否可以被會計覆核
*/
public function canBeReviewed(): bool
{
return $this->isPending() && $this->prepared_at !== null;
}
/**
* 是否可以被主管核准
*/
public function canBeApproved(): bool
{
return $this->reviewed_at !== null && $this->approved_at === null;
}
/**
* 取得狀態文字
*/
public function getStatusText(): string
{
return match ($this->reconciliation_status) {
self::STATUS_PENDING => '待覆核',
self::STATUS_COMPLETED => '已完成',
self::STATUS_DISCREPANCY => '有差異',
default => '未知',
};
}
/**
* 取得未達帳項總計
*/
public function getOutstandingItemsSummary(): array
{
$checksTotal = 0;
if ($this->outstanding_checks) {
foreach ($this->outstanding_checks as $check) {
$checksTotal += floatval($check['amount'] ?? 0);
}
}
$depositsTotal = 0;
if ($this->deposits_in_transit) {
foreach ($this->deposits_in_transit as $deposit) {
$depositsTotal += floatval($deposit['amount'] ?? 0);
}
}
$chargesTotal = 0;
if ($this->bank_charges) {
foreach ($this->bank_charges as $charge) {
$chargesTotal += floatval($charge['amount'] ?? 0);
}
}
return [
'outstanding_checks_total' => $checksTotal,
'deposits_in_transit_total' => $depositsTotal,
'bank_charges_total' => $chargesTotal,
'net_adjustment' => $depositsTotal - $checksTotal - $chargesTotal,
];
}
}

121
app/Models/Budget.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Budget extends Model
{
use HasFactory;
public const STATUS_DRAFT = 'draft';
public const STATUS_SUBMITTED = 'submitted';
public const STATUS_APPROVED = 'approved';
public const STATUS_ACTIVE = 'active';
public const STATUS_CLOSED = 'closed';
protected $fillable = [
'fiscal_year',
'name',
'period_type',
'period_start',
'period_end',
'status',
'created_by_user_id',
'approved_by_user_id',
'approved_at',
'notes',
];
protected $casts = [
'fiscal_year' => 'integer',
'period_start' => 'date',
'period_end' => 'date',
'approved_at' => 'datetime',
];
// Relationships
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
public function budgetItems(): HasMany
{
return $this->hasMany(BudgetItem::class);
}
public function financialReports(): HasMany
{
return $this->hasMany(FinancialReport::class);
}
// Helper methods
public function isDraft(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isApproved(): bool
{
return $this->status === self::STATUS_APPROVED;
}
public function isActive(): bool
{
return $this->status === self::STATUS_ACTIVE;
}
public function isClosed(): bool
{
return $this->status === self::STATUS_CLOSED;
}
public function canBeEdited(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SUBMITTED]);
}
public function canBeApproved(): bool
{
return $this->status === self::STATUS_SUBMITTED;
}
public function getTotalBudgetedIncomeAttribute(): float
{
return $this->budgetItems()
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'income'))
->sum('budgeted_amount');
}
public function getTotalBudgetedExpenseAttribute(): float
{
return $this->budgetItems()
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'expense'))
->sum('budgeted_amount');
}
public function getTotalActualIncomeAttribute(): float
{
return $this->budgetItems()
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'income'))
->sum('actual_amount');
}
public function getTotalActualExpenseAttribute(): float
{
return $this->budgetItems()
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'expense'))
->sum('actual_amount');
}
}

76
app/Models/BudgetItem.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BudgetItem extends Model
{
use HasFactory;
protected $fillable = [
'budget_id',
'chart_of_account_id',
'budgeted_amount',
'actual_amount',
'notes',
];
protected $casts = [
'budgeted_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
];
// Relationships
public function budget(): BelongsTo
{
return $this->belongsTo(Budget::class);
}
public function chartOfAccount(): BelongsTo
{
return $this->belongsTo(ChartOfAccount::class);
}
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
// Helper methods
public function getVarianceAttribute(): float
{
return $this->actual_amount - $this->budgeted_amount;
}
public function getVariancePercentageAttribute(): float
{
if ($this->budgeted_amount == 0) {
return 0;
}
return ($this->variance / $this->budgeted_amount) * 100;
}
public function getRemainingBudgetAttribute(): float
{
return $this->budgeted_amount - $this->actual_amount;
}
public function isOverBudget(): bool
{
return $this->actual_amount > $this->budgeted_amount;
}
public function getUtilizationPercentageAttribute(): float
{
if ($this->budgeted_amount == 0) {
return 0;
}
return ($this->actual_amount / $this->budgeted_amount) * 100;
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CashierLedgerEntry extends Model
{
use HasFactory;
protected $fillable = [
'finance_document_id',
'entry_date',
'entry_type',
'payment_method',
'bank_account',
'amount',
'balance_before',
'balance_after',
'receipt_number',
'transaction_reference',
'recorded_by_cashier_id',
'recorded_at',
'notes',
];
protected $casts = [
'entry_date' => 'date',
'amount' => 'decimal:2',
'balance_before' => 'decimal:2',
'balance_after' => 'decimal:2',
'recorded_at' => 'datetime',
];
/**
* 類型常數
*/
const ENTRY_TYPE_RECEIPT = 'receipt';
const ENTRY_TYPE_PAYMENT = 'payment';
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
const PAYMENT_METHOD_CHECK = 'check';
const PAYMENT_METHOD_CASH = 'cash';
/**
* 關聯到財務申請單
*/
public function financeDocument(): BelongsTo
{
return $this->belongsTo(FinanceDocument::class);
}
/**
* 記錄的出納人員
*/
public function recordedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by_cashier_id');
}
/**
* 計算交易後餘額
*/
public function calculateBalanceAfter(float $currentBalance): float
{
if ($this->entry_type === self::ENTRY_TYPE_RECEIPT) {
return $currentBalance + $this->amount;
} else {
return $currentBalance - $this->amount;
}
}
/**
* 取得最新餘額(從最後一筆記錄)
*/
public static function getLatestBalance(string $bankAccount = null): float
{
$query = self::orderBy('entry_date', 'desc')
->orderBy('id', 'desc');
if ($bankAccount) {
$query->where('bank_account', $bankAccount);
}
$latest = $query->first();
return $latest ? $latest->balance_after : 0.00;
}
/**
* 取得類型文字
*/
public function getEntryTypeText(): string
{
return match ($this->entry_type) {
self::ENTRY_TYPE_RECEIPT => '收入',
self::ENTRY_TYPE_PAYMENT => '支出',
default => '未知',
};
}
/**
* 取得付款方式文字
*/
public function getPaymentMethodText(): string
{
return match ($this->payment_method) {
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
self::PAYMENT_METHOD_CHECK => '支票',
self::PAYMENT_METHOD_CASH => '現金',
default => '未知',
};
}
/**
* 是否為收入記錄
*/
public function isReceipt(): bool
{
return $this->entry_type === self::ENTRY_TYPE_RECEIPT;
}
/**
* 是否為支出記錄
*/
public function isPayment(): bool
{
return $this->entry_type === self::ENTRY_TYPE_PAYMENT;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ChartOfAccount extends Model
{
use HasFactory;
protected $fillable = [
'account_code',
'account_name_zh',
'account_name_en',
'account_type',
'category',
'parent_account_id',
'is_active',
'display_order',
'description',
];
protected $casts = [
'is_active' => 'boolean',
'display_order' => 'integer',
];
// Relationships
public function parentAccount(): BelongsTo
{
return $this->belongsTo(ChartOfAccount::class, 'parent_account_id');
}
public function childAccounts(): HasMany
{
return $this->hasMany(ChartOfAccount::class, 'parent_account_id')->orderBy('display_order');
}
public function budgetItems(): HasMany
{
return $this->hasMany(BudgetItem::class);
}
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
// Helper methods
public function getFullNameAttribute(): string
{
return "{$this->account_code} - {$this->account_name_zh}";
}
public function isIncome(): bool
{
return $this->account_type === 'income';
}
public function isExpense(): bool
{
return $this->account_type === 'expense';
}
public function isAsset(): bool
{
return $this->account_type === 'asset';
}
public function isLiability(): bool
{
return $this->account_type === 'liability';
}
public function isNetAsset(): bool
{
return $this->account_type === 'net_asset';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CustomField extends Model
{
use HasFactory;
public const TYPE_TEXT = 'text';
public const TYPE_NUMBER = 'number';
public const TYPE_DATE = 'date';
public const TYPE_SELECT = 'select';
protected $fillable = [
'name',
'field_type',
'options',
'applies_to_issue_types',
'is_required',
'display_order',
];
protected $casts = [
'options' => 'array',
'applies_to_issue_types' => 'array',
'is_required' => 'boolean',
];
public function values(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
public function appliesToIssueType(string $issueType): bool
{
return in_array($issueType, $this->applies_to_issue_types ?? []);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class CustomFieldValue extends Model
{
use HasFactory;
protected $fillable = [
'custom_field_id',
'customizable_type',
'customizable_id',
'value',
];
protected $casts = [
'value' => 'array',
];
public function customField(): BelongsTo
{
return $this->belongsTo(CustomField::class);
}
public function customizable(): MorphTo
{
return $this->morphTo();
}
public function getDisplayValueAttribute(): string
{
$value = $this->value;
return match($this->customField->field_type) {
CustomField::TYPE_DATE => \Carbon\Carbon::parse($value)->format('Y-m-d'),
CustomField::TYPE_SELECT => is_array($value) ? implode(', ', $value) : $value,
default => (string) $value,
};
}
}

446
app/Models/Document.php Normal file
View File

@@ -0,0 +1,446 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Document extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'document_category_id',
'title',
'document_number',
'description',
'public_uuid',
'access_level',
'current_version_id',
'status',
'archived_at',
'created_by_user_id',
'last_updated_by_user_id',
'view_count',
'download_count',
'version_count',
'expires_at',
'auto_archive_on_expiry',
'expiry_notice',
];
protected $casts = [
'archived_at' => 'datetime',
'expires_at' => 'date',
'auto_archive_on_expiry' => 'boolean',
];
protected static function boot()
{
parent::boot();
// Auto-generate UUID for public sharing
static::creating(function ($document) {
if (empty($document->public_uuid)) {
$document->public_uuid = (string) Str::uuid();
}
});
}
// ==================== Relationships ====================
/**
* Get the category this document belongs to
*/
public function category()
{
return $this->belongsTo(DocumentCategory::class, 'document_category_id');
}
/**
* Get all versions of this document
*/
public function versions()
{
return $this->hasMany(DocumentVersion::class)->orderBy('uploaded_at', 'desc');
}
/**
* Get the current published version
*/
public function currentVersion()
{
return $this->belongsTo(DocumentVersion::class, 'current_version_id');
}
/**
* Get the user who created this document
*/
public function createdBy()
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
/**
* Get the user who last updated this document
*/
public function lastUpdatedBy()
{
return $this->belongsTo(User::class, 'last_updated_by_user_id');
}
/**
* Get the tags for this document
*/
public function tags()
{
return $this->belongsToMany(DocumentTag::class, 'document_document_tag')
->withTimestamps();
}
/**
* Get access logs for this document
*/
public function accessLogs()
{
return $this->hasMany(DocumentAccessLog::class)->orderBy('accessed_at', 'desc');
}
// ==================== Status Check Methods ====================
/**
* Check if document is active
*/
public function isActive(): bool
{
return $this->status === 'active';
}
/**
* Check if document is archived
*/
public function isArchived(): bool
{
return $this->status === 'archived';
}
/**
* Check if document is publicly accessible
*/
public function isPublic(): bool
{
return $this->access_level === 'public';
}
/**
* Check if document requires membership
*/
public function requiresMembership(): bool
{
return $this->access_level === 'members';
}
/**
* Check if document is admin-only
*/
public function isAdminOnly(): bool
{
return in_array($this->access_level, ['admin', 'board']);
}
// ==================== Version Control Methods ====================
/**
* Add a new version to this document
*/
public function addVersion(
string $filePath,
string $originalFilename,
string $mimeType,
int $fileSize,
User $uploadedBy,
?string $versionNotes = null
): DocumentVersion {
// Calculate next version number
$nextVersionNumber = $this->calculateNextVersionNumber();
// Unset current version flag on existing versions
$this->versions()->update(['is_current' => false]);
// Create new version
$version = $this->versions()->create([
'version_number' => $nextVersionNumber,
'version_notes' => $versionNotes,
'is_current' => true,
'file_path' => $filePath,
'original_filename' => $originalFilename,
'mime_type' => $mimeType,
'file_size' => $fileSize,
'file_hash' => hash_file('sha256', storage_path('app/' . $filePath)),
'uploaded_by_user_id' => $uploadedBy->id,
'uploaded_at' => now(),
]);
// Update document's current_version_id and increment version count
$this->update([
'current_version_id' => $version->id,
'version_count' => $this->version_count + 1,
'last_updated_by_user_id' => $uploadedBy->id,
]);
return $version;
}
/**
* Calculate the next version number
*/
private function calculateNextVersionNumber(): string
{
$latestVersion = $this->versions()->orderBy('id', 'desc')->first();
if (!$latestVersion) {
return '1.0';
}
// Parse current version (e.g., "1.5" -> major: 1, minor: 5)
$parts = explode('.', $latestVersion->version_number);
$major = (int) ($parts[0] ?? 1);
$minor = (int) ($parts[1] ?? 0);
// Increment minor version
$minor++;
return "{$major}.{$minor}";
}
/**
* Promote an old version to be the current version
*/
public function promoteVersion(DocumentVersion $version, User $user): void
{
if ($version->document_id !== $this->id) {
throw new \Exception('Version does not belong to this document');
}
// Unset current flag on all versions
$this->versions()->update(['is_current' => false]);
// Set this version as current
$version->update(['is_current' => true]);
// Update document's current_version_id
$this->update([
'current_version_id' => $version->id,
'last_updated_by_user_id' => $user->id,
]);
}
/**
* Get version history with comparison data
*/
public function getVersionHistory(): array
{
$versions = $this->versions()->with('uploadedBy')->get();
$history = [];
foreach ($versions as $index => $version) {
$previousVersion = $versions->get($index + 1);
$history[] = [
'version' => $version,
'size_change' => $previousVersion ? $version->file_size - $previousVersion->file_size : 0,
'days_since_previous' => $previousVersion ? $version->uploaded_at->diffInDays($previousVersion->uploaded_at) : null,
];
}
return $history;
}
// ==================== Access Control Methods ====================
/**
* Check if a user can view this document
*/
public function canBeViewedBy(?User $user): bool
{
if ($this->isPublic()) {
return true;
}
if (!$user) {
return false;
}
if ($user->is_admin || $user->hasRole('admin')) {
return true;
}
if ($this->access_level === 'members') {
return $user->member && $user->member->hasPaidMembership();
}
if ($this->access_level === 'board') {
return $user->hasRole(['admin', 'chair', 'board']);
}
return false;
}
/**
* Log access to this document
*/
public function logAccess(string $action, ?User $user = null): void
{
$this->accessLogs()->create([
'document_version_id' => $this->current_version_id,
'action' => $action,
'user_id' => $user?->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'accessed_at' => now(),
]);
// Increment counters
if ($action === 'view') {
$this->increment('view_count');
} elseif ($action === 'download') {
$this->increment('download_count');
}
}
// ==================== Helper Methods ====================
/**
* Get the public URL for this document
*/
public function getPublicUrl(): string
{
return route('documents.public.show', $this->public_uuid);
}
/**
* Get the access level label in Chinese
*/
public function getAccessLevelLabel(): string
{
return match($this->access_level) {
'public' => '公開',
'members' => '會員',
'admin' => '管理員',
'board' => '理事會',
default => '未知',
};
}
/**
* Get status label in Chinese
*/
public function getStatusLabel(): string
{
return match($this->status) {
'active' => '啟用',
'archived' => '封存',
default => '未知',
};
}
/**
* Archive this document
*/
public function archive(): void
{
$this->update([
'status' => 'archived',
'archived_at' => now(),
]);
}
/**
* Restore archived document
*/
public function unarchive(): void
{
$this->update([
'status' => 'active',
'archived_at' => null,
]);
}
/**
* Check if document is expired
*/
public function isExpired(): bool
{
if (!$this->expires_at) {
return false;
}
return $this->expires_at->isPast();
}
/**
* Check if document is expiring soon (within 30 days)
*/
public function isExpiringSoon(int $days = 30): bool
{
if (!$this->expires_at) {
return false;
}
return $this->expires_at->isFuture() &&
$this->expires_at->diffInDays(now()) <= $days;
}
/**
* Get expiration status label
*/
public function getExpirationStatusLabel(): ?string
{
if (!$this->expires_at) {
return null;
}
if ($this->isExpired()) {
return '已過期';
}
if ($this->isExpiringSoon(7)) {
return '即將過期';
}
if ($this->isExpiringSoon(30)) {
return '接近過期';
}
return '有效';
}
/**
* Generate QR code for this document
*/
public function generateQRCode(?int $size = null, ?string $format = null): string
{
$settings = app(\App\Services\SettingsService::class);
$size = $size ?? $settings->getQRCodeSize();
$format = $format ?? $settings->getQRCodeFormat();
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
->format($format)
->generate($this->getPublicUrl());
}
/**
* Generate QR code as PNG
*/
public function generateQRCodePNG(?int $size = null): string
{
$settings = app(\App\Services\SettingsService::class);
$size = $size ?? $settings->getQRCodeSize();
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
->format('png')
->generate($this->getPublicUrl());
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DocumentAccessLog extends Model
{
use HasFactory;
protected $fillable = [
'document_id',
'document_version_id',
'action',
'user_id',
'ip_address',
'user_agent',
'accessed_at',
];
protected $casts = [
'accessed_at' => 'datetime',
];
// ==================== Relationships ====================
/**
* Get the document this log belongs to
*/
public function document()
{
return $this->belongsTo(Document::class);
}
/**
* Get the document version accessed
*/
public function version()
{
return $this->belongsTo(DocumentVersion::class, 'document_version_id');
}
/**
* Get the user who accessed (null if anonymous)
*/
public function user()
{
return $this->belongsTo(User::class);
}
// ==================== Helper Methods ====================
/**
* Get action label in Chinese
*/
public function getActionLabel(): string
{
return match($this->action) {
'view' => '檢視',
'download' => '下載',
default => '未知',
};
}
/**
* Get user display name (anonymous if no user)
*/
public function getUserDisplay(): string
{
return $this->user ? $this->user->name : '匿名訪客';
}
/**
* Get browser from user agent
*/
public function getBrowser(): string
{
if (!$this->user_agent) {
return '未知';
}
if (str_contains($this->user_agent, 'Chrome')) {
return 'Chrome';
}
if (str_contains($this->user_agent, 'Safari')) {
return 'Safari';
}
if (str_contains($this->user_agent, 'Firefox')) {
return 'Firefox';
}
if (str_contains($this->user_agent, 'Edge')) {
return 'Edge';
}
return '未知';
}
/**
* Check if access was by authenticated user
*/
public function isAuthenticated(): bool
{
return $this->user_id !== null;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DocumentCategory extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'icon',
'sort_order',
'default_access_level',
];
protected static function boot()
{
parent::boot();
// Auto-generate slug from name if not provided
static::creating(function ($category) {
if (empty($category->slug)) {
$category->slug = Str::slug($category->name);
}
});
}
// ==================== Relationships ====================
/**
* Get all documents in this category
*/
public function documents()
{
return $this->hasMany(Document::class);
}
/**
* Get active (non-archived) documents in this category
*/
public function activeDocuments()
{
return $this->hasMany(Document::class)->where('status', 'active');
}
// ==================== Accessors ====================
/**
* Get the count of active documents in this category
*/
public function getDocumentCountAttribute(): int
{
return $this->activeDocuments()->count();
}
// ==================== Helper Methods ====================
/**
* Get the icon with fallback
*/
public function getIconDisplay(): string
{
return $this->icon ?? '📄';
}
/**
* Get the access level label
*/
public function getAccessLevelLabel(): string
{
return match($this->default_access_level) {
'public' => '公開',
'members' => '會員',
'admin' => '管理員',
'board' => '理事會',
default => '未知',
};
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DocumentTag extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'color',
'description',
];
/**
* Boot the model
*/
protected static function booted()
{
static::creating(function ($tag) {
if (empty($tag->slug)) {
$tag->slug = Str::slug($tag->name);
}
});
}
/**
* Get the documents that have this tag
*/
public function documents()
{
return $this->belongsToMany(Document::class, 'document_document_tag')
->withTimestamps();
}
/**
* Get count of active documents with this tag
*/
public function activeDocuments()
{
return $this->belongsToMany(Document::class, 'document_document_tag')
->where('status', 'active')
->withTimestamps();
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class DocumentVersion extends Model
{
use HasFactory;
protected $fillable = [
'document_id',
'version_number',
'version_notes',
'is_current',
'file_path',
'original_filename',
'mime_type',
'file_size',
'file_hash',
'uploaded_by_user_id',
'uploaded_at',
];
protected $casts = [
'is_current' => 'boolean',
'uploaded_at' => 'datetime',
];
// Versions are immutable - disable updating
protected static function booted()
{
static::updating(function ($version) {
// Only allow updating is_current flag
$dirty = $version->getDirty();
if (count($dirty) > 1 || !isset($dirty['is_current'])) {
throw new \Exception('Document versions are immutable and cannot be modified');
}
});
}
// ==================== Relationships ====================
/**
* Get the document this version belongs to
*/
public function document()
{
return $this->belongsTo(Document::class);
}
/**
* Get the user who uploaded this version
*/
public function uploadedBy()
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
// ==================== File Methods ====================
/**
* Get the full file path
*/
public function getFullPath(): string
{
return storage_path('app/' . $this->file_path);
}
/**
* Check if file exists
*/
public function fileExists(): bool
{
return Storage::exists($this->file_path);
}
/**
* Get file download URL
*/
public function getDownloadUrl(): string
{
return route('admin.documents.download-version', [
'document' => $this->document_id,
'version' => $this->id,
]);
}
/**
* Verify file integrity
*/
public function verifyIntegrity(): bool
{
if (!$this->fileExists()) {
return false;
}
$currentHash = hash_file('sha256', $this->getFullPath());
return $currentHash === $this->file_hash;
}
// ==================== Helper Methods ====================
/**
* Get human-readable file size
*/
public function getFileSizeHuman(): string
{
$bytes = $this->file_size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Get file extension
*/
public function getFileExtension(): string
{
return pathinfo($this->original_filename, PATHINFO_EXTENSION);
}
/**
* Get file icon based on mime type
*/
public function getFileIcon(): string
{
return match(true) {
str_contains($this->mime_type, 'pdf') => '📄',
str_contains($this->mime_type, 'word') || str_contains($this->mime_type, 'document') => '📝',
str_contains($this->mime_type, 'sheet') || str_contains($this->mime_type, 'excel') => '📊',
str_contains($this->mime_type, 'image') => '🖼️',
str_contains($this->mime_type, 'zip') || str_contains($this->mime_type, 'compressed') => '🗜️',
default => '📎',
};
}
/**
* Check if version is current
*/
public function isCurrent(): bool
{
return $this->is_current;
}
/**
* Get version badge class for UI
*/
public function getBadgeClass(): string
{
return $this->is_current ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
}
/**
* Get version badge text
*/
public function getBadgeText(): string
{
return $this->is_current ? '當前版本' : '歷史版本';
}
}

View File

@@ -0,0 +1,435 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class FinanceDocument extends Model
{
use HasFactory;
// Status constants
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED_CASHIER = 'approved_cashier';
public const STATUS_APPROVED_ACCOUNTANT = 'approved_accountant';
public const STATUS_APPROVED_CHAIR = 'approved_chair';
public const STATUS_REJECTED = 'rejected';
// Request type constants
public const REQUEST_TYPE_EXPENSE_REIMBURSEMENT = 'expense_reimbursement';
public const REQUEST_TYPE_ADVANCE_PAYMENT = 'advance_payment';
public const REQUEST_TYPE_PURCHASE_REQUEST = 'purchase_request';
public const REQUEST_TYPE_PETTY_CASH = 'petty_cash';
// Amount tier constants
public const AMOUNT_TIER_SMALL = 'small'; // < 5,000
public const AMOUNT_TIER_MEDIUM = 'medium'; // 5,000 - 50,000
public const AMOUNT_TIER_LARGE = 'large'; // > 50,000
// Reconciliation status constants
public const RECONCILIATION_PENDING = 'pending';
public const RECONCILIATION_MATCHED = 'matched';
public const RECONCILIATION_DISCREPANCY = 'discrepancy';
public const RECONCILIATION_RESOLVED = 'resolved';
// Payment method constants
public const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
public const PAYMENT_METHOD_CHECK = 'check';
public const PAYMENT_METHOD_CASH = 'cash';
protected $fillable = [
'member_id',
'submitted_by_user_id',
'title',
'amount',
'status',
'description',
'attachment_path',
'submitted_at',
'approved_by_cashier_id',
'cashier_approved_at',
'approved_by_accountant_id',
'accountant_approved_at',
'approved_by_chair_id',
'chair_approved_at',
'rejected_by_user_id',
'rejected_at',
'rejection_reason',
// New payment stage fields
'request_type',
'amount_tier',
'chart_of_account_id',
'budget_item_id',
'requires_board_meeting',
'approved_by_board_meeting_id',
'board_meeting_approved_at',
'payment_order_created_by_accountant_id',
'payment_order_created_at',
'payment_method',
'payee_name',
'payee_account_number',
'payee_bank_name',
'payment_verified_by_cashier_id',
'payment_verified_at',
'payment_executed_by_cashier_id',
'payment_executed_at',
'payment_transaction_id',
'payment_receipt_path',
'actual_payment_amount',
'cashier_ledger_entry_id',
'accounting_transaction_id',
'reconciliation_status',
'reconciled_at',
];
protected $casts = [
'amount' => 'decimal:2',
'submitted_at' => 'datetime',
'cashier_approved_at' => 'datetime',
'accountant_approved_at' => 'datetime',
'chair_approved_at' => 'datetime',
'rejected_at' => 'datetime',
// New payment stage casts
'requires_board_meeting' => 'boolean',
'board_meeting_approved_at' => 'datetime',
'payment_order_created_at' => 'datetime',
'payment_verified_at' => 'datetime',
'payment_executed_at' => 'datetime',
'actual_payment_amount' => 'decimal:2',
'reconciled_at' => 'datetime',
];
public function member()
{
return $this->belongsTo(Member::class);
}
public function submittedBy()
{
return $this->belongsTo(User::class, 'submitted_by_user_id');
}
public function approvedByCashier()
{
return $this->belongsTo(User::class, 'approved_by_cashier_id');
}
public function approvedByAccountant()
{
return $this->belongsTo(User::class, 'approved_by_accountant_id');
}
public function approvedByChair()
{
return $this->belongsTo(User::class, 'approved_by_chair_id');
}
public function rejectedBy()
{
return $this->belongsTo(User::class, 'rejected_by_user_id');
}
/**
* New payment stage relationships
*/
public function chartOfAccount(): BelongsTo
{
return $this->belongsTo(ChartOfAccount::class);
}
public function budgetItem(): BelongsTo
{
return $this->belongsTo(BudgetItem::class);
}
public function approvedByBoardMeeting(): BelongsTo
{
return $this->belongsTo(BoardMeeting::class, 'approved_by_board_meeting_id');
}
public function paymentOrderCreatedByAccountant(): BelongsTo
{
return $this->belongsTo(User::class, 'payment_order_created_by_accountant_id');
}
public function paymentVerifiedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'payment_verified_by_cashier_id');
}
public function paymentExecutedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'payment_executed_by_cashier_id');
}
public function cashierLedgerEntry(): BelongsTo
{
return $this->belongsTo(CashierLedgerEntry::class);
}
public function accountingTransaction(): BelongsTo
{
return $this->belongsTo(Transaction::class, 'accounting_transaction_id');
}
public function paymentOrder(): HasOne
{
return $this->hasOne(PaymentOrder::class);
}
/**
* Check if document can be approved by cashier
*/
public function canBeApprovedByCashier(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* Check if document can be approved by accountant
*/
public function canBeApprovedByAccountant(): bool
{
return $this->status === self::STATUS_APPROVED_CASHIER;
}
/**
* Check if document can be approved by chair
*/
public function canBeApprovedByChair(): bool
{
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
}
/**
* Check if document is fully approved
*/
public function isFullyApproved(): bool
{
return $this->status === self::STATUS_APPROVED_CHAIR;
}
/**
* Check if document is rejected
*/
public function isRejected(): bool
{
return $this->status === self::STATUS_REJECTED;
}
/**
* Get human-readable status
*/
public function getStatusLabelAttribute(): string
{
return match($this->status) {
self::STATUS_PENDING => 'Pending Cashier Approval',
self::STATUS_APPROVED_CASHIER => 'Pending Accountant Approval',
self::STATUS_APPROVED_ACCOUNTANT => 'Pending Chair Approval',
self::STATUS_APPROVED_CHAIR => 'Fully Approved',
self::STATUS_REJECTED => 'Rejected',
default => ucfirst($this->status),
};
}
/**
* New payment stage business logic methods
*/
/**
* Determine amount tier based on amount
*/
public function determineAmountTier(): string
{
if ($this->amount < 5000) {
return self::AMOUNT_TIER_SMALL;
} elseif ($this->amount <= 50000) {
return self::AMOUNT_TIER_MEDIUM;
} else {
return self::AMOUNT_TIER_LARGE;
}
}
/**
* Check if document needs board meeting approval
*/
public function needsBoardMeetingApproval(): bool
{
return $this->amount_tier === self::AMOUNT_TIER_LARGE;
}
/**
* Check if approval stage is complete (ready for payment order creation)
*/
public function isApprovalStageComplete(): bool
{
// For small amounts: cashier + accountant
if ($this->amount_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) {
return $this->status === self::STATUS_APPROVED_CHAIR;
}
// For large amounts: cashier + accountant + chair + board meeting
if ($this->amount_tier === self::AMOUNT_TIER_LARGE) {
return $this->status === self::STATUS_APPROVED_CHAIR &&
$this->board_meeting_approved_at !== null;
}
return false;
}
/**
* Check if accountant can create payment order
*/
public function canCreatePaymentOrder(): bool
{
return $this->isApprovalStageComplete() &&
$this->payment_order_created_at === null;
}
/**
* Check if cashier can verify payment
*/
public function canVerifyPayment(): bool
{
return $this->payment_order_created_at !== null &&
$this->payment_verified_at === null &&
$this->paymentOrder !== null &&
$this->paymentOrder->canBeVerifiedByCashier();
}
/**
* Check if cashier can execute payment
*/
public function canExecutePayment(): bool
{
return $this->payment_verified_at !== null &&
$this->payment_executed_at === null &&
$this->paymentOrder !== null &&
$this->paymentOrder->canBeExecuted();
}
/**
* Check if payment is completed
*/
public function isPaymentCompleted(): bool
{
return $this->payment_executed_at !== null &&
$this->paymentOrder !== null &&
$this->paymentOrder->isExecuted();
}
/**
* Check if recording stage is complete
*/
public function isRecordingComplete(): bool
{
return $this->cashier_ledger_entry_id !== null &&
$this->accounting_transaction_id !== null;
}
/**
* Check if document is fully processed (all stages complete)
*/
public function isFullyProcessed(): bool
{
return $this->isApprovalStageComplete() &&
$this->isPaymentCompleted() &&
$this->isRecordingComplete();
}
/**
* Check if reconciliation is complete
*/
public function isReconciled(): bool
{
return $this->reconciliation_status === self::RECONCILIATION_MATCHED ||
$this->reconciliation_status === self::RECONCILIATION_RESOLVED;
}
/**
* Get request type text
*/
public function getRequestTypeText(): string
{
return match ($this->request_type) {
self::REQUEST_TYPE_EXPENSE_REIMBURSEMENT => '事後報銷',
self::REQUEST_TYPE_ADVANCE_PAYMENT => '預支/借款',
self::REQUEST_TYPE_PURCHASE_REQUEST => '採購申請',
self::REQUEST_TYPE_PETTY_CASH => '零用金領取',
default => '未知',
};
}
/**
* Get amount tier text
*/
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)',
default => '未知',
};
}
/**
* Get payment method text
*/
public function getPaymentMethodText(): string
{
return match ($this->payment_method) {
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
self::PAYMENT_METHOD_CHECK => '支票',
self::PAYMENT_METHOD_CASH => '現金',
default => '未知',
};
}
/**
* Get reconciliation status text
*/
public function getReconciliationStatusText(): string
{
return match ($this->reconciliation_status) {
self::RECONCILIATION_PENDING => '待調節',
self::RECONCILIATION_MATCHED => '已調節',
self::RECONCILIATION_DISCREPANCY => '有差異',
self::RECONCILIATION_RESOLVED => '已解決',
default => '未知',
};
}
/**
* Get current workflow stage
*/
public function getCurrentWorkflowStage(): string
{
if (!$this->isApprovalStageComplete()) {
return 'approval';
}
if (!$this->isPaymentCompleted()) {
return 'payment';
}
if (!$this->isRecordingComplete()) {
return 'recording';
}
if (!$this->isReconciled()) {
return 'reconciliation';
}
return 'completed';
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FinancialReport extends Model
{
use HasFactory;
public const TYPE_REVENUE_EXPENDITURE = 'revenue_expenditure';
public const TYPE_BALANCE_SHEET = 'balance_sheet';
public const TYPE_PROPERTY_INVENTORY = 'property_inventory';
public const TYPE_INTERNAL_MANAGEMENT = 'internal_management';
public const STATUS_DRAFT = 'draft';
public const STATUS_FINALIZED = 'finalized';
public const STATUS_APPROVED = 'approved';
public const STATUS_SUBMITTED = 'submitted';
protected $fillable = [
'report_type',
'fiscal_year',
'period_start',
'period_end',
'status',
'budget_id',
'generated_by_user_id',
'approved_by_user_id',
'approved_at',
'file_path',
'notes',
];
protected $casts = [
'fiscal_year' => 'integer',
'period_start' => 'date',
'period_end' => 'date',
'approved_at' => 'datetime',
];
// Relationships
public function budget(): BelongsTo
{
return $this->belongsTo(Budget::class);
}
public function generatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'generated_by_user_id');
}
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
// Helper methods
public function isDraft(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isFinalized(): bool
{
return $this->status === self::STATUS_FINALIZED;
}
public function isApproved(): bool
{
return $this->status === self::STATUS_APPROVED;
}
public function isSubmitted(): bool
{
return $this->status === self::STATUS_SUBMITTED;
}
public function canBeEdited(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function getReportTypeNameAttribute(): string
{
return match($this->report_type) {
self::TYPE_REVENUE_EXPENDITURE => '收支決算表',
self::TYPE_BALANCE_SHEET => '資產負債表',
self::TYPE_PROPERTY_INVENTORY => '財產目錄',
self::TYPE_INTERNAL_MANAGEMENT => '內部管理報表',
default => $this->report_type,
};
}
}

363
app/Models/Issue.php Normal file
View File

@@ -0,0 +1,363 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Issue extends Model
{
use HasFactory, SoftDeletes;
// Status constants
public const STATUS_NEW = 'new';
public const STATUS_ASSIGNED = 'assigned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_REVIEW = 'review';
public const STATUS_CLOSED = 'closed';
// Issue type constants
public const TYPE_WORK_ITEM = 'work_item';
public const TYPE_PROJECT_TASK = 'project_task';
public const TYPE_MAINTENANCE = 'maintenance';
public const TYPE_MEMBER_REQUEST = 'member_request';
// Priority constants
public const PRIORITY_LOW = 'low';
public const PRIORITY_MEDIUM = 'medium';
public const PRIORITY_HIGH = 'high';
public const PRIORITY_URGENT = 'urgent';
protected $fillable = [
'issue_number',
'title',
'description',
'issue_type',
'status',
'priority',
'created_by_user_id',
'assigned_to_user_id',
'reviewer_id',
'member_id',
'parent_issue_id',
'due_date',
'closed_at',
'estimated_hours',
'actual_hours',
];
protected $casts = [
'due_date' => 'date',
'closed_at' => 'datetime',
'estimated_hours' => 'decimal:2',
'actual_hours' => 'decimal:2',
];
protected static function boot()
{
parent::boot();
// Auto-generate issue number on create
static::creating(function ($issue) {
if (!$issue->issue_number) {
$year = now()->year;
$lastIssue = static::whereYear('created_at', $year)
->orderBy('id', 'desc')
->first();
$nextNumber = $lastIssue ? ((int) substr($lastIssue->issue_number, -3)) + 1 : 1;
$issue->issue_number = sprintf('ISS-%d-%03d', $year, $nextNumber);
}
});
}
// ==================== Relationships ====================
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to_user_id');
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
public function parentIssue(): BelongsTo
{
return $this->belongsTo(Issue::class, 'parent_issue_id');
}
public function subTasks(): HasMany
{
return $this->hasMany(Issue::class, 'parent_issue_id');
}
public function comments(): HasMany
{
return $this->hasMany(IssueComment::class);
}
public function attachments(): HasMany
{
return $this->hasMany(IssueAttachment::class);
}
public function labels(): BelongsToMany
{
return $this->belongsToMany(IssueLabel::class, 'issue_label_pivot');
}
public function watchers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'issue_watchers');
}
public function timeLogs(): HasMany
{
return $this->hasMany(IssueTimeLog::class);
}
public function relationships(): HasMany
{
return $this->hasMany(IssueRelationship::class);
}
public function relatedIssues()
{
return $this->belongsToMany(Issue::class, 'issue_relationships', 'issue_id', 'related_issue_id')
->withPivot('relationship_type')
->withTimestamps();
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'customizable');
}
// ==================== Status Helpers ====================
public function isNew(): bool
{
return $this->status === self::STATUS_NEW;
}
public function isAssigned(): bool
{
return $this->status === self::STATUS_ASSIGNED;
}
public function isInProgress(): bool
{
return $this->status === self::STATUS_IN_PROGRESS;
}
public function inReview(): bool
{
return $this->status === self::STATUS_REVIEW;
}
public function isClosed(): bool
{
return $this->status === self::STATUS_CLOSED;
}
public function isOpen(): bool
{
return !$this->isClosed();
}
// ==================== Workflow Methods ====================
public function canBeAssigned(): bool
{
return $this->isNew() || $this->isAssigned();
}
public function canMoveToInProgress(): bool
{
return $this->isAssigned() && $this->assigned_to_user_id !== null;
}
public function canMoveToReview(): bool
{
return $this->isInProgress();
}
public function canBeClosed(): bool
{
return in_array($this->status, [
self::STATUS_REVIEW,
self::STATUS_IN_PROGRESS,
self::STATUS_ASSIGNED,
]);
}
public function canBeReopened(): bool
{
return $this->isClosed();
}
// ==================== Accessors ====================
public function getStatusLabelAttribute(): string
{
return match($this->status) {
self::STATUS_NEW => __('New'),
self::STATUS_ASSIGNED => __('Assigned'),
self::STATUS_IN_PROGRESS => __('In Progress'),
self::STATUS_REVIEW => __('Review'),
self::STATUS_CLOSED => __('Closed'),
default => $this->status,
};
}
public function getIssueTypeLabelAttribute(): string
{
return match($this->issue_type) {
self::TYPE_WORK_ITEM => __('Work Item'),
self::TYPE_PROJECT_TASK => __('Project Task'),
self::TYPE_MAINTENANCE => __('Maintenance'),
self::TYPE_MEMBER_REQUEST => __('Member Request'),
default => $this->issue_type,
};
}
public function getPriorityLabelAttribute(): string
{
return match($this->priority) {
self::PRIORITY_LOW => __('Low'),
self::PRIORITY_MEDIUM => __('Medium'),
self::PRIORITY_HIGH => __('High'),
self::PRIORITY_URGENT => __('Urgent'),
default => $this->priority,
};
}
public function getPriorityBadgeColorAttribute(): string
{
return match($this->priority) {
self::PRIORITY_LOW => 'gray',
self::PRIORITY_MEDIUM => 'blue',
self::PRIORITY_HIGH => 'orange',
self::PRIORITY_URGENT => 'red',
default => 'gray',
};
}
public function getStatusBadgeColorAttribute(): string
{
return match($this->status) {
self::STATUS_NEW => 'blue',
self::STATUS_ASSIGNED => 'purple',
self::STATUS_IN_PROGRESS => 'yellow',
self::STATUS_REVIEW => 'orange',
self::STATUS_CLOSED => 'green',
default => 'gray',
};
}
public function getProgressPercentageAttribute(): int
{
return match($this->status) {
self::STATUS_NEW => 0,
self::STATUS_ASSIGNED => 20,
self::STATUS_IN_PROGRESS => 50,
self::STATUS_REVIEW => 80,
self::STATUS_CLOSED => 100,
default => 0,
};
}
public function getIsOverdueAttribute(): bool
{
return $this->due_date &&
$this->due_date->isPast() &&
!$this->isClosed();
}
public function getDaysUntilDueAttribute(): ?int
{
if (!$this->due_date) {
return null;
}
return now()->startOfDay()->diffInDays($this->due_date->startOfDay(), false);
}
public function getTotalTimeLoggedAttribute(): float
{
return (float) $this->timeLogs()->sum('hours');
}
// ==================== Scopes ====================
public function scopeOpen($query)
{
return $query->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeClosed($query)
{
return $query->where('status', self::STATUS_CLOSED);
}
public function scopeByType($query, string $type)
{
return $query->where('issue_type', $type);
}
public function scopeByPriority($query, string $priority)
{
return $query->where('priority', $priority);
}
public function scopeByStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeOverdue($query)
{
return $query->where('due_date', '<', now())
->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeAssignedTo($query, int $userId)
{
return $query->where('assigned_to_user_id', $userId);
}
public function scopeCreatedBy($query, int $userId)
{
return $query->where('created_by_user_id', $userId);
}
public function scopeDueWithin($query, int $days)
{
return $query->whereBetween('due_date', [now(), now()->addDays($days)])
->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeWithLabel($query, int $labelId)
{
return $query->whereHas('labels', function ($q) use ($labelId) {
$q->where('issue_labels.id', $labelId);
});
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class IssueAttachment extends Model
{
use HasFactory;
protected $fillable = [
'issue_id',
'user_id',
'file_name',
'file_path',
'file_size',
'mime_type',
];
public function issue(): BelongsTo
{
return $this->belongsTo(Issue::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getFileSizeHumanAttribute(): string
{
$bytes = $this->file_size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
public function getDownloadUrlAttribute(): string
{
return route('admin.issues.attachments.download', $this);
}
protected static function boot()
{
parent::boot();
static::deleting(function ($attachment) {
// Delete file from storage when attachment record is deleted
if (Storage::exists($attachment->file_path)) {
Storage::delete($attachment->file_path);
}
});
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IssueComment extends Model
{
use HasFactory;
protected $fillable = [
'issue_id',
'user_id',
'comment_text',
'is_internal',
];
protected $casts = [
'is_internal' => 'boolean',
];
public function issue(): BelongsTo
{
return $this->belongsTo(Issue::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

37
app/Models/IssueLabel.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class IssueLabel extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color',
'description',
];
public function issues(): BelongsToMany
{
return $this->belongsToMany(Issue::class, 'issue_label_pivot');
}
public function getTextColorAttribute(): string
{
// Calculate if we should use black or white text based on background color
$color = $this->color;
$r = hexdec(substr($color, 1, 2));
$g = hexdec(substr($color, 3, 2));
$b = hexdec(substr($color, 5, 2));
// Calculate perceived brightness
$brightness = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
return $brightness > 128 ? '#000000' : '#FFFFFF';
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IssueRelationship extends Model
{
use HasFactory;
public const TYPE_BLOCKS = 'blocks';
public const TYPE_BLOCKED_BY = 'blocked_by';
public const TYPE_RELATED_TO = 'related_to';
public const TYPE_DUPLICATE_OF = 'duplicate_of';
protected $fillable = [
'issue_id',
'related_issue_id',
'relationship_type',
];
public function issue(): BelongsTo
{
return $this->belongsTo(Issue::class);
}
public function relatedIssue(): BelongsTo
{
return $this->belongsTo(Issue::class, 'related_issue_id');
}
public function getRelationshipLabelAttribute(): string
{
return match($this->relationship_type) {
self::TYPE_BLOCKS => __('Blocks'),
self::TYPE_BLOCKED_BY => __('Blocked by'),
self::TYPE_RELATED_TO => __('Related to'),
self::TYPE_DUPLICATE_OF => __('Duplicate of'),
default => $this->relationship_type,
};
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IssueTimeLog extends Model
{
use HasFactory;
protected $fillable = [
'issue_id',
'user_id',
'hours',
'description',
'logged_at',
];
protected $casts = [
'hours' => 'decimal:2',
'logged_at' => 'datetime',
];
public function issue(): BelongsTo
{
return $this->belongsTo(Issue::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected static function boot()
{
parent::boot();
// Update issue actual_hours when time log is created, updated, or deleted
static::created(function ($timeLog) {
$timeLog->updateIssueActualHours();
});
static::updated(function ($timeLog) {
$timeLog->updateIssueActualHours();
});
static::deleted(function ($timeLog) {
$timeLog->updateIssueActualHours();
});
}
protected function updateIssueActualHours(): void
{
$totalHours = $this->issue->timeLogs()->sum('hours');
$this->issue->update(['actual_hours' => $totalHours]);
}
}

202
app/Models/Member.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class Member extends Model
{
use HasFactory;
// Membership status constants
const STATUS_PENDING = 'pending';
const STATUS_ACTIVE = 'active';
const STATUS_EXPIRED = 'expired';
const STATUS_SUSPENDED = 'suspended';
// Membership type constants
const TYPE_REGULAR = 'regular';
const TYPE_HONORARY = 'honorary';
const TYPE_LIFETIME = 'lifetime';
const TYPE_STUDENT = 'student';
protected $fillable = [
'user_id',
'full_name',
'email',
'phone',
'address_line_1',
'address_line_2',
'city',
'postal_code',
'emergency_contact_name',
'emergency_contact_phone',
'national_id_encrypted',
'national_id_hash',
'membership_started_at',
'membership_expires_at',
'membership_status',
'membership_type',
];
protected $casts = [
'membership_started_at' => 'date',
'membership_expires_at' => 'date',
];
protected $appends = ['national_id'];
public function user()
{
return $this->belongsTo(User::class);
}
public function payments()
{
return $this->hasMany(MembershipPayment::class);
}
/**
* Get the decrypted national ID
*/
public function getNationalIdAttribute(): ?string
{
if (empty($this->national_id_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->national_id_encrypted);
} catch (\Exception $e) {
\Log::error('Failed to decrypt national_id', [
'member_id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Set the national ID (encrypt and hash)
*/
public function setNationalIdAttribute(?string $value): void
{
if (empty($value)) {
$this->attributes['national_id_encrypted'] = null;
$this->attributes['national_id_hash'] = null;
return;
}
$this->attributes['national_id_encrypted'] = Crypt::encryptString($value);
$this->attributes['national_id_hash'] = hash('sha256', $value);
}
/**
* Check if membership status is pending (not yet paid/verified)
*/
public function isPending(): bool
{
return $this->membership_status === self::STATUS_PENDING;
}
/**
* Check if membership is active (paid & activated)
*/
public function isActive(): bool
{
return $this->membership_status === self::STATUS_ACTIVE;
}
/**
* Check if membership is expired
*/
public function isExpired(): bool
{
return $this->membership_status === self::STATUS_EXPIRED;
}
/**
* Check if membership is suspended
*/
public function isSuspended(): bool
{
return $this->membership_status === self::STATUS_SUSPENDED;
}
/**
* Check if member has paid membership (active status with valid dates)
*/
public function hasPaidMembership(): bool
{
return $this->isActive()
&& $this->membership_started_at
&& $this->membership_expires_at
&& $this->membership_expires_at->isFuture();
}
/**
* Get the membership status badge class for display
*/
public function getMembershipStatusBadgeAttribute(): string
{
return 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',
};
}
/**
* Get the membership status label in Chinese
*/
public function getMembershipStatusLabelAttribute(): string
{
return match($this->membership_status) {
self::STATUS_PENDING => '待繳費',
self::STATUS_ACTIVE => '已啟用',
self::STATUS_EXPIRED => '已過期',
self::STATUS_SUSPENDED => '已暫停',
default => $this->membership_status,
};
}
/**
* Get the membership type label in Chinese
*/
public function getMembershipTypeLabelAttribute(): string
{
return match($this->membership_type) {
self::TYPE_REGULAR => '一般會員',
self::TYPE_HONORARY => '榮譽會員',
self::TYPE_LIFETIME => '終身會員',
self::TYPE_STUDENT => '學生會員',
default => $this->membership_type,
};
}
/**
* Get pending payment (if any)
*/
public function getPendingPayment()
{
return $this->payments()
->where('status', MembershipPayment::STATUS_PENDING)
->orWhere('status', MembershipPayment::STATUS_APPROVED_CASHIER)
->orWhere('status', MembershipPayment::STATUS_APPROVED_ACCOUNTANT)
->latest()
->first();
}
/**
* Check if member can submit payment
*/
public function canSubmitPayment(): bool
{
// Can submit if pending status and no pending payment
return $this->isPending() && !$this->getPendingPayment();
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class MembershipPayment extends Model
{
use HasFactory;
// Status constants
const STATUS_PENDING = 'pending';
const STATUS_APPROVED_CASHIER = 'approved_cashier';
const STATUS_APPROVED_ACCOUNTANT = 'approved_accountant';
const STATUS_APPROVED_CHAIR = 'approved_chair';
const STATUS_REJECTED = 'rejected';
// Payment method constants
const METHOD_BANK_TRANSFER = 'bank_transfer';
const METHOD_CONVENIENCE_STORE = 'convenience_store';
const METHOD_CASH = 'cash';
const METHOD_CREDIT_CARD = 'credit_card';
protected $fillable = [
'member_id',
'paid_at',
'amount',
'method',
'reference',
'status',
'payment_method',
'receipt_path',
'submitted_by_user_id',
'verified_by_cashier_id',
'cashier_verified_at',
'verified_by_accountant_id',
'accountant_verified_at',
'verified_by_chair_id',
'chair_verified_at',
'rejected_by_user_id',
'rejected_at',
'rejection_reason',
'notes',
];
protected $casts = [
'paid_at' => 'date',
'cashier_verified_at' => 'datetime',
'accountant_verified_at' => 'datetime',
'chair_verified_at' => 'datetime',
'rejected_at' => 'datetime',
];
// Relationships
public function member()
{
return $this->belongsTo(Member::class);
}
public function submittedBy()
{
return $this->belongsTo(User::class, 'submitted_by_user_id');
}
public function verifiedByCashier()
{
return $this->belongsTo(User::class, 'verified_by_cashier_id');
}
public function verifiedByAccountant()
{
return $this->belongsTo(User::class, 'verified_by_accountant_id');
}
public function verifiedByChair()
{
return $this->belongsTo(User::class, 'verified_by_chair_id');
}
public function rejectedBy()
{
return $this->belongsTo(User::class, 'rejected_by_user_id');
}
// Status check methods
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isApprovedByCashier(): bool
{
return $this->status === self::STATUS_APPROVED_CASHIER;
}
public function isApprovedByAccountant(): bool
{
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
}
public function isFullyApproved(): bool
{
return $this->status === self::STATUS_APPROVED_CHAIR;
}
public function isRejected(): bool
{
return $this->status === self::STATUS_REJECTED;
}
// Workflow validation methods
public function canBeApprovedByCashier(): bool
{
return $this->isPending();
}
public function canBeApprovedByAccountant(): bool
{
return $this->isApprovedByCashier();
}
public function canBeApprovedByChair(): bool
{
return $this->isApprovedByAccountant();
}
// Accessor for status label
public function getStatusLabelAttribute(): string
{
return match($this->status) {
self::STATUS_PENDING => '待審核',
self::STATUS_APPROVED_CASHIER => '出納已審',
self::STATUS_APPROVED_ACCOUNTANT => '會計已審',
self::STATUS_APPROVED_CHAIR => '主席已審',
self::STATUS_REJECTED => '已拒絕',
default => $this->status,
};
}
// Accessor for payment method label
public function getPaymentMethodLabelAttribute(): string
{
return match($this->payment_method) {
self::METHOD_BANK_TRANSFER => '銀行轉帳',
self::METHOD_CONVENIENCE_STORE => '便利商店繳費',
self::METHOD_CASH => '現金',
self::METHOD_CREDIT_CARD => '信用卡',
default => $this->payment_method ?? '未指定',
};
}
// Clean up receipt file when payment is deleted
protected static function boot()
{
parent::boot();
static::deleting(function ($payment) {
if ($payment->receipt_path && Storage::exists($payment->receipt_path)) {
Storage::delete($payment->receipt_path);
}
});
}
}

168
app/Models/PaymentOrder.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentOrder extends Model
{
use HasFactory;
protected $fillable = [
'finance_document_id',
'payee_name',
'payee_bank_code',
'payee_account_number',
'payee_bank_name',
'payment_amount',
'payment_method',
'created_by_accountant_id',
'payment_order_number',
'notes',
'verified_by_cashier_id',
'verified_at',
'verification_status',
'verification_notes',
'executed_by_cashier_id',
'executed_at',
'execution_status',
'transaction_reference',
'payment_receipt_path',
'status',
];
protected $casts = [
'payment_amount' => 'decimal:2',
'verified_at' => 'datetime',
'executed_at' => 'datetime',
];
/**
* 狀態常數
*/
const STATUS_DRAFT = 'draft';
const STATUS_PENDING_VERIFICATION = 'pending_verification';
const STATUS_VERIFIED = 'verified';
const STATUS_EXECUTED = 'executed';
const STATUS_CANCELLED = 'cancelled';
const VERIFICATION_PENDING = 'pending';
const VERIFICATION_APPROVED = 'approved';
const VERIFICATION_REJECTED = 'rejected';
const EXECUTION_PENDING = 'pending';
const EXECUTION_COMPLETED = 'completed';
const EXECUTION_FAILED = 'failed';
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
const PAYMENT_METHOD_CHECK = 'check';
const PAYMENT_METHOD_CASH = 'cash';
/**
* 關聯到財務申請單
*/
public function financeDocument(): BelongsTo
{
return $this->belongsTo(FinanceDocument::class);
}
/**
* 會計製單人
*/
public function createdByAccountant(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_accountant_id');
}
/**
* 出納覆核人
*/
public function verifiedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_cashier_id');
}
/**
* 出納執行人
*/
public function executedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'executed_by_cashier_id');
}
/**
* 產生付款單號
*/
public static function generatePaymentOrderNumber(): string
{
$date = now()->format('Ymd');
$latest = self::where('payment_order_number', 'like', "PO-{$date}%")->latest('id')->first();
if ($latest) {
$lastNumber = (int) substr($latest->payment_order_number, -4);
$newNumber = $lastNumber + 1;
} else {
$newNumber = 1;
}
return sprintf('PO-%s%04d', $date, $newNumber);
}
/**
* 檢查是否可以被出納覆核
*/
public function canBeVerifiedByCashier(): bool
{
return $this->status === self::STATUS_PENDING_VERIFICATION &&
$this->verification_status === self::VERIFICATION_PENDING;
}
/**
* 檢查是否可以執行付款
*/
public function canBeExecuted(): bool
{
return $this->status === self::STATUS_VERIFIED &&
$this->verification_status === self::VERIFICATION_APPROVED &&
$this->execution_status === self::EXECUTION_PENDING;
}
/**
* 是否已執行
*/
public function isExecuted(): bool
{
return $this->status === self::STATUS_EXECUTED &&
$this->execution_status === self::EXECUTION_COMPLETED;
}
/**
* 取得付款方式文字
*/
public function getPaymentMethodText(): string
{
return match ($this->payment_method) {
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
self::PAYMENT_METHOD_CHECK => '支票',
self::PAYMENT_METHOD_CASH => '現金',
default => '未知',
};
}
/**
* 取得狀態文字
*/
public function getStatusText(): string
{
return match ($this->status) {
self::STATUS_DRAFT => '草稿',
self::STATUS_PENDING_VERIFICATION => '待出納覆核',
self::STATUS_VERIFIED => '已覆核',
self::STATUS_EXECUTED => '已執行付款',
self::STATUS_CANCELLED => '已取消',
default => '未知',
};
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class SystemSetting extends Model
{
use HasFactory;
protected $fillable = [
'key',
'value',
'type',
'group',
'description',
];
/**
* Cache key prefix for settings
*/
const CACHE_PREFIX = 'system_setting_';
/**
* Cache duration in seconds (1 hour)
*/
const CACHE_DURATION = 3600;
/**
* Boot the model
*/
protected static function booted()
{
// Clear cache when setting is updated or deleted
static::saved(function ($setting) {
Cache::forget(self::CACHE_PREFIX . $setting->key);
Cache::forget('all_system_settings');
});
static::deleted(function ($setting) {
Cache::forget(self::CACHE_PREFIX . $setting->key);
Cache::forget('all_system_settings');
});
}
/**
* Get a setting value by key with caching
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public static function get(string $key, $default = null)
{
return Cache::remember(
self::CACHE_PREFIX . $key,
self::CACHE_DURATION,
function () use ($key, $default) {
$setting = self::where('key', $key)->first();
if (!$setting) {
return $default;
}
return $setting->getCastedValue();
}
);
}
/**
* Set a setting value (creates if not exists)
*
* @param string $key
* @param mixed $value
* @param string $type
* @param string|null $group
* @param string|null $description
* @return SystemSetting
*/
public static function set(string $key, $value, string $type = 'string', ?string $group = null, ?string $description = null): SystemSetting
{
$setting = self::updateOrCreate(
['key' => $key],
[
'value' => self::encodeValue($value, $type),
'type' => $type,
'group' => $group,
'description' => $description,
]
);
return $setting;
}
/**
* Check if a setting exists
*
* @param string $key
* @return bool
*/
public static function has(string $key): bool
{
return Cache::remember(
self::CACHE_PREFIX . $key . '_exists',
self::CACHE_DURATION,
fn() => self::where('key', $key)->exists()
);
}
/**
* Delete a setting by key
*
* @param string $key
* @return bool
*/
public static function forget(string $key): bool
{
return self::where('key', $key)->delete() > 0;
}
/**
* Get all settings grouped by group
*
* @return \Illuminate\Support\Collection
*/
public static function getAllGrouped()
{
return Cache::remember(
'all_system_settings',
self::CACHE_DURATION,
function () {
return self::all()->groupBy('group')->map(function ($groupSettings) {
return $groupSettings->mapWithKeys(function ($setting) {
return [$setting->key => $setting->getCastedValue()];
});
});
}
);
}
/**
* Get the casted value based on type
*
* @return mixed
*/
public function getCastedValue()
{
if ($this->value === null) {
return null;
}
return match ($this->type) {
'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
'integer' => (int) $this->value,
'json', 'array' => json_decode($this->value, true),
default => $this->value,
};
}
/**
* Encode value for storage based on type
*
* @param mixed $value
* @param string $type
* @return string|null
*/
protected static function encodeValue($value, string $type): ?string
{
if ($value === null) {
return null;
}
return match ($type) {
'boolean' => $value ? '1' : '0',
'integer' => (string) $value,
'json', 'array' => json_encode($value),
default => (string) $value,
};
}
/**
* Toggle a boolean setting
*
* @param string $key
* @return bool New value after toggle
*/
public static function toggle(string $key): bool
{
$currentValue = self::get($key, false);
$newValue = !$currentValue;
self::set($key, $newValue, 'boolean');
return $newValue;
}
/**
* Increment an integer setting
*
* @param string $key
* @param int $amount
* @return int
*/
public static function incrementSetting(string $key, int $amount = 1): int
{
$currentValue = self::get($key, 0);
$newValue = $currentValue + $amount;
self::set($key, $newValue, 'integer');
return $newValue;
}
/**
* Clear all settings cache
*
* @return void
*/
public static function clearCache(): void
{
Cache::flush();
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Transaction extends Model
{
use HasFactory;
protected $fillable = [
'budget_item_id',
'chart_of_account_id',
'transaction_date',
'amount',
'transaction_type',
'description',
'reference_number',
'finance_document_id',
'membership_payment_id',
'created_by_user_id',
'notes',
];
protected $casts = [
'transaction_date' => 'date',
'amount' => 'decimal:2',
];
// Relationships
public function budgetItem(): BelongsTo
{
return $this->belongsTo(BudgetItem::class);
}
public function chartOfAccount(): BelongsTo
{
return $this->belongsTo(ChartOfAccount::class);
}
public function financeDocument(): BelongsTo
{
return $this->belongsTo(FinanceDocument::class);
}
public function membershipPayment(): BelongsTo
{
return $this->belongsTo(MembershipPayment::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
// Helper methods
public function isIncome(): bool
{
return $this->transaction_type === 'income';
}
public function isExpense(): bool
{
return $this->transaction_type === 'expense';
}
// Scopes
public function scopeIncome($query)
{
return $query->where('transaction_type', 'income');
}
public function scopeExpense($query)
{
return $query->where('transaction_type', 'expense');
}
public function scopeForPeriod($query, $startDate, $endDate)
{
return $query->whereBetween('transaction_date', [$startDate, $endDate]);
}
}

Some files were not shown because too many files have changed in this diff Show More