feat: implement PaymentMethod enum and integrate payment method handling in tuition payment processes
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
d0793b739a
commit
2212498136
21
app/Enums/PaymentMethod.php
Normal file
21
app/Enums/PaymentMethod.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -36,10 +36,12 @@ public function index(PaginatedRequest $request): Response
|
||||
'invoices' => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
academicTermId: $request->validated('academic_term_id'),
|
||||
status: $request->validated('status'),
|
||||
paymentMethod: $request->validated('payment_method'),
|
||||
),
|
||||
'students' => $students,
|
||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||
'filters' => $request->only(['academic_term_id']),
|
||||
'filters' => $request->only(['academic_term_id', 'status', 'payment_method']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin\Finances;
|
||||
|
||||
use App\Enums\PaymentStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -15,13 +15,29 @@ public function authorize(): bool
|
||||
|
||||
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 [
|
||||
'amount_paid' => ['required', 'numeric', 'min:0'],
|
||||
'paid_at' => ['required', 'date'],
|
||||
'payment_method' => ['nullable', 'string', 'max:30'],
|
||||
'status' => ['required', Rule::enum(PaymentStatus::class)],
|
||||
'amount_paid' => ['required', 'numeric', 'min:0.01', "max:{$remaining}"],
|
||||
'paid_at' => ['required', 'date', 'before_or_equal:now'],
|
||||
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Enums\ClassMethod;
|
||||
use App\Enums\Gender;
|
||||
use App\Enums\PaymentMethod;
|
||||
use App\Enums\Semester;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -33,6 +34,7 @@ public function rules(): array
|
||||
'course_class_id' => ['nullable', 'integer'],
|
||||
'lecturer_id' => ['nullable', 'integer'],
|
||||
'type' => ['nullable', 'string'],
|
||||
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -22,8 +22,13 @@ public function getInvoicedTermIdsByStudent(array $studentIds): Collection
|
||||
->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()
|
||||
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
|
||||
->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}%")
|
||||
->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'),
|
||||
'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()
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Admin\Finances;
|
||||
|
||||
use App\Enums\PaymentStatus;
|
||||
use App\Models\TuitionInvoice;
|
||||
use App\Models\TuitionPayment;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@ -23,7 +24,7 @@ public function create(TuitionInvoice $invoice, array $data, ?UploadedFile $file
|
||||
'amount_paid' => $data['amount_paid'],
|
||||
'paid_at' => $data['paid_at'],
|
||||
'payment_method' => $data['payment_method'] ?? null,
|
||||
'status' => $data['status'],
|
||||
'status' => $this->resolveStatus($invoice, $data['amount_paid']),
|
||||
'recorded_by' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
@ -40,7 +41,7 @@ public function update(TuitionPayment $payment, array $data, ?UploadedFile $file
|
||||
$payment->amount_paid = $data['amount_paid'];
|
||||
$payment->paid_at = $data['paid_at'];
|
||||
$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->update();
|
||||
|
||||
@ -55,4 +56,16 @@ public function delete(TuitionPayment $payment): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PaymentMethod;
|
||||
use App\Enums\PaymentStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
@ -14,7 +15,7 @@ public function up(): void
|
||||
$table->foreignId('invoice_id')->constrained('tuition_invoices')->cascadeOnDelete();
|
||||
$table->decimal('amount_paid', 15, 2);
|
||||
$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->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('notes')->nullable();
|
||||
|
||||
@ -16,6 +16,7 @@ type DatePickerProps = {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
maxDate?: Date;
|
||||
};
|
||||
|
||||
export function DatePicker({
|
||||
@ -24,6 +25,7 @@ export function DatePicker({
|
||||
placeholder = 'Pilih tanggal',
|
||||
className,
|
||||
disabled,
|
||||
maxDate,
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
@ -48,6 +50,7 @@ export function DatePicker({
|
||||
defaultMonth={value ?? undefined}
|
||||
captionLayout="dropdown"
|
||||
onSelect={onChange}
|
||||
disabled={maxDate ? { after: maxDate } : undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { format } from 'date-fns';
|
||||
import { format, isSameDay } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import InputError from '@/components/input-error';
|
||||
@ -12,6 +12,7 @@ type DateTimeFieldProps = {
|
||||
defaultValue?: string | null;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
maxNow?: boolean;
|
||||
};
|
||||
|
||||
function parseDefault(value?: string | null): Date | undefined {
|
||||
@ -31,6 +32,7 @@ export function DateTimeField({
|
||||
defaultValue,
|
||||
error,
|
||||
placeholder = 'Pilih tanggal',
|
||||
maxNow,
|
||||
}: DateTimeFieldProps) {
|
||||
const initial = parseDefault(defaultValue);
|
||||
const [date, setDate] = useState<Date | undefined>(initial);
|
||||
@ -40,6 +42,12 @@ export function DateTimeField({
|
||||
? `${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 (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
@ -53,11 +61,13 @@ export function DateTimeField({
|
||||
onChange={setDate}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
maxDate={maxNow ? now : undefined}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
max={maxTime}
|
||||
className="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
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 { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatRupiah } from '@/lib/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments';
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { TuitionInvoice } from '@/types/tuition-invoice';
|
||||
@ -92,9 +93,28 @@ export function createTuitionInvoiceColumns(
|
||||
accessorKey: 'due_date',
|
||||
header: () => <span>Jatuh Tempo</span>,
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -45,6 +45,12 @@ import type {
|
||||
TuitionInvoice,
|
||||
TuitionInvoiceStudent,
|
||||
} from '@/types/tuition-invoice';
|
||||
import {
|
||||
PaymentMethodLabels,
|
||||
PaymentMethods,
|
||||
PaymentStatusLabels,
|
||||
PaymentStatuses,
|
||||
} from '@/types/tuition-payment';
|
||||
import { createTuitionInvoiceColumns } from './columns';
|
||||
|
||||
type AcademicTermOption = { id: number; name: string; semester: string };
|
||||
@ -62,6 +68,8 @@ type Props = {
|
||||
highlight?: number;
|
||||
filters: {
|
||||
academic_term_id?: string;
|
||||
status?: string;
|
||||
payment_method?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -89,6 +97,22 @@ export default function TuitionInvoiceIndex({
|
||||
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 = {
|
||||
|
||||
@ -15,15 +15,8 @@ import { RupiahInput } from '@/components/rupiah-input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatRupiah } from '@/lib/currency';
|
||||
import { index as tuitionInvoiceIndex } from '@/routes/admin/finances/tuition-invoices';
|
||||
@ -35,7 +28,11 @@ import {
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { TuitionInvoice } from '@/types/tuition-invoice';
|
||||
import type { TuitionPayment } from '@/types/tuition-payment';
|
||||
import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment';
|
||||
import {
|
||||
PaymentMethodLabels,
|
||||
PaymentMethods,
|
||||
PaymentStatusLabels,
|
||||
} from '@/types/tuition-payment';
|
||||
|
||||
type Props = {
|
||||
invoice: TuitionInvoice;
|
||||
@ -61,6 +58,8 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
(sum, payment) => sum + Number(payment.amount_paid),
|
||||
0,
|
||||
);
|
||||
const amountDue = Number(invoice.amount_due);
|
||||
const isFullyPaid = paidTotal >= amountDue;
|
||||
|
||||
const columns: ColumnDef<TuitionPayment>[] = [
|
||||
{
|
||||
@ -77,8 +76,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
{
|
||||
accessorKey: 'payment_method',
|
||||
header: () => <span>Metode</span>,
|
||||
cell: ({ row }) =>
|
||||
(row.getValue('payment_method') as string | null) ?? '-',
|
||||
cell: ({ row }) => {
|
||||
const method = row.getValue('payment_method') as
|
||||
keyof typeof PaymentMethodLabels | null;
|
||||
|
||||
return method ? PaymentMethodLabels[method] : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@ -207,7 +210,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
</p>
|
||||
<p>
|
||||
Total Terbayar: {formatRupiah(paidTotal)}{' '}
|
||||
{paidTotal >= Number(invoice.amount_due) ? (
|
||||
{isFullyPaid ? (
|
||||
<Badge variant="default" className="ml-1">
|
||||
Lunas
|
||||
</Badge>
|
||||
@ -228,10 +231,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
<h2 className="text-lg font-semibold">
|
||||
Riwayat Pembayaran
|
||||
</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Pembayaran
|
||||
</Button>
|
||||
{!isFullyPaid && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Pembayaran
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@ -244,6 +249,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
invoiceId={invoice.id}
|
||||
remaining={amountDue - paidTotal}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
@ -256,6 +262,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
|
||||
}}
|
||||
invoiceId={invoice.id}
|
||||
editing={editing}
|
||||
remaining={
|
||||
editing
|
||||
? amountDue -
|
||||
(paidTotal - Number(editing.amount_paid))
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@ -280,10 +292,12 @@ function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
invoiceId,
|
||||
remaining,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
invoiceId: number;
|
||||
remaining: number;
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
@ -299,6 +313,7 @@ function CreateForm({
|
||||
<PaymentFields
|
||||
errors={errors}
|
||||
resetKey={open ? 'open' : 'closed'}
|
||||
remaining={remaining}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -311,11 +326,13 @@ function EditForm({
|
||||
onOpenChange,
|
||||
invoiceId,
|
||||
editing,
|
||||
remaining,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
invoiceId: number;
|
||||
editing: TuitionPayment | null;
|
||||
remaining: number;
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
@ -329,7 +346,11 @@ function EditForm({
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<PaymentFields errors={errors} editing={editing} />
|
||||
<PaymentFields
|
||||
errors={errors}
|
||||
editing={editing}
|
||||
remaining={remaining}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -341,10 +362,12 @@ function PaymentFields({
|
||||
errors,
|
||||
editing,
|
||||
resetKey,
|
||||
remaining,
|
||||
}: {
|
||||
errors: Record<string, string>;
|
||||
editing?: TuitionPayment;
|
||||
resetKey?: string;
|
||||
remaining: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@ -359,6 +382,9 @@ function PaymentFields({
|
||||
defaultValue={editing?.amount_paid}
|
||||
ariaInvalid={!!errors.amount_paid}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sisa tagihan: {formatRupiah(remaining)}
|
||||
</p>
|
||||
<InputError message={errors.amount_paid} />
|
||||
</div>
|
||||
<DateTimeField
|
||||
@ -368,38 +394,34 @@ function PaymentFields({
|
||||
defaultValue={editing?.paid_at}
|
||||
placeholder="Pilih tanggal bayar"
|
||||
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">
|
||||
<Label>
|
||||
Status <span className="text-destructive">*</span>
|
||||
Metode Pembayaran{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input type="hidden" name="status" />
|
||||
<Select
|
||||
name="status"
|
||||
defaultValue={editing?.status ?? 'unpaid'}
|
||||
<RadioGroup
|
||||
name="payment_method"
|
||||
defaultValue={editing?.payment_method ?? ''}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PaymentStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{PaymentStatusLabels[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.status} />
|
||||
{PaymentMethods.map((method) => (
|
||||
<div key={method} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value={method}
|
||||
id={`payment_method-${method}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`payment_method-${method}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{PaymentMethodLabels[method]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<InputError message={errors.payment_method} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Catatan</Label>
|
||||
|
||||
@ -8,12 +8,21 @@ export const PaymentStatusLabels: Record<PaymentStatus, string> = {
|
||||
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 = {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
amount_paid: string;
|
||||
paid_at: string;
|
||||
payment_method: string | null;
|
||||
payment_method: PaymentMethod | null;
|
||||
status: PaymentStatus | null;
|
||||
recorded_by: number | null;
|
||||
recorder: { id: number; full_name: string } | null;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user