feat: enhance Tuition Invoice management with multi-student selection and validation
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-30 14:26:10 +07:00
parent 783a0f6dda
commit 1213d828b5
5 changed files with 159 additions and 49 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Finances; namespace App\Http\Controllers\Admin\Finances;
use App\Enums\StudentStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finances\TuitionInvoiceRequest; use App\Http\Requests\Admin\Finances\TuitionInvoiceRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
@ -28,7 +29,7 @@ public function index(PaginatedRequest $request): Response
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'), academicTermId: $request->validated('academic_term_id'),
), ),
'students' => $this->studentService->getAllForSelect(), 'students' => $this->studentService->getAllForSelect(status: StudentStatus::Active->value),
'academicTerms' => $this->academicTermService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => $request->only(['academic_term_id']), 'filters' => $request->only(['academic_term_id']),
]); ]);
@ -36,7 +37,7 @@ public function index(PaginatedRequest $request): Response
public function store(TuitionInvoiceRequest $request): RedirectResponse public function store(TuitionInvoiceRequest $request): RedirectResponse
{ {
$this->service->create($request->validated()); $this->service->createMany($request->validated('student_ids'), $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tagihan berhasil ditambahkan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Tagihan berhasil ditambahkan.']);

View File

@ -14,11 +14,19 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ $rules = [
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'amount_due' => ['required', 'numeric', 'min:0'], 'amount_due' => ['required', 'numeric', 'min:0'],
'due_date' => ['nullable', 'date'], 'due_date' => ['required', 'date'],
]; ];
if ($this->isMethod('post')) {
$rules['student_ids'] = ['required', 'array', 'min:1'];
$rules['student_ids.*'] = ['integer', Rule::exists('students', 'id')];
} else {
$rules['student_id'] = ['required', 'integer', Rule::exists('students', 'id')];
}
return $rules;
} }
} }

View File

