feat: enhance Tuition Invoice management with multi-student selection and validation #65

Merged
pangestu merged 1 commits from feat/enhance-tuition-with-multiple-student into dev 2026-08-30 15:26:31 +08:00
5 changed files with 159 additions and 49 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Finances;
use App\Enums\StudentStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finances\TuitionInvoiceRequest;
use App\Http\Requests\PaginatedRequest;
@ -28,7 +29,7 @@ public function index(PaginatedRequest $request): Response
...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'),
),
'students' => $this->studentService->getAllForSelect(),
'students' => $this->studentService->getAllForSelect(status: StudentStatus::Active->value),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => $request->only(['academic_term_id']),
]);
@ -36,7 +37,7 @@ public function index(PaginatedRequest $request): Response
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.']);

View File

@ -14,11 +14,19 @@ public function authorize(): bool
public function rules(): array
{
return [
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
$rules = [
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'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 Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class TuitionInvoiceService
{
@ -21,14 +22,18 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->paginate($perPage);
}
public function create(array $data): TuitionInvoice
public function createMany(array $studentIds, array $data): void
{
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,
]);
DB::transaction(function () use ($studentIds, $data) {
foreach ($studentIds as $studentId) {
TuitionInvoice::create([
'student_id' => $studentId,
'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

View File

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

View File

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