diff --git a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php index a70cd2b..37ba9a1 100644 --- a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php +++ b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php @@ -24,12 +24,20 @@ public function __construct( public function index(PaginatedRequest $request): Response { + $students = $this->studentService->getAllForSelect(status: StudentStatus::Active->value); + + $invoicedTermIdsByStudent = $this->service->getInvoicedTermIdsByStudent($students->pluck('id')->all()); + + $students->each(function ($student) use ($invoicedTermIdsByStudent) { + $student->invoiced_term_ids = $invoicedTermIdsByStudent->get($student->id, []); + }); + return Inertia::render('admin/finances/tuition-invoices/index', [ 'invoices' => $this->service->paginated( ...$request->validatedWithDefaults(), academicTermId: $request->validated('academic_term_id'), ), - 'students' => $this->studentService->getAllForSelect(status: StudentStatus::Active->value), + 'students' => $students, 'academicTerms' => $this->academicTermService->getAllForSelect(), 'filters' => $request->only(['academic_term_id']), ]); diff --git a/app/Http/Requests/Admin/Finances/TuitionInvoiceRequest.php b/app/Http/Requests/Admin/Finances/TuitionInvoiceRequest.php index e034f33..e296039 100644 --- a/app/Http/Requests/Admin/Finances/TuitionInvoiceRequest.php +++ b/app/Http/Requests/Admin/Finances/TuitionInvoiceRequest.php @@ -22,11 +22,33 @@ public function rules(): array if ($this->isMethod('post')) { $rules['student_ids'] = ['required', 'array', 'min:1']; - $rules['student_ids.*'] = ['integer', Rule::exists('students', 'id')]; + $rules['student_ids.*'] = [ + 'integer', + Rule::exists('students', 'id'), + Rule::unique('tuition_invoices', 'student_id') + ->where('academic_term_id', $this->input('academic_term_id')), + ]; } else { - $rules['student_id'] = ['required', 'integer', Rule::exists('students', 'id')]; + $invoice = $this->route('tuition_invoice'); + + $rules['student_id'] = [ + 'required', + 'integer', + Rule::exists('students', 'id'), + Rule::unique('tuition_invoices', 'student_id') + ->where('academic_term_id', $this->input('academic_term_id')) + ->ignore($invoice?->id), + ]; } return $rules; } + + public function messages(): array + { + return [ + 'student_ids.*.unique' => 'Salah satu mahasiswa yang dipilih sudah memiliki tagihan untuk periode akademik tersebut.', + 'student_id.unique' => 'Mahasiswa ini sudah memiliki tagihan untuk periode akademik tersebut.', + ]; + } } diff --git a/app/Services/Admin/Finances/TuitionInvoiceService.php b/app/Services/Admin/Finances/TuitionInvoiceService.php index 6965c2f..aa32bd4 100644 --- a/app/Services/Admin/Finances/TuitionInvoiceService.php +++ b/app/Services/Admin/Finances/TuitionInvoiceService.php @@ -4,10 +4,24 @@ use App\Models\TuitionInvoice; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class TuitionInvoiceService { + /** + * @param array $studentIds + * @return Collection> daftar academic_term_id per student_id yang sudah punya tagihan + */ + public function getInvoicedTermIdsByStudent(array $studentIds): Collection + { + return TuitionInvoice::query() + ->whereIn('student_id', $studentIds) + ->get(['student_id', 'academic_term_id']) + ->groupBy('student_id') + ->map(fn ($rows) => $rows->pluck('academic_term_id')->values()->all()); + } + public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null): LengthAwarePaginator { return TuitionInvoice::query() diff --git a/resources/js/components/ui/combobox.tsx b/resources/js/components/ui/combobox.tsx index 2415bd8..4b2dfc6 100644 --- a/resources/js/components/ui/combobox.tsx +++ b/resources/js/components/ui/combobox.tsx @@ -110,7 +110,7 @@ function ComboboxContent({ align={align} alignOffset={alignOffset} anchor={anchor} - className="isolate z-50" + className="isolate z-50 pointer-events-auto" > }) { + const listRef = React.useRef(null) + return ( { + listRef.current = node + if (typeof ref === "function") ref(node) + else if (ref) ref.current = node + }} data-slot="combobox-list" + onWheel={(event) => { + // Radix Dialog's scroll lock (react-remove-scroll) always cancels + // wheel scroll on elements outside its own lock/shards, even if + // they're independently scrollable. Since this popup is portaled by + // Base UI (a different library not registered with Radix), scroll + // it manually instead of relying on the browser's default action. + listRef.current?.scrollBy({ top: event.deltaY }) + }} className={cn( "max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0", className diff --git a/resources/js/pages/admin/finances/tuition-invoices/columns.tsx b/resources/js/pages/admin/finances/tuition-invoices/columns.tsx index b22ad71..31b82c2 100644 --- a/resources/js/pages/admin/finances/tuition-invoices/columns.tsx +++ b/resources/js/pages/admin/finances/tuition-invoices/columns.tsx @@ -5,6 +5,7 @@ import { RowActions } from '@/components/row-actions'; import { Badge } from '@/components/ui/badge'; import { formatRupiah } from '@/lib/currency'; import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments'; +import { formatAcademicTermLabel } from '@/types/academic-term'; import type { TuitionInvoice } from '@/types/tuition-invoice'; export type { TuitionInvoice } from '@/types/tuition-invoice'; @@ -42,7 +43,11 @@ export function createTuitionInvoiceColumns( { accessorKey: 'academic_term.name', header: () => Periode, - cell: ({ row }) => row.original.academic_term?.name ?? '-', + cell: ({ row }) => { + const term = row.original.academic_term; + + return term ? formatAcademicTermLabel(term) : '-'; + }, }, { accessorKey: 'amount_due', diff --git a/resources/js/pages/admin/finances/tuition-invoices/index.tsx b/resources/js/pages/admin/finances/tuition-invoices/index.tsx index ee7dc73..044d726 100644 --- a/resources/js/pages/admin/finances/tuition-invoices/index.tsx +++ b/resources/js/pages/admin/finances/tuition-invoices/index.tsx @@ -40,6 +40,7 @@ import { store, update, } from '@/routes/admin/finances/tuition-invoices'; +import { formatAcademicTermLabel } from '@/types/academic-term'; import type { TuitionInvoice, TuitionInvoiceStudent, @@ -85,7 +86,7 @@ export default function TuitionInvoiceIndex({ label: 'Periode Akademik', options: academicTerms.map((term) => ({ value: String(term.id), - label: term.name, + label: formatAcademicTermLabel(term), })), }, ]; @@ -220,13 +221,22 @@ function CreateForm({ students: TuitionInvoiceStudent[]; academicTerms: AcademicTermOption[]; }) { + const [academicTermId, setAcademicTermId] = useState(''); const [dueDate, setDueDate] = useState(); const [selectedStudents, setSelectedStudents] = useState< TuitionInvoiceStudent[] >([]); const studentAnchor = useComboboxAnchor(); + const availableStudents = academicTermId + ? students.filter( + (student) => + !student.invoiced_term_ids?.includes(Number(academicTermId)), + ) + : []; + function reset() { + setAcademicTermId(''); setDueDate(undefined); setSelectedStudents([]); } @@ -245,6 +255,39 @@ function CreateForm({ > {({ errors }) => (
+
+ + + + +
@@ -277,8 +323,9 @@ function CreateForm({ /> ))} ))} 0 + ? '' + : academicTermId + ? 'Pilih mahasiswa (aktif)' + : 'Pilih periode akademik terlebih dahulu' } /> @@ -306,42 +356,19 @@ function CreateForm({ Mahasiswa tidak ditemukan. - {students.map((student) => ( + {(student: TuitionInvoiceStudent) => ( {studentLabel(student)} - ))} + )}
-
- - - - -