영업관리 4개 메뉴 검색폼 wace 일치 + 공통 UX(초기화·date input) 정비

- 검색 폼 정합성: wace JSP `<!-- 주석처리된 검색필터 -->` 블록까지 잘못 이식했던 부분 정정
  - 견적: 11→7개 (제품구분/국내해외/유무상/요청납기 제거)
  - 주문: 13→9개 (제품구분/국내해외/유무상/견적환종 제거)
  - 매출: 10→11개 (출하지시상태 제거 + 제품구분·국내/해외 추가, JSP 순서로 재배치)
  - 판매: 변경 없음 (원본 그대로 일치)
- 매출 백엔드: SaleListFilter에 productType/nation 추가, getRevenueList에 partObjId/serialNo/orderDate/productType/nation 5개 필터 처리
- 공통 UX
  - 초기화 버튼을 4개 메뉴 동일하게 통일 (variant=ghost, 버튼 영역 끝)
  - <Input type="date">는 빈 값 placeholder 숨김 + 캘린더 아이콘 숨김 + 영역 클릭으로 picker 자동(showPicker)
- 신규 공통 컴포넌트: CommCodeSelect/CustomerSelect/CustomerSearchDialog/PartSelect/ItemSearchDialog + backend salesCommonRoutes
- 문서: 01/02/04 검색 폼 표를 활성/비활성 분리 형식으로 정정, README에 8. 공통 UX 규칙 섹션 신설

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-08 10:42:16 +09:00
parent 4c3ea194a0
commit 489fa50d11
20 changed files with 1317 additions and 146 deletions
+2
View File
@@ -174,6 +174,7 @@ import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 (vexplor 자체
import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>견적 (wace_plm 도메인 이식)
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@@ -407,6 +408,7 @@ app.use("/api/quotes", quoteRoutes); // 견적관리 (vexplor 자체)
app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wace_plm 도메인)
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@@ -0,0 +1,72 @@
import { Router, Request, Response } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const router = Router();
router.use(authenticateToken);
/**
* 영업관리 4개 메뉴(견적/주문/판매/매출) 공통 옵션 endpoint.
* - 공통코드(comm_code)는 wace_plm parent_code_id 그룹 키로 조회
* - 품목(item_info)은 wace 마이그레이션 데이터(company_code 비어있음) +
* 현재 운영 회사 데이터 모두 노출
*/
/** GET /api/sales/codes/:groupId → [{code, label, sort}] */
router.get("/codes/:groupId", async (req: Request, res: Response) => {
try {
const { groupId } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT code_id AS code, code_name AS label, sort_order AS sort
FROM comm_code
WHERE parent_code_id = $1
AND status = 'active'
ORDER BY sort_order, code_name`,
[groupId],
);
res.json({ success: true, data: result.rows });
} catch (err) {
logger.error("salesCommon /codes error", err);
res.status(500).json({ success: false, message: (err as Error).message });
}
});
/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) */
router.get("/parts", async (_req: Request, res: Response) => {
try {
const pool = getPool();
// wace 이식 데이터(company_code 빈 값/별표) 우선, COMPANY_16 데이터 추가
const result = await pool.query(
`SELECT id, item_number, item_name
FROM item_info
WHERE COALESCE(company_code, '') IN ('', '*', 'COMPANY_16')
AND (item_number IS NOT NULL OR item_name IS NOT NULL)
ORDER BY item_number NULLS LAST, item_name NULLS LAST`,
);
res.json({ success: true, data: result.rows });
} catch (err) {
logger.error("salesCommon /parts error", err);
res.status(500).json({ success: false, message: (err as Error).message });
}
});
/** GET /api/sales/customers → [{id, customer_name, customer_code}] */
router.get("/customers", async (_req: Request, res: Response) => {
try {
const pool = getPool();
const result = await pool.query(
`SELECT id, customer_name, customer_code
FROM customer_mng
WHERE customer_name IS NOT NULL AND customer_name <> ''
ORDER BY customer_name`,
);
res.json({ success: true, data: result.rows });
} catch (err) {
logger.error("salesCommon /customers error", err);
res.status(500).json({ success: false, message: (err as Error).message });
}
});
export default router;
@@ -6,6 +6,7 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { generateContractNo } from "./salesOrderMgmtService";
// ─── 타입 ─────────────────────────────────────────────────────
@@ -13,6 +14,7 @@ export interface EstimateListFilter {
category_cd?: string;
customer_objid?: string;
search_partObjId?: string;
search_partNo?: string;
search_partName?: string;
search_serialNo?: string;
appr_status?: string;
@@ -131,6 +133,24 @@ export async function getList(filter: EstimateListFilter) {
)`);
params.push(filter.search_partObjId);
}
if (filter.search_partNo) {
conditions.push(`EXISTS (
SELECT 1 FROM contract_item CI
WHERE CI.contract_objid = T.OBJID
AND CI.status = 'ACTIVE'
AND UPPER(CI.part_no) LIKE UPPER($${idx++})
)`);
params.push(`%${filter.search_partNo}%`);
}
if (filter.search_partName) {
conditions.push(`EXISTS (
SELECT 1 FROM contract_item CI
WHERE CI.contract_objid = T.OBJID
AND CI.status = 'ACTIVE'
AND UPPER(CI.part_name) LIKE UPPER($${idx++})
)`);
params.push(`%${filter.search_partName}%`);
}
if (filter.search_serialNo) {
conditions.push(`EXISTS (
SELECT 1 FROM contract_item CI
@@ -418,6 +438,7 @@ export async function create(userId: string, body: EstimateTemplateBody) {
if (!contractObjid) {
contractObjid = genVarcharObjid("CM");
const ctx = body.contract_context ?? {};
const contractNo = ctx.contract_no || (await generateContractNo());
await client.query(
`INSERT INTO contract_mgmt (
objid, contract_no, customer_objid, category_cd, area_cd,
@@ -426,7 +447,7 @@ export async function create(userId: string, body: EstimateTemplateBody) {
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, NOW())`,
[
contractObjid,
ctx.contract_no || null,
contractNo,
ctx.customer_objid || null,
ctx.category_cd || null,
ctx.area_cd || null,
@@ -21,6 +21,8 @@ export interface SaleListFilter {
shippingDateFrom?: string;
shippingDateTo?: string;
salesStatus?: string; // 판매상태 (registered/cancelled 등)
productType?: string; // project_mgmt.product (제품구분)
nation?: string; // project_mgmt.area_cd (국내/해외)
// 매출관리 전용
revenueMode?: string;
salesDeadlineFrom?: string;
@@ -220,6 +222,17 @@ export async function getRevenueList(filter: SaleListFilter) {
if (filter.orderType) { conditions.push(`T.category_cd = $${idx++}`); params.push(filter.orderType); }
if (filter.poNo) { conditions.push(`T.po_no ILIKE $${idx++}`); params.push(`%${filter.poNo}%`); }
if (filter.customer_objid) { conditions.push(`T.customer_objid = $${idx++}`); params.push(filter.customer_objid); }
if (filter.productType) { conditions.push(`T.product = $${idx++}`); params.push(filter.productType); }
if (filter.nation) { conditions.push(`T.area_cd = $${idx++}`); params.push(filter.nation); }
if (filter.search_partObjId) { conditions.push(`T.part_objid = $${idx++}`); params.push(filter.search_partObjId); }
if (filter.serialNo) {
conditions.push(`EXISTS (SELECT 1 FROM contract_item_serial CIS
WHERE CIS.item_objid = T.contract_item_objid AND CIS.status='ACTIVE'
AND UPPER(CIS.serial_no) LIKE UPPER($${idx++}))`);
params.push(`%${filter.serialNo}%`);
}
if (filter.orderDateFrom) { conditions.push(`COALESCE(T.contract_date, CM.order_date) >= $${idx++}`); params.push(filter.orderDateFrom); }
if (filter.orderDateTo) { conditions.push(`COALESCE(T.contract_date, CM.order_date) <= $${idx++}`); params.push(filter.orderDateTo); }
if (filter.salesDeadlineFrom) { conditions.push(`T.sales_deadline_date >= $${idx++}`); params.push(filter.salesDeadlineFrom); }
if (filter.salesDeadlineTo) { conditions.push(`T.sales_deadline_date <= $${idx++}`); params.push(filter.salesDeadlineTo); }
if (filter.shippingDateFrom) { conditions.push(`SR.shipping_date >= $${idx++}`); params.push(filter.shippingDateFrom); }
+11 -4
View File
@@ -7,6 +7,8 @@
### 1.1 검색 폼 (`#plmSearchZon`)
**활성 7개** (estimateList_new.jsp line 1110~1170):
| 필드 | name/id | 타입 | 비고 |
|---|---|---|---|
| 주문유형 | `category_cd` | select2 (공통코드) | |
@@ -16,10 +18,15 @@
| 시리얼 | `search_serialNo` | text | |
| 결재상태 | `appr_status` | select2 (대기/상신중/완료/반려/불필요) | |
| 접수일 from~to | `receipt_start_date` / `receipt_end_date` | date_icon | |
| 제품 | `product` | select2 (공통코드) | |
| 지역 | `area_cd` | select2 (공통코드) | |
| 유/무상 | `paid_type` | select2 (공통코드) | |
| 요청납기 from~to | `due_start_date` / `due_end_date` | date_icon | |
**비활성 (JSP line 1174~1203 `<!-- 주석처리된 검색필터 - 필요시 활성화 -->` 블록 안)** — 이식 대상 아님:
| 필드 | name/id |
|---|---|
| 제품 | `product` |
| 지역 | `area_cd` |
| 유/무상 | `paid_type` |
| 요청납기 from~to | `due_start_date` / `due_end_date` |
### 1.2 버튼 영역 (`.plm_btn_wrap`)
+11 -4
View File
@@ -7,6 +7,8 @@
### 1.1 검색 폼 (`#plmSearchZon`)
**활성 9개** (orderMgmtList.jsp line 1109~1182):
| 필드 | name | 타입 | 비고 |
|---|---|---|---|
| 주문유형 | `category_cd` | select2 (공통코드) | |
@@ -18,10 +20,15 @@
| 수주상태 | `contract_result` | select2 (공통코드) | |
| 발주일 from~to | `order_start_date` / `order_end_date` | date_icon | |
| 요청납기 from~to | `due_start_date` / `due_end_date` | date_icon | |
| 제품 | `product` | select2 | |
| 지역 | `area_cd` | select2 | |
| 유/무상 | `paid_type` | select2 | |
| 환종 | `contract_currency` | select2 | |
**비활성 (JSP line 1184~ `<!-- 주석처리된 검색필터 -->` 블록 안)** — 이식 대상 아님:
| 필드 | name |
|---|---|
| 제품 | `product` |
| 지역 | `area_cd` |
| 유/무상 | `paid_type` |
| 환종 | `contract_currency` |
### 1.2 버튼 영역
+14 -7
View File
@@ -11,6 +11,8 @@
### 1.1 검색 폼 (판매와 유사하나 매출 항목 추가)
**활성 11개** (revenueMgmtList.jsp line 813~883):
| 필드 | name | 타입 | 비고 |
|---|---|---|---|
| 주문유형 | `orderType` | select2 | |
@@ -24,13 +26,18 @@
| **매출마감 기간** | `salesDeadlineFrom` / `salesDeadlineTo` | date_icon | |
| 발주일 from~to | `orderDateFrom` / `orderDateTo` | date_icon | |
| 출하일 from~to | `shippingDateFrom` / `shippingDateTo` | date_icon | |
| 유/무상 | `paymentType` | select2 | |
| 수주상태 | `orderStatus` | select2 | |
| 요청납기 from~to | `requestDateFrom` / `requestDateTo` | date_icon | |
| 출하상태 | `shippingStatus` | select2 | |
| 출하방법 | `shippingMethod` | select2 | |
| 담당자 | `manager` | select2 | |
| 인도조건 | `incoterms` | select2 | |
**비활성 (JSP line 885~ `<!-- 주석처리된 검색필터 -->` 블록 안)** — 이식 대상 아님:
| 필드 | name |
|---|---|
| 유/무상 | `paymentType` |
| 수주상태 | `orderStatus` |
| 요청납기 from~to | `requestDateFrom` / `requestDateTo` |
| 출하상태 | `shippingStatus` |
| 출하방법 | `shippingMethod` |
| 담당자 | `manager` |
| 인도조건 | `incoterms` |
Hidden: `revenueMode=Y` (shipment_log 기반 조회 모드)
+35
View File
@@ -144,3 +144,38 @@ import { useAuth } from "@/hooks/useAuth";
1. **운영 DB 접속해서 누락 테이블 DDL 추출** (`estimate_template`, `estimate_template_item`, `sales_registration`)
2. [01-estimate.md](./01-estimate.md) 견적관리 상세 매핑 작성 → 코드 시작
3. 마스터 매핑 테이블 설계 (`legacy_id_map`)
## 8. 공통 UX 규칙 (검색 폼 / 영업관리 4개 메뉴 동일 적용)
### 8.1 버튼 영역
상단 우측 버튼 영역에 다음 순서로 배치 — **모든 메뉴 공통**:
1. `조회` (`variant="outline"` + `<Search>` 아이콘)
2. 메뉴 고유 액션 버튼들 (등록/수정/삭제/확정 등)
3. **`초기화`** (`variant="ghost"`) — `searchForm` state를 모든 키 빈 값으로 reset
```tsx
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({ /* 모든 키 "" */ })}>
</Button>
```
### 8.2 date input
- `<Input type="date">` 사용 시 별도 처리 불필요. 다음은 `Input` 컴포넌트 + `globals.css`에 자동 반영:
- 빈 값일 때 `'YYYY/MM/DD'` 자리표시 텍스트 숨김 (data-empty="true" 자동)
- 캘린더 아이콘 숨김 (`::-webkit-calendar-picker-indicator { display: none }`)
- input 영역 어디 클릭해도 picker 자동 표시 (`showPicker()`)
- 위 동작은 `frontend/components/ui/input.tsx` + `frontend/app/globals.css`에서 일괄 처리.
### 8.3 검색 폼 그리드
- `grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30`
- 라벨: `text-[11px] mb-0.5 block text-muted-foreground`
- 입력: `h-8 text-xs` (date는 `px-1 flex-1 min-w-0` 추가)
### 8.4 wace JSP 주석 함정
검색 폼 추출 시 JSP 끝부분 `<!-- 주석처리된 검색필터 - 필요시 활성화 -->` 블록은 **이식 대상 아님**. 활성/비활성 분리 표로 문서화. 자세한 내용은 메모리 [feedback_wace_jsp_columns](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_wace_jsp_columns.md).
@@ -16,7 +16,12 @@ import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
// ─── 컬럼 ─────────────────────────────────────────────────────
@@ -75,12 +80,12 @@ export default function SalesEstimatePage() {
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<EstimateRow | null>(null);
// 검색 폼 — wace 원본 견적관리: 주문유형/고객사/품번/품명/S/N/결재상태/접수일
// 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개
// 주문유형/고객사/품번/품명/S/N/결재상태/접수일
const [searchForm, setSearchForm] = useState({
category_cd: "",
customer_objid: "",
search_partNo: "",
search_partName: "",
search_partObjId: "",
search_serialNo: "",
appr_status: "",
receipt_start_date: "",
@@ -93,6 +98,9 @@ export default function SalesEstimatePage() {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<EstimateBody>({ template_type: "1", show_total_row: "Y", items: [] });
// 품목 검색 모달
const [itemDialogOpen, setItemDialogOpen] = useState(false);
// 메일 발송 다이얼로그
const [mailDialogOpen, setMailDialogOpen] = useState(false);
const [mailSending, setMailSending] = useState(false);
@@ -139,14 +147,17 @@ export default function SalesEstimatePage() {
const openCreate = async () => {
setDialogMode("create");
const estimateNo = await salesEstimateApi.generateNumber().catch(() => "");
const [estimateNo, contractNo] = await Promise.all([
salesEstimateApi.generateNumber().catch(() => ""),
salesOrderMgmtApi.generateNumber().catch(() => ""),
]);
setForm({
template_type: "1",
estimate_no: estimateNo,
show_total_row: "Y",
items: [{ ...EMPTY_ITEM }],
contract_context: {
contract_no: "",
contract_no: contractNo,
customer_objid: "",
category_cd: "",
product: "",
@@ -368,7 +379,7 @@ export default function SalesEstimatePage() {
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
category_cd: "", customer_objid: "",
search_partNo: "", search_partName: "", search_serialNo: "",
search_partObjId: "", search_serialNo: "",
appr_status: "",
receipt_start_date: "", receipt_end_date: "",
})}>
@@ -377,49 +388,46 @@ export default function SalesEstimatePage() {
</div>
</div>
{/* 검색 폼 — wace 원본: 주문유형 / 고객사 / 품번 / 품명 / S/N / 결재상태 / 접수일 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 p-3 border rounded-md bg-muted/30">
{/* 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-xs"></Label>
<Select value={searchForm.category_cd || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v === "all" ? "" : v })}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CATEGORY_OPTIONS.filter((o) => o.value).map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000167"
value={searchForm.category_cd}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-xs"></Label>
<Input className="h-9" placeholder="customer_mng.id"
value={searchForm.customer_objid}
onChange={(e) => setSearchForm({ ...searchForm, customer_objid: e.target.value })} />
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-xs"></Label>
<Input className="h-9" placeholder="품번 입력하여 검색..."
value={searchForm.search_partNo}
onChange={(e) => setSearchForm({ ...searchForm, search_partNo: e.target.value })} />
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-xs"></Label>
<Input className="h-9" placeholder="품명 입력하여 검색..."
value={searchForm.search_partName}
onChange={(e) => setSearchForm({ ...searchForm, search_partName: e.target.value })} />
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-xs">S/N</Label>
<Input className="h-9" value={searchForm.search_serialNo}
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs" value={searchForm.search_serialNo}
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Select value={searchForm.appr_status || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, appr_status: v === "all" ? "" : v })}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="작성중"></SelectItem>
@@ -430,15 +438,13 @@ export default function SalesEstimatePage() {
</SelectContent>
</Select>
</div>
<div className="flex gap-1 items-end">
<div className="flex-1">
<Label className="text-xs"></Label>
<Input type="date" className="h-9" value={searchForm.receipt_start_date}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_start_date}
onChange={(e) => setSearchForm({ ...searchForm, receipt_start_date: e.target.value })} />
</div>
<div className="flex-1">
<Label className="text-xs">~</Label>
<Input type="date" className="h-9" value={searchForm.receipt_end_date}
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_end_date}
onChange={(e) => setSearchForm({ ...searchForm, receipt_end_date: e.target.value })} />
</div>
</div>
@@ -473,30 +479,32 @@ export default function SalesEstimatePage() {
<legend className="text-sm font-semibold px-2"> ( )</legend>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs"> ( )</Label>
<Input value={form.contract_context.contract_no ?? ""}
onChange={(e) => setForm({
...form,
contract_context: { ...form.contract_context!, contract_no: e.target.value },
})}
placeholder="예: SO-2026-001 (비우면 자동)" />
<Label className="text-xs"> ()</Label>
<Input
readOnly
className="bg-muted/30"
value={form.contract_context.contract_no ?? ""}
placeholder="저장 시 자동 부여됩니다"
/>
</div>
<div>
<Label className="text-xs"> OBJID/ID</Label>
<Input value={form.contract_context.customer_objid ?? ""}
onChange={(e) => setForm({
...form,
contract_context: { ...form.contract_context!, customer_objid: e.target.value },
})}
placeholder="customer_mng.id 값" />
<Label className="text-xs"></Label>
<CustomerSelect
value={form.contract_context.customer_objid ?? ""}
onValueChange={(v) => setForm({
...form,
contract_context: { ...form.contract_context!, customer_objid: v },
})}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.contract_context.contract_currency ?? "KRW"}
onChange={(e) => setForm({
...form,
contract_context: { ...form.contract_context!, contract_currency: e.target.value },
})} />
<Label className="text-xs"></Label>
<CommCodeSelect groupId="0001533"
value={form.contract_context.contract_currency ?? ""}
onValueChange={(v) => setForm({
...form,
contract_context: { ...form.contract_context!, contract_currency: v },
})} />
</div>
<div>
<Label className="text-xs"></Label>
@@ -521,12 +529,13 @@ export default function SalesEstimatePage() {
</Select>
</div>
<div>
<Label className="text-xs"> ()</Label>
<Input value={form.contract_context.category_cd ?? ""}
onChange={(e) => setForm({
...form,
contract_context: { ...form.contract_context!, category_cd: e.target.value },
})} />
<Label className="text-xs"></Label>
<CommCodeSelect groupId="0000167"
value={form.contract_context.category_cd ?? ""}
onValueChange={(v) => setForm({
...form,
contract_context: { ...form.contract_context!, category_cd: v },
})} />
</div>
</div>
</fieldset>
@@ -645,9 +654,14 @@ export default function SalesEstimatePage() {
</tbody>
</table>
</div>
<Button size="sm" variant="outline" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => setItemDialogOpen(true)}>
<Search className="w-3 h-3 mr-1" />
</Button>
</div>
</fieldset>
<DialogFooter>
@@ -708,6 +722,32 @@ export default function SalesEstimatePage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 검색 — 등록 다이얼로그 */}
<ItemSearchDialog
open={itemDialogOpen}
onOpenChange={setItemDialogOpen}
onSelect={(items: ItemRow[]) => {
setForm((prev) => {
const startSeq = (prev.items?.length ?? 0) + 1;
const newItems: EstimateItem[] = items.map((it, idx) => ({
seq: startSeq + idx,
category: "",
description: it.item_name ?? "",
specification: it.size ?? it.spec ?? it.standard ?? "",
quantity: "1",
unit: it.unit_label ?? "EA",
unit_price: String(it.selling_price ?? it.standard_price ?? "0"),
amount: "0",
note: "",
remark: "",
part_objid: String(it.objid ?? ""),
}));
return { ...prev, items: [...(prev.items ?? []), ...newItems] };
});
toast.success(`${items.length}건 품목 추가`);
}}
/>
</div>
);
}
@@ -16,6 +16,10 @@ import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
// wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤
@@ -67,10 +71,12 @@ export default function SalesOrderPage() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<OrderRow | null>(null);
// wace orderMgmtList.jsp 활성 9개 (1줄 7개 / 2줄 2개)
const [searchForm, setSearchForm] = useState({
category_cd: "", search_poNo: "", contract_result: "",
customer_objid: "", search_serialNo: "",
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
});
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<"create" | "edit">("create");
@@ -80,6 +86,9 @@ export default function SalesOrderPage() {
items: [],
});
// 품목 검색 모달
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const fetchList = useCallback(async () => {
if (!user) return;
setLoading(true);
@@ -243,38 +252,86 @@ export default function SalesOrderPage() {
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
})}>
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 border rounded-md bg-muted/30">
{/* 검색 폼 — wace 원본 orderMgmtList.jsp 활성 9개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.search_poNo}
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000167"
value={searchForm.category_cd}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
value={searchForm.search_poNo}
onChange={(e) => setSearchForm({ ...searchForm, search_poNo: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={searchForm.contract_result || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, contract_result: v === "all" ? "" : v })}>
<SelectTrigger className="h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
</SelectContent>
</Select>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.search_serialNo}
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs" value={searchForm.search_serialNo}
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
</div>
<div className="flex gap-1 items-end">
<div className="flex-1"><Label className="text-xs"> </Label>
<Input type="date" className="h-9" value={searchForm.order_start_date}
onChange={(e) => setSearchForm({ ...searchForm, order_start_date: e.target.value })} /></div>
<div className="flex-1"><Label className="text-xs"></Label>
<Input type="date" className="h-9" value={searchForm.order_end_date}
onChange={(e) => setSearchForm({ ...searchForm, order_end_date: e.target.value })} /></div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000963"
value={searchForm.contract_result}
onValueChange={(v) => setSearchForm({ ...searchForm, contract_result: v })}
className="h-8 text-xs" />
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.order_start_date}
onChange={(e) => setSearchForm({ ...searchForm, order_start_date: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.order_end_date}
onChange={(e) => setSearchForm({ ...searchForm, order_end_date: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.due_start_date}
onChange={(e) => setSearchForm({ ...searchForm, due_start_date: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.due_end_date}
onChange={(e) => setSearchForm({ ...searchForm, due_end_date: e.target.value })} />
</div>
</div>
</div>
@@ -298,17 +355,21 @@ export default function SalesOrderPage() {
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-semibold px-2"> </legend>
<div className="grid grid-cols-4 gap-3">
<div><Label className="text-xs"></Label>
<Input value={form.contract_no ?? ""} onChange={(e) => setForm({ ...form, contract_no: e.target.value })} /></div>
<div><Label className="text-xs"> ()</Label>
<Input readOnly className="bg-muted/30"
value={form.contract_no ?? ""}
placeholder="저장 시 자동 부여됩니다" /></div>
<div><Label className="text-xs"></Label>
<Input value={form.po_no ?? ""} onChange={(e) => setForm({ ...form, po_no: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.contract_date ?? ""} onChange={(e) => setForm({ ...form, contract_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.req_del_date ?? ""} onChange={(e) => setForm({ ...form, req_del_date: e.target.value })} /></div>
<div><Label className="text-xs"> ID</Label>
<Input value={form.customer_objid ?? ""} onChange={(e) => setForm({ ...form, customer_objid: e.target.value })}
placeholder="customer_mng.id" /></div>
<div><Label className="text-xs"></Label>
<CustomerSelect
value={form.customer_objid ?? ""}
onValueChange={(v) => setForm({ ...form, customer_objid: v })}
/></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.receipt_date ?? ""} onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
@@ -380,7 +441,14 @@ export default function SalesOrderPage() {
</tbody>
</table>
</div>
<Button size="sm" variant="outline" onClick={addItem}><Plus className="w-3 h-3 mr-1" /> </Button>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => setItemDialogOpen(true)}>
<Search className="w-3 h-3 mr-1" />
</Button>
</div>
</fieldset>
<DialogFooter>
@@ -391,6 +459,31 @@ export default function SalesOrderPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 검색 — 등록 다이얼로그 (다중 선택) */}
<ItemSearchDialog
open={itemDialogOpen}
onOpenChange={setItemDialogOpen}
onSelect={(items: ItemRow[]) => {
setForm((prev) => {
const startSeq = (prev.items?.length ?? 0) + 1;
const newItems: OrderItem[] = items.map((it, idx) => ({
seq: startSeq + idx,
part_objid: String(it.objid ?? ""),
part_no: it.item_number ?? it.item_code ?? "",
part_name: it.item_name ?? "",
quantity: 1,
order_quantity: "1",
order_unit_price: String(it.selling_price ?? it.standard_price ?? "0"),
order_supply_price: "0",
order_vat: "0",
order_total_amount: "0",
}));
return { ...prev, items: [...(prev.items ?? []), ...newItems] };
});
toast.success(`${items.length}건 품목 추가`);
}}
/>
</div>
);
}
@@ -16,6 +16,9 @@ import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale";
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
@@ -56,9 +59,13 @@ export default function SalesRevenuePage() {
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<RevenueListRow | null>(null);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// wace revenueMgmtList.jsp 활성 11개
const [searchForm, setSearchForm] = useState({
poNo: "", customer_objid: "",
orderType: "", poNo: "", customer_objid: "",
productType: "", search_partObjId: "", nation: "",
serialNo: "",
salesDeadlineFrom: "", salesDeadlineTo: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
});
@@ -146,23 +153,106 @@ export default function SalesRevenuePage() {
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleConfirmDeadline} disabled={checkedIds.length === 0}>
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
orderType: "", poNo: "", customer_objid: "",
productType: "", search_partObjId: "", nation: "",
serialNo: "",
salesDeadlineFrom: "", salesDeadlineTo: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
})}>
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 border rounded-md bg-muted/30">
<div><Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.poNo} onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} /></div>
<div className="flex gap-1 items-end">
<div className="flex-1"><Label className="text-xs"> ~</Label>
<Input type="date" className="h-9" value={searchForm.salesDeadlineFrom} onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineFrom: e.target.value })} /></div>
<div className="flex-1"><Label className="text-xs">~</Label>
<Input type="date" className="h-9" value={searchForm.salesDeadlineTo} onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineTo: e.target.value })} /></div>
{/* 검색 폼 — wace 원본 revenueMgmtList.jsp 활성 11개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000167"
value={searchForm.orderType}
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })}
className="h-8 text-xs" />
</div>
<div className="flex gap-1 items-end">
<div className="flex-1"><Label className="text-xs"> ~</Label>
<Input type="date" className="h-9" value={searchForm.shippingDateFrom} onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} /></div>
<div className="flex-1"><Label className="text-xs">~</Label>
<Input type="date" className="h-9" value={searchForm.shippingDateTo} onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} /></div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
value={searchForm.poNo}
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000001"
value={searchForm.productType}
onValueChange={(v) => setSearchForm({ ...searchForm, productType: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">/</Label>
<CommCodeSelect groupId="0001219"
value={searchForm.nation}
onValueChange={(v) => setSearchForm({ ...searchForm, nation: v })}
className="h-8 text-xs" />
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs"
value={searchForm.serialNo}
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.salesDeadlineFrom}
onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.salesDeadlineTo}
onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateTo}
onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateTo}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} />
</div>
</div>
</div>
@@ -14,6 +14,9 @@ import { Save, Loader2, Search, Truck } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale";
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
@@ -54,9 +57,12 @@ export default function SalesSalePage() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<SaleListRow | null>(null);
// wace salesMgmtList.jsp 검색 폼: 1줄 7개 / 2줄 3개
const [searchForm, setSearchForm] = useState({
poNo: "", customer_objid: "", serialNo: "", shippingStatus: "",
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
serialNo: "", shippingStatus: "", salesStatus: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
});
const [registerOpen, setRegisterOpen] = useState(false);
@@ -134,31 +140,101 @@ export default function SalesSalePage() {
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openRegister} disabled={!selected}>
<Truck className="w-4 h-4 mr-1" />/
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
serialNo: "", shippingStatus: "", salesStatus: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
})}>
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 border rounded-md bg-muted/30">
<div><Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.poNo} onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.serialNo} onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} /></div>
<div className="flex gap-1 items-end">
<div className="flex-1"><Label className="text-xs"> ~</Label>
<Input type="date" className="h-9" value={searchForm.orderDateFrom} onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} /></div>
<div className="flex-1"><Label className="text-xs">~</Label>
<Input type="date" className="h-9" value={searchForm.orderDateTo} onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} /></div>
{/* 검색 폼 — wace 원본 salesMgmtList.jsp 재현 (1줄 7개 / 2줄 3개) */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0000167"
value={searchForm.orderType}
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })}
className="h-8 text-xs" />
</div>
<div><Label className="text-xs"></Label>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
value={searchForm.poNo}
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs"
value={searchForm.serialNo}
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Select value={searchForm.shippingStatus || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, shippingStatus: v === "all" ? "" : v })}>
<SelectTrigger className="h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="PENDING"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
<SelectItem value="CANCELLED"></SelectItem>
</SelectContent>
</Select></div>
</Select>
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateTo}
onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateTo}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CommCodeSelect groupId="0900207"
value={searchForm.salesStatus}
onValueChange={(v) => setSearchForm({ ...searchForm, salesStatus: v })}
className="h-8 text-xs" />
</div>
</div>
<DataGrid
+10
View File
@@ -1208,3 +1208,13 @@ body span.messenger-time {
height: 50%;
}
/* date input — 빈 값일 때 'YYYY/MM/DD' 자리표시 텍스트 숨김 */
/* (Input 컴포넌트가 type="date" + 빈 값일 때 data-empty="true" 부여) */
input[type="date"][data-empty="true"]:not(:focus)::-webkit-datetime-edit {
color: transparent;
}
/* 캘린더 아이콘은 숨김 (input 영역 어디 클릭해도 picker가 뜸 — Input 컴포넌트 onClick 처리) */
input[type="date"]::-webkit-calendar-picker-indicator {
display: none;
}
@@ -0,0 +1,82 @@
"use client";
import React, { useEffect, useState } from "react";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { apiClient } from "@/lib/api/client";
/**
* 영업관리 4개 메뉴 공통: wace_plm comm_code 그룹키 단위 옵션 셀렉트.
*
* group_id (parent_code_id) 별 알려진 키:
* - 0000167: 주문유형 (category_cd)
* - 0000001: 제품구분 (product)
* - 0001219: 국내/해외 (area_cd)
* - 0000156: 유/무상 (paid_type, code_id 0000157=유상, 0000158=무상)
* - 0000963: 수주상태 (contract_result)
* - 0001533: 환종 (contract_currency)
* - 0900207: 판매상태
* - 0900215: 과세구분 (tax_type)
*/
interface CommCodeSelectProps {
groupId: string;
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
/** "전체" 옵션을 맨 앞에 추가 (기본 true) */
withAll?: boolean;
disabled?: boolean;
className?: string;
}
const cache = new Map<string, SmartSelectOption[]>();
const inflight = new Map<string, Promise<SmartSelectOption[]>>();
const fetchGroup = async (groupId: string): Promise<SmartSelectOption[]> => {
if (cache.has(groupId)) return cache.get(groupId)!;
if (inflight.has(groupId)) return inflight.get(groupId)!;
const p = (async () => {
const res = await apiClient.get(`/sales/codes/${groupId}`);
const rows = (res.data?.data ?? []) as Array<{ code: string; label: string }>;
const opts = rows
.filter((r) => r.code)
.map((r) => ({ code: r.code, label: r.label || r.code }));
cache.set(groupId, opts);
return opts;
})();
inflight.set(groupId, p);
try {
return await p;
} finally {
inflight.delete(groupId);
}
};
export function CommCodeSelect({
groupId, value, onValueChange,
placeholder = "전체",
withAll = true,
disabled, className,
}: CommCodeSelectProps) {
const [options, setOptions] = useState<SmartSelectOption[]>(cache.get(groupId) ?? []);
useEffect(() => {
let alive = true;
fetchGroup(groupId)
.then((opts) => { if (alive) setOptions(opts); })
.catch(() => {});
return () => { alive = false; };
}, [groupId]);
// SmartSelect 자체는 빈 string value 처리 못함 → withAll은 placeholder로 표현
return (
<SmartSelect
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
@@ -0,0 +1,150 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
export interface CustomerRow {
objid?: string | number;
id?: string | number;
customer_name?: string;
contact_person?: string;
business_number?: string;
contact_phone?: string;
address?: string;
customer_code?: string;
[key: string]: any;
}
/**
* customer_mng.id (정수) → contract_mgmt.customer_objid 'C_xxxxxxxxxx' (10자리 padded)
* 영업관리(contract_mgmt) 테이블이 사용하는 포맷.
*/
export const toContractCustomerObjid = (id: number | string | null | undefined) => {
if (id === null || id === undefined || id === "") return "";
return `C_${String(id).padStart(10, "0")}`;
};
interface CustomerSearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (customer: CustomerRow) => void;
title?: string;
description?: string;
}
export function CustomerSearchDialog({
open, onOpenChange, onSelect,
title = "거래처 검색",
description = "거래처를 검색하여 선택하세요.",
}: CustomerSearchDialogProps) {
const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<CustomerRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setKeyword("");
setResults([]);
void search("");
}
}, [open]);
const search = async (kw?: string) => {
const k = kw ?? keyword;
setLoading(true);
try {
const filters: any[] = [];
if (k) filters.push({ columnName: "customer_name", operator: "contains", value: k });
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
setResults(resData?.data || resData?.rows || []);
} catch {
toast.error("거래처 조회 실패");
} finally {
setLoading(false);
}
};
const handleSelect = (cust: CustomerRow) => {
onSelect(cust);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
placeholder="거래처명 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
className="text-sm"
/>
<Button onClick={() => search()} disabled={loading} className="shrink-0 gap-1">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{results.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
{loading ? "검색 중..." : "검색 결과가 없습니다."}
</td>
</tr>
) : results.map((row, i) => (
<tr
key={row.objid ?? row.id ?? i}
className="cursor-pointer border-t hover:bg-blue-50"
onClick={() => handleSelect(row)}
>
<td className="px-3 py-2 font-medium">{row.customer_name || "-"}</td>
<td className="px-3 py-2">{row.contact_person || "-"}</td>
<td className="px-3 py-2">{row.business_number || "-"}</td>
<td className="px-3 py-2">{row.contact_phone || "-"}</td>
<td className="px-2 py-2 text-center">
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={(e) => { e.stopPropagation(); handleSelect(row); }}
></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,62 @@
"use client";
import React, { useEffect, useState } from "react";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { apiClient } from "@/lib/api/client";
import { toContractCustomerObjid } from "@/components/common/CustomerSearchDialog";
interface CustomerSelectProps {
/** contract_mgmt.customer_objid 형식 ('C_0000007555') */
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
let cached: SmartSelectOption[] | null = null;
let inflight: Promise<SmartSelectOption[]> | null = null;
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
const res = await apiClient.get("/sales/customers");
const rows = (res.data?.data ?? []) as any[];
cached = rows
.filter((r) => r.id != null && r.customer_name)
.map((r) => ({
code: toContractCustomerObjid(r.id),
label: String(r.customer_name),
}));
return cached!;
})();
try {
return await inflight;
} finally {
inflight = null;
}
};
export function CustomerSelect({
value, onValueChange, placeholder = "거래처 선택", disabled, className,
}: CustomerSelectProps) {
const [options, setOptions] = useState<SmartSelectOption[]>(cached ?? []);
useEffect(() => {
let alive = true;
fetchCustomers().then((opts) => { if (alive) setOptions(opts); }).catch(() => {});
return () => { alive = false; };
}, []);
return (
<SmartSelect
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
@@ -0,0 +1,295 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Loader2, Plus, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
export interface ItemRow {
objid?: string | number;
id?: string | number;
item_number?: string;
item_code?: string;
item_name?: string;
size?: string;
spec?: string;
standard?: string;
unit?: string;
unit_label?: string;
selling_price?: number | string;
standard_price?: number | string;
[key: string]: any;
}
interface ItemSearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (items: ItemRow[]) => void;
multiSelect?: boolean;
title?: string;
description?: string;
}
const PAGE_SIZE = 20;
const fmt = (val: string | number) => {
const num = String(val).replace(/[^\d.-]/g, "");
if (!num) return "0";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
export function ItemSearchDialog({
open, onOpenChange, onSelect,
multiSelect = true,
title = "품목 검색",
description = "추가할 품목을 검색하여 선택하세요.",
}: ItemSearchDialogProps) {
const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<ItemRow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMap, setSelectedMap] = useState<Map<string, ItemRow>>(new Map());
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const [unitMap, setUnitMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get(`/table-categories/item_info/unit/values`);
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (arr: any[]) => {
for (const v of arr) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setUnitMap(map);
}
} catch { /* skip */ }
})();
}, []);
const resolveUnit = (code: string) => unitMap[code] || code || "EA";
useEffect(() => {
if (open) {
setKeyword("");
setSelectedMap(new Map());
setPage(1);
void search(1, "");
}
}, [open]);
const search = async (p: number, kw?: string) => {
const k = kw ?? keyword;
setLoading(true);
try {
const filters: any[] = [];
if (k) filters.push({ columnName: "item_name", operator: "contains", value: k });
const res = await apiClient.post("/table-management/tables/item_info/data", {
page: p, size: PAGE_SIZE,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
setResults(resData?.data || resData?.rows || []);
setTotal(resData?.total || resData?.totalCount || 0);
} catch {
toast.error("품목 조회 실패");
} finally {
setLoading(false);
}
};
const rowKey = (row: ItemRow) =>
String(row.item_number || row.objid || row.id || "");
const enrich = (row: ItemRow): ItemRow => ({
...row,
unit_label: resolveUnit(row.unit ?? ""),
});
const toggleSelect = (row: ItemRow) => {
const key = rowKey(row);
if (!multiSelect) {
onSelect([enrich(row)]);
onOpenChange(false);
return;
}
setSelectedMap((prev) => {
const next = new Map(prev);
if (next.has(key)) next.delete(key); else next.set(key, row);
return next;
});
};
const handleApply = () => {
const arr = Array.from(selectedMap.values()).map(enrich);
if (arr.length === 0) {
toast.info("품목을 선택하세요.");
return;
}
onSelect(arr);
onOpenChange(false);
};
const goPage = (p: number) => {
if (p < 1 || p > totalPages) return;
setPage(p);
void search(p);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-3xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
placeholder="품명/품목코드 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setPage(1); void search(1); } }}
className="text-sm"
/>
<Button
onClick={() => { setPage(1); void search(1); }}
disabled={loading}
className="shrink-0 gap-1"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{multiSelect && <th className="w-10 px-2 py-2 text-center"></th>}
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
<th className="w-24 px-2 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{results.length === 0 ? (
<tr>
<td colSpan={multiSelect ? 6 : 5} className="px-4 py-8 text-center text-gray-400">
{loading ? "검색 중..." : "검색 결과가 없습니다."}
</td>
</tr>
) : results.map((row, i) => {
const key = rowKey(row) || String(i);
const checked = selectedMap.has(key);
return (
<tr
key={key}
className={cn(
"cursor-pointer border-t",
checked ? "bg-blue-50" : "hover:bg-gray-50",
)}
onClick={() => toggleSelect(row)}
>
{multiSelect && (
<td className="px-2 py-1.5 text-center">
<input type="checkbox" checked={checked} readOnly className="accent-blue-500" />
</td>
)}
<td className="px-2 py-1.5">{row.item_number || row.item_code || "-"}</td>
<td className="px-2 py-1.5">{row.item_name || "-"}</td>
<td className="px-2 py-1.5">{row.size || row.spec || row.standard || "-"}</td>
<td className="px-2 py-1.5 text-center">{resolveUnit(row.unit ?? "")}</td>
<td className="px-2 py-1.5 text-right">
{fmt(String(row.selling_price ?? row.standard_price ?? 0))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{total.toLocaleString()}</span>
<span>{multiSelect ? ` · 선택 ${selectedMap.size}` : ""}</span>
</div>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => goPage(1)}
disabled={page <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => goPage(page - 1)}
disabled={page <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const p = start + i;
if (p > totalPages) return null;
return (
<button
key={p}
onClick={() => goPage(p)}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-xs",
p === page ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted",
)}
>
{p}
</button>
);
})}
<button
onClick={() => goPage(page + 1)}
disabled={page >= totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
onClick={() => goPage(totalPages)}
disabled={page >= totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
<DialogFooter className="shrink-0 flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
{multiSelect && (
<Button onClick={handleApply} disabled={selectedMap.size === 0} className="gap-1">
<Plus className="h-4 w-4" />
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { fmt as fmtItemPrice };
+94
View File
@@ -0,0 +1,94 @@
"use client";
import React, { useEffect, useState } from "react";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { apiClient } from "@/lib/api/client";
/**
* 품번/품명 자동완성 셀렉트
*
* wace_plm orderMgmtList의 select2-part AJAX 패턴을 단순화한 형태.
* - item_info 전체를 한 번 캐시 (id 기준 단일 소스)
* - mode='partNo': 라벨로 item_number 표시
* - mode='partName': 라벨로 item_name 표시
* - 선택값(value)은 양쪽 모두 item_info.id (= part_objid)
*/
interface PartRow {
id: string;
item_number?: string;
item_name?: string;
}
interface PartSelectProps {
mode: "partNo" | "partName";
/** item_info.id (part_objid) */
value: string;
onValueChange: (partObjId: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
let cachedRows: PartRow[] | null = null;
let inflight: Promise<PartRow[]> | null = null;
const fetchParts = async (): Promise<PartRow[]> => {
if (cachedRows) return cachedRows;
if (inflight) return inflight;
inflight = (async () => {
// 영업관리 4개 메뉴 공통 endpoint — wace 이식 8179건 + COMPANY_16 데이터.
const res = await apiClient.get("/sales/parts");
const rows = (res.data?.data ?? []) as any[];
cachedRows = rows
.filter((r) => r.id != null)
.map((r) => ({
id: String(r.id),
item_number: r.item_number ?? "",
item_name: r.item_name ?? "",
}));
return cachedRows!;
})();
try {
return await inflight;
} finally {
inflight = null;
}
};
const toOptions = (rows: PartRow[], mode: PartSelectProps["mode"]): SmartSelectOption[] =>
rows
.filter((r) => mode === "partNo" ? r.item_number : r.item_name)
.map((r) => ({
code: r.id,
label: String(mode === "partNo" ? r.item_number : r.item_name),
}));
export function PartSelect({
mode, value, onValueChange,
placeholder = mode === "partNo" ? "품번 선택" : "품명 선택",
disabled, className,
}: PartSelectProps) {
const [options, setOptions] = useState<SmartSelectOption[]>(
cachedRows ? toOptions(cachedRows, mode) : [],
);
useEffect(() => {
let alive = true;
fetchParts()
.then((rows) => { if (alive) setOptions(toOptions(rows, mode)); })
.catch(() => {});
return () => { alive = false; };
}, [mode]);
return (
<SmartSelect
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
+14 -1
View File
@@ -8,10 +8,20 @@ export interface InputProps extends React.ComponentProps<"input"> {
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, enableEnterNavigation = false, onKeyDown, ...props }, ref) => {
({ className, type, label, enableEnterNavigation = false, onKeyDown, onClick, ...props }, ref) => {
const [showTooltip, setShowTooltip] = React.useState(false);
const [tooltipPosition, setTooltipPosition] = React.useState({ x: 0, y: 0 });
// type="date": 어디 클릭해도 picker가 뜨도록 + 빈 값일 때 'YYYY/MM/DD' 자리표시 텍스트 숨김
const isDateType = type === "date";
const isDateEmpty = isDateType && (props.value === "" || props.value == null);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
if (isDateType) {
try { e.currentTarget.showPicker?.(); } catch { /* unsupported browser */ }
}
onClick?.(e);
};
const handleMouseMove = (e: React.MouseEvent<HTMLInputElement>) => {
if (label) {
setTooltipPosition({ x: e.clientX, y: e.clientY });
@@ -54,16 +64,19 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
type={type}
data-slot="input"
data-empty={isDateEmpty ? "true" : undefined}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
isDateType && "cursor-pointer",
className,
)}
onMouseEnter={() => label && setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onMouseMove={handleMouseMove}
onKeyDown={handleKeyDown}
onClick={handleClick}
{...props}
/>
+2
View File
@@ -4,6 +4,8 @@ export interface SaleListFilter {
orderType?: string;
poNo?: string;
customer_objid?: string;
productType?: string;
nation?: string;
search_partObjId?: string;
serialNo?: string;
shippingStatus?: string;