feat(WEB): 입력 컴포넌트 공통화 및 UI 개선

- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가
- MobileCard 컴포넌트 통합 (ListMobileCard 제거)
- IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈)
- IntegratedDetailTemplate 타이틀 중복 수정
- 문서 시스템 컴포넌트 추가
- 헤더 벨 아이콘 포커스 스타일 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-21 20:56:17 +09:00
parent cfa72fe19b
commit 835c06ce94
190 changed files with 8575 additions and 2354 deletions

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NumberInput } from '@/components/ui/number-input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -227,13 +228,13 @@ export function BOMManagementSection({
<div className="grid grid-cols-3 gap-4">
<div>
<Label> *</Label>
<Input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
<NumberInput
value={parseFloat(quantity) || 0}
onChange={(value) => setQuantity(String(value ?? 0))}
placeholder="1"
min="0"
step="0.01"
min={0}
step={0.01}
allowDecimal
className={isSubmitted && isQuantityInvalid ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isQuantityInvalid && (

View File

@@ -6,7 +6,7 @@
'use client';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import type { DynamicFieldRendererProps } from '../types';
export function NumberField({
@@ -30,20 +30,19 @@ export function NumberField({
{unit && ` (${unit})`}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<Input
<NumberInput
id={fieldKey}
type="number"
placeholder={field.placeholder || `${field.field_name}을(를) 입력하세요`}
value={stringValue}
onChange={(e) => {
const newValue = e.target.value;
// 빈 문자열이면 null, 아니면 숫자로 변환
onChange(newValue === '' ? null : Number(newValue));
value={value !== null && value !== undefined ? Number(value) : undefined}
onChange={(newValue) => {
// undefined이면 null, 아니면 숫자로 변환
onChange(newValue ?? null);
}}
disabled={disabled}
step={step}
min={field.validation_rules?.min}
max={field.validation_rules?.max}
allowDecimal={step !== 1}
className={error ? 'border-red-500' : ''}
/>
{error && (

View File

@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { NumberInput } from '@/components/ui/number-input';
import {
Table,
TableBody,
@@ -436,18 +437,18 @@ function BOMLineRow({
{line.material || '-'}
</TableCell>
<TableCell>
<Input
type="number"
<NumberInput
value={line.quantity}
onChange={(e) => {
onChange={(value) => {
setBomLines(
bomLines.map((l) =>
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
l.id === line.id ? { ...l, quantity: value ?? 0 } : l
)
);
}}
min="0"
step="0.01"
min={0}
step={0.01}
allowDecimal
className="w-full"
/>
</TableCell>

View File

@@ -8,6 +8,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { NumberInput } from '@/components/ui/number-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
Table,
TableBody,
@@ -265,18 +267,18 @@ export default function BOMSection({
/>
</TableCell>
<TableCell>
<Input
type="number"
<NumberInput
value={line.quantity}
onChange={(e) => {
onChange={(value) => {
setBomLines(
bomLines.map((l) =>
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
l.id === line.id ? { ...l, quantity: value ?? 0 } : l
)
);
}}
min="0"
step="0.01"
min={0}
step={0.01}
allowDecimal
className="w-full"
/>
</TableCell>
@@ -284,18 +286,16 @@ export default function BOMSection({
<Badge variant="secondary">{line.unit}</Badge>
</TableCell>
<TableCell>
<Input
type="number"
<CurrencyInput
value={line.unitPrice || 0}
onChange={(e) => {
onChange={(value) => {
setBomLines(
bomLines.map((l) =>
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
l.id === line.id ? { ...l, unitPrice: value ?? 0 } : l
)
);
}}
min="0"
className="w-full text-right"
className="w-full"
/>
</TableCell>
<TableCell>

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NumberInput } from '@/components/ui/number-input';
import { FileImage, Plus, Trash2, X, Download, Loader2 } from 'lucide-react';
import type { BendingDetail } from '@/types/item';
import type { UseFormSetValue } from 'react-hook-form';
@@ -357,36 +358,34 @@ export default function BendingDiagramSection({
<tr key={detail.id} className={detail.shaded ? 'bg-gray-100' : ''}>
<td className="px-3 py-2 text-center border-b">{detail.no}</td>
<td className="px-3 py-2 border-b">
<Input
type="number"
<NumberInput
value={detail.input}
onChange={(e) => {
onChange={(value) => {
const newDetails = [...bendingDetails];
const value = e.target.value === '' ? 0 : parseFloat(e.target.value);
newDetails[index] = {
...detail,
input: isNaN(value) ? 0 : value,
input: value ?? 0,
};
setBendingDetails(newDetails);
updateWidthSum(newDetails);
}}
allowDecimal
className="h-8 text-center"
/>
</td>
<td className="px-3 py-2 border-b">
<Input
type="number"
<NumberInput
value={detail.elongation}
onChange={(e) => {
onChange={(value) => {
const newDetails = [...bendingDetails];
const value = e.target.value === '' ? -1 : parseFloat(e.target.value);
newDetails[index] = {
...detail,
elongation: isNaN(value) ? -1 : value,
elongation: value ?? -1,
};
setBendingDetails(newDetails);
updateWidthSum(newDetails);
}}
allowDecimal
className="h-8 text-center"
/>
</td>
@@ -409,17 +408,17 @@ export default function BendingDiagramSection({
/>
</td>
<td className="px-3 py-2 border-b">
<Input
type="number"
value={detail.aAngle || ''}
onChange={(e) => {
<NumberInput
value={detail.aAngle ?? undefined}
onChange={(value) => {
const newDetails = [...bendingDetails];
newDetails[index] = {
...detail,
aAngle: parseFloat(e.target.value) || undefined,
aAngle: value ?? undefined,
};
setBendingDetails(newDetails);
}}
allowDecimal
className="h-8 text-center"
placeholder="각도"
/>

View File

@@ -6,6 +6,7 @@
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
import {
Select,
SelectContent,
@@ -206,13 +207,13 @@ export default function AssemblyPartForm({
<Label>
() <span className="text-red-500">*</span>
</Label>
<Input
type="number"
<NumberInput
placeholder="예: 50"
value={sideSpecWidth}
onChange={(e) => {
setSideSpecWidth(e.target.value);
setValue('sideSpecWidth', e.target.value);
value={parseFloat(sideSpecWidth) || 0}
onChange={(value) => {
const strValue = String(value ?? 0);
setSideSpecWidth(strValue);
setValue('sideSpecWidth', strValue);
}}
/>
</div>
@@ -220,13 +221,13 @@ export default function AssemblyPartForm({
<Label>
() <span className="text-red-500">*</span>
</Label>
<Input
type="number"
<NumberInput
placeholder="예: 100"
value={sideSpecHeight}
onChange={(e) => {
setSideSpecHeight(e.target.value);
setValue('sideSpecHeight', e.target.value);
value={parseFloat(sideSpecHeight) || 0}
onChange={(value) => {
const strValue = String(value ?? 0);
setSideSpecHeight(strValue);
setValue('sideSpecHeight', strValue);
}}
/>
</div>

View File

@@ -5,6 +5,7 @@
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NumberInput } from '@/components/ui/number-input';
import {
Select,
SelectContent,
@@ -156,15 +157,16 @@ export default function BendingPartForm({
<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={widthSum}
onChange={(e) => {
setWidthSum(e.target.value);
setValue('length', e.target.value);
<NumberInput
value={parseFloat(widthSum) || 0}
onChange={(value) => {
const strValue = String(value ?? 0);
setWidthSum(strValue);
setValue('length', strValue);
}}
placeholder="전개도 상세를 입력해주세요"
readOnly={bendingDetailsLength > 0}
disabled={bendingDetailsLength > 0}
allowDecimal
className={`${bendingDetailsLength > 0 ? "bg-blue-50 font-medium" : ""} ${(errors as any).length ? 'border-red-500' : ''}`}
/>
<span className="text-sm text-muted-foreground">mm</span>

View File

@@ -7,6 +7,8 @@ import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
import { QuantityInput } from '@/components/ui/quantity-input';
import {
Select,
SelectContent,
@@ -171,7 +173,7 @@ export default function PurchasedPartForm({
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
<div>
<Label> (kg) *</Label>
<Input type="number" placeholder="예: 1.5" step="0.1" />
<NumberInput placeholder="예: 1.5" step={0.1} allowDecimal />
</div>
<div>
<Label> (V) *</Label>
@@ -229,7 +231,7 @@ export default function PurchasedPartForm({
</div>
<div>
<Label> ( ) *</Label>
<Input type="number" placeholder="예: 100" />
<QuantityInput placeholder="예: 100" />
</div>
</div>
)}

View File

@@ -39,7 +39,7 @@ import {
type TabOption,
type StatCard,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
// Debounce 훅
function useDebounce<T>(value: T, delay: number): T {

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { QuantityInput } from '@/components/ui/quantity-input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
@@ -221,13 +222,12 @@ export function MasterFieldDialog({
<div className="space-y-4 pt-4 border-t">
<div>
<Label> </Label>
<Input
type="number"
min="2"
max="10"
<QuantityInput
min={2}
max={10}
value={newMasterFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
onChange={(value) => {
const count = value ?? 2;
setNewMasterFieldColumnCount(count);
// 컬럼 개수에 맞게 이름 배열 조정
const newNames = Array.from({ length: count }, (_, i) =>

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { QuantityInput } from '@/components/ui/quantity-input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
@@ -321,13 +322,12 @@ export function TemplateFieldDialog({
<>
<div>
<Label> </Label>
<Input
type="number"
<QuantityInput
min={2}
max={10}
value={templateFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
onChange={(value) => {
const count = value ?? 2;
setTemplateFieldColumnCount(count);
const newNames = Array.from({ length: count }, (_, i) =>
templateFieldColumnNames[i] || `컬럼${i + 1}`