From 8e4d0ba53034232b101df66ed9cbfa17f34cd514 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 25 Aug 2026 00:34:32 +0700 Subject: [PATCH] feat: add tuition invoice and payment management - Implemented StudentService to retrieve all students for selection. - Created migration for tuition_invoices and tuition_payments tables. - Added seeders for TuitionInvoice and TuitionPayment with sample data. - Updated DatabaseSeeder to include new seeders. - Developed UI components for managing tuition invoices and payments, including forms and data tables. - Introduced RupiahInput component for formatted currency input. - Added routes for tuition invoices and payments management. - Defined TypeScript types for tuition invoices and payments. --- app/Enums/PaymentStatus.php | 19 + .../Admin/Manage/TuitionInvoiceController.php | 59 +++ .../Admin/Manage/TuitionPaymentController.php | 50 +++ .../Admin/Manage/TuitionInvoiceRequest.php | 24 + .../Admin/Manage/TuitionPaymentRequest.php | 27 ++ app/Models/AcademicTerm.php | 6 + app/Models/Student.php | 5 + app/Models/TuitionInvoice.php | 38 ++ app/Models/TuitionPayment.php | 58 +++ .../Admin/Manage/TuitionInvoiceService.php | 48 ++ .../Admin/Manage/TuitionPaymentService.php | 58 +++ app/Services/Admin/Users/StudentService.php | 7 + ...4_000005_create_tuition_invoices_table.php | 25 ++ ...4_000006_create_tuition_payments_table.php | 29 ++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/TuitionInvoiceSeeder.php | 31 ++ database/seeders/TuitionPaymentSeeder.php | 37 ++ resources/js/components/app-sidebar.tsx | 12 + resources/js/components/rupiah-input.tsx | 132 ++++++ resources/js/lib/currency.ts | 12 + .../admin/manage/tuition-invoices/columns.tsx | 131 ++++++ .../admin/manage/tuition-invoices/index.tsx | 393 +++++++++++++++++ .../manage/tuition-invoices/payments.tsx | 415 ++++++++++++++++++ resources/js/types/tuition-invoice.ts | 20 + resources/js/types/tuition-payment.ts | 25 ++ routes/admin.php | 11 + 26 files changed, 1674 insertions(+) create mode 100644 app/Enums/PaymentStatus.php create mode 100644 app/Http/Controllers/Admin/Manage/TuitionInvoiceController.php create mode 100644 app/Http/Controllers/Admin/Manage/TuitionPaymentController.php create mode 100644 app/Http/Requests/Admin/Manage/TuitionInvoiceRequest.php create mode 100644 app/Http/Requests/Admin/Manage/TuitionPaymentRequest.php create mode 100644 app/Models/TuitionInvoice.php create mode 100644 app/Models/TuitionPayment.php create mode 100644 app/Services/Admin/Manage/TuitionInvoiceService.php create mode 100644 app/Services/Admin/Manage/TuitionPaymentService.php create mode 100644 database/migrations/2026_08_24_000005_create_tuition_invoices_table.php create mode 100644 database/migrations/2026_08_24_000006_create_tuition_payments_table.php create mode 100644 database/seeders/TuitionInvoiceSeeder.php create mode 100644 database/seeders/TuitionPaymentSeeder.php create mode 100644 resources/js/components/rupiah-input.tsx create mode 100644 resources/js/lib/currency.ts create mode 100644 resources/js/pages/admin/manage/tuition-invoices/columns.tsx create mode 100644 resources/js/pages/admin/manage/tuition-invoices/index.tsx create mode 100644 resources/js/pages/admin/manage/tuition-invoices/payments.tsx create mode 100644 resources/js/types/tuition-invoice.ts create mode 100644 resources/js/types/tuition-payment.ts diff --git a/app/Enums/PaymentStatus.php b/app/Enums/PaymentStatus.php new file mode 100644 index 0000000..8527118 --- /dev/null +++ b/app/Enums/PaymentStatus.php @@ -0,0 +1,19 @@ + 'Belum Bayar', + self::Partial => 'Sebagian', + self::Paid => 'Lunas', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/TuitionInvoiceController.php b/app/Http/Controllers/Admin/Manage/TuitionInvoiceController.php new file mode 100644 index 0000000..d38d696 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/TuitionInvoiceController.php @@ -0,0 +1,59 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'students' => $this->studentService->getAllForSelect(), + 'academicTerms' => $this->academicTermService->getAll(), + ]); + } + + public function store(TuitionInvoiceRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tagihan berhasil ditambahkan.']); + + return to_route('admin.manage.tuition-invoices.index'); + } + + public function update(TuitionInvoiceRequest $request, TuitionInvoice $tuitionInvoice): RedirectResponse + { + $this->service->update($tuitionInvoice, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tagihan berhasil diperbarui.']); + + return to_route('admin.manage.tuition-invoices.index'); + } + + public function destroy(TuitionInvoice $tuitionInvoice): RedirectResponse + { + $this->service->delete($tuitionInvoice); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tagihan berhasil dihapus.']); + + return back(); + } +} diff --git a/app/Http/Controllers/Admin/Manage/TuitionPaymentController.php b/app/Http/Controllers/Admin/Manage/TuitionPaymentController.php new file mode 100644 index 0000000..f2ab4c5 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/TuitionPaymentController.php @@ -0,0 +1,50 @@ +load(['student.user.profile', 'student.department', 'academicTerm']); + + return Inertia::render('admin/manage/tuition-invoices/payments', [ + 'invoice' => $tuitionInvoice, + 'payments' => $this->service->forInvoice($tuitionInvoice), + ]); + } + + public function store(TuitionPaymentRequest $request, TuitionInvoice $tuitionInvoice): RedirectResponse + { + $this->service->create($tuitionInvoice, $request->validated(), $request->file('proof')); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pembayaran berhasil ditambahkan.'])->back(); + } + + public function update(TuitionPaymentRequest $request, TuitionInvoice $tuitionInvoice, TuitionPayment $payment): RedirectResponse + { + $this->service->update($payment, $request->validated(), $request->file('proof')); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pembayaran berhasil diperbarui.'])->back(); + } + + public function destroy(TuitionInvoice $tuitionInvoice, TuitionPayment $payment): RedirectResponse + { + $this->service->delete($payment); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pembayaran berhasil dihapus.'])->back(); + } +} diff --git a/app/Http/Requests/Admin/Manage/TuitionInvoiceRequest.php b/app/Http/Requests/Admin/Manage/TuitionInvoiceRequest.php new file mode 100644 index 0000000..ea2a44c --- /dev/null +++ b/app/Http/Requests/Admin/Manage/TuitionInvoiceRequest.php @@ -0,0 +1,24 @@ + ['required', 'integer', Rule::exists('students', 'id')], + 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], + 'amount_due' => ['required', 'numeric', 'min:0'], + 'due_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/TuitionPaymentRequest.php b/app/Http/Requests/Admin/Manage/TuitionPaymentRequest.php new file mode 100644 index 0000000..a818687 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/TuitionPaymentRequest.php @@ -0,0 +1,27 @@ + ['required', 'numeric', 'min:0'], + 'paid_at' => ['required', 'date'], + 'payment_method' => ['nullable', 'string', 'max:30'], + 'status' => ['required', Rule::enum(PaymentStatus::class)], + 'notes' => ['nullable', 'string'], + 'proof' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png'], + ]; + } +} diff --git a/app/Models/AcademicTerm.php b/app/Models/AcademicTerm.php index b4dc745..692f3ae 100644 --- a/app/Models/AcademicTerm.php +++ b/app/Models/AcademicTerm.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Guarded(['id'])] #[Appends(['formatted_start_date', 'formatted_end_date'])] @@ -15,6 +16,11 @@ class AcademicTerm extends Model { use HasFactory; + public function tuitionInvoices(): HasMany + { + return $this->hasMany(TuitionInvoice::class); + } + protected function casts(): array { return [ diff --git a/app/Models/Student.php b/app/Models/Student.php index b2a5e2b..7e1d64f 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -51,4 +51,9 @@ public function attendances(): HasMany { return $this->hasMany(Attendance::class); } + + public function tuitionInvoices(): HasMany + { + return $this->hasMany(TuitionInvoice::class); + } } diff --git a/app/Models/TuitionInvoice.php b/app/Models/TuitionInvoice.php new file mode 100644 index 0000000..67449e4 --- /dev/null +++ b/app/Models/TuitionInvoice.php @@ -0,0 +1,38 @@ + 'decimal:2', + 'due_date' => 'date', + ]; + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function academicTerm(): BelongsTo + { + return $this->belongsTo(AcademicTerm::class); + } + + public function payments(): HasMany + { + return $this->hasMany(TuitionPayment::class, 'invoice_id'); + } +} diff --git a/app/Models/TuitionPayment.php b/app/Models/TuitionPayment.php new file mode 100644 index 0000000..9c9bd5a --- /dev/null +++ b/app/Models/TuitionPayment.php @@ -0,0 +1,58 @@ + 'decimal:2', + 'paid_at' => 'datetime', + 'status' => PaymentStatus::class, + ]; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('payment_proof')->singleFile(); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(TuitionInvoice::class, 'invoice_id'); + } + + public function recorder(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by'); + } + + protected function proofUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('payment_proof') ?: null, + ); + } + + protected function proofName(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMedia('payment_proof')?->file_name, + ); + } +} diff --git a/app/Services/Admin/Manage/TuitionInvoiceService.php b/app/Services/Admin/Manage/TuitionInvoiceService.php new file mode 100644 index 0000000..56289f1 --- /dev/null +++ b/app/Services/Admin/Manage/TuitionInvoiceService.php @@ -0,0 +1,48 @@ +select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date']) + ->withSum('payments as paid_total', 'amount_paid') + ->withCount('payments') + ->with(['student.user.profile', 'student.department', 'academicTerm:id,name,semester,start_date,end_date']) + ->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%") + ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function create(array $data): TuitionInvoice + { + return TuitionInvoice::create([ + 'student_id' => $data['student_id'], + 'academic_term_id' => $data['academic_term_id'], + 'amount_due' => $data['amount_due'], + 'due_date' => $data['due_date'] ?? null, + ]); + } + + public function update(TuitionInvoice $invoice, array $data): TuitionInvoice + { + $invoice->student_id = $data['student_id']; + $invoice->academic_term_id = $data['academic_term_id']; + $invoice->amount_due = $data['amount_due']; + $invoice->due_date = $data['due_date'] ?? null; + $invoice->update(); + + return $invoice; + } + + public function delete(TuitionInvoice $invoice): bool + { + return $invoice->delete(); + } +} diff --git a/app/Services/Admin/Manage/TuitionPaymentService.php b/app/Services/Admin/Manage/TuitionPaymentService.php new file mode 100644 index 0000000..4997db0 --- /dev/null +++ b/app/Services/Admin/Manage/TuitionPaymentService.php @@ -0,0 +1,58 @@ +payments() + ->with('recorder.profile') + ->latest('paid_at') + ->get(); + } + + public function create(TuitionInvoice $invoice, array $data, ?UploadedFile $file): TuitionPayment + { + $payment = $invoice->payments()->create([ + 'amount_paid' => $data['amount_paid'], + 'paid_at' => $data['paid_at'], + 'payment_method' => $data['payment_method'] ?? null, + 'status' => $data['status'], + 'recorded_by' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + + if ($file) { + $payment->addMedia($file)->toMediaCollection('payment_proof'); + } + + return $payment; + } + + public function update(TuitionPayment $payment, array $data, ?UploadedFile $file): TuitionPayment + { + $payment->amount_paid = $data['amount_paid']; + $payment->paid_at = $data['paid_at']; + $payment->payment_method = $data['payment_method'] ?? null; + $payment->status = $data['status']; + $payment->notes = $data['notes'] ?? null; + $payment->update(); + + if ($file) { + $payment->addMedia($file)->toMediaCollection('payment_proof'); + } + + return $payment; + } + + public function delete(TuitionPayment $payment): bool + { + return $payment->delete(); + } +} diff --git a/app/Services/Admin/Users/StudentService.php b/app/Services/Admin/Users/StudentService.php index 2a9d869..cfa9466 100644 --- a/app/Services/Admin/Users/StudentService.php +++ b/app/Services/Admin/Users/StudentService.php @@ -2,13 +2,20 @@ namespace App\Services\Admin\Users; +use App\Models\Student; use App\Models\User; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; class StudentService { + public function getAllForSelect(): Collection + { + return Student::with(['user.profile', 'department'])->get(); + } + public function getPaginated(array $filters = []): LengthAwarePaginator { return User::with(['profile', 'student.department']) diff --git a/database/migrations/2026_08_24_000005_create_tuition_invoices_table.php b/database/migrations/2026_08_24_000005_create_tuition_invoices_table.php new file mode 100644 index 0000000..fb12464 --- /dev/null +++ b/database/migrations/2026_08_24_000005_create_tuition_invoices_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->foreignId('academic_term_id')->constrained()->cascadeOnDelete(); + $table->decimal('amount_due', 15, 2); + $table->date('due_date')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tuition_invoices'); + } +}; diff --git a/database/migrations/2026_08_24_000006_create_tuition_payments_table.php b/database/migrations/2026_08_24_000006_create_tuition_payments_table.php new file mode 100644 index 0000000..b97ba87 --- /dev/null +++ b/database/migrations/2026_08_24_000006_create_tuition_payments_table.php @@ -0,0 +1,29 @@ +id(); + $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('status', array_values(PaymentStatus::cases()))->nullable()->default(PaymentStatus::Unpaid->value); + $table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tuition_payments'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6238346..cfb29eb 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -25,6 +25,8 @@ public function run(): void SubmissionSeeder::class, ScheduleSeeder::class, AttendanceSeeder::class, + TuitionInvoiceSeeder::class, + TuitionPaymentSeeder::class, ]); } } diff --git a/database/seeders/TuitionInvoiceSeeder.php b/database/seeders/TuitionInvoiceSeeder.php new file mode 100644 index 0000000..3865175 --- /dev/null +++ b/database/seeders/TuitionInvoiceSeeder.php @@ -0,0 +1,31 @@ + 1, + 'academic_term_id' => 2, + 'amount_due' => 3000000, + 'due_date' => '2026-08-10', + 'created_at' => '2026-07-25 08:00:00', + 'updated_at' => '2026-07-25 08:00:00', + ], + [ + 'student_id' => 2, + 'academic_term_id' => 2, + 'amount_due' => 3000000, + 'due_date' => '2026-08-10', + 'created_at' => '2026-07-25 08:00:00', + 'updated_at' => '2026-07-25 08:00:00', + ], + ]); + } +} diff --git a/database/seeders/TuitionPaymentSeeder.php b/database/seeders/TuitionPaymentSeeder.php new file mode 100644 index 0000000..4f5eab7 --- /dev/null +++ b/database/seeders/TuitionPaymentSeeder.php @@ -0,0 +1,37 @@ + 1, + 'amount_paid' => 3000000, + 'paid_at' => '2026-08-03 10:00:00', + 'payment_method' => 'transfer', + 'status' => 'paid', + 'recorded_by' => 6, + 'notes' => 'Lunas sekaligus', + 'created_at' => '2026-08-03 10:00:00', + 'updated_at' => '2026-08-03 10:00:00', + ], + [ + 'invoice_id' => 2, + 'amount_paid' => 1500000, + 'paid_at' => '2026-08-04 11:00:00', + 'payment_method' => 'transfer', + 'status' => 'partial', + 'recorded_by' => 6, + 'notes' => 'Cicilan tahap 1', + 'created_at' => '2026-08-04 11:00:00', + 'updated_at' => '2026-08-04 11:00:00', + ], + ]); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 0d2adac..73e4a39 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -15,6 +15,7 @@ import { ClipboardList, FileText, GraduationCap, + Receipt, School, User, Users, @@ -39,6 +40,7 @@ import { index as courseClassesRoute } from '@/routes/admin/manage/course-classe import { index as coursesRoute } from '@/routes/admin/manage/courses'; import { index as materialsRoute } from '@/routes/admin/manage/materials'; import { index as schedulesRoute } from '@/routes/admin/manage/schedules'; +import { index as tuitionInvoicesRoute } from '@/routes/admin/manage/tuition-invoices'; import { index as academicTerm } from '@/routes/admin/master/academic-terms'; import { index as departmentsRoute } from '@/routes/admin/master/departments'; import { index as administratorsRoute } from '@/routes/admin/users/administrators'; @@ -110,6 +112,16 @@ const data: { }, ], }, + { + label: 'Keuangan', + items: [ + { + name: 'Tagihan', + url: tuitionInvoicesRoute.url(), + icon: Receipt, + }, + ], + }, { label: 'Pengguna', items: [ diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx new file mode 100644 index 0000000..97a2254 --- /dev/null +++ b/resources/js/components/rupiah-input.tsx @@ -0,0 +1,132 @@ +import { useRef, useState } from 'react'; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from '@/components/ui/input-group'; + +type RupiahInputProps = { + name: string; + id?: string; + defaultValue?: string | number | null; + placeholder?: string; + disabled?: boolean; + ariaInvalid?: boolean; +}; + +// Strips everything but digits from a display value (e.g. "1.500.000" -> "1500000"). +function digitsOnly(value: string): string { + return value.replace(/\D/g, '').replace(/^0+(?=\d)/, ''); +} + +// Parses the initial value coming from the backend, which may be a decimal +// string like "3000000.00" - only the integer part before the decimal point +// is kept (unlike digitsOnly, which would wrongly treat "." as a thousands +// separator here). +function parseInitialDigits(value: string | number | null | undefined): string { + return digitsOnly(String(value ?? '').split('.')[0]); +} + +function formatThousands(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); +} + +// Counts digit characters (ignoring separators) before `caret` in `value`. +function digitsBeforeCaret(value: string, caret: number): number { + let count = 0; + + for (let i = 0; i < caret && i < value.length; i++) { + if (/\d/.test(value[i])) { + count++; + } + } + + return count; +} + +// Finds the position in `formatted` right after the `digitCount`-th digit. +function caretForDigitCount(formatted: string, digitCount: number): number { + if (digitCount <= 0) { + return 0; + } + + let seen = 0; + + for (let i = 0; i < formatted.length; i++) { + if (/\d/.test(formatted[i])) { + seen++; + + if (seen === digitCount) { + return i + 1; + } + } + } + + return formatted.length; +} + +/** + * The visible 's value IS the formatted "1.000.000" text - there is + * no separate overlay, so the caret can never visually drift from the + * displayed digits. On every change, the caret's digit-position is measured + * first, the value is reformatted, and the caret is restored to the same + * digit-position - all synchronously inside the change handler (never via + * useLayoutEffect or requestAnimationFrame), so the DOM is already + * consistent by the time React commits and fast typing never drops + * keystrokes. + */ +export function RupiahInput({ + name, + id, + defaultValue, + placeholder = '0', + disabled, + ariaInvalid, +}: RupiahInputProps) { + const hiddenRef = useRef(null); + const initialDigits = parseInitialDigits(defaultValue); + const [value, setValue] = useState(formatThousands(initialDigits)); + + function handleChange(e: React.ChangeEvent) { + const input = e.target; + const caret = input.selectionStart ?? input.value.length; + const digitCount = digitsBeforeCaret(input.value, caret); + + const digits = digitsOnly(input.value); + const formatted = formatThousands(digits); + const newCaret = caretForDigitCount(formatted, digitCount); + + input.value = formatted; + input.setSelectionRange(newCaret, newCaret); + + setValue(formatted); + + if (hiddenRef.current) { + hiddenRef.current.value = digits; + } + } + + return ( + + + Rp + + + + + ); +} diff --git a/resources/js/lib/currency.ts b/resources/js/lib/currency.ts new file mode 100644 index 0000000..db60460 --- /dev/null +++ b/resources/js/lib/currency.ts @@ -0,0 +1,12 @@ +export function formatRupiah( + value: string | number | null | undefined, +): string { + const amount = Number(value ?? 0); + + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} diff --git a/resources/js/pages/admin/manage/tuition-invoices/columns.tsx b/resources/js/pages/admin/manage/tuition-invoices/columns.tsx new file mode 100644 index 0000000..006cbd8 --- /dev/null +++ b/resources/js/pages/admin/manage/tuition-invoices/columns.tsx @@ -0,0 +1,131 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { format } 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 { index as paymentsIndex } from '@/routes/admin/manage/tuition-invoices/payments'; +import type { TuitionInvoice } from '@/types/tuition-invoice'; + +export type { TuitionInvoice } from '@/types/tuition-invoice'; + +type CreateColumnsParams = { + handleEdit: (invoice: TuitionInvoice) => void; + handleDeleteClick: (invoice: TuitionInvoice) => void; +}; + +export function createTuitionInvoiceColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'student.student_number', + header: () => Mahasiswa, + cell: ({ row }) => { + const student = row.original.student; + + return ( +
+

