feat: implement PaymentMethod enum and integrate payment method handling in tuition payment processes
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-30 16:06:05 +07:00
parent d0793b739a
commit 2212498136
13 changed files with 214 additions and 59 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\HasValues;
enum PaymentMethod: string
{
use HasValues;
case Transfer = 'transfer';
case Cash = 'cash';
public function label(): string
{
return match ($this) {
self::Transfer => 'Transfer',
self::Cash => 'Tunai',
};
}
}

View File

@ -36,10 +36,12 @@ public function index(PaginatedRequest $request): Response
'invoices' => $this->service->paginated( 'invoices' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'), academicTermId: $request->validated('academic_term_id'),
status: $request->validated('status'),
paymentMethod: $request->validated('payment_method'),
), ),
'students' => $students, 'students' => $students,
'academicTerms' => $this->academicTermService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => $request->only(['academic_term_id']), 'filters' => $request->only(['academic_term_id', 'status', 'payment_method']),
]); ]);
} }

View File

@ -2,7 +2,7 @@
namespace App\Http\Requests\Admin\Finances; namespace App\Http\Requests\Admin\Finances;
use App\Enums\PaymentStatus; use App\Enums\PaymentMethod;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -15,13 +15,29 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$invoice = $this->route('tuition_invoice');
$payment = $this->route('payment');
$otherPaidTotal = $invoice->payments()
->when($payment, fn ($query) => $query->where('id', '!=', $payment->id))
->sum('amount_paid');
$remaining = max(0, round($invoice->amount_due - $otherPaidTotal, 2));
return [ return [
'amount_paid' => ['required', 'numeric', 'min:0'], 'amount_paid' => ['required', 'numeric', 'min:0.01', "max:{$remaining}"],
'paid_at' => ['required', 'date'], 'paid_at' => ['required', 'date', 'before_or_equal:now'],
'payment_method' => ['nullable', 'string', 'max:30'], 'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
'status' => ['required', Rule::enum(PaymentStatus::class)],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
'proof' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png'], 'proof' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png'],
]; ];
} }
public function messages(): array
{
return [
'amount_paid.max' => 'Jumlah dibayar tidak boleh melebihi sisa tagihan.',
'paid_at.before_or_equal' => 'Tanggal & waktu bayar tidak boleh melebihi waktu saat ini.',
];
}
} }

View File

@ -4,6 +4,7 @@
use App\Enums\ClassMethod; use App\Enums\ClassMethod;
use App\Enums\Gender; use App\Enums\Gender;
use App\Enums\PaymentMethod;
use App\Enums\Semester; use App\Enums\Semester;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -33,6 +34,7 @@ public function rules(): array
'course_class_id' => ['nullable', 'integer'], 'course_class_id' => ['nullable', 'integer'],
'lecturer_id' => ['nullable', 'integer'], 'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
]; ];
} }

View File

