Compare commits

...

2 Commits

3 changed files with 167 additions and 17 deletions

View File

@ -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']),
]);
}

View File

@ -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) {

View File

@ -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>