공용 — native <select> 금지 + CompactFilterBar 신설 + M-BOM 시범 마이그레이션

영업관리에 이미 적용된 SmartSelect/CustomerSelect 패턴을 다른 메뉴(생산/개발/프로젝트)
의 native <select> 7개 자리에 일괄 적용. customer-cs/cs 메뉴의 컴팩트 검색바 패턴을
공용 컴포넌트로 추출하고 M-BOM 페이지에 시범 마이그레이션.

신설:
  - components/common/CompactFilterBar.tsx — CompactFilterBar + CompactFilterField + CompactDateRange
    · rounded-md border bg-muted/20 p-2 + flex-wrap (자동 줄바꿈)
    · 자식 input/combobox 자동 h-7 + text-xs 컴팩트화
    · onSearch / onReset / totalText 슬롯

native <select> → SmartSelect 일괄 교체:
  - production/mbom/page.tsx          5건 (주문유형/제품구분/국내해외/고객사/유무상)
  - development/change-list/page.tsx  1건 (년도)
  - development/ebom-regist/page.tsx  1건 (상태)
  - development/ebom-search/page.tsx  1건 (표시레벨)
  - project/progress/page.tsx         3건 (년도/국내해외/유무상)
  - components/development/PartFormDialog.tsx — BasicSelect 가 내부적으로 SmartSelect 위임
  - components/development/BomReportExcelImportDialog.tsx — E-BOM 복사 옵션

M-BOM 시범 마이그레이션:
  - 기존: 2행 grid 6×2 검색 폼 (h-9 큰 입력)
  - 변경: <CompactFilterBar> 안에 <CompactFilterField> 10개 (h-7 컴팩트)

원칙:
  - 향후 모든 신규/수정 페이지는 CompactFilterBar + SmartSelect/CustomerSelect 사용 필수
  - native <select> + 자체 grid 검색폼 작성 금지
  - 메모리: feedback_compact_search_pattern.md

타입체크 0건 에러 (변경 파일 기준).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 16:44:26 +09:00
parent dd88dc6e8c
commit 364d4707fe
8 changed files with 321 additions and 234 deletions
@@ -13,6 +13,7 @@ import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory";
import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog";
@@ -21,10 +22,10 @@ const GROUP_PART_TYPE = "0000062";
// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영.
// (시드 후 그룹 ID 확인되면 SmartSelect 전환)
const YEAR_OPTIONS = (() => {
const YEAR_OPTIONS: SmartSelectOption[] = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y));
const arr: SmartSelectOption[] = [];
for (let y = cur + 4; y >= cur - 8; y--) arr.push({ code: String(y), label: String(y) });
return arr;
})();
@@ -95,12 +96,12 @@ export default function EoHistoryPage() {
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="년도">
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<SmartSelect
options={YEAR_OPTIONS}
value={filter.Year ?? ""}
onValueChange={(v) => setFilter({ ...filter, Year: v })}
placeholder="전체"
/>
</Field>
<Field label="프로젝트 OBJID">
<Input value={filter.contract_objid ?? ""}
@@ -15,6 +15,7 @@ import {
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom";
import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog";
import { DevPartSelect } from "@/components/development/DevPartSelect";
@@ -23,7 +24,7 @@ import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialo
const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용)
const STATUS_OPTIONS = [
const STATUS_OPTIONS: SmartSelectOption[] = [
{ code: "create", label: "등록중" },
{ code: "changeDesign", label: "설계변경미배포" },
{ code: "deploy", label: "배포완료" },
@@ -126,15 +127,12 @@ export default function EbomRegistPage() {
/>
</Field>
<Field label="상태">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<SmartSelect
options={STATUS_OPTIONS}
value={filter.status ?? ""}
onChange={(e) => setFilter({ ...filter, status: e.target.value })}
>
<option value=""></option>
{STATUS_OPTIONS.map((o) =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</select>
onValueChange={(v) => setFilter({ ...filter, status: v })}
placeholder="전체"
/>
</Field>
{/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
<Field label="품번">
@@ -15,6 +15,15 @@ import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
import { DevPartSelect } from "@/components/development/DevPartSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
const LEVEL_OPTIONS: SmartSelectOption[] = [
{ code: "1", label: "1레벨" },
{ code: "2", label: "2레벨" },
{ code: "3", label: "3레벨" },
{ code: "4", label: "4레벨" },
{ code: "5", label: "5레벨" },
];
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
type Direction = "ascending" | "descending";
@@ -203,18 +212,12 @@ export default function EbomSearchPage() {
}))} />
</Field>
<Field label="표시 레벨">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<SmartSelect
options={LEVEL_OPTIONS}
value={String(filter.search_level ?? "")}
onChange={(e) => setFilter({ ...filter, search_level: e.target.value })}
>
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
onValueChange={(v) => setFilter({ ...filter, search_level: v })}
placeholder="전체"
/>
</Field>
</div>
<div className="mt-3 flex items-center justify-between">
@@ -9,12 +9,12 @@
// ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { apiClient } from "@/lib/api/client";
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
@@ -23,8 +23,18 @@ const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback)
interface CodeOpt { code: string; label: string; sort?: number | null }
interface CustomerOpt { id: number | string; customer_name: string | null; customer_code: string | null }
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const AREA_OPTS: SmartSelectOption[] = [
{ code: "국내", label: "국내" },
{ code: "해외", label: "해외" },
];
// 운영판 1:1 — paid/free raw 매칭이 기본. comm_code 응답이 비어있을 때 사용.
const PAID_FALLBACK_OPTS: SmartSelectOption[] = [
{ code: "paid", label: "유상" },
{ code: "free", label: "무상" },
];
const EMPTY_FILTER: MbomListFilter = {
search_category_cd: "",
@@ -71,7 +81,6 @@ export default function MbomMgmtPage() {
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [paidOpts, setPaidOpts] = useState<CodeOpt[]>([]);
const [customerOpts, setCustomerOpts] = useState<CustomerOpt[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogObjid, setDialogObjid] = useState<string | null>(null);
@@ -95,17 +104,15 @@ export default function MbomMgmtPage() {
let dead = false;
(async () => {
try {
const [c1, c2, c3, cust] = await Promise.all([
const [c1, c2, c3] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PAID}`),
apiClient.get(`/sales/customers`),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setPaidOpts(c3.data?.data ?? []);
setCustomerOpts(cust.data?.data ?? []);
} catch {
/* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */
}
@@ -132,133 +139,84 @@ export default function MbomMgmtPage() {
};
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
{/* 운영판 wace mBomMgmtList.jsp 1:1 — 2행 검색 폼 */}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
<Field label="주문유형">
<SelectBox
value={filter.search_category_cd ?? ""}
options={categoryOpts}
onChange={(v) => setFilter({ ...filter, search_category_cd: v })}
/>
</Field>
<Field label="제품구분">
<SelectBox
value={filter.search_product_cd ?? ""}
options={productOpts}
onChange={(v) => setFilter({ ...filter, search_product_cd: v })}
/>
</Field>
<Field label="국내/해외">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_area_cd ?? ""}
onChange={(e) => setFilter({ ...filter, search_area_cd: e.target.value })}
>
<option value=""></option>
<option value="국내"></option>
<option value="해외"></option>
</select>
</Field>
<Field label="고객사">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_customer_objid ?? ""}
onChange={(e) => setFilter({ ...filter, search_customer_objid: e.target.value })}
>
<option value=""></option>
{customerOpts.map((c) => (
<option key={`${c.id}`} value={c.customer_code ?? `${c.id}`}>
{c.customer_name}
</option>
))}
</select>
</Field>
<Field label="유/무상">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_paid_type ?? ""}
onChange={(e) => setFilter({ ...filter, search_paid_type: e.target.value })}
>
<option value=""></option>
{/* 운영판 1:1 — paid/free raw + comm_code 라벨 fallback */}
<option value="paid"></option>
<option value="free"></option>
{paidOpts.map((p) => (
<option key={p.code} value={p.code}>{p.label}</option>
))}
</select>
</Field>
<Field label="S/N">
<Input
value={filter.search_serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
<Field label="품번">
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
/>
</Field>
<Field label="품명">
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
/>
</Field>
<Field label="접수일 (시작)">
<Input
type="date"
value={filter.search_receipt_date_from ?? ""}
onChange={(e) => setFilter({ ...filter, search_receipt_date_from: e.target.value })}
/>
</Field>
<Field label="접수일 (종료)">
<Input
type="date"
value={filter.search_receipt_date_to ?? ""}
onChange={(e) => setFilter({ ...filter, search_receipt_date_to: e.target.value })}
/>
</Field>
<Field label="요청납기 (시작)">
<Input
type="date"
value={filter.search_req_del_date_from ?? ""}
onChange={(e) => setFilter({ ...filter, search_req_del_date_from: e.target.value })}
/>
</Field>
<Field label="요청납기 (종료)">
<Input
type="date"
value={filter.search_req_del_date_to ?? ""}
onChange={(e) => setFilter({ ...filter, search_req_del_date_to: e.target.value })}
/>
</Field>
</div>
</div>
<div className="flex h-full flex-col gap-2 p-2">
<CompactFilterBar
loading={loading}
onSearch={handleSearch}
onReset={handleReset}
totalText={<> {total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM </>}
>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect
options={categoryOpts}
value={filter.search_category_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect
options={productOpts}
value={filter.search_product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="국내/해외" width={100}>
<SmartSelect
options={AREA_OPTS}
value={filter.search_area_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_area_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: v })}
/>
</CompactFilterField>
<CompactFilterField label="유/무상" width={110}>
<SmartSelect
options={paidOpts.length > 0 ? paidOpts : PAID_FALLBACK_OPTS}
value={filter.search_paid_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_paid_type: v })}
/>
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input
value={filter.search_serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={filter.search_receipt_date_from ?? ""}
setFrom={(v) => setFilter({ ...filter, search_receipt_date_from: v })}
to={filter.search_receipt_date_to ?? ""}
setTo={(v) => setFilter({ ...filter, search_receipt_date_to: v })}
/>
</CompactFilterField>
<CompactFilterField label="요청납기" width={280}>
<CompactDateRange
from={filter.search_req_del_date_from ?? ""}
setFrom={(v) => setFilter({ ...filter, search_req_del_date_from: v })}
to={filter.search_req_del_date_to ?? ""}
setTo={(v) => setFilter({ ...filter, search_req_del_date_to: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM
</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<div className="min-h-0 flex-1 p-2">
<div className="min-h-0 flex-1">
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
@@ -283,28 +241,3 @@ export default function MbomMgmtPage() {
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function SelectBox({
value, options, onChange,
}: { value: string; options: CodeOpt[]; onChange: (v: string) => void }) {
return (
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{options.map((o) => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
);
}
@@ -78,13 +78,23 @@ const CATEGORY_GROUP = "0000167"; // 주문유형
const PRODUCT_GROUP = "0000001"; // 제품구분
// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유.
const YEAR_OPTIONS = (() => {
const YEAR_OPTIONS: SmartSelectOption[] = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y));
const arr: SmartSelectOption[] = [];
for (let y = cur + 4; y >= cur - 4; y--) arr.push({ code: String(y), label: String(y) });
return arr;
})();
const AREA_OPTIONS: SmartSelectOption[] = [
{ code: "국내", label: "국내" },
{ code: "해외", label: "해외" },
];
const PAID_OPTIONS: SmartSelectOption[] = [
{ code: "유상", label: "유상" },
{ code: "무상", label: "무상" },
];
const EMPTY_FILTER: ProgressListFilter = {
Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "",
contract_start_date: "", contract_end_date: "",
@@ -143,14 +153,12 @@ export default function ProjectProgressPage() {
<div className="grid grid-cols-6 gap-3 text-sm">
{/* 1행 */}
<Field label="년도">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<SmartSelect
options={YEAR_OPTIONS}
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}
>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
onValueChange={(v) => setFilter({ ...filter, Year: v })}
placeholder="전체"
/>
</Field>
<Field label="프로젝트번호">
<SmartSelect
@@ -190,26 +198,20 @@ export default function ProjectProgressPage() {
{/* 2행 */}
<Field label="국내/해외">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<SmartSelect
options={AREA_OPTIONS}
value={filter.area_cd ?? ""}
onChange={(e) => setFilter({ ...filter, area_cd: e.target.value })}
>
<option value=""></option>
<option value="국내"></option>
<option value="해외"></option>
</select>
onValueChange={(v) => setFilter({ ...filter, area_cd: v })}
placeholder="전체"
/>
</Field>
<Field label="유/무상">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<SmartSelect
options={PAID_OPTIONS}
value={filter.free_of_charge ?? ""}
onChange={(e) => setFilter({ ...filter, free_of_charge: e.target.value })}
>
<option value=""></option>
<option value="유상"></option>
<option value="무상"></option>
</select>
onValueChange={(v) => setFilter({ ...filter, free_of_charge: v })}
placeholder="전체"
/>
</Field>
<Field label="품번">
<PartSelect
@@ -0,0 +1,150 @@
"use client";
/**
* CompactFilterBar — 컴팩트 검색 필터바 공용 컴포넌트.
*
* customer-cs/cs 페이지 패턴 1:1 추출:
* - 외곽 `rounded-md border bg-muted/20 p-2` + flex-wrap (좁아도 자동 줄바꿈)
* - 컨트롤 높이 h-7, 폰트 text-xs (기존 h-9 보다 컴팩트)
* - 우측에 검색/초기화 버튼 + 합계 텍스트
*
* 사용 예:
* <CompactFilterBar
* onSearch={() => fetchList()}
* onReset={() => handleReset()}
* totalText={`총 ${total}건`}
* >
* <CompactFilterField label="고객사" width={140}>
* <CustomerSelect ... />
* </CompactFilterField>
* <CompactFilterField label="품번" width={120}>
* <Input ... className="h-7 text-xs" />
* </CompactFilterField>
* </CompactFilterBar>
*
* 원칙:
* - 모든 RPS 메뉴의 검색 폼은 이 컴포넌트를 사용. 자체 검색 폼 구성 금지.
* - SmartSelect / CustomerSelect / CommCodeSelect / Input 모두 h-7 + text-xs 자동 적용.
*/
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface CompactFilterBarProps {
children: React.ReactNode;
onSearch?: () => void;
onReset?: () => void;
/** 우측에 표시할 합계/통계 텍스트 (예: "총 12,345건 · 합계 12,000,000원") */
totalText?: React.ReactNode;
loading?: boolean;
searchLabel?: string;
resetLabel?: string;
className?: string;
}
export function CompactFilterBar({
children,
onSearch,
onReset,
totalText,
loading,
searchLabel = "검색",
resetLabel = "초기화",
className,
}: CompactFilterBarProps) {
return (
<div
className={cn(
"flex flex-shrink-0 flex-wrap items-end gap-2 rounded-md border bg-muted/20 p-2",
className,
)}
>
{children}
{(onReset || onSearch) && (
<div className="flex items-end gap-1">
{onReset && (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={onReset}>
<RotateCcw className="h-3 w-3" />
<span>{resetLabel}</span>
</Button>
)}
{onSearch && (
<Button size="sm" className="h-7 gap-1 px-2 text-xs" onClick={onSearch} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Search className="h-3 w-3" />}
<span>{searchLabel}</span>
</Button>
)}
</div>
)}
{totalText != null && (
<span className="ml-auto text-[11px] text-muted-foreground">{totalText}</span>
)}
</div>
);
}
interface CompactFilterFieldProps {
label: string;
/** 컨트롤 박스 폭(px). 기본 120. */
width?: number;
/** 폭 자동 (자식이 100% 폭을 차지하지 않게 할 때 유용) */
flex?: boolean;
children: React.ReactNode;
className?: string;
}
export function CompactFilterField({
label, width = 120, flex, children, className,
}: CompactFilterFieldProps) {
return (
<div className={cn("space-y-1", className)} style={flex ? undefined : { width }}>
<Label className="text-[11px] text-muted-foreground">{label}</Label>
<div className="[&_input]:h-7 [&_input]:text-xs [&_button[role=combobox]]:h-7 [&_button[role=combobox]]:text-xs [&_[data-slot=select-trigger]]:h-7 [&_[data-slot=select-trigger]]:text-xs">
{children}
</div>
</div>
);
}
/**
* 날짜 범위 입력 (CompactFilterField 자식으로 사용).
*
* <CompactFilterField label="접수일" width={280}>
* <CompactDateRange
* from={fromDate} setFrom={setFromDate}
* to={toDate} setTo={setToDate}
* />
* </CompactFilterField>
*/
export function CompactDateRange({
from, setFrom, to, setTo, disabled,
}: {
from: string;
setFrom: (v: string) => void;
to: string;
setTo: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center gap-1">
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
value={from}
onChange={(e) => setFrom(e.target.value)}
disabled={disabled}
/>
<span className="text-xs text-muted-foreground">~</span>
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
value={to}
onChange={(e) => setTo(e.target.value)}
disabled={disabled}
/>
</div>
);
}
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -261,18 +262,17 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground whitespace-nowrap">E-BOM </Label>
<select
className="h-9 rounded-md border bg-background px-2 text-sm min-w-[280px]"
value={copySelect}
onChange={(e) => setCopySelect(e.target.value)}
>
<option value=""></option>
{copyOptions.map((o) => (
<option key={o.objid} value={o.objid}>
{o.part_no} / {o.part_name} {o.revision ? `(v${o.revision})` : ""} - {o.regdate ?? ""}
</option>
))}
</select>
<div className="min-w-[280px]">
<SmartSelect
options={copyOptions.map<SmartSelectOption>((o) => ({
code: o.objid,
label: `${o.part_no} / ${o.part_name}${o.revision ? ` (v${o.revision})` : ""}${o.regdate ? ` - ${o.regdate}` : ""}`,
}))}
value={copySelect}
onValueChange={setCopySelect}
placeholder="선택"
/>
</div>
<Button variant="outline" size="sm" onClick={handleCopy} disabled={copying || !copySelect}>
{copying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Copy className="h-4 w-4" />}
<span className="ml-1"></span>
@@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect } from "@/components/common/SmartSelect";
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
import { cn } from "@/lib/utils";
import { createObjId } from "@/lib/utils/objidUtil";
@@ -462,15 +463,14 @@ function BasicSelect({
options: { v: string; t: string }[];
onChange: (v: string) => void;
}) {
// SmartSelect 로 위임 (옵션 5+ 면 자동 검색, 미만이면 일반 Select 모드)
return (
<select
className={cn("h-9 w-full rounded-md border bg-background px-2 text-sm")}
<SmartSelect
options={options.map((o) => ({ code: o.v, label: o.t }))}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{options.map((o) => <option key={o.v} value={o.v}>{o.t}</option>)}
</select>
onValueChange={onChange}
placeholder="선택"
/>
);
}
// ─── PartRow → FormState ────────────────────────────────────