Compare commits

..

No commits in common. "ef137ca3640e1e1c159d2a205ef7fa647f0b1a23" and "d0793b739a3bace14865ac226715dcdfe8b9fffa" have entirely different histories.

13 changed files with 59 additions and 214 deletions

View File

@ -1,21 +0,0 @@
<?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,12 +36,10 @@ 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', 'status', 'payment_method']), 'filters' => $request->only(['academic_term_id']),
]); ]);
} }

View File

@ -2,7 +2,7 @@
namespace App\Http\Requests\Admin\Finances; namespace App\Http\Requests\Admin\Finances;
use App\Enums\PaymentMethod; use App\Enums\PaymentStatus;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -15,29 +15,13 @@ 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.01', "max:{$remaining}"], 'amount_paid' => ['required', 'numeric', 'min:0'],
'paid_at' => ['required', 'date', 'before_or_equal:now'], 'paid_at' => ['required', 'date'],
'payment_method' => ['required', Rule::enum(PaymentMethod::class)], 'payment_method' => ['nullable', 'string', 'max:30'],
'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,7 +4,6 @@
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;
@ -34,7 +33,6 @@ 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,13 +22,8 @@ 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( public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null): LengthAwarePaginator
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')
@ -37,13 +32,6 @@ public function paginated(
->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,7 +2,6 @@
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;
@ -24,7 +23,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' => $this->resolveStatus($invoice, $data['amount_paid']), 'status' => $data['status'],
'recorded_by' => auth()->id(), 'recorded_by' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -41,7 +40,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 = $this->resolveStatus($payment->invoice, $data['amount_paid'], excludePaymentId: $payment->id); $payment->status = $data['status'];
$payment->notes = $data['notes'] ?? null; $payment->notes = $data['notes'] ?? null;
$payment->update(); $payment->update();
@ -56,16 +55,4 @@ 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,6 +1,5 @@
<?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;
@ -15,7 +14,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->enum('payment_method', PaymentMethod::values())->nullable(); $table->string('payment_method', 30)->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,7 +16,6 @@ type DatePickerProps = {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
maxDate?: Date;
}; };
export function DatePicker({ export function DatePicker({
@ -25,7 +24,6 @@ export function DatePicker({
placeholder = 'Pilih tanggal', placeholder = 'Pilih tanggal',
className, className,
disabled, disabled,
maxDate,
}: DatePickerProps) { }: DatePickerProps) {
return ( return (
<Popover> <Popover>
@ -50,7 +48,6 @@ 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, isSameDay } from 'date-fns'; import { format } 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,7 +12,6 @@ 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 {
@ -32,7 +31,6 @@ 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);
@ -42,12 +40,6 @@ 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>
@ -61,13 +53,11 @@ 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,10 +1,9 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { format, isBefore, startOfDay } from 'date-fns'; import { format } 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';
@ -93,28 +92,9 @@ 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;
if (!dueDate) { return dueDate ? format(new Date(dueDate), 'd MMM yyyy') : '-';
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,12 +45,6 @@ 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 };
@ -68,8 +62,6 @@ type Props = {
highlight?: number; highlight?: number;
filters: { filters: {
academic_term_id?: string; academic_term_id?: string;
status?: string;
payment_method?: string;
}; };
}; };
@ -97,22 +89,6 @@ 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,8 +15,15 @@ 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import {
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';
@ -28,11 +35,7 @@ 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 { import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment';
PaymentMethodLabels,
PaymentMethods,
PaymentStatusLabels,
} from '@/types/tuition-payment';
type Props = { type Props = {
invoice: TuitionInvoice; invoice: TuitionInvoice;
@ -58,8 +61,6 @@ 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>[] = [
{ {
@ -76,12 +77,8 @@ 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 }) =>
const method = row.getValue('payment_method') as (row.getValue('payment_method') as string | null) ?? '-',
keyof typeof PaymentMethodLabels | null;
return method ? PaymentMethodLabels[method] : '-';
},
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@ -210,7 +207,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
</p> </p>
<p> <p>
Total Terbayar: {formatRupiah(paidTotal)}{' '} Total Terbayar: {formatRupiah(paidTotal)}{' '}
{isFullyPaid ? ( {paidTotal >= Number(invoice.amount_due) ? (
<Badge variant="default" className="ml-1"> <Badge variant="default" className="ml-1">
Lunas Lunas
</Badge> </Badge>
@ -231,12 +228,10 @@ 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>
{!isFullyPaid && (
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Pembayaran Tambah Pembayaran
</Button> </Button>
)}
</div> </div>
<DataTable <DataTable
@ -249,7 +244,6 @@ 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
@ -262,12 +256,6 @@ 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
@ -292,12 +280,10 @@ 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
@ -313,7 +299,6 @@ function CreateForm({
<PaymentFields <PaymentFields
errors={errors} errors={errors}
resetKey={open ? 'open' : 'closed'} resetKey={open ? 'open' : 'closed'}
remaining={remaining}
/> />
</div> </div>
)} )}
@ -326,13 +311,11 @@ 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
@ -346,11 +329,7 @@ function EditForm({
{({ errors }) => {({ errors }) =>
editing && ( editing && (
<div className="grid gap-4"> <div className="grid gap-4">
<PaymentFields <PaymentFields errors={errors} editing={editing} />
errors={errors}
editing={editing}
remaining={remaining}
/>
</div> </div>
) )
} }
@ -362,12 +341,10 @@ 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 (
<> <>
@ -382,9 +359,6 @@ 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
@ -394,35 +368,39 @@ 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"> <div className="grid gap-2">
<Label> <Label htmlFor="payment_method">Metode Pembayaran</Label>
Metode Pembayaran{' '} <Input
<span className="text-destructive">*</span> id="payment_method"
</Label>
<RadioGroup
name="payment_method" name="payment_method"
placeholder="Contoh: transfer"
defaultValue={editing?.payment_method ?? ''} defaultValue={editing?.payment_method ?? ''}
className="flex flex-row gap-4"
>
{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} /> <InputError message={errors.payment_method} />
</div> </div>
<div className="grid gap-2">
<Label>
Status <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="status" />
<Select
name="status"
defaultValue={editing?.status ?? 'unpaid'}
>
<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} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="notes">Catatan</Label> <Label htmlFor="notes">Catatan</Label>
<Textarea <Textarea

View File

@ -8,21 +8,12 @@ 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: PaymentMethod | null; payment_method: string | 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;