Some checks failed
tests / ci (pull_request) Has been cancelled
- Implemented StudentService to retrieve all students for selection. - Created migration for tuition_invoices and tuition_payments tables. - Added seeders for TuitionInvoice and TuitionPayment with sample data. - Updated DatabaseSeeder to include new seeders. - Developed UI components for managing tuition invoices and payments, including forms and data tables. - Introduced RupiahInput component for formatted currency input. - Added routes for tuition invoices and payments management. - Defined TypeScript types for tuition invoices and payments.
133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
InputGroupText,
|
|
} from '@/components/ui/input-group';
|
|
|
|
type RupiahInputProps = {
|
|
name: string;
|
|
id?: string;
|
|
defaultValue?: string | number | null;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
ariaInvalid?: boolean;
|
|
};
|
|
|
|
// Strips everything but digits from a display value (e.g. "1.500.000" -> "1500000").
|
|
function digitsOnly(value: string): string {
|
|
return value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
|
|
}
|
|
|
|
// Parses the initial value coming from the backend, which may be a decimal
|
|
// string like "3000000.00" - only the integer part before the decimal point
|
|
// is kept (unlike digitsOnly, which would wrongly treat "." as a thousands
|
|
// separator here).
|
|
function parseInitialDigits(value: string | number | null | undefined): string {
|
|
return digitsOnly(String(value ?? '').split('.')[0]);
|
|
}
|
|
|
|
function formatThousands(digits: string): string {
|
|
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
}
|
|
|
|
// Counts digit characters (ignoring separators) before `caret` in `value`.
|
|
function digitsBeforeCaret(value: string, caret: number): number {
|
|
let count = 0;
|
|
|
|
for (let i = 0; i < caret && i < value.length; i++) {
|
|
if (/\d/.test(value[i])) {
|
|
count++;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
// Finds the position in `formatted` right after the `digitCount`-th digit.
|
|
function caretForDigitCount(formatted: string, digitCount: number): number {
|
|
if (digitCount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
let seen = 0;
|
|
|
|
for (let i = 0; i < formatted.length; i++) {
|
|
if (/\d/.test(formatted[i])) {
|
|
seen++;
|
|
|
|
if (seen === digitCount) {
|
|
return i + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return formatted.length;
|
|
}
|
|
|
|
/**
|
|
* The visible <input>'s value IS the formatted "1.000.000" text - there is
|
|
* no separate overlay, so the caret can never visually drift from the
|
|
* displayed digits. On every change, the caret's digit-position is measured
|
|
* first, the value is reformatted, and the caret is restored to the same
|
|
* digit-position - all synchronously inside the change handler (never via
|
|
* useLayoutEffect or requestAnimationFrame), so the DOM is already
|
|
* consistent by the time React commits and fast typing never drops
|
|
* keystrokes.
|
|
*/
|
|
export function RupiahInput({
|
|
name,
|
|
id,
|
|
defaultValue,
|
|
placeholder = '0',
|
|
disabled,
|
|
ariaInvalid,
|
|
}: RupiahInputProps) {
|
|
const hiddenRef = useRef<HTMLInputElement>(null);
|
|
const initialDigits = parseInitialDigits(defaultValue);
|
|
const [value, setValue] = useState(formatThousands(initialDigits));
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const input = e.target;
|
|
const caret = input.selectionStart ?? input.value.length;
|
|
const digitCount = digitsBeforeCaret(input.value, caret);
|
|
|
|
const digits = digitsOnly(input.value);
|
|
const formatted = formatThousands(digits);
|
|
const newCaret = caretForDigitCount(formatted, digitCount);
|
|
|
|
input.value = formatted;
|
|
input.setSelectionRange(newCaret, newCaret);
|
|
|
|
setValue(formatted);
|
|
|
|
if (hiddenRef.current) {
|
|
hiddenRef.current.value = digits;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<InputGroup>
|
|
<InputGroupAddon>
|
|
<InputGroupText>Rp</InputGroupText>
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
id={id}
|
|
inputMode="numeric"
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={handleChange}
|
|
disabled={disabled}
|
|
aria-invalid={ariaInvalid}
|
|
/>
|
|
<input
|
|
ref={hiddenRef}
|
|
type="hidden"
|
|
name={name}
|
|
defaultValue={initialDigits}
|
|
/>
|
|
</InputGroup>
|
|
);
|
|
}
|