Add membership fee system with disability discount and fix document permissions
Features: - Implement two fee types: entrance fee and annual fee (both NT$1,000) - Add 50% discount for disability certificate holders - Add disability certificate upload in member profile - Integrate disability verification into cashier approval workflow - Add membership fee settings in system admin Document permissions: - Fix hard-coded role logic in Document model - Use permission-based authorization instead of role checks Additional features: - Add announcements, general ledger, and trial balance modules - Add income management and accounting entries - Add comprehensive test suite with factories - Update UI translations to Traditional Chinese 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
346
app/Http/Controllers/Admin/AnnouncementController.php
Normal file
346
app/Http/Controllers/Admin/AnnouncementController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Announcement;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AnnouncementController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:view_announcements')->only(['index', 'show']);
|
||||
$this->middleware('can:create_announcements')->only(['create', 'store']);
|
||||
$this->middleware('can:edit_announcements')->only(['edit', 'update']);
|
||||
$this->middleware('can:delete_announcements')->only(['destroy']);
|
||||
$this->middleware('can:publish_announcements')->only(['publish', 'archive']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of announcements
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Announcement::with(['creator', 'lastUpdatedBy'])
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// Filter by status
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filter by access level
|
||||
if ($request->filled('access_level')) {
|
||||
$query->where('access_level', $request->access_level);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if ($request->filled('pinned')) {
|
||||
$query->where('is_pinned', $request->pinned === 'yes');
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('content', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$announcements = $query->paginate(20);
|
||||
|
||||
// Statistics
|
||||
$stats = [
|
||||
'total' => Announcement::count(),
|
||||
'draft' => Announcement::draft()->count(),
|
||||
'published' => Announcement::published()->count(),
|
||||
'archived' => Announcement::archived()->count(),
|
||||
'pinned' => Announcement::pinned()->count(),
|
||||
];
|
||||
|
||||
return view('admin.announcements.index', compact('announcements', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new announcement
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('admin.announcements.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created announcement
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'access_level' => 'required|in:public,members,board,admin',
|
||||
'published_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date|after:published_at',
|
||||
'is_pinned' => 'boolean',
|
||||
'display_order' => 'nullable|integer',
|
||||
'save_action' => 'required|in:draft,publish',
|
||||
]);
|
||||
|
||||
$announcement = Announcement::create([
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'access_level' => $validated['access_level'],
|
||||
'status' => $validated['save_action'] === 'publish' ? Announcement::STATUS_PUBLISHED : Announcement::STATUS_DRAFT,
|
||||
'published_at' => $validated['save_action'] === 'publish' ? ($validated['published_at'] ?? now()) : null,
|
||||
'expires_at' => $validated['expires_at'] ?? null,
|
||||
'is_pinned' => $validated['is_pinned'] ?? false,
|
||||
'display_order' => $validated['display_order'] ?? 0,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
'last_updated_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.created',
|
||||
'description' => "建立公告:{$announcement->title} (狀態:{$announcement->getStatusLabel()})",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
$message = $validated['save_action'] === 'publish' ? '公告已成功發布' : '公告已儲存為草稿';
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.show', $announcement)
|
||||
->with('status', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified announcement
|
||||
*/
|
||||
public function show(Announcement $announcement)
|
||||
{
|
||||
// Check if user can view this announcement
|
||||
if (!$announcement->canBeViewedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限查看此公告');
|
||||
}
|
||||
|
||||
$announcement->load(['creator', 'lastUpdatedBy']);
|
||||
|
||||
// Increment view count if viewing published announcement
|
||||
if ($announcement->isPublished()) {
|
||||
$announcement->incrementViewCount();
|
||||
}
|
||||
|
||||
return view('admin.announcements.show', compact('announcement'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified announcement
|
||||
*/
|
||||
public function edit(Announcement $announcement)
|
||||
{
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限編輯此公告');
|
||||
}
|
||||
|
||||
return view('admin.announcements.edit', compact('announcement'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified announcement
|
||||
*/
|
||||
public function update(Request $request, Announcement $announcement)
|
||||
{
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限編輯此公告');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'access_level' => 'required|in:public,members,board,admin',
|
||||
'published_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date|after:published_at',
|
||||
'is_pinned' => 'boolean',
|
||||
'display_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$announcement->update([
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'access_level' => $validated['access_level'],
|
||||
'published_at' => $validated['published_at'],
|
||||
'expires_at' => $validated['expires_at'] ?? null,
|
||||
'is_pinned' => $validated['is_pinned'] ?? false,
|
||||
'display_order' => $validated['display_order'] ?? 0,
|
||||
'last_updated_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.updated',
|
||||
'description' => "更新公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.show', $announcement)
|
||||
->with('status', '公告已成功更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified announcement (soft delete)
|
||||
*/
|
||||
public function destroy(Announcement $announcement)
|
||||
{
|
||||
// Check if user can delete this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限刪除此公告');
|
||||
}
|
||||
|
||||
$title = $announcement->title;
|
||||
$announcement->delete();
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.deleted',
|
||||
'description' => "刪除公告:{$title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.index')
|
||||
->with('status', '公告已成功刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a draft announcement
|
||||
*/
|
||||
public function publish(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('publish_announcements')) {
|
||||
abort(403, '您沒有權限發布公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限發布此公告');
|
||||
}
|
||||
|
||||
if ($announcement->isPublished()) {
|
||||
return back()->with('error', '此公告已經發布');
|
||||
}
|
||||
|
||||
$announcement->publish(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.published',
|
||||
'description' => "發布公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功發布');
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive an announcement
|
||||
*/
|
||||
public function archive(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('publish_announcements')) {
|
||||
abort(403, '您沒有權限歸檔公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限歸檔此公告');
|
||||
}
|
||||
|
||||
if ($announcement->isArchived()) {
|
||||
return back()->with('error', '此公告已經歸檔');
|
||||
}
|
||||
|
||||
$announcement->archive(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.archived',
|
||||
'description' => "歸檔公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功歸檔');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an announcement
|
||||
*/
|
||||
public function pin(Request $request, Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('edit_announcements')) {
|
||||
abort(403, '您沒有權限置頂公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限置頂此公告');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'display_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$announcement->pin($validated['display_order'] ?? 0, auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.pinned',
|
||||
'description' => "置頂公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功置頂');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin an announcement
|
||||
*/
|
||||
public function unpin(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('edit_announcements')) {
|
||||
abort(403, '您沒有權限取消置頂公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限取消置頂此公告');
|
||||
}
|
||||
|
||||
$announcement->unpin(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.unpinned',
|
||||
'description' => "取消置頂公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已取消置頂');
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,8 @@ class DocumentController extends Controller
|
||||
$document = Document::create([
|
||||
'document_category_id' => $validated['document_category_id'],
|
||||
'title' => $validated['title'],
|
||||
'document_number' => $validated['document_number'],
|
||||
'description' => $validated['description'],
|
||||
'document_number' => $validated['document_number'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'access_level' => $validated['access_level'],
|
||||
'status' => 'active',
|
||||
'created_by_user_id' => auth()->id(),
|
||||
@@ -360,7 +360,7 @@ class DocumentController extends Controller
|
||||
->get();
|
||||
|
||||
// Monthly upload trends (last 6 months)
|
||||
$uploadTrends = Document::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, COUNT(*) as count')
|
||||
$uploadTrends = Document::selectRaw("strftime('%Y-%m', created_at) as month, COUNT(*) as count")
|
||||
->where('created_at', '>=', now()->subMonths(6))
|
||||
->groupBy('month')
|
||||
->orderBy('month', 'desc')
|
||||
|
||||
87
app/Http/Controllers/Admin/GeneralLedgerController.php
Normal file
87
app/Http/Controllers/Admin/GeneralLedgerController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountingEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeneralLedgerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the general ledger
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$accounts = ChartOfAccount::where('is_active', true)
|
||||
->orderBy('account_code')
|
||||
->get();
|
||||
|
||||
$selectedAccountId = $request->input('account_id');
|
||||
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->format('Y-m-d'));
|
||||
|
||||
$entries = null;
|
||||
$selectedAccount = null;
|
||||
$openingBalance = 0;
|
||||
$debitTotal = 0;
|
||||
$creditTotal = 0;
|
||||
$closingBalance = 0;
|
||||
|
||||
if ($selectedAccountId) {
|
||||
$selectedAccount = ChartOfAccount::findOrFail($selectedAccountId);
|
||||
|
||||
// Get opening balance (all entries before start date)
|
||||
$openingDebit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
|
||||
->where('entry_date', '<', $startDate)
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
|
||||
->sum('amount');
|
||||
|
||||
$openingCredit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
|
||||
->where('entry_date', '<', $startDate)
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
|
||||
->sum('amount');
|
||||
|
||||
// Calculate opening balance based on account type
|
||||
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
|
||||
// Assets and Expenses: Debit increases, Credit decreases
|
||||
$openingBalance = $openingDebit - $openingCredit;
|
||||
} else {
|
||||
// Liabilities, Equity, Income: Credit increases, Debit decreases
|
||||
$openingBalance = $openingCredit - $openingDebit;
|
||||
}
|
||||
|
||||
// Get entries for the period
|
||||
$entries = AccountingEntry::with(['financeDocument', 'chartOfAccount'])
|
||||
->where('chart_of_account_id', $selectedAccountId)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->orderBy('entry_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// Calculate totals for the period
|
||||
$debitTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)->sum('amount');
|
||||
$creditTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)->sum('amount');
|
||||
|
||||
// Calculate closing balance
|
||||
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
|
||||
$closingBalance = $openingBalance + $debitTotal - $creditTotal;
|
||||
} else {
|
||||
$closingBalance = $openingBalance + $creditTotal - $debitTotal;
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.general-ledger.index', compact(
|
||||
'accounts',
|
||||
'selectedAccount',
|
||||
'entries',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'openingBalance',
|
||||
'debitTotal',
|
||||
'creditTotal',
|
||||
'closingBalance'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\MembershipFeeCalculator;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -270,4 +271,45 @@ class SystemSettingsController extends Controller
|
||||
|
||||
return redirect()->route('admin.settings.advanced')->with('status', '進階設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show membership fee settings page
|
||||
*/
|
||||
public function membership()
|
||||
{
|
||||
$feeCalculator = app(MembershipFeeCalculator::class);
|
||||
|
||||
$settings = [
|
||||
'entrance_fee' => $feeCalculator->getEntranceFee(),
|
||||
'annual_fee' => $feeCalculator->getAnnualFee(),
|
||||
'disability_discount_rate' => $feeCalculator->getDisabilityDiscountRate() * 100, // Convert to percentage
|
||||
];
|
||||
|
||||
return view('admin.settings.membership', compact('settings'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update membership fee settings
|
||||
*/
|
||||
public function updateMembership(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'entrance_fee' => 'required|numeric|min:0|max:100000',
|
||||
'annual_fee' => 'required|numeric|min:0|max:100000',
|
||||
'disability_discount_rate' => 'required|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
SystemSetting::set('membership_fee.entrance_fee', $validated['entrance_fee'], 'float', 'membership');
|
||||
SystemSetting::set('membership_fee.annual_fee', $validated['annual_fee'], 'float', 'membership');
|
||||
SystemSetting::set('membership_fee.disability_discount_rate', $validated['disability_discount_rate'] / 100, 'float', 'membership');
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'settings.membership.updated',
|
||||
'description' => '更新會費設定',
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.settings.membership')->with('status', '會費設定已更新');
|
||||
}
|
||||
}
|
||||
|
||||
75
app/Http/Controllers/Admin/TrialBalanceController.php
Normal file
75
app/Http/Controllers/Admin/TrialBalanceController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountingEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TrialBalanceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the trial balance
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->format('Y-m-d'));
|
||||
|
||||
// Get all active accounts with their balances
|
||||
$accounts = ChartOfAccount::where('is_active', true)
|
||||
->orderBy('account_code')
|
||||
->get()
|
||||
->map(function ($account) use ($startDate, $endDate) {
|
||||
// Get debit and credit totals for this account
|
||||
$debitTotal = AccountingEntry::where('chart_of_account_id', $account->id)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
|
||||
->sum('amount');
|
||||
|
||||
$creditTotal = AccountingEntry::where('chart_of_account_id', $account->id)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
|
||||
->sum('amount');
|
||||
|
||||
// Only include accounts with activity
|
||||
if ($debitTotal == 0 && $creditTotal == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'account' => $account,
|
||||
'debit_total' => $debitTotal,
|
||||
'credit_total' => $creditTotal,
|
||||
];
|
||||
})
|
||||
->filter() // Remove null entries
|
||||
->values();
|
||||
|
||||
// Calculate grand totals
|
||||
$grandDebitTotal = $accounts->sum('debit_total');
|
||||
$grandCreditTotal = $accounts->sum('credit_total');
|
||||
|
||||
// Check if balanced
|
||||
$isBalanced = bccomp((string)$grandDebitTotal, (string)$grandCreditTotal, 2) === 0;
|
||||
$difference = $grandDebitTotal - $grandCreditTotal;
|
||||
|
||||
// Group accounts by type
|
||||
$accountsByType = $accounts->groupBy(function ($item) {
|
||||
return $item['account']->account_type;
|
||||
});
|
||||
|
||||
return view('admin.trial-balance.index', compact(
|
||||
'accounts',
|
||||
'accountsByType',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'grandDebitTotal',
|
||||
'grandCreditTotal',
|
||||
'isBalanced',
|
||||
'difference'
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user