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, []);
|
$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', [
|
return Inertia::render('admin/finances/tuition-invoices/index', [
|
||||||
'invoices' => $this->service->paginated(
|
'invoices' => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
academicTermId: $request->validated('academic_term_id'),
|
academicTermId: $requestedTermId,
|
||||||
status: $request->validated('status'),
|
status: $request->validated('status'),
|
||||||
paymentMethod: $request->validated('payment_method'),
|
paymentMethod: $request->validated('payment_method'),
|
||||||
),
|
),
|
||||||
|
'summary' => $this->service->summary(
|
||||||
|
search: $request->validated('search') ?? '',
|
||||||
|
academicTermId: $summaryTermId,
|
||||||
|
),
|
||||||
|
'summaryAcademicTerm' => $academicTerms->firstWhere('id', $summaryTermId),
|
||||||
'students' => $students,
|
'students' => $students,
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'academicTerms' => $academicTerms,
|
||||||
'filters' => $request->only(['academic_term_id', 'status', 'payment_method']),
|
'filters' => $request->only(['academic_term_id', 'status', 'payment_method']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,14 +29,11 @@ public function paginated(
|
|||||||
?string $status = null,
|
?string $status = null,
|
||||||
?string $paymentMethod = null,
|
?string $paymentMethod = null,
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
return TuitionInvoice::query()
|
return $this->scopedQuery($search, $academicTermId)
|
||||||
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
|
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
|
||||||
->withSum('payments as paid_total', 'amount_paid')
|
->withSum('payments as paid_total', 'amount_paid')
|
||||||
->withCount('payments')
|
->withCount('payments')
|
||||||
->with(['student.user.profile', 'student.department', 'academicTerm:id,name,semester,start_date,end_date'])
|
->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) {
|
->when($status, fn ($q) => match ($status) {
|
||||||
'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
|
'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
|
||||||
'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND 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);
|
->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
|
public function createMany(array $studentIds, array $data): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($studentIds, $data) {
|
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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
@ -13,6 +9,13 @@ import InputError from '@/components/input-error';
|
|||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RupiahInput } from '@/components/rupiah-input';
|
import { RupiahInput } from '@/components/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxChip,
|
ComboboxChip,
|
||||||
@ -34,10 +37,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
|
import { formatRupiah } from '@/lib/currency';
|
||||||
import {
|
import {
|
||||||
index as tuitionInvoiceIndex,
|
|
||||||
destroy,
|
destroy,
|
||||||
store,
|
store,
|
||||||
|
index as tuitionInvoiceIndex,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/finances/tuition-invoices';
|
} from '@/routes/admin/finances/tuition-invoices';
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
@ -51,10 +55,30 @@ import {
|
|||||||
PaymentStatusLabels,
|
PaymentStatusLabels,
|
||||||
PaymentStatuses,
|
PaymentStatuses,
|
||||||
} from '@/types/tuition-payment';
|
} 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';
|
import { createTuitionInvoiceColumns } from './columns';
|
||||||
|
|
||||||
type AcademicTermOption = { id: number; name: string; semester: string };
|
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 = {
|
type Props = {
|
||||||
invoices: {
|
invoices: {
|
||||||
data: TuitionInvoice[];
|
data: TuitionInvoice[];
|
||||||
@ -63,6 +87,8 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
summary: InvoiceSummary;
|
||||||
|
summaryAcademicTerm: AcademicTermOption | null;
|
||||||
students: TuitionInvoiceStudent[];
|
students: TuitionInvoiceStudent[];
|
||||||
academicTerms: AcademicTermOption[];
|
academicTerms: AcademicTermOption[];
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
@ -79,6 +105,8 @@ function studentLabel(student: TuitionInvoiceStudent): string {
|
|||||||
|
|
||||||
export default function TuitionInvoiceIndex({
|
export default function TuitionInvoiceIndex({
|
||||||
invoices,
|
invoices,
|
||||||
|
summary,
|
||||||
|
summaryAcademicTerm,
|
||||||
students,
|
students,
|
||||||
academicTerms,
|
academicTerms,
|
||||||
highlight,
|
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
|
<CreateForm
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
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({
|
function CreateForm({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -254,9 +362,9 @@ function CreateForm({
|
|||||||
|
|
||||||
const availableStudents = academicTermId
|
const availableStudents = academicTermId
|
||||||
? students.filter(
|
? students.filter(
|
||||||
(student) =>
|
(student) =>
|
||||||
!student.invoiced_term_ids?.includes(Number(academicTermId)),
|
!student.invoiced_term_ids?.includes(Number(academicTermId)),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@ -333,7 +441,7 @@ function CreateForm({
|
|||||||
>
|
>
|
||||||
{selectedStudents.length ===
|
{selectedStudents.length ===
|
||||||
availableStudents.length &&
|
availableStudents.length &&
|
||||||
availableStudents.length > 0
|
availableStudents.length > 0
|
||||||
? 'Batalkan Semua'
|
? 'Batalkan Semua'
|
||||||
: 'Pilih Semua'}
|
: 'Pilih Semua'}
|
||||||
</button>
|
</button>
|
||||||
@ -370,8 +478,8 @@ function CreateForm({
|
|||||||
selectedStudents.length > 0
|
selectedStudents.length > 0
|
||||||
? ''
|
? ''
|
||||||
: academicTermId
|
: academicTermId
|
||||||
? 'Pilih mahasiswa (aktif)'
|
? 'Pilih mahasiswa (aktif)'
|
||||||
: 'Pilih periode akademik terlebih dahulu'
|
: 'Pilih periode akademik terlebih dahulu'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ComboboxChips>
|
</ComboboxChips>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user