@ -22,8 +22,13 @@ public function getInvoicedTermIdsByStudent(array $studentIds): Collection
->map(fn ($rows) => $rows->pluck('academic_term_id')->values()->all()); ->map(fn ($rows) => $rows->pluck('academic_term_id')->values()->all());
} }
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null): LengthAwarePaginator public function paginated(
{ int $perPage = 25,
string $search = '',
?int $academicTermId = null,
?string $status = null,
?string $paymentMethod = null,
): LengthAwarePaginator {
return TuitionInvoice::query() return TuitionInvoice::query()
->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')
@ -32,6 +37,13 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") ->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}%")))) ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)) ->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'),
'unpaid' => $q->havingRaw('COALESCE(paid_total, 0) <= 0'),
default => $q,
})
->when($paymentMethod, fn ($q) => $q->whereHas('payments', fn ($q) => $q->where('payment_method', $paymentMethod)))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage);
} }

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Finances; namespace App\Services\Admin\Finances;
use App\Enums\PaymentStatus;
use App\Models\TuitionInvoice; use App\Models\TuitionInvoice;
use App\Models\TuitionPayment; use App\Models\TuitionPayment;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -23,7 +24,7 @@ public function create(TuitionInvoice $invoice, array $data, ?UploadedFile $file
'amount_paid' => $data['amount_paid'], 'amount_paid' => $data['amount_paid'],
'paid_at' => $data['paid_at'], 'paid_at' => $data['paid_at'],
'payment_method' => $data['payment_method'] ?? null, 'payment_method' => $data['payment_method'] ?? null,
'status' => $data['status'], 'status' => $this->resolveStatus($invoice, $data['amount_paid']),
'recorded_by' => auth()->id(), 'recorded_by' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -40,7 +41,7 @@ public function update(TuitionPayment $payment, array $data, ?UploadedFile $file
$payment->amount_paid = $data['amount_paid']; $payment->amount_paid = $data['amount_paid'];
$payment->paid_at = $data['paid_at']; $payment->paid_at = $data['paid_at'];
$payment->payment_method = $data['payment_method'] ?? null; $payment->payment_method = $data['payment_method'] ?? null;
$payment->status = $data['status']; $payment->status = $this->resolveStatus($payment->invoice, $data['amount_paid'], excludePaymentId: $payment->id);
$payment->notes = $data['notes'] ?? null; $payment->notes = $data['notes'] ?? null;
$payment->update(); $payment->update();
@ -55,4 +56,16 @@ public function delete(TuitionPayment $payment): bool
{ {
return $payment->delete(); return $payment->delete();
} }
private function resolveStatus(TuitionInvoice $invoice, float|string $amountPaid, ?int $excludePaymentId = null): PaymentStatus
{
$otherPaidTotal = $invoice->payments()
->when($excludePaymentId, fn ($query) => $query->where('id', '!=', $excludePaymentId))
->sum('amount_paid');
$totalPaid = round($otherPaidTotal + (float) $amountPaid, 2);
$amountDue = round((float) $invoice->amount_due, 2);
return $totalPaid >= $amountDue ? PaymentStatus::Paid : PaymentStatus::Partial;
}
} }

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus; use App\Enums\PaymentStatus;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
@ -14,7 +15,7 @@ public function up(): void
$table->foreignId('invoice_id')->constrained('tuition_invoices')->cascadeOnDelete(); $table->foreignId('invoice_id')->constrained('tuition_invoices')->cascadeOnDelete();
$table->decimal('amount_paid', 15, 2); $table->decimal('amount_paid', 15, 2);
$table->timestamp('paid_at'); $table->timestamp('paid_at');
$table->string('payment_method', 30)->nullable(); $table->enum('payment_method', PaymentMethod::values())->nullable();
$table->enum('status', PaymentStatus::values())->nullable()->default(PaymentStatus::Unpaid->value); $table->enum('status', PaymentStatus::values())->nullable()->default(PaymentStatus::Unpaid->value);
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('notes')->nullable(); $table->text('notes')->nullable();

View File

