Initial commit
This commit is contained in:
103
app/Http/Controllers/Admin/DocumentCategoryController.php
Normal file
103
app/Http/Controllers/Admin/DocumentCategoryController.php
Normal 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', '文件類別已成功刪除');
|
||||
}
|
||||
}
|
||||
385
app/Http/Controllers/Admin/DocumentController.php
Normal file
385
app/Http/Controllers/Admin/DocumentController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
273
app/Http/Controllers/Admin/SystemSettingsController.php
Normal file
273
app/Http/Controllers/Admin/SystemSettingsController.php
Normal 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', '進階設定已更新');
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/AdminAuditLogController.php
Normal file
110
app/Http/Controllers/AdminAuditLogController.php
Normal 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"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/Http/Controllers/AdminDashboardController.php
Normal file
76
app/Http/Controllers/AdminDashboardController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
347
app/Http/Controllers/AdminMemberController.php
Normal file
347
app/Http/Controllers/AdminMemberController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/AdminPaymentController.php
Normal file
90
app/Http/Controllers/AdminPaymentController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
109
app/Http/Controllers/AdminRoleController.php
Normal file
109
app/Http/Controllers/AdminRoleController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
48
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
61
app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
28
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
306
app/Http/Controllers/BankReconciliationController.php
Normal file
306
app/Http/Controllers/BankReconciliationController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
269
app/Http/Controllers/BudgetController.php
Normal file
269
app/Http/Controllers/BudgetController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
292
app/Http/Controllers/CashierLedgerController.php
Normal file
292
app/Http/Controllers/CashierLedgerController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
app/Http/Controllers/Controller.php
Normal file
12
app/Http/Controllers/Controller.php
Normal 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;
|
||||
}
|
||||
315
app/Http/Controllers/FinanceDocumentController.php
Normal file
315
app/Http/Controllers/FinanceDocumentController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
507
app/Http/Controllers/IssueController.php
Normal file
507
app/Http/Controllers/IssueController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
79
app/Http/Controllers/IssueLabelController.php
Normal file
79
app/Http/Controllers/IssueLabelController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/IssueReportsController.php
Normal file
130
app/Http/Controllers/IssueReportsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/MemberDashboardController.php
Normal file
35
app/Http/Controllers/MemberDashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
99
app/Http/Controllers/MemberPaymentController.php
Normal file
99
app/Http/Controllers/MemberPaymentController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
359
app/Http/Controllers/PaymentOrderController.php
Normal file
359
app/Http/Controllers/PaymentOrderController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
261
app/Http/Controllers/PaymentVerificationController.php
Normal file
261
app/Http/Controllers/PaymentVerificationController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/ProfileController.php
Normal file
100
app/Http/Controllers/ProfileController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
179
app/Http/Controllers/PublicDocumentController.php
Normal file
179
app/Http/Controllers/PublicDocumentController.php
Normal 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"');
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/PublicMemberRegistrationController.php
Normal file
88
app/Http/Controllers/PublicMemberRegistrationController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
272
app/Http/Controllers/TransactionController.php
Normal file
272
app/Http/Controllers/TransactionController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user