feat: enhance Tuition Invoice management with summary statistics and improved filtering
Some checks failed
tests / ci (pull_request) Has been cancelled
Some checks failed
tests / ci (pull_request) Has been cancelled
This commit is contained in:
parent
ef137ca364
commit
cad1e109fe
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ringkasan berikut hanya menampilkan periode{' '}
|
||||
{summaryAcademicTerm
|
||||
? formatAcademicTermLabel(summaryAcademicTerm)
|
||||
: '-'}
|
||||
{' '} dan bisa disesuaikan lewat filter.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StudentStatusSummaryCard summary={summary} />
|
||||
<SummaryCard
|
||||
icon={Wallet}
|
||||
label="Uang Tertahan"
|
||||
value={formatRupiah(summary.total_outstanding)}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={CircleDollarSign}
|
||||
label="Total Terkumpul"
|
||||
value={formatRupiah(summary.total_collected)}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={FileText}
|
||||
label="Total Tagihan"
|
||||
value={summary.total_invoices.toLocaleString('id-ID')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
@ -234,6 +289,59 @@ export default function TuitionInvoiceIndex({
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
{value}
|
||||
</CardTitle>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentStatusSummaryCard({ summary }: { summary: InvoiceSummary }) {
|
||||
const items: { label: string; value: number }[] = [
|
||||
{ label: 'Belum Bayar', value: summary.unpaid_students },
|
||||
{ label: 'Sebagian', value: summary.partial_students },
|
||||
{ label: 'Lunas', value: summary.paid_students },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardDescription>Status Mahasiswa</CardDescription>
|
||||
<UserRoundX className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<p className="text-2xl font-semibold">
|
||||
{item.value.toLocaleString('id-ID')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
@ -254,9 +362,9 @@ function CreateForm({
|
||||
|
||||
const availableStudents = academicTermId
|
||||
? students.filter(
|
||||
(student) =>
|
||||
!student.invoiced_term_ids?.includes(Number(academicTermId)),
|
||||
)
|
||||
(student) =>
|
||||
!student.invoiced_term_ids?.includes(Number(academicTermId)),
|
||||
)
|
||||
: [];
|
||||
|
||||
function reset() {
|
||||
@ -333,7 +441,7 @@ function CreateForm({
|
||||
>
|
||||
{selectedStudents.length ===
|
||||
availableStudents.length &&
|
||||
availableStudents.length > 0
|
||||
availableStudents.length > 0
|
||||
? 'Batalkan Semua'
|
||||
: 'Pilih Semua'}
|
||||
</button>
|
||||
@ -370,8 +478,8 @@ function CreateForm({
|
||||
selectedStudents.length > 0
|
||||
? ''
|
||||
: academicTermId
|
||||
? 'Pilih mahasiswa (aktif)'
|
||||
: 'Pilih periode akademik terlebih dahulu'
|
||||
? 'Pilih mahasiswa (aktif)'
|
||||
: 'Pilih periode akademik terlebih dahulu'
|
||||
}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user