feat: enhance Tuition Invoice management with multi-student selection and validation
Some checks failed
tests / ci (pull_request) Has been cancelled
Some checks failed
tests / ci (pull_request) Has been cancelled
This commit is contained in:
parent
783a0f6dda
commit
1213d828b5
@ -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.']);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user