영업관리 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:
@@ -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); }
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 버튼 영역
|
||||
|
||||
|
||||
@@ -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 기반 조회 모드)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user