공용 — 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:
@@ -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 ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user