@ -4,6 +4,7 @@
use App\Models\TuitionInvoice; use App\Models\TuitionInvoice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class TuitionInvoiceService class TuitionInvoiceService
{ {
@ -21,14 +22,18 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): TuitionInvoice public function createMany(array $studentIds, array $data): void
{ {
return TuitionInvoice::create([ DB::transaction(function () use ($studentIds, $data) {
'student_id' => $data['student_id'], foreach ($studentIds as $studentId) {
'academic_term_id' => $data['academic_term_id'], TuitionInvoice::create([
'amount_due' => $data['amount_due'], 'student_id' => $studentId,
'due_date' => $data['due_date'] ?? null, 'academic_term_id' => $data['academic_term_id'],
]); 'amount_due' => $data['amount_due'],
'due_date' => $data['due_date'],
]);
}
});
} }
public function update(TuitionInvoice $invoice, array $data): TuitionInvoice public function update(TuitionInvoice $invoice, array $data): TuitionInvoice

View File

@ -12,7 +12,7 @@
class StudentService class StudentService
{ {
public function getAllForSelect(): Collection public function getAllForSelect(?string $status = null): Collection
{ {
return Student::select(['id', 'user_id', 'student_number', 'department_id']) return Student::select(['id', 'user_id', 'student_number', 'department_id'])
->with([ ->with([
@ -20,6 +20,7 @@ public function getAllForSelect(): Collection
'user.profile:id,user_id,full_name', 'user.profile:id,user_id,full_name',
'department:id,name', 'department:id,name',
]) ])
->when($status, fn ($q) => $q->where('status', $status))
->get(); ->get();
} }

View File

@ -13,6 +13,18 @@ import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, Select,
@ -209,6 +221,15 @@ function CreateForm({
academicTerms: AcademicTermOption[]; academicTerms: AcademicTermOption[];
}) { }) {
const [dueDate, setDueDate] = useState<Date | undefined>(); const [dueDate, setDueDate] = useState<Date | undefined>();
const [selectedStudents, setSelectedStudents] = useState<
TuitionInvoiceStudent[]
>([]);
const studentAnchor = useComboboxAnchor();
function reset() {
setDueDate(undefined);
setSelectedStudents([]);
}
return ( return (
<FormDialog <FormDialog
@ -219,33 +240,84 @@ function CreateForm({
resetOnSuccess resetOnSuccess
onSuccess={() => { onSuccess={() => {
onOpenChange(false); onOpenChange(false);
setDueDate(undefined); reset();
}} }}
> >
{({ errors }) => ( {({ errors }) => (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <div className="flex items-center justify-between">
Mahasiswa{' '} <Label>
<span className="text-destructive">*</span> Mahasiswa{' '}
</Label> <span className="text-destructive">*</span>
<input type="hidden" name="student_id" /> </Label>
<Select name="student_id"> <button
<SelectTrigger className="w-full"> type="button"
<SelectValue placeholder="Pilih mahasiswa" /> className="text-xs text-primary underline underline-offset-4 hover:text-primary/80"
</SelectTrigger> onClick={() =>
<SelectContent> setSelectedStudents(
{students.map((student) => ( selectedStudents.length ===
<SelectItem students.length
? []
: students,
)
}
>
{selectedStudents.length === students.length
? 'Batalkan Semua'
: 'Pilih Semua'}
</button>
</div>
{selectedStudents.map((student) => (
<input
key={student.id}
type="hidden"
name="student_ids[]"
value={student.id}
/>
))}
<Combobox
items={students}
multiple
value={selectedStudents}
onValueChange={setSelectedStudents}
itemToStringLabel={studentLabel}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxChips ref={studentAnchor}>
{selectedStudents.map((student) => (
<ComboboxChip
key={student.id} key={student.id}
value={String(student.id)} aria-label={studentLabel(student)}
> >
{studentLabel(student)} {studentLabel(student)}
</SelectItem> </ComboboxChip>
))} ))}
</SelectContent> <ComboboxChipsInput
</Select> placeholder={
<InputError message={errors.student_id} /> selectedStudents.length === 0
? 'Pilih mahasiswa (aktif)'
: ''
}
/>
</ComboboxChips>
<ComboboxContent anchor={studentAnchor}>
<ComboboxEmpty>
Mahasiswa tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{students.map((student) => (
<ComboboxItem
key={student.id}
value={student}
>
{studentLabel(student)}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_ids} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
@ -284,7 +356,10 @@ function CreateForm({
<InputError message={errors.amount_due} /> <InputError message={errors.amount_due} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jatuh Tempo</Label> <Label>
Jatuh Tempo{' '}
<span className="text-destructive">*</span>
</Label>
<input <input
type="hidden" type="hidden"
name="due_date" name="due_date"
@ -319,6 +394,9 @@ function EditForm({
const [dueDate, setDueDate] = useState<Date | undefined>( const [dueDate, setDueDate] = useState<Date | undefined>(
editing?.due_date ? new Date(editing.due_date) : undefined, editing?.due_date ? new Date(editing.due_date) : undefined,
); );
const [student, setStudent] = useState<TuitionInvoiceStudent | null>(
students.find((s) => s.id === editing?.student_id) ?? null,
);
return ( return (
<FormDialog <FormDialog
@ -337,24 +415,38 @@ function EditForm({
Mahasiswa{' '} Mahasiswa{' '}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Select <input
type="hidden"
name="student_id" name="student_id"
defaultValue={String(editing.student_id)} value={student?.id ?? ''}
/>
<Combobox
items={students}
value={student}
onValueChange={setStudent}
itemToStringLabel={studentLabel}
isItemEqualToValue={(a, b) => a.id === b.id}
> >
<SelectTrigger className="w-full"> <ComboboxInput
<SelectValue placeholder="Pilih mahasiswa" /> placeholder="Pilih mahasiswa (aktif)"
</SelectTrigger> className="w-full"
<SelectContent> />
{students.map((student) => ( <ComboboxContent>
<SelectItem <ComboboxEmpty>
key={student.id} Mahasiswa tidak ditemukan.
value={String(student.id)} </ComboboxEmpty>
> <ComboboxList>
{studentLabel(student)} {students.map((option) => (
</SelectItem> <ComboboxItem
))} key={option.id}
</SelectContent> value={option}
</Select> >
{studentLabel(option)}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_id} /> <InputError message={errors.student_id} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -396,7 +488,10 @@ function EditForm({
<InputError message={errors.amount_due} /> <InputError message={errors.amount_due} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jatuh Tempo</Label> <Label>
Jatuh Tempo{' '}
<span className="text-destructive">*</span>
</Label>
<input <input
type="hidden" type="hidden"
name="due_date" name="due_date"