diff --git a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php index 651d963..ec47952 100644 --- a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php +++ b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php @@ -32,15 +32,27 @@ public function index(PaginatedRequest $request): Response $student->invoiced_term_ids = $invoicedTermIdsByStudent->get($student->id, []); }); + $academicTerms = $this->academicTermService->getAllForSelect(); + + $requestedTermId = $request->validated('academic_term_id'); + $defaultTermId = $academicTerms->firstWhere('is_active', true)?->id + ?? $academicTerms->sortByDesc('start_date')->first()?->id; + $summaryTermId = $requestedTermId ?? $defaultTermId; + return Inertia::render('admin/finances/tuition-invoices/index', [ 'invoices' => $this->service->paginated( ...$request->validatedWithDefaults(), - academicTermId: $request->validated('academic_term_id'), + academicTermId: $requestedTermId, status: $request->validated('status'), paymentMethod: $request->validated('payment_method'), ), + 'summary' => $this->service->summary( + search: $request->validated('search') ?? '', + academicTermId: $summaryTermId, + ), + 'summaryAcademicTerm' => $academicTerms->firstWhere('id', $summaryTermId), 'students' => $students, - 'academicTerms' => $this->academicTermService->getAllForSelect(), + 'academicTerms' => $academicTerms, 'filters' => $request->only(['academic_term_id', 'status', 'payment_method']), ]); } diff --git a/app/Services/Admin/Finances/TuitionInvoiceService.php b/app/Services/Admin/Finances/TuitionInvoiceService.php index cc67ec3..31e26aa 100644 --- a/app/Services/Admin/Finances/TuitionInvoiceService.php +++ b/app/Services/Admin/Finances/TuitionInvoiceService.php @@ -29,14 +29,11 @@ public function paginated( ?string $status = null, ?string $paymentMethod = null, ): LengthAwarePaginator { - return TuitionInvoice::query() + return $this->scopedQuery($search, $academicTermId) ->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date']) ->withSum('payments as paid_total', 'amount_paid') ->withCount('payments') ->with(['student.user.profile', 'student.department', 'academicTerm:id,name,semester,start_date,end_date']) - ->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") - ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) - ->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)) ->when($status, fn ($q) => match ($status) { 'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'), 'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND COALESCE(paid_total, 0) < amount_due'), @@ -48,6 +45,39 @@ public function paginated( ->paginate($perPage); } + /** + * @return array{unpaid_students: int, partial_students: int, paid_students: int, total_outstanding: float, total_collected: float, total_invoices: int} + */ + public function summary(string $search = '', ?int $academicTermId = null): array + { + $invoices = $this->scopedQuery($search, $academicTermId) + ->select(['id', 'student_id', 'amount_due']) + ->withSum('payments as paid_total', 'amount_paid') + ->get(); + + $totalCollected = (float) $invoices->sum('paid_total'); + $totalOutstanding = (float) $invoices->sum(fn (TuitionInvoice $invoice) => max(0, $invoice->amount_due - ($invoice->paid_total ?? 0))); + + $studentsByStatus = fn (\Closure $matches) => $invoices->filter($matches)->pluck('student_id')->unique()->count(); + + return [ + 'unpaid_students' => $studentsByStatus(fn (TuitionInvoice $invoice) => (float) ($invoice->paid_total ?? 0) <= 0), + 'partial_students' => $studentsByStatus(fn (TuitionInvoice $invoice) => (float) ($invoice->paid_total ?? 0) > 0 && (float) ($invoice->paid_total ?? 0) < (float) $invoice->amount_due), + 'paid_students' => $studentsByStatus(fn (TuitionInvoice $invoice) => (float) ($invoice->paid_total ?? 0) >= (float) $invoice->amount_due), + 'total_outstanding' => $totalOutstanding, + 'total_collected' => $totalCollected, + 'total_invoices' => $invoices->count(), + ]; + } + + private function scopedQuery(string $search = '', ?int $academicTermId = null) + { + return TuitionInvoice::query() + ->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") + ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) + ->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)); + } + public function createMany(array $studentIds, array $data): void { DB::transaction(function () use ($studentIds, $data) { diff --git a/resources/js/pages/admin/finances/tuition-invoices/index.tsx b/resources/js/pages/admin/finances/tuition-invoices/index.tsx index 27ce003..f6070f6 100644 --- a/resources/js/pages/admin/finances/tuition-invoices/index.tsx +++ b/resources/js/pages/admin/finances/tuition-invoices/index.tsx @@ -1,7 +1,3 @@ -import { Head, router } from '@inertiajs/react'; -import { format } from 'date-fns'; -import { Plus } from 'lucide-react'; -import { useState } from 'react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; import { DatePicker } from '@/components/date-picker'; @@ -13,6 +9,13 @@ import InputError from '@/components/input-error'; import { PageHeader } from '@/components/page-header'; import { RupiahInput } from '@/components/rupiah-input'; import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Combobox, ComboboxChip, @@ -34,10 +37,11 @@ import { SelectValue, } from '@/components/ui/select'; import { useServerTable } from '@/hooks/use-server-table'; +import { formatRupiah } from '@/lib/currency'; import { - index as tuitionInvoiceIndex, destroy, store, + index as tuitionInvoiceIndex, update, } from '@/routes/admin/finances/tuition-invoices'; import { formatAcademicTermLabel } from '@/types/academic-term'; @@ -51,10 +55,30 @@ import { PaymentStatusLabels, PaymentStatuses, } from '@/types/tuition-payment'; +import { Head, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import type { LucideIcon } from 'lucide-react'; +import { + CircleDollarSign, + FileText, + Plus, + UserRoundX, + Wallet, +} from 'lucide-react'; +import { useState } from 'react'; import { createTuitionInvoiceColumns } from './columns'; type AcademicTermOption = { id: number; name: string; semester: string }; +type InvoiceSummary = { + unpaid_students: number; + partial_students: number; + paid_students: number; + total_outstanding: number; + total_collected: number; + total_invoices: number; +}; + type Props = { invoices: { data: TuitionInvoice[]; @@ -63,6 +87,8 @@ type Props = { per_page: number; total: number; }; + summary: InvoiceSummary; + summaryAcademicTerm: AcademicTermOption | null; students: TuitionInvoiceStudent[]; academicTerms: AcademicTermOption[]; highlight?: number; @@ -79,6 +105,8 @@ function studentLabel(student: TuitionInvoiceStudent): string { export default function TuitionInvoiceIndex({ invoices, + summary, + summaryAcademicTerm, students, academicTerms, highlight, @@ -176,6 +204,33 @@ export default function TuitionInvoiceIndex({ } /> +
+ Ringkasan berikut hanya menampilkan periode{' '} + {summaryAcademicTerm + ? formatAcademicTermLabel(summaryAcademicTerm) + : '-'} + {' '} dan bisa disesuaikan lewat filter. +
+ ++ {item.value.toLocaleString('id-ID')} +
++ {item.label} +
+