+ {student?.user?.profile?.full_name ?? 'N/A'} +

+

+ {student?.student_number} ·{' '} + {student?.department?.name ?? '-'} +

+
+ ); + }, + }, + { + accessorKey: 'academic_term.name', + header: () => Periode, + cell: ({ row }) => row.original.academic_term?.name ?? '-', + }, + { + accessorKey: 'amount_due', + header: () => Tagihan, + cell: ({ row }) => formatRupiah(row.getValue('amount_due')), + }, + { + accessorKey: 'paid_total', + header: () => Status Bayar, + cell: ({ row }) => { + const invoice = row.original; + const paidTotal = Number(invoice.paid_total ?? 0); + const amountDue = Number(invoice.amount_due); + + const variant = + paidTotal >= amountDue && amountDue > 0 + ? 'default' + : paidTotal > 0 + ? 'secondary' + : 'outline'; + const label = + paidTotal >= amountDue && amountDue > 0 + ? 'Lunas' + : paidTotal > 0 + ? 'Sebagian' + : 'Belum Bayar'; + + return ( +
+ + {label} + + + {formatRupiah(paidTotal)} /{' '} + {formatRupiah(amountDue)} + +
+ ); + }, + }, + { + accessorKey: 'due_date', + header: () => Jatuh Tempo, + cell: ({ row }) => { + const dueDate = row.getValue('due_date') as string | null; + + return dueDate ? format(new Date(dueDate), 'd MMM yyyy') : '-'; + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[130px] text-center', + headerClassName: 'w-[130px] text-center', + }, + cell: ({ row }) => { + const invoice = row.original; + + return ( + , + href: paymentsIndex.url(invoice.id), + }, + { + label: 'Edit', + icon: , + onClick: () => handleEdit(invoice), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(invoice), + }, + ]} + /> + ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/manage/tuition-invoices/index.tsx b/resources/js/pages/admin/manage/tuition-invoices/index.tsx new file mode 100644 index 0000000..7a89802 --- /dev/null +++ b/resources/js/pages/admin/manage/tuition-invoices/index.tsx @@ -0,0 +1,393 @@ +import { Head, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import { DatePicker } from '@/components/date-picker'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useServerTable } from '@/hooks/use-server-table'; +import { + index as tuitionInvoiceIndex, + destroy, + store, + update, +} from '@/routes/admin/manage/tuition-invoices'; +import type { + TuitionInvoice, + TuitionInvoiceStudent, +} from '@/types/tuition-invoice'; +import { createTuitionInvoiceColumns } from './columns'; + +type AcademicTermOption = { id: number; name: string; semester: string }; + +type Props = { + invoices: { + data: TuitionInvoice[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + students: TuitionInvoiceStudent[]; + academicTerms: AcademicTermOption[]; + highlight?: number; +}; + +function studentLabel(student: TuitionInvoiceStudent): string { + return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`; +} + +export default function TuitionInvoiceIndex({ + invoices, + students, + academicTerms, + highlight, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const pagination: PaginationState = { + current_page: invoices.current_page, + last_page: invoices.last_page, + per_page: invoices.per_page, + total: invoices.total, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + } = useServerTable({ + route: () => tuitionInvoiceIndex.url(), + pagination, + }); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createTuitionInvoiceColumns({ + handleEdit: (invoice) => setEditing(invoice), + handleDeleteClick: (invoice) => setDeleting(invoice), + }); + + return ( + <> + + +
+ + Menampilkan tagihan dari notifikasi. +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + students={students} + academicTerms={academicTerms} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Tagihan" + description={(invoice) => + `Apakah Anda yakin ingin menghapus tagihan untuk "${invoice.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + students, + academicTerms, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + students: TuitionInvoiceStudent[]; + academicTerms: AcademicTermOption[]; +}) { + const [dueDate, setDueDate] = useState(); + + return ( + { + onOpenChange(false); + setDueDate(undefined); + }} + > + {({ errors }) => ( +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ + + + +
+
+ )} +
+ ); +} + +function EditForm({ + open, + onOpenChange, + editing, + students, + academicTerms, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: TuitionInvoice | null; + students: TuitionInvoiceStudent[]; + academicTerms: AcademicTermOption[]; +}) { + const [dueDate, setDueDate] = useState( + editing?.due_date ? new Date(editing.due_date) : undefined, + ); + + return ( + onOpenChange(false)} + > + {({ errors }) => + editing && ( +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + +
+
+ ) + } +
+ ); +} diff --git a/resources/js/pages/admin/manage/tuition-invoices/payments.tsx b/resources/js/pages/admin/manage/tuition-invoices/payments.tsx new file mode 100644 index 0000000..28b1fa8 --- /dev/null +++ b/resources/js/pages/admin/manage/tuition-invoices/payments.tsx @@ -0,0 +1,415 @@ +import { Head, router } from '@inertiajs/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Paperclip, Pencil, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { DataTable } from '@/components/data-table'; +import { DateTimeField } from '@/components/datetime-field'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FileUploadField } from '@/components/file-upload-field'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { RowActions } from '@/components/row-actions'; +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 { Textarea } from '@/components/ui/textarea'; +import { formatRupiah } from '@/lib/currency'; +import { index as tuitionInvoiceIndex } from '@/routes/admin/manage/tuition-invoices'; +import { + destroy, + store, + update, +} from '@/routes/admin/manage/tuition-invoices/payments'; +import type { TuitionInvoice } from '@/types/tuition-invoice'; +import type { TuitionPayment } from '@/types/tuition-payment'; +import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment'; + +type Props = { + invoice: TuitionInvoice; + payments: TuitionPayment[]; +}; + +export default function TuitionPaymentIndex({ invoice, payments }: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy([invoice.id, deleting.id]), { + onSuccess: () => setDeleting(null), + }); + } + + const paidTotal = payments.reduce( + (sum, payment) => sum + Number(payment.amount_paid), + 0, + ); + + const columns: ColumnDef[] = [ + { + accessorKey: 'paid_at', + header: () => Tanggal Bayar, + cell: ({ row }) => + format(new Date(row.original.paid_at), 'd MMM yyyy, HH:mm'), + }, + { + accessorKey: 'amount_paid', + header: () => Jumlah, + cell: ({ row }) => formatRupiah(row.getValue('amount_paid')), + }, + { + accessorKey: 'payment_method', + header: () => Metode, + cell: ({ row }) => + (row.getValue('payment_method') as string | null) ?? '-', + }, + { + accessorKey: 'status', + header: () => Status, + meta: { + className: 'w-[120px] text-center', + headerClassName: 'w-[120px] text-center', + }, + cell: ({ row }) => { + const status = row.getValue('status') as + keyof typeof PaymentStatusLabels | null; + + return ( +
+ {status ? ( + + {PaymentStatusLabels[status]} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'recorder.full_name', + header: () => Dicatat Oleh, + cell: ({ row }) => row.original.recorder?.full_name ?? '-', + }, + { + accessorKey: 'proof_name', + header: () => Bukti, + cell: ({ row }) => { + const payment = row.original; + + if (!payment.proof_url) { + return -; + } + + return ( + + + {payment.proof_name} + + ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => ( + , + onClick: () => setEditing(row.original), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => setDeleting(row.original), + }, + ]} + /> + ), + }, + ]; + + return ( + <> + + +
+ + Kembali + + } + /> + + + + + {invoice.student?.user?.profile?.full_name ?? 'N/A'} + + + +

+ NIM: {invoice.student?.student_number} ·{' '} + {invoice.student?.department?.name ?? '-'} +

+

Periode: {invoice.academic_term?.name}

+

+ Jumlah Tagihan: {formatRupiah(invoice.amount_due)} +

+

+ Total Terbayar: {formatRupiah(paidTotal)}{' '} + {paidTotal >= Number(invoice.amount_due) ? ( + + Lunas + + ) : paidTotal > 0 ? ( + + Sebagian + + ) : ( + + Belum Bayar + + )} +

+
+
+ +
+

+ Riwayat Pembayaran +

+ +
+ + + + + + { + if (!open) { + setEditing(null); + } + }} + invoiceId={invoice.id} + editing={editing} + /> + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Pembayaran" + description={() => + 'Apakah Anda yakin ingin menghapus data pembayaran ini? Tindakan ini tidak dapat dibatalkan.' + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + invoiceId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + invoiceId: number; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => ( +
+ +
+ )} +
+ ); +} + +function EditForm({ + open, + onOpenChange, + invoiceId, + editing, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + invoiceId: number; + editing: TuitionPayment | null; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => + editing && ( +
+ +
+ ) + } +
+ ); +} + +function PaymentFields({ + errors, + editing, + resetKey, +}: { + errors: Record; + editing?: TuitionPayment; + resetKey?: string; +}) { + return ( + <> +
+ + + +
+ +
+ + + +
+
+ + + + +
+
+ +