@ -16,6 +16,7 @@ type DatePickerProps = {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
maxDate?: Date;
}; };
export function DatePicker({ export function DatePicker({
@ -24,6 +25,7 @@ export function DatePicker({
placeholder = 'Pilih tanggal', placeholder = 'Pilih tanggal',
className, className,
disabled, disabled,
maxDate,
}: DatePickerProps) { }: DatePickerProps) {
return ( return (
<Popover> <Popover>
@ -48,6 +50,7 @@ export function DatePicker({
defaultMonth={value ?? undefined} defaultMonth={value ?? undefined}
captionLayout="dropdown" captionLayout="dropdown"
onSelect={onChange} onSelect={onChange}
disabled={maxDate ? { after: maxDate } : undefined}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@ -1,4 +1,4 @@
import { format } from 'date-fns'; import { format, isSameDay } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
@ -12,6 +12,7 @@ type DateTimeFieldProps = {
defaultValue?: string | null; defaultValue?: string | null;
error?: string; error?: string;
placeholder?: string; placeholder?: string;
maxNow?: boolean;
}; };
function parseDefault(value?: string | null): Date | undefined { function parseDefault(value?: string | null): Date | undefined {
@ -31,6 +32,7 @@ export function DateTimeField({
defaultValue, defaultValue,
error, error,
placeholder = 'Pilih tanggal', placeholder = 'Pilih tanggal',
maxNow,
}: DateTimeFieldProps) { }: DateTimeFieldProps) {
const initial = parseDefault(defaultValue); const initial = parseDefault(defaultValue);
const [date, setDate] = useState<Date | undefined>(initial); const [date, setDate] = useState<Date | undefined>(initial);
@ -40,6 +42,12 @@ export function DateTimeField({
? `${format(date, 'yyyy-MM-dd')} ${time || '00:00'}:00` ? `${format(date, 'yyyy-MM-dd')} ${time || '00:00'}:00`
: ''; : '';
const now = new Date();
const maxTime =
maxNow && date && isSameDay(date, now)
? format(now, 'HH:mm')
: undefined;
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
@ -53,11 +61,13 @@ export function DateTimeField({
onChange={setDate} onChange={setDate}
placeholder={placeholder} placeholder={placeholder}
className="flex-1" className="flex-1"
maxDate={maxNow ? now : undefined}
/> />
<Input <Input
type="time" type="time"
value={time} value={time}
onChange={(e) => setTime(e.target.value)} onChange={(e) => setTime(e.target.value)}
max={maxTime}
className="w-28" className="w-28"
/> />
</div> </div>

View File

@ -1,9 +1,10 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; import { format, isBefore, startOfDay } from 'date-fns';
import { CreditCard, Pencil, Trash2 } from 'lucide-react'; import { CreditCard, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatRupiah } from '@/lib/currency'; import { formatRupiah } from '@/lib/currency';
import { cn } from '@/lib/utils';
import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments'; import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments';
import { formatAcademicTermLabel } from '@/types/academic-term'; import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice'; import type { TuitionInvoice } from '@/types/tuition-invoice';
@ -92,9 +93,28 @@ export function createTuitionInvoiceColumns(
accessorKey: 'due_date', accessorKey: 'due_date',
header: () => <span>Jatuh Tempo</span>, header: () => <span>Jatuh Tempo</span>,
cell: ({ row }) => { cell: ({ row }) => {
const invoice = row.original;
const dueDate = row.getValue('due_date') as string | null; const dueDate = row.getValue('due_date') as string | null;
return dueDate ? format(new Date(dueDate), 'd MMM yyyy') : '-'; if (!dueDate) {
return '-';
}
const paidTotal = Number(invoice.paid_total ?? 0);
const amountDue = Number(invoice.amount_due);
const isOverdue =
paidTotal < amountDue &&
isBefore(new Date(dueDate), startOfDay(new Date()));
return (
<span
className={cn(
isOverdue && 'font-medium text-destructive',
)}
>
{format(new Date(dueDate), 'd MMM yyyy')}
</span>
);
}, },
}, },
{ {

View File

@ -45,6 +45,12 @@ import type {
TuitionInvoice, TuitionInvoice,
TuitionInvoiceStudent, TuitionInvoiceStudent,
} from '@/types/tuition-invoice'; } from '@/types/tuition-invoice';
import {
PaymentMethodLabels,
PaymentMethods,
PaymentStatusLabels,
PaymentStatuses,
} from '@/types/tuition-payment';
import { createTuitionInvoiceColumns } from './columns'; import { createTuitionInvoiceColumns } from './columns';
type AcademicTermOption = { id: number; name: string; semester: string }; type AcademicTermOption = { id: number; name: string; semester: string };
@ -62,6 +68,8 @@ type Props = {
highlight?: number; highlight?: number;
filters: { filters: {
academic_term_id?: string; academic_term_id?: string;
status?: string;
payment_method?: string;
}; };
}; };
@ -89,6 +97,22 @@ export default function TuitionInvoiceIndex({
label: formatAcademicTermLabel(term), label: formatAcademicTermLabel(term),
})), })),
}, },
{
key: 'status',
label: 'Status Bayar',
options: PaymentStatuses.map((status) => ({
value: status,
label: PaymentStatusLabels[status],
})),
},
{
key: 'payment_method',
label: 'Metode Pembayaran',
options: PaymentMethods.map((method) => ({
value: method,
label: PaymentMethodLabels[method],
})),
},
]; ];
const pagination: PaginationState = { const pagination: PaginationState = {

View File

@ -15,15 +15,8 @@ import { RupiahInput } from '@/components/rupiah-input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formatRupiah } from '@/lib/currency'; import { formatRupiah } from '@/lib/currency';
import { index as tuitionInvoiceIndex } from '@/routes/admin/finances/tuition-invoices'; import { index as tuitionInvoiceIndex } from '@/routes/admin/finances/tuition-invoices';
@ -35,7 +28,11 @@ import {
import { formatAcademicTermLabel } from '@/types/academic-term'; import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice'; import type { TuitionInvoice } from '@/types/tuition-invoice';
import type { TuitionPayment } from '@/types/tuition-payment'; import type { TuitionPayment } from '@/types/tuition-payment';
import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment'; import {
PaymentMethodLabels,
PaymentMethods,
PaymentStatusLabels,
} from '@/types/tuition-payment';
type Props = { type Props = {
invoice: TuitionInvoice; invoice: TuitionInvoice;
@ -61,6 +58,8 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
(sum, payment) => sum + Number(payment.amount_paid), (sum, payment) => sum + Number(payment.amount_paid),
0, 0,
); );
const amountDue = Number(invoice.amount_due);
const isFullyPaid = paidTotal >= amountDue;
const columns: ColumnDef<TuitionPayment>[] = [ const columns: ColumnDef<TuitionPayment>[] = [
{ {
@ -77,8 +76,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
{ {
accessorKey: 'payment_method', accessorKey: 'payment_method',
header: () => <span>Metode</span>, header: () => <span>Metode</span>,
cell: ({ row }) => cell: ({ row }) => {
(row.getValue('payment_method') as string | null) ?? '-', const method = row.getValue('payment_method') as
keyof typeof PaymentMethodLabels | null;
return method ? PaymentMethodLabels[method] : '-';
},
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@ -207,7 +210,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
</p> </p>
<p> <p>
Total Terbayar: {formatRupiah(paidTotal)}{' '} Total Terbayar: {formatRupiah(paidTotal)}{' '}
{paidTotal >= Number(invoice.amount_due) ? ( {isFullyPaid ? (
<Badge variant="default" className="ml-1"> <Badge variant="default" className="ml-1">
Lunas Lunas
</Badge> </Badge>
@ -228,10 +231,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
Riwayat Pembayaran Riwayat Pembayaran
</h2> </h2>
<Button onClick={() => setCreateOpen(true)}> {!isFullyPaid && (
<Plus className="h-4 w-4" /> <Button onClick={() => setCreateOpen(true)}>
Tambah Pembayaran <Plus className="h-4 w-4" />
</Button> Tambah Pembayaran
</Button>
)}
</div> </div>
<DataTable <DataTable
@ -244,6 +249,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
invoiceId={invoice.id} invoiceId={invoice.id}
remaining={amountDue - paidTotal}
/> />
<EditForm <EditForm
@ -256,6 +262,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
}} }}
invoiceId={invoice.id} invoiceId={invoice.id}
editing={editing} editing={editing}
remaining={
editing
? amountDue -
(paidTotal - Number(editing.amount_paid))
: 0
}
/> />
<DeleteConfirmDialog <DeleteConfirmDialog
@ -280,10 +292,12 @@ function CreateForm({
open, open,
onOpenChange, onOpenChange,
invoiceId, invoiceId,
remaining,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
invoiceId: number; invoiceId: number;
remaining: number;
}) { }) {
return ( return (
<FormDialog <FormDialog
@ -299,6 +313,7 @@ function CreateForm({
<PaymentFields <PaymentFields
errors={errors} errors={errors}
resetKey={open ? 'open' : 'closed'} resetKey={open ? 'open' : 'closed'}
remaining={remaining}
/> />
</div> </div>
)} )}
@ -311,11 +326,13 @@ function EditForm({
onOpenChange, onOpenChange,
invoiceId, invoiceId,
editing, editing,
remaining,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
invoiceId: number; invoiceId: number;
editing: TuitionPayment | null; editing: TuitionPayment | null;
remaining: number;
}) { }) {
return ( return (
<FormDialog <FormDialog
@ -329,7 +346,11 @@ function EditForm({
{({ errors }) => {({ errors }) =>
editing && ( editing && (
<div className="grid gap-4"> <div className="grid gap-4">
<PaymentFields errors={errors} editing={editing} /> <PaymentFields
errors={errors}
editing={editing}
remaining={remaining}
/>
</div> </div>
) )
} }
@ -341,10 +362,12 @@ function PaymentFields({
errors, errors,
editing, editing,
resetKey, resetKey,
remaining,
}: { }: {
errors: Record<string, string>; errors: Record<string, string>;
editing?: TuitionPayment; editing?: TuitionPayment;
resetKey?: string; resetKey?: string;
remaining: number;
}) { }) {
return ( return (
<> <>
@ -359,6 +382,9 @@ function PaymentFields({
defaultValue={editing?.amount_paid} defaultValue={editing?.amount_paid}
ariaInvalid={!!errors.amount_paid} ariaInvalid={!!errors.amount_paid}
/> />
<p className="text-xs text-muted-foreground">
Sisa tagihan: {formatRupiah(remaining)}
</p>
<InputError message={errors.amount_paid} /> <InputError message={errors.amount_paid} />
</div> </div>
<DateTimeField <DateTimeField
@ -368,38 +394,34 @@ function PaymentFields({
defaultValue={editing?.paid_at} defaultValue={editing?.paid_at}
placeholder="Pilih tanggal bayar" placeholder="Pilih tanggal bayar"
error={errors.paid_at} error={errors.paid_at}
maxNow
/> />
<div className="grid gap-2">
<Label htmlFor="payment_method">Metode Pembayaran</Label>
<Input
id="payment_method"
name="payment_method"
placeholder="Contoh: transfer"
defaultValue={editing?.payment_method ?? ''}
/>
<InputError message={errors.payment_method} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Status <span className="text-destructive">*</span> Metode Pembayaran{' '}
<span className="text-destructive">*</span>
</Label> </Label>
<input type="hidden" name="status" /> <RadioGroup
<Select name="payment_method"
name="status" defaultValue={editing?.payment_method ?? ''}
defaultValue={editing?.status ?? 'unpaid'} className="flex flex-row gap-4"
> >
<SelectTrigger className="w-full"> {PaymentMethods.map((method) => (
<SelectValue placeholder="Pilih status" /> <div key={method} className="flex items-center gap-2">
</SelectTrigger> <RadioGroupItem
<SelectContent> value={method}
{PaymentStatuses.map((status) => ( id={`payment_method-${method}`}
<SelectItem key={status} value={status}> />
{PaymentStatusLabels[status]} <Label
</SelectItem> htmlFor={`payment_method-${method}`}
))} className="font-normal"
</SelectContent> >
</Select> {PaymentMethodLabels[method]}
<InputError message={errors.status} /> </Label>
</div>
))}
</RadioGroup>
<InputError message={errors.payment_method} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="notes">Catatan</Label> <Label htmlFor="notes">Catatan</Label>

View File

@ -8,12 +8,21 @@ export const PaymentStatusLabels: Record<PaymentStatus, string> = {
paid: 'Lunas', paid: 'Lunas',
}; };
export const PaymentMethods = ['transfer', 'cash'] as const;
export type PaymentMethod = (typeof PaymentMethods)[number];
export const PaymentMethodLabels: Record<PaymentMethod, string> = {
transfer: 'Transfer',
cash: 'Tunai',
};
export type TuitionPayment = { export type TuitionPayment = {
id: number; id: number;
invoice_id: number; invoice_id: number;
amount_paid: string; amount_paid: string;
paid_at: string; paid_at: string;
payment_method: string | null; payment_method: PaymentMethod | null;
status: PaymentStatus | null; status: PaymentStatus | null;
recorded_by: number | null; recorded_by: number | null;
recorder: { id: number; full_name: string } | null; recorder: { id: number; full_name: string } | null;