fix: TypeScript 타입 오류 수정 및 설정 페이지 추가
- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext) - HeadersInit → Record<string, string> 타입 변경 - Zustand useShallow 마이그레이션 (zustand/react/shallow) - DataTable, ListPageTemplate 제네릭 타입 제약 추가 - 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한) - HR 관리 페이지 추가 (급여, 휴가) - 단가관리 페이지 리팩토링 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
src/components/ui/scroll-area.tsx
Normal file
52
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-bar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -13,8 +13,8 @@ import { cn } from "@/lib/utils";
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
// modal={false}로 설정하여 aria-hidden 충돌 방지
|
||||
return <SelectPrimitive.Root data-slot="select" modal={false} {...props} />;
|
||||
// aria-hidden 충돌 방지를 위해 props만 전달
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
|
||||
@@ -6,11 +6,14 @@ import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
// modal={false}: aria-hidden이 sibling 요소에 설정되지 않도록 함
|
||||
// 사이드바 용도로는 modal 모드가 필요하지 않음 (focus trap 불필요)
|
||||
// 이렇게 하면 Select, Dialog 등 다른 Radix 컴포넌트와 충돌 방지
|
||||
return <SheetPrimitive.Root data-slot="sheet" modal={false} {...props} />;
|
||||
function Sheet({
|
||||
modal = true, // 기본값을 true로 변경 (딤 처리 및 포커스 트랩 활성화)
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Root> & { modal?: boolean }) {
|
||||
// modal={true}: 딤 처리, 포커스 트랩, ESC 키로 닫기 등 모달 동작 활성화
|
||||
// modal={false}: aria-hidden이 sibling 요소에 설정되지 않음, 다른 Radix 컴포넌트와 충돌 방지
|
||||
// 모바일 사이드바에서는 modal={true}가 필요 (딤 클릭으로 닫기 기능)
|
||||
return <SheetPrimitive.Root data-slot="sheet" modal={modal} {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
|
||||
190
src/components/ui/time-picker.tsx
Normal file
190
src/components/ui/time-picker.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "./popover";
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
interface TimePickerProps {
|
||||
value?: string; // "HH:mm" format
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** 분 단위 간격 (기본값: 5) */
|
||||
minuteStep?: number;
|
||||
}
|
||||
|
||||
function TimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "시간 선택",
|
||||
disabled = false,
|
||||
className,
|
||||
minuteStep = 5,
|
||||
}: TimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// 현재 선택된 시/분 파싱
|
||||
const [selectedHour, selectedMinute] = React.useMemo(() => {
|
||||
if (!value) return [null, null];
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
return [h, m];
|
||||
}, [value]);
|
||||
|
||||
// 시간 배열 생성 (0-23)
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
// 분 배열 생성 (minuteStep 간격)
|
||||
const minutes = Array.from(
|
||||
{ length: Math.ceil(60 / minuteStep) },
|
||||
(_, i) => i * minuteStep
|
||||
);
|
||||
|
||||
// 시간 선택 핸들러
|
||||
const handleHourSelect = (hour: number) => {
|
||||
const minute = selectedMinute ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// 분 선택 핸들러
|
||||
const handleMinuteSelect = (minute: number) => {
|
||||
const hour = selectedHour ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// 표시할 시간 텍스트
|
||||
const displayValue = value || placeholder;
|
||||
|
||||
// 스크롤 영역 ref
|
||||
const hourScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const minuteScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 팝오버 열릴 때 선택된 시간으로 스크롤
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
if (selectedHour !== null && hourScrollRef.current) {
|
||||
const hourElement = hourScrollRef.current.querySelector(
|
||||
`[data-hour="${selectedHour}"]`
|
||||
);
|
||||
hourElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
if (selectedMinute !== null && minuteScrollRef.current) {
|
||||
const minuteElement = minuteScrollRef.current.querySelector(
|
||||
`[data-minute="${selectedMinute}"]`
|
||||
);
|
||||
minuteElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [open, selectedHour, selectedMinute]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{displayValue}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-3">
|
||||
<span className="text-lg font-bold">시간 선택</span>
|
||||
</div>
|
||||
|
||||
{/* 시간/분 선택 영역 */}
|
||||
<div className="flex gap-2">
|
||||
{/* 시간 선택 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground text-center mb-1 font-semibold">
|
||||
시
|
||||
</span>
|
||||
<ScrollArea className="h-[200px] w-[70px] rounded-md border">
|
||||
<div className="p-1" ref={hourScrollRef}>
|
||||
{hours.map((hour) => (
|
||||
<button
|
||||
key={hour}
|
||||
data-hour={hour}
|
||||
onClick={() => handleHourSelect(hour)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm rounded-md transition-colors",
|
||||
"hover:bg-primary/10",
|
||||
selectedHour === hour
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{hour.toString().padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 구분자 */}
|
||||
<div className="flex items-center justify-center pt-5">
|
||||
<span className="text-2xl font-bold text-muted-foreground">:</span>
|
||||
</div>
|
||||
|
||||
{/* 분 선택 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground text-center mb-1 font-semibold">
|
||||
분
|
||||
</span>
|
||||
<ScrollArea className="h-[200px] w-[70px] rounded-md border">
|
||||
<div className="p-1" ref={minuteScrollRef}>
|
||||
{minutes.map((minute) => (
|
||||
<button
|
||||
key={minute}
|
||||
data-minute={minute}
|
||||
onClick={() => handleMinuteSelect(minute)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm rounded-md transition-colors",
|
||||
"hover:bg-primary/10",
|
||||
selectedMinute === minute
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{minute.toString().padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 선택된 시간 표시 */}
|
||||
{value && (
|
||||
<div className="mt-3 pt-3 border-t text-center">
|
||||
<span className="text-sm text-muted-foreground">선택: </span>
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { TimePicker };
|
||||
export type { TimePickerProps };
|
||||
Reference in New Issue
Block a user