Compare commits
No commits in common. "ef137ca3640e1e1c159d2a205ef7fa647f0b1a23" and "d0793b739a3bace14865ac226715dcdfe8b9fffa" have entirely different histories.
ef137ca364
...
d0793b739a
@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user