Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -11,6 +11,7 @@ sam_project:
|
||||
frontend: sam_project/sam-next/sma-next-project/sam-react-prod # Next.js (현재)
|
||||
backend_api: sam_project/sam-api/sam-api # PHP Laravel
|
||||
design: sam_project/sam-design/sam-design # React 디자인 시스템
|
||||
hotfix: sam_project/sam-hotfix/sam-hotfix # E2E 테스트 결과/핫픽스 관리
|
||||
특성: 인증 필수 폐쇄형 ERP 시스템 (SEO 불필요)
|
||||
```
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-white bg-white/10 border border-white/20 rounded-lg hover:bg-white/20 transition-colors"
|
||||
className="flex items-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm text-white bg-white/10 border border-white/20 rounded-lg hover:bg-white/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>화면 설정</span>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { YearQuarterFilter, type Quarter } from '@/components/molecules/YearQuarterFilter';
|
||||
|
||||
interface FiltersProps {
|
||||
selectedYear: number;
|
||||
selectedQuarter: string;
|
||||
selectedQuarter: Quarter | '전체';
|
||||
searchTerm: string;
|
||||
onYearChange: (year: number) => void;
|
||||
onQuarterChange: (quarter: string) => void;
|
||||
onQuarterChange: (quarter: Quarter | '전체') => void;
|
||||
onSearchChange: (term: string) => void;
|
||||
}
|
||||
|
||||
@@ -20,48 +21,16 @@ export const Filters = ({
|
||||
onQuarterChange,
|
||||
onSearchChange,
|
||||
}: FiltersProps) => {
|
||||
const quarters = ['전체', '1분기', '2분기', '3분기', '4분기'];
|
||||
const years = [2025, 2024, 2023, 2022, 2021];
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white p-3 sm:p-4 rounded-lg mb-3 sm:mb-4 shadow-sm">
|
||||
{/* 상단: 년도/분기 선택 */}
|
||||
<div className="flex flex-wrap items-end gap-3 sm:gap-4 mb-3 sm:mb-4">
|
||||
{/* Year Selection */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-500">년도</span>
|
||||
<div className="w-28 sm:w-32">
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => onYearChange(parseInt(e.target.value))}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>{year}년</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quarter Selection */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-500">분기</span>
|
||||
<div className="flex bg-gray-100 rounded-md p-1 gap-1 overflow-x-auto">
|
||||
{quarters.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => onQuarterChange(q)}
|
||||
className={`px-3 sm:px-4 py-1.5 text-sm rounded-sm transition-all whitespace-nowrap ${
|
||||
selectedQuarter === q
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<YearQuarterFilter
|
||||
year={selectedYear}
|
||||
quarter={selectedQuarter}
|
||||
onYearChange={onYearChange}
|
||||
onQuarterChange={onQuarterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 하단: 검색 입력 + 버튼 */}
|
||||
@@ -85,4 +54,4 @@ export const Filters = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,13 +6,13 @@ interface HeaderProps {
|
||||
|
||||
export const Header = ({ rightContent }: HeaderProps) => {
|
||||
return (
|
||||
<div className="w-full bg-[#1e3a8a] text-white p-3 sm:p-6 rounded-lg mb-3 sm:mb-4 shadow-md flex items-center justify-between h-16 sm:h-24">
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="text-lg sm:text-2xl font-bold mb-0.5 sm:mb-1">품질인정심사 시스템</h1>
|
||||
<p className="text-xs sm:text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
|
||||
<div className="w-full bg-[#1e3a8a] text-white p-3 sm:p-6 rounded-lg mb-3 sm:mb-4 shadow-md flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<h1 className="text-base sm:text-2xl font-bold mb-0.5 sm:mb-1 truncate">품질인정심사 시스템</h1>
|
||||
<p className="text-xs sm:text-sm opacity-80 text-blue-100 truncate">SAM - Smart Automation Management</p>
|
||||
</div>
|
||||
{rightContent && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center shrink-0">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function QualityInspectionPage() {
|
||||
|
||||
// 2일차(로트추적) 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2025);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<string>('전체');
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 2일차 선택 상태
|
||||
@@ -153,7 +153,7 @@ export default function QualityInspectionPage() {
|
||||
return MOCK_REPORTS.filter((report) => {
|
||||
if (report.year !== selectedYear) return false;
|
||||
if (selectedQuarter !== '전체') {
|
||||
const quarterNum = parseInt(selectedQuarter.replace('분기', ''));
|
||||
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
|
||||
if (report.quarterNum !== quarterNum) return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
@@ -198,7 +198,7 @@ export default function QualityInspectionPage() {
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleQuarterChange = (quarter: string) => {
|
||||
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||
setSelectedQuarter(quarter);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
|
||||
52
src/components/atoms/ScrollableButtonGroup.tsx
Normal file
52
src/components/atoms/ScrollableButtonGroup.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const GAP_MAP = {
|
||||
sm: 'gap-0.5',
|
||||
md: 'gap-1',
|
||||
lg: 'gap-2',
|
||||
} as const;
|
||||
|
||||
const SCROLL_UNTIL_MAP = {
|
||||
sm: 'sm:overflow-visible',
|
||||
md: 'md:overflow-visible',
|
||||
lg: 'lg:overflow-visible',
|
||||
xl: 'xl:overflow-visible',
|
||||
} as const;
|
||||
|
||||
interface ScrollableButtonGroupProps {
|
||||
children: ReactNode;
|
||||
/** 버튼 간격 (default: 'md') */
|
||||
gap?: keyof typeof GAP_MAP;
|
||||
/** 이 breakpoint 이상에서 스크롤 해제 (default: 'xl') */
|
||||
scrollUntil?: keyof typeof SCROLL_UNTIL_MAP;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가로 스크롤 버튼 컨테이너
|
||||
*
|
||||
* 좁은 화면에서 overflow-x-auto + 히든 스크롤바,
|
||||
* scrollUntil breakpoint 이상에서 일반 레이아웃 전환.
|
||||
*/
|
||||
export function ScrollableButtonGroup({
|
||||
children,
|
||||
gap = 'md',
|
||||
scrollUntil = 'xl',
|
||||
className,
|
||||
}: ScrollableButtonGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-x-auto min-w-0',
|
||||
SCROLL_UNTIL_MAP[scrollUntil],
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className={cn('flex items-center min-w-max [&::-webkit-scrollbar]:hidden', GAP_MAP[gap])}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,6 @@ export { BadgeSm, getQuoteStatusBadge } from "./BadgeSm";
|
||||
export type { BadgeSmVariant } from "./BadgeSm";
|
||||
|
||||
export { TabChip } from "./TabChip";
|
||||
export type { TabChipProps } from "./TabChip";
|
||||
export type { TabChipProps } from "./TabChip";
|
||||
|
||||
export { ScrollableButtonGroup } from "./ScrollableButtonGroup";
|
||||
@@ -473,9 +473,12 @@ function ItemMasterDataManagementContent() {
|
||||
icon={Database}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TabsList className="flex-1">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="min-w-0 overflow-x-hidden">
|
||||
<div
|
||||
className="mb-4 min-w-0 overflow-x-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<TabsList>
|
||||
{customTabs.sort((a, b) => a.order - b.order).map(tab => {
|
||||
const Icon = getTabIcon(tab.icon);
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReactNode, useCallback } from 'react';
|
||||
import { format, startOfYear, endOfYear, subMonths, startOfMonth, endOfMonth, subDays } from 'date-fns';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
|
||||
/**
|
||||
* 날짜 범위 프리셋 타입
|
||||
@@ -126,24 +127,19 @@ export function DateRangeSelector({
|
||||
const renderPresets = () => {
|
||||
if (hidePresets || presets.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableButtonGroup>
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</ScrollableButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
97
src/components/molecules/YearQuarterFilter.tsx
Normal file
97
src/components/molecules/YearQuarterFilter.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** 분기 타입 */
|
||||
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
|
||||
|
||||
const QUARTER_OPTIONS = ['전체', 'Q1', 'Q2', 'Q3', 'Q4'] as const;
|
||||
|
||||
function quarterLabel(q: typeof QUARTER_OPTIONS[number]): string {
|
||||
return q === '전체' ? '전체' : `${q.replace('Q', '')}분기`;
|
||||
}
|
||||
|
||||
interface YearQuarterFilterProps {
|
||||
/** 선택된 연도 */
|
||||
year: number;
|
||||
/** 선택된 분기 ('전체' 포함) */
|
||||
quarter: Quarter | '전체';
|
||||
/** 연도 변경 핸들러 */
|
||||
onYearChange: (year: number) => void;
|
||||
/** 분기 변경 핸들러 */
|
||||
onQuarterChange: (quarter: Quarter | '전체') => void;
|
||||
/** 연도 범위 [시작, 끝] (default: [현재-5, 현재]) */
|
||||
yearRange?: [number, number];
|
||||
/** '전체' 옵션 포함 여부 (default: true) */
|
||||
includeAll?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 Select + 분기 버튼 그룹 필터
|
||||
*
|
||||
* Controlled 컴포넌트. 연도/분기 state는 부모에서 관리.
|
||||
*/
|
||||
export function YearQuarterFilter({
|
||||
year,
|
||||
quarter,
|
||||
onYearChange,
|
||||
onQuarterChange,
|
||||
yearRange,
|
||||
includeAll = true,
|
||||
className,
|
||||
}: YearQuarterFilterProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [startYear, endYear] = yearRange ?? [currentYear - 5, currentYear];
|
||||
|
||||
const yearOptions = useMemo(() => {
|
||||
const years: number[] = [];
|
||||
for (let y = endYear; y >= startYear; y--) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}, [startYear, endYear]);
|
||||
|
||||
const quarters = includeAll ? QUARTER_OPTIONS : QUARTER_OPTIONS.slice(1);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 flex-wrap min-w-0', className)}>
|
||||
<Select value={String(year)} onValueChange={(v) => onYearChange(Number(v))}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}년
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ScrollableButtonGroup>
|
||||
{quarters.map((q) => (
|
||||
<Button
|
||||
key={q}
|
||||
size="sm"
|
||||
variant={quarter === q ? 'default' : 'outline'}
|
||||
className="h-8 px-3 text-xs shrink-0"
|
||||
onClick={() => onQuarterChange(q)}
|
||||
>
|
||||
{quarterLabel(q)}
|
||||
</Button>
|
||||
))}
|
||||
</ScrollableButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,4 +7,7 @@ export { TableActions } from "./TableActions";
|
||||
export type { TableAction } from "./TableActions";
|
||||
|
||||
export { StandardDialog, ConfirmDialog, FormDialog } from "./StandardDialog";
|
||||
export type { StandardDialogProps, ConfirmDialogProps, FormDialogProps } from "./StandardDialog";
|
||||
export type { StandardDialogProps, ConfirmDialogProps, FormDialogProps } from "./StandardDialog";
|
||||
|
||||
export { YearQuarterFilter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
@@ -29,13 +29,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { YearQuarterFilter } from '@/components/molecules/YearQuarterFilter';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -93,15 +87,6 @@ export function PerformanceReportList() {
|
||||
// ===== 현재 데이터 추적 (메모 모달 개소 계산용) =====
|
||||
const [currentData, setCurrentData] = useState<ReportListItem[]>([]);
|
||||
|
||||
// ===== 연도 옵션 =====
|
||||
const yearOptions = useMemo(() => {
|
||||
const years = [];
|
||||
for (let y = currentYear; y >= currentYear - 5; y--) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}, [currentYear]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
@@ -118,11 +103,6 @@ export function PerformanceReportList() {
|
||||
loadStats();
|
||||
}, [year, quarter, refreshKey]);
|
||||
|
||||
// ===== 분기 버튼 클릭 =====
|
||||
const handleQuarterChange = useCallback((q: Quarter | '전체') => {
|
||||
setQuarter(q);
|
||||
}, []);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleConfirm = useCallback(async (
|
||||
selectedItems: Set<string>,
|
||||
@@ -267,35 +247,15 @@ export function PerformanceReportList() {
|
||||
// ===== 연도/분기 필터 슬롯 (dateRangeSelector.extraActions) =====
|
||||
const quarterFilterSlot = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-2 flex-wrap order-first">
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}년
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1">
|
||||
{(['전체', 'Q1', 'Q2', 'Q3', 'Q4'] as const).map((q) => (
|
||||
<Button
|
||||
key={q}
|
||||
size="sm"
|
||||
variant={quarter === q ? 'default' : 'outline'}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => handleQuarterChange(q)}
|
||||
>
|
||||
{q === '전체' ? '전체' : `${q.replace('Q', '')}분기`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<YearQuarterFilter
|
||||
year={year}
|
||||
quarter={quarter}
|
||||
onYearChange={setYear}
|
||||
onQuarterChange={setQuarter}
|
||||
className="order-first"
|
||||
/>
|
||||
),
|
||||
[year, quarter, yearOptions, handleQuarterChange]
|
||||
[year, quarter]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
|
||||
Reference in New Issue
Block a user