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:
commit
d0793b739a
@ -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']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
? 'Pilih mahasiswa (aktif)'
|
? ''
|
||||||
: ''
|
: academicTermId
|
||||||
|
? '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{' '}
|
||||||
|
|||||||
@ -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} ·{' '}
|
NIM: {invoice.student?.student_number} ·{' '}
|
||||||
{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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user