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;
|
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.']);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user