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,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]);
}
}