siakad-itm/resources/js/components/rupiah-input.tsx
Yoga Pangestu 8e4d0ba530
Some checks failed
tests / ci (pull_request) Has been cancelled
feat: add tuition invoice and payment management
- 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.
2026-08-25 00:34:32 +07:00

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>
);
}