Merge pull request 'feat: enhance Tuition Invoice management with academic term formatting and validation' (#66) from feat/enhance-tuition-invoice into dev

Reviewed-on: #66
This commit is contained in:
pangestu 2026-08-30 16:23:00 +08:00
commit d0793b739a
20 changed files with 231 additions and 98 deletions

View File

@ -24,12 +24,20 @@ public function __construct(
public function index(PaginatedRequest $request): Response 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', [ return Inertia::render('admin/finances/tuition-invoices/index', [
'invoices' => $this->service->paginated( 'invoices' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'), academicTermId: $request->validated('academic_term_id'),
), ),
'students' => $this->studentService->getAllForSelect(status: StudentStatus::Active->value), 'students' => $students,
'academicTerms' => $this->academicTermService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => $request->only(['academic_term_id']), 'filters' => $request->only(['academic_term_id']),
]); ]);

View File

@ -22,11 +22,33 @@ public function rules(): array
if ($this->isMethod('post')) { if ($this->isMethod('post')) {
$rules['student_ids'] = ['required', 'array', 'min:1']; $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 { } 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; 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.',
];
}
} }

View File

@ -4,10 +4,24 @@
use App\Models\TuitionInvoice; use App\Models\TuitionInvoice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class TuitionInvoiceService class TuitionInvoiceService
{ {
/**
* @param array<int> $studentIds
* @return Collection<int, array<int>> 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 public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null): LengthAwarePaginator
{ {
return TuitionInvoice::query() return TuitionInvoice::query()

View File

@ -110,7 +110,7 @@ function ComboboxContent({
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}
anchor={anchor} anchor={anchor}
className="isolate z-50" className="isolate z-50 pointer-events-auto"
> >
<ComboboxPrimitive.Popup <ComboboxPrimitive.Popup
data-slot="combobox-content" data-slot="combobox-content"
@ -126,10 +126,29 @@ function ComboboxContent({
) )
} }
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { function ComboboxList({
className,
ref,
...props
}: ComboboxPrimitive.List.Props & { ref?: React.Ref<HTMLDivElement> }) {
const listRef = React.useRef<HTMLDivElement>(null)
return ( return (
<ComboboxPrimitive.List <ComboboxPrimitive.List
ref={(node) => {
listRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
}}
data-slot="combobox-list" 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( 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", "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 className

View File

@ -5,6 +5,7 @@ import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatRupiah } from '@/lib/currency'; import { formatRupiah } from '@/lib/currency';
import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments'; import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice'; import type { TuitionInvoice } from '@/types/tuition-invoice';
export type { TuitionInvoice } from '@/types/tuition-invoice'; export type { TuitionInvoice } from '@/types/tuition-invoice';
@ -42,7 +43,11 @@ export function createTuitionInvoiceColumns(
{ {
accessorKey: 'academic_term.name', accessorKey: 'academic_term.name',
header: () => <span>Periode</span>, header: () => <span>Periode</span>,
cell: ({ row }) => row.original.academic_term?.name ?? '-', cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
}, },
{ {
accessorKey: 'amount_due', accessorKey: 'amount_due',

View File

@ -40,6 +40,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/finances/tuition-invoices'; } from '@/routes/admin/finances/tuition-invoices';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { import type {
TuitionInvoice, TuitionInvoice,
TuitionInvoiceStudent, TuitionInvoiceStudent,
@ -85,7 +86,7 @@ export default function TuitionInvoiceIndex({
label: 'Periode Akademik', label: 'Periode Akademik',
options: academicTerms.map((term) => ({ options: academicTerms.map((term) => ({
value: String(term.id), value: String(term.id),
label: term.name, label: formatAcademicTermLabel(term),
})), })),
}, },
]; ];
@ -220,13 +221,22 @@ function CreateForm({
students: TuitionInvoiceStudent[]; students: TuitionInvoiceStudent[];
academicTerms: AcademicTermOption[]; academicTerms: AcademicTermOption[];
}) { }) {
const [academicTermId, setAcademicTermId] = useState('');
const [dueDate, setDueDate] = useState<Date | undefined>(); const [dueDate, setDueDate] = useState<Date | undefined>();
const [selectedStudents, setSelectedStudents] = useState< const [selectedStudents, setSelectedStudents] = useState<
TuitionInvoiceStudent[] TuitionInvoiceStudent[]
>([]); >([]);
const studentAnchor = useComboboxAnchor(); const studentAnchor = useComboboxAnchor();
const availableStudents = academicTermId
? students.filter(
(student) =>
!student.invoiced_term_ids?.includes(Number(academicTermId)),
)
: [];
function reset() { function reset() {
setAcademicTermId('');
setDueDate(undefined); setDueDate(undefined);
setSelectedStudents([]); setSelectedStudents([]);
} }
@ -245,6 +255,39 @@ function CreateForm({
> >
{({ errors }) => ( {({ errors }) => (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="academic_term_id"
value={academicTermId}
/>
<Select
value={academicTermId}
onValueChange={(value) => {
setAcademicTermId(value);
setSelectedStudents([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label> <Label>
@ -253,17 +296,20 @@ function CreateForm({
</Label> </Label>
<button <button
type="button" type="button"
className="text-xs text-primary underline underline-offset-4 hover:text-primary/80" disabled={!academicTermId}
className="text-xs text-primary underline underline-offset-4 hover:text-primary/80 disabled:pointer-events-none disabled:opacity-50"
onClick={() => onClick={() =>
setSelectedStudents( setSelectedStudents(
selectedStudents.length === selectedStudents.length ===
students.length availableStudents.length
? [] ? []
: students, : availableStudents,
) )
} }
> >
{selectedStudents.length === students.length {selectedStudents.length ===
availableStudents.length &&
availableStudents.length > 0
? 'Batalkan Semua' ? 'Batalkan Semua'
: 'Pilih Semua'} : 'Pilih Semua'}
</button> </button>
@ -277,8 +323,9 @@ function CreateForm({
/> />
))} ))}
<Combobox <Combobox
items={students} items={availableStudents}
multiple multiple
disabled={!academicTermId}
value={selectedStudents} value={selectedStudents}
onValueChange={setSelectedStudents} onValueChange={setSelectedStudents}
itemToStringLabel={studentLabel} itemToStringLabel={studentLabel}
@ -294,10 +341,13 @@ function CreateForm({
</ComboboxChip> </ComboboxChip>
))} ))}
<ComboboxChipsInput <ComboboxChipsInput
disabled={!academicTermId}
placeholder={ placeholder={
selectedStudents.length === 0 selectedStudents.length > 0
? ''
: academicTermId
? 'Pilih mahasiswa (aktif)' ? 'Pilih mahasiswa (aktif)'
: '' : 'Pilih periode akademik terlebih dahulu'
} }
/> />
</ComboboxChips> </ComboboxChips>
@ -306,42 +356,19 @@ function CreateForm({
Mahasiswa tidak ditemukan. Mahasiswa tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{students.map((student) => ( {(student: TuitionInvoiceStudent) => (
<ComboboxItem <ComboboxItem
key={student.id} key={student.id}
value={student} value={student}
> >
{studentLabel(student)} {studentLabel(student)}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<InputError message={errors.student_ids} /> <InputError message={errors.student_ids} />
</div> </div>
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="academic_term_id" />
<Select name="academic_term_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{term.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="amount_due"> <Label htmlFor="amount_due">
Jumlah Tagihan{' '} Jumlah Tagihan{' '}
@ -410,6 +437,31 @@ function EditForm({
{({ errors }) => {({ errors }) =>
editing && ( editing && (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<Select
name="academic_term_id"
defaultValue={String(editing.academic_term_id)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{formatAcademicTermLabel(term)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Mahasiswa{' '} Mahasiswa{' '}
@ -436,44 +488,19 @@ function EditForm({
Mahasiswa tidak ditemukan. Mahasiswa tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{students.map((option) => ( {(option: TuitionInvoiceStudent) => (
<ComboboxItem <ComboboxItem
key={option.id} key={option.id}
value={option} value={option}
> >
{studentLabel(option)} {studentLabel(option)}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
<InputError message={errors.student_id} /> <InputError message={errors.student_id} />
</div> </div>
<div className="grid gap-2">
<Label>
Periode Akademik{' '}
<span className="text-destructive">*</span>
</Label>
<Select
name="academic_term_id"
defaultValue={String(editing.academic_term_id)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih periode akademik" />
</SelectTrigger>
<SelectContent>
{academicTerms.map((term) => (
<SelectItem
key={term.id}
value={String(term.id)}
>
{term.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_term_id} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-amount_due"> <Label htmlFor="edit-amount_due">
Jumlah Tagihan{' '} Jumlah Tagihan{' '}

View File

@ -32,6 +32,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/finances/tuition-invoices/payments'; } from '@/routes/admin/finances/tuition-invoices/payments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice'; import type { TuitionInvoice } from '@/types/tuition-invoice';
import type { TuitionPayment } from '@/types/tuition-payment'; import type { TuitionPayment } from '@/types/tuition-payment';
import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment'; import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment';
@ -195,7 +196,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
NIM: {invoice.student?.student_number} &middot;{' '} NIM: {invoice.student?.student_number} &middot;{' '}
{invoice.student?.department?.name ?? '-'} {invoice.student?.department?.name ?? '-'}
</p> </p>
<p>Periode: {invoice.academic_term?.name}</p> <p>
Periode:{' '}
{invoice.academic_term
? formatAcademicTermLabel(invoice.academic_term)
: '-'}
</p>
<p> <p>
Jumlah Tagihan: {formatRupiah(invoice.amount_due)} Jumlah Tagihan: {formatRupiah(invoice.amount_due)}
</p> </p>

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2, Users } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments'; import { index as enrollmentsIndex } from '@/routes/admin/manage/course-classes/enrollments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import { ClassMethodLabels } from '@/types/course-class'; import { ClassMethodLabels } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class'; import type { CourseClass } from '@/types/course-class';
@ -37,7 +38,11 @@ export function createCourseClassColumns(
{ {
accessorKey: 'academic_term.name', accessorKey: 'academic_term.name',
header: () => <span>Periode</span>, header: () => <span>Periode</span>,
cell: ({ row }) => row.original.academic_term?.name ?? '-', cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
}, },
{ {
accessorKey: 'method', accessorKey: 'method',

View File

@ -18,6 +18,7 @@ import {
destroy, destroy,
store, store,
} from '@/routes/admin/manage/course-classes/enrollments'; } from '@/routes/admin/manage/course-classes/enrollments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { import type {
ClassEnrollment, ClassEnrollment,
EnrollmentStudent, EnrollmentStudent,
@ -131,7 +132,14 @@ export default function ClassEnrollmentIndex({
{courseClass.lecturer?.user?.profile?.full_name ?? {courseClass.lecturer?.user?.profile?.full_name ??
'-'} '-'}
</p> </p>
<p>Periode: {courseClass.academic_term?.name}</p> <p>
Periode:{' '}
{courseClass.academic_term
? formatAcademicTermLabel(
courseClass.academic_term,
)
: '-'}
</p>
<p> <p>
Metode:{' '} Metode:{' '}
{courseClass.method {courseClass.method

View File

@ -33,6 +33,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/manage/course-classes'; } from '@/routes/admin/manage/course-classes';
import { formatAcademicTermLabel } from '@/types/academic-term';
import { ClassMethodLabels, ClassMethods } from '@/types/course-class'; import { ClassMethodLabels, ClassMethods } from '@/types/course-class';
import type { CourseClass } from '@/types/course-class'; import type { CourseClass } from '@/types/course-class';
import { createCourseClassColumns } from './columns'; import { createCourseClassColumns } from './columns';
@ -82,7 +83,7 @@ export default function CourseClassIndex({
label: 'Periode Akademik', label: 'Periode Akademik',
options: academicTerms.map((term) => ({ options: academicTerms.map((term) => ({
value: String(term.id), value: String(term.id),
label: term.name, label: formatAcademicTermLabel(term),
})), })),
}, },
{ {
@ -292,7 +293,7 @@ function CreateForm({
key={term.id} key={term.id}
value={String(term.id)} value={String(term.id)}
> >
{term.name} {formatAcademicTermLabel(term)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -366,7 +367,7 @@ function CreateForm({
Dosen tidak ditemukan. Dosen tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{availableLecturers.map((lect) => ( {(lect: Lecturer) => (
<ComboboxItem <ComboboxItem
key={lect.id} key={lect.id}
value={lect} value={lect}
@ -375,7 +376,7 @@ function CreateForm({
'N/A'}{' '} 'N/A'}{' '}
- {lect.lecturer_number} - {lect.lecturer_number}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
@ -517,7 +518,7 @@ function EditForm({
Dosen tidak ditemukan. Dosen tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{availableLecturers.map((lect) => ( {(lect: Lecturer) => (
<ComboboxItem <ComboboxItem
key={lect.id} key={lect.id}
value={lect} value={lect}
@ -526,7 +527,7 @@ function EditForm({
?.full_name ?? 'N/A'}{' '} ?.full_name ?? 'N/A'}{' '}
- {lect.lecturer_number} - {lect.lecturer_number}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
@ -550,7 +551,7 @@ function EditForm({
key={term.id} key={term.id}
value={String(term.id)} value={String(term.id)}
> >
{term.name} {formatAcademicTermLabel(term)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -3,6 +3,7 @@ import { format } from 'date-fns';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { import type {
CourseRegistration, CourseRegistration,
RegistrationStatus, RegistrationStatus,
@ -62,7 +63,11 @@ export function createCourseRegistrationColumns(
{ {
accessorKey: 'academic_term.name', accessorKey: 'academic_term.name',
header: () => <span>Periode</span>, header: () => <span>Periode</span>,
cell: ({ row }) => row.original.academic_term?.name ?? '-', cell: ({ row }) => {
const term = row.original.academic_term;
return term ? formatAcademicTermLabel(term) : '-';
},
}, },
{ {
accessorKey: 'status', accessorKey: 'status',

View File

@ -25,6 +25,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/manage/course-registrations'; } from '@/routes/admin/manage/course-registrations';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { import type {
CourseRegistration, CourseRegistration,
CourseRegistrationCourseClass, CourseRegistrationCourseClass,
@ -103,7 +104,7 @@ export default function CourseRegistrationIndex({
label: 'Periode Akademik', label: 'Periode Akademik',
options: academicTerms.map((term) => ({ options: academicTerms.map((term) => ({
value: String(term.id), value: String(term.id),
label: term.name, label: formatAcademicTermLabel(term),
})), })),
}, },
]; ];
@ -320,7 +321,7 @@ function RegistrationFields({
<SelectContent> <SelectContent>
{academicTerms.map((term) => ( {academicTerms.map((term) => (
<SelectItem key={term.id} value={String(term.id)}> <SelectItem key={term.id} value={String(term.id)}>
{term.name} {formatAcademicTermLabel(term)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -99,6 +99,7 @@ export default function AdministratorCreate({ roles }: Props) {
value={role} value={role}
/> />
<Combobox <Combobox
items={roles.map((r) => r.name)}
value={role} value={role}
onValueChange={(v) => onValueChange={(v) =>
setRole(v ?? '') setRole(v ?? '')
@ -110,14 +111,14 @@ export default function AdministratorCreate({ roles }: Props) {
/> />
<ComboboxContent> <ComboboxContent>
<ComboboxList> <ComboboxList>
{roles.map((r) => ( {(name: string) => (
<ComboboxItem <ComboboxItem
key={r.id} key={name}
value={r.name} value={name}
> >
{r.name} {name}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -119,6 +119,7 @@ export default function AdministratorEdit({ user, roles }: Props) {
value={role} value={role}
/> />
<Combobox <Combobox
items={roles.map((r) => r.name)}
value={role} value={role}
onValueChange={(v) => onValueChange={(v) =>
setRole(v ?? '') setRole(v ?? '')
@ -130,14 +131,14 @@ export default function AdministratorEdit({ user, roles }: Props) {
/> />
<ComboboxContent> <ComboboxContent>
<ComboboxList> <ComboboxList>
{roles.map((r) => ( {(name: string) => (
<ComboboxItem <ComboboxItem
key={r.id} key={name}
value={r.name} value={name}
> >
{r.name} {name}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -324,7 +324,7 @@ export default function LecturerCreate({ departments }: Props) {
Jurusan tidak ditemukan. Jurusan tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{departments.map((dept) => ( {(dept: Department) => (
<ComboboxItem <ComboboxItem
key={dept.id} key={dept.id}
value={dept} value={dept}
@ -333,7 +333,7 @@ export default function LecturerCreate({ departments }: Props) {
dept, dept,
)} )}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -363,7 +363,7 @@ export default function LecturerEdit({ user, departments }: Props) {
Jurusan tidak ditemukan. Jurusan tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{departments.map((dept) => ( {(dept: Department) => (
<ComboboxItem <ComboboxItem
key={dept.id} key={dept.id}
value={dept} value={dept}
@ -372,7 +372,7 @@ export default function LecturerEdit({ user, departments }: Props) {
dept, dept,
)} )}
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -350,7 +350,7 @@ export default function StudentCreate({ departments, lecturers }: Props) {
Dosen tidak ditemukan. Dosen tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{lecturers.map((lect) => ( {(lect: Lecturer) => (
<ComboboxItem <ComboboxItem
key={lect.id} key={lect.id}
value={lect} value={lect}
@ -363,7 +363,7 @@ export default function StudentCreate({ departments, lecturers }: Props) {
lect.lecturer_number lect.lecturer_number
} }
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -400,7 +400,7 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
Dosen tidak ditemukan. Dosen tidak ditemukan.
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{lecturers.map((lect) => ( {(lect: Lecturer) => (
<ComboboxItem <ComboboxItem
key={lect.id} key={lect.id}
value={lect} value={lect}
@ -413,7 +413,7 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
lect.lecturer_number lect.lecturer_number
} }
</ComboboxItem> </ComboboxItem>
))} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>

View File

@ -20,3 +20,12 @@ export type AcademicTerm = {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
export function formatAcademicTermLabel(term: {
name: string;
semester: string;
}): string {
const label = SemesterLabels[term.semester as Semester];
return label ? `${term.name} (${label})` : term.name;
}

View File

@ -3,6 +3,7 @@ export type TuitionInvoiceStudent = {
student_number: string; student_number: string;
department: { id: number; name: string } | null; department: { id: number; name: string } | null;
user: { profile: { full_name: string } | null } | null; user: { profile: { full_name: string } | null } | null;
invoiced_term_ids?: number[];
}; };
export type TuitionInvoice = { export type TuitionInvoice = {