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>
508 lines
17 KiB
PHP
508 lines
17 KiB
PHP
<?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()->hasRole('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()->hasRole('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()->hasRole('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.'));
|
|
}
|
|
}
|