Merge pull request 'V20260210' (#186) from V20260210 into main

Reviewed-on: #186
This commit was merged in pull request #186.
This commit is contained in:
2026-03-26 06:45:52 +00:00
11 changed files with 1097 additions and 68 deletions

3
.gitignore vendored
View File

@@ -35,7 +35,8 @@ Thumbs.db
.cursor/
# Claude Code
CLAUDE.md
.claude/
.playwright-mcp/
.omc/
.mcp.json

343
CLAUDE.md Normal file
View File

@@ -0,0 +1,343 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
WACE-PLM (웨이스 PLM) — a Java-based enterprise Resource Planning System (RPS) for managing business processes, resources, projects, and documents. Korean-language UI and documentation throughout.
## Tech Stack
- **Backend:** Java 7, Spring Framework 3.2.4, MyBatis 3.2.3
- **Frontend:** JSP (1,100+ views), jQuery 2.1.4, Select2, SweetAlert2, jqGrid 4.7.1, Tabulator/TUI-Grid, Highcharts, dhtmlxGantt, SmartEditor 2 (HuskyEZCreator)
- **Database:** PostgreSQL (JNDI datasource `java:/comp/env/plm`)
- **Server:** Apache Tomcat 7.0.94
- **Build:** Eclipse IDE-based (no Maven/Gradle), Docker for dev/prod
- **CSS:** Custom `basic.css`, Bootstrap, jQuery UI, Select2 CSS, Tabulator CSS
## Build & Run Commands
```bash
# Development environment (Docker)
docker-compose -f docker-compose.dev.yml up --build -d
docker-compose -f docker-compose.dev.yml down
# Compile only
./compile_only.sh
# Full rebuild + restart
./rebuild-and-restart.sh
# Production
./start-prod-full.sh
```
Dev port: 9090 (maps to 8080 in container). JVM: Xms=512m, Xmx=1024m.
## Architecture
### MVC Pattern (Spring + MyBatis)
All source code lives under `src/com/pms/`. The request flow is:
```
HTTP Request (*.do) → Controller (@RequestMapping) → Service (@Service) → SqlSession → Mapper XML → PostgreSQL
```
- View resolution: prefix `/WEB-INF/view`, suffix `.jsp`
- JSON responses: `@ResponseBody` with Jackson converter
- Component scan: base package `com.pms`
- Session timeout: 1440 minutes (24 hours)
### Module Structure (`src/com/pms/`)
| Package | Domain | Scale |
|---------|--------|-------|
| `salesmgmt/` | Sales management (contracts, orders, estimates, delivery, dealers, goods, inspections) | 25+ controllers, 20+ mappers |
| `ions/itemmgmt/` | Item/BOM management, purchasing, material warehousing | 9 controllers, 8 mappers |
| `ions/productioninventory/` | Production & inventory | controllers + mappers |
| `controller/`, `service/`, `mapper/` | Core modules (admin, login, specs, distribution, project, quality, dashboard) | root-level |
| `api/` | External API integrations (ERP sync, Amaranth approval/user) | 11 client classes |
| `common/` | Shared utilities, beans, base services | CommonUtils, Constants, SessionManager |
### View Directory Structure (`WebContent/WEB-INF/view/`)
Major directories: `admin/` (40+ sub-modules), `salesmgmt/` (20+ sub-modules), `ions/` (itemmgmt, productioninventory), `project/` (gate, partMaster, wbs), `board/`, `bom/`, `contractMgmt/`, `costMgmt/`, `dashboard/`, `documentMng/`, `fundMgmt/`, `inventoryMng/`, `orderMgmt/`, `part/`, `quality/`, `productionplanning/`, `purchaseOrder/`, and more.
### Key Configuration Files
| File | Purpose |
|------|---------|
| `WebContent/WEB-INF/web.xml` | Deployment descriptor (UTF-8 filter, `*.do` mapping, 1440min session, error pages) |
| `WebContent/WEB-INF/dispatcher-servlet.xml` | Spring MVC config (component scan, view resolver, Jackson JSON, scheduler pool=10) |
| `src/com/pms/mapper/mybatisConf.xml` | MyBatis config with 52 registered mapper XML files |
| `WebContent/WEB-INF/log4j.xml` | Logging config (DEBUG level, daily rolling) |
| `tomcat-conf/context.xml` | JNDI DataSource (max 200 connections, 50 idle, 10s wait) |
| `.env.development` | Dev DB: `jdbc:postgresql://211.115.91.141:11133/waceplm` |
| `.env.production` | Prod DB: Docker internal `wace-plm-db:5432/waceplm` |
### Database
PostgreSQL with MyBatis XML mappers (52 registered). Automated backup via `db/backup.py` (2x daily, 7-day retention). Database dump: `db/dbexport.pgsql`.
**Table naming:** UPPERCASE_SNAKE_CASE (primary: `USER_INFO`, `PART_MGMT`, `ORDER_MGMT`). Some legacy tables use ERP-style naming (`SWSB110A_TBL`).
**Column naming:** UPPERCASE_SNAKE_CASE (`USER_ID`, `PART_NO`, `DEL_YN`).
**Boolean columns:** `DEL_YN` with Y/N values.
---
## Coding Conventions
### Controller Pattern
```java
@Controller
public class {Feature}Controller {
@Autowired
private {Feature}Service service;
// List page
@RequestMapping(value = "/{module}/{feature}List.do", method = RequestMethod.GET)
public String get{Feature}All(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) {
// ... returns JSP path string
}
// Form popup
@RequestMapping(value = "/{module}/{feature}FormPopup.do", method = RequestMethod.GET)
public String {feature}FormPopup(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) { ... }
// Save (AJAX)
@ResponseBody
@RequestMapping(value = "/{module}/save{Feature}.do", method = RequestMethod.POST)
public Map<String, Object> save{Feature}(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) { ... }
// Delete (AJAX)
@ResponseBody
@RequestMapping(value = "/{module}/delete{Feature}.do", method = RequestMethod.POST)
public Map<String, Object> delete{Feature}(HttpServletRequest request, @RequestParam Map<String, Object> paramMap) { ... }
}
```
**Naming:** `{Feature}Controller.java` — CamelCase. Methods: `get{Feature}All`, `save{Feature}`, `delete{Feature}`.
### Service Pattern
```java
@Service
public class {Feature}Service {
@Autowired
CommonService commonService;
public List<Map<String,Object>> get{Feature}All(HttpServletRequest request, Map<String, Object> paramMap) {
SqlSession sqlSession = null;
try {
sqlSession = SqlMapConfig.getInstance().getSqlSession();
// Pagination
String countPerPage = CommonUtils.checkNull(request.getParameter("countPerPage"), Constants.ADMIN_COUNT_PER_PAGE+"");
paramMap.put("COUNT_PER_PAGE", Integer.parseInt(countPerPage));
Map pageMap = (HashMap) sqlSession.selectOne("namespace.get{Feature}ListCnt", paramMap);
pageMap = (HashMap) CommonUtils.setPagingInfo(request, pageMap, countPerPage);
paramMap.put("PAGE_END", CommonUtils.checkNull(pageMap.get("PAGE_END")));
paramMap.put("PAGE_START", CommonUtils.checkNull(pageMap.get("PAGE_START")));
resultList = (ArrayList) sqlSession.selectList("namespace.get{Feature}List", paramMap);
} catch(Exception e) {
e.printStackTrace();
} finally {
sqlSession.close();
}
return CommonUtils.toUpperCaseMapKey(resultList);
}
}
```
**Key points:**
- SqlSession obtained via `SqlMapConfig.getInstance().getSqlSession()` — must close in finally block.
- Result maps converted to uppercase keys via `CommonUtils.toUpperCaseMapKey()`.
- Pagination uses `PAGE_START`/`PAGE_END` parameters with count query + list query.
### Mapper XML Pattern
```xml
<mapper namespace="{module}.{feature}">
<!-- Count for pagination -->
<select id="get{Feature}ListCnt" parameterType="map" resultType="map">
SELECT COUNT(*) AS TOTAL_COUNT FROM TABLE_NAME WHERE 1=1
<if test='condField != null and condField != ""'>AND FIELD = #{condField}</if>
</select>
<!-- List with pagination -->
<select id="get{Feature}List" parameterType="map" resultType="map">
SELECT * FROM (
SELECT ROW_NUMBER() OVER (ORDER BY CRET_DATE) AS RNUM, ...
FROM TABLE_NAME WHERE 1=1
<if test='condField != null and condField != ""'>AND FIELD = #{condField}</if>
) T WHERE 1=1
<if test="PAGE_END != null and PAGE_END != ''">
<![CDATA[ AND RNUM <= #{PAGE_END}::integer ]]>
</if>
<if test="PAGE_START != null and PAGE_START != ''">
<![CDATA[ AND RNUM >= #{PAGE_START}::integer ]]>
</if>
</select>
<!-- Single record -->
<select id="get{Feature}" parameterType="map" resultType="map"> ... </select>
<!-- Insert/Update -->
<insert id="insert{Feature}" parameterType="map"> ... </insert>
<!-- Delete -->
<delete id="delete{Feature}" parameterType="map"> ... </delete>
</mapper>
```
**Naming conventions:**
- File: `{feature}.xml` (camelCase)
- Namespace: `{module}.{feature}` or just `{feature}` for root-level
- SQL IDs: `get{Feature}List`, `get{Feature}ListCnt`, `get{Feature}`, `insert{Feature}`, `delete{Feature}`
- Parameter type casting: `#{param}::numeric`, `#{param}::integer` for PostgreSQL
- All `parameterType="map"`, `resultType="map"` (no typed beans for queries)
### JSP Pattern
**Every JSP starts with:**
```jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="com.pms.common.utils.*"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ page import="java.util.*" %>
<%@include file="/init.jsp" %>
```
**`init.jsp`** (common include) provides:
- Session check and PersonBean extraction (`connectUserId`, `connectUserDeptCode`, `connectUserName`, etc.)
- All CSS/JS includes (jQuery, Select2, SweetAlert2, Tabulator, common.js, datepicker)
- Loading overlay markup
- Page authorization AJAX call
**List page structure:**
```
Search form (#plmSearchZon) → Button group (.plm_btn_wrap) → Data table (.plm_table) → Pagination (.pdm_page)
```
**Form popup structure:**
```
Hidden fields → Form table (#adminPopupForm) → Button group (저장/닫기)
```
**No Tiles framework** — layout is managed through `init.jsp` include and consistent HTML structure.
### JavaScript/AJAX Patterns
**Standard AJAX call:**
```javascript
$.ajax({
url: "/module/action.do",
type: "POST",
data: {"param1": value1, "param2": value2},
dataType: "json",
success: function(data) {
if (data == "SUCCESS") {
Swal.fire("완료되었습니다.");
fn_search();
}
},
error: function(jqxhr, status, error) {
Swal.fire(jqxhr.statusText + ", " + status + ", " + error);
}
});
```
**Popup pattern:**
```javascript
window.open("", "formPopup", "width=1150, height=676");
document.form1.action = "/module/formPopup.do";
document.form1.target = "formPopup";
document.form1.submit();
// From popup back to parent:
opener.fn_search();
self.close(0);
```
**Key functions in `common.js` (WebContent/js/common.js, 3500+ lines):**
| Category | Functions |
|----------|-----------|
| Validation | `fnc_valitate(formName)`, `fnc_validate(formName)`, `fnc_checkDataType(dataType, value)` |
| Null check | `fnc_isEmpty(val)`, `fnc_isNotEmpty(val)`, `fnc_checkNull(val)`, `fnc_checkNullDefaultValue(val, default)` |
| Date | `fnc_datepick()` (auto-init on ID containing "Date"), `fnc_monthpick()`, `fnc_getDate(delimiter)`, `fnc_dateCheck(from, to)` |
| Pagination | `fnc_goPrev(page)`, `fnc_goNext(page)`, `fnc_goPage(page)`, `fnc_goStart()`, `fnc_goEnd(maxPage)` |
| Code lookup | `fnc_getCodeList(codeId)`, `fnc_getCodeJsonStr(codeId)` |
| File ops | `fnc_deleteFile(fileObjId, callback)`, `fnc_downloadFile(fileObjId)` |
| Grid | `fnc_createGridAnchorTag(cell, params, onRendered)` |
| Form | `serializeObject()` (jQuery extension), custom `Map` class |
**Common AJAX endpoints:** `/common/getCodeList.do`, `/common/getSupplyCodeList.do`, `/common/getClientMngList.do`, `/common/getProjectNoList.do`, `/common/getDeptList.do`, `/common/searchUserList.do`, `/common/getCarTypeList.do`, `/common/getOEMList.do`, `/common/getPartMngList.do`, `/common/getProductMgmtList.do`
---
## Key Utility Classes
### CommonUtils (`src/com/pms/common/utils/CommonUtils.java`)
| Method | Purpose |
|--------|---------|
| `checkNull(Object obj)` | Returns empty string if null |
| `isBlank(Object)` / `isNotBlank(Object)` | Null/empty check |
| `isEmpty(Object)` / `isNotEmpty(Object)` | Null/empty check |
| `setPagingInfo(request, pageMap, countPerPage)` | Calculate pagination |
| `toUpperCaseMapKey(List/Map)` | Convert map keys to uppercase |
| `createObjId()` | Generate unique object ID |
| Date format constants | `DATE_FORMAT1="yyyy-MM-dd"`, `DATE_FORMAT2="yyyyMMdd"`, etc. |
### Constants (`src/com/pms/common/utils/Constants.java`)
| Constant | Value | Purpose |
|----------|-------|---------|
| `PERSON_BEAN` | `"PERSON_BEAN"` | Session key for logged-in user |
| `COUNT_PER_PAGE` | `25` | Default pagination size |
| `ADMIN_COUNT_PER_PAGE` | `25` | Admin pagination size |
| `FILE_STORAGE` | `"/data_storage"` | File storage root |
| `AJAX_RESPONSOR` | `"/common/ajaxResponsor"` | AJAX response view |
| `SYSTEM_CHARSET` | `"UTF-8"` | System encoding |
| `SUPER_ADMIN` | `"plm_admin"` | Super admin user ID |
### SessionManager (`src/com/pms/common/utils/SessionManager.java`)
- `setSessionManage(request, paramMap)` — Creates PersonBean from login query, stores in session
- `hasSession(session)` — Checks if PERSON_BEAN exists
- `destroy(session)` — Removes session attributes and invalidates
### SqlMapConfig (`src/com/pms/common/utils/SqlMapConfig.java`)
- Singleton pattern: `SqlMapConfig.getInstance().getSqlSession()`
- Reads `mybatisConf.xml` for DataSource and mapper configuration
---
## Adding New Features
To add a new screen/feature, follow the existing pattern:
1. **Controller** — Create `{Feature}Controller.java` with `@Controller`, `@RequestMapping` for `*.do` URLs
2. **Service** — Create `{Feature}Service.java` with `@Service`, use `SqlMapConfig.getInstance().getSqlSession()`
3. **Mapper XML** — Create `{feature}.xml` with namespace matching, register in `mybatisConf.xml`
4. **JSP View** — Create in `WebContent/WEB-INF/view/{module}/`, start with `<%@include file="/init.jsp" %>`
5. **Wire**`@Autowired` service into controller
**Pagination:** Use count query (`get{Feature}ListCnt`) + list query (`get{Feature}List`) with `PAGE_START`/`PAGE_END` pattern.
**Session:** Get user via `request.getSession().getAttribute(Constants.PERSON_BEAN)``PersonBean`.
**Grids:** Use jqGrid or Tabulator depending on module. Sales modules increasingly use Tabulator.
**Alerts:** Use `Swal.fire()` (SweetAlert2) for user notifications.
**Popups:** Use `window.open()` with form target submission.
## External Integrations
11 API client classes in `src/com/pms/api/` handle ERP synchronization (parts, sales slips, warehouses), Amaranth system (approvals, users), and master data sync (accounts, customers, departments, employees).
## No Automated Tests
There is no test framework configured. Testing is manual via browser.

View File

@@ -3031,10 +3031,12 @@
FROM
PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON PM.CONTRACT_OBJID = CI.CONTRACT_OBJID
AND PM.PART_OBJID = CI.PART_OBJID
-- CONTRACT_ITEM과 LEFT JOIN하여 품목별로 펼쳐서 보이기
-- LEFT JOIN CONTRACT_ITEM CI ON CM.OBJID::VARCHAR = CI.CONTRACT_OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
WHERE 1=1
AND PM.PROJECT_NO IS NOT NULL
@@ -4597,8 +4599,12 @@
FROM
PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON PM.CONTRACT_OBJID = CI.CONTRACT_OBJID
AND PM.PART_OBJID = CI.PART_OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID
AND PP.STATUS = 'active'
@@ -4731,8 +4737,13 @@
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON PM.CONTRACT_OBJID = CI.CONTRACT_OBJID
AND PM.PART_OBJID = CI.PART_OBJID AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
WHERE PM.OBJID::VARCHAR = #{projectObjid}::VARCHAR
</select>
@@ -4935,7 +4946,13 @@
FROM
PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON PM.CONTRACT_OBJID = CI.CONTRACT_OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
AND PM.PART_OBJID = CI.PART_OBJID
AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID
@@ -5008,8 +5025,12 @@
), '') AS SERIAL_NO
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT JOIN CONTRACT_ITEM CI ON CI.CONTRACT_OBJID = PM.CONTRACT_OBJID
AND CI.PART_OBJID = PM.PART_OBJID
LEFT JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
WHERE PM.OBJID::VARCHAR = #{projectObjid}
@@ -5051,8 +5072,12 @@
COALESCE(
(SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO)
FROM PROJECT_MGMT PM
JOIN CONTRACT_ITEM CI ON CI.CONTRACT_OBJID = PM.CONTRACT_OBJID
AND CI.PART_OBJID = PM.PART_OBJID AND CI.STATUS = 'ACTIVE'
JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
) AND CI.STATUS = 'ACTIVE'
JOIN CONTRACT_ITEM_SERIAL CIS ON CIS.ITEM_OBJID = CI.OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE' AND CIS.SERIAL_NO IS NOT NULL
WHERE PM.OBJID::VARCHAR = PP.PROJECT_OBJID),
@@ -5346,7 +5371,13 @@
FROM MBOM_HISTORY MH
INNER JOIN MBOM_HEADER MHD ON MH.MBOM_HEADER_OBJID = MHD.OBJID
INNER JOIN PROJECT_MGMT PM ON MHD.PROJECT_OBJID = PM.OBJID::VARCHAR
LEFT OUTER JOIN CONTRACT_ITEM CI ON PM.CONTRACT_OBJID = CI.CONTRACT_OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
WHERE 1=1
<if test="search_category_cd != null and search_category_cd != ''">
@@ -5394,5 +5425,60 @@
INNER JOIN PROJECT_MGMT PM ON MHD.PROJECT_OBJID = PM.OBJID::VARCHAR
WHERE MH.OBJID::VARCHAR = #{historyObjId}
</select>
<!-- M-BOM → ERP BOM 동기화용: 모품목-자품목 관계 추출 -->
<select id="selectMbomBomRelationsForErp" parameterType="map" resultType="map">
SELECT
ITEM_PARENT_CD,
ITEM_CHILD_CD,
JUST_QT,
SUPPLY_TYPE,
ROW_NUMBER() OVER (PARTITION BY ITEM_PARENT_CD ORDER BY SEQ, CHILD_OBJID) AS BOM_SQ
FROM (
SELECT
CASE
WHEN MD.PARENT_OBJID IS NULL OR MD.PARENT_OBJID = ''
THEN MH.PART_NO
ELSE PARENT_MD.PART_NO
END AS ITEM_PARENT_CD,
MD.PART_NO AS ITEM_CHILD_CD,
COALESCE(MD.QTY, 1) AS JUST_QT,
COALESCE(MD.SUPPLY_TYPE, '') AS SUPPLY_TYPE,
MD.SEQ,
MD.CHILD_OBJID
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH ON MH.OBJID::VARCHAR = MD.MBOM_HEADER_OBJID::VARCHAR
LEFT JOIN MBOM_DETAIL PARENT_MD
ON MD.PARENT_OBJID = PARENT_MD.CHILD_OBJID
AND MD.MBOM_HEADER_OBJID = PARENT_MD.MBOM_HEADER_OBJID
AND PARENT_MD.STATUS = 'ACTIVE'
WHERE MD.MBOM_HEADER_OBJID = #{mbomHeaderObjid}
AND MD.STATUS = 'ACTIVE'
) SUB
ORDER BY ITEM_PARENT_CD, BOM_SQ
</select>
<!-- M-BOM → ERP BOM 동기화용: DISTINCT 모품목 목록 -->
<select id="selectDistinctParentItemsForErp" parameterType="map" resultType="string">
SELECT DISTINCT ITEM_PARENT_CD
FROM (
SELECT
CASE
WHEN MD.PARENT_OBJID IS NULL OR MD.PARENT_OBJID = ''
THEN MH.PART_NO
ELSE PARENT_MD.PART_NO
END AS ITEM_PARENT_CD
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH ON MH.OBJID::VARCHAR = MD.MBOM_HEADER_OBJID::VARCHAR
LEFT JOIN MBOM_DETAIL PARENT_MD
ON MD.PARENT_OBJID = PARENT_MD.CHILD_OBJID
AND MD.MBOM_HEADER_OBJID = PARENT_MD.MBOM_HEADER_OBJID
AND PARENT_MD.STATUS = 'ACTIVE'
WHERE MD.MBOM_HEADER_OBJID = #{mbomHeaderObjid}
AND MD.STATUS = 'ACTIVE'
) SUB
WHERE ITEM_PARENT_CD IS NOT NULL AND ITEM_PARENT_CD != ''
</select>
</mapper>

View File

@@ -3933,12 +3933,13 @@
AND S.SERIAL_NO IS NOT NULL) AS SERIAL_NO
-- 요청납기: CONTRACT_ITEM 우선, 없으면 PROJECT_MGMT.DUE_DATE, 없으면 CONTRACT_MGMT.due_date
,COALESCE(
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'),
T.DUE_DATE,
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'
ORDER BY CI.OBJID DESC LIMIT 1),
T.DUE_DATE,
(SELECT CM.due_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)
) AS REQ_DEL_DATE
-- 영업관리_주문서관리_수주등록
@@ -3951,9 +3952,10 @@
,PRODUCTION_TEAM_3
-- 출하일: sales_registration 테이블에서 가져오기 (영업관리_판매관리와 동일)
,COALESCE(
(SELECT TO_CHAR(SR.shipping_date, 'YYYY-MM-DD')
FROM sales_registration SR
WHERE SR.project_no = T.PROJECT_NO),
(SELECT TO_CHAR(SR.shipping_date, 'YYYY-MM-DD')
FROM sales_registration SR
WHERE SR.project_no = T.PROJECT_NO
ORDER BY SR.sale_no DESC LIMIT 1),
''
) AS SHIPMENT_DATE
,(((SELECT SUM(COALESCE(DESIGN_RATE,'0')::INTEGER) / COUNT(1) FROM PMS_WBS_TASK AS O WHERE O.CONTRACT_OBJID = T.OBJID)
@@ -7468,6 +7470,7 @@ SELECT
,PART_NO
,PART_NAME
,QUANTITY
,CONTRACT_ITEM_OBJID
)
(
@@ -7609,12 +7612,21 @@ SELECT
,#{part_no}
,#{part_name}
,#{quantity}
,#{contract_item_objid}
FROM CONTRACT_MGMT
WHERE OBJID=#{objId}
)
</insert>
<!-- 기존 프로젝트에 CONTRACT_ITEM_OBJID 매핑 업데이트 -->
<update id="updateProjectContractItemObjid" parameterType="map">
UPDATE PROJECT_MGMT
SET CONTRACT_ITEM_OBJID = #{contract_item_objid}
WHERE CONTRACT_OBJID = #{contract_objid}
AND PART_OBJID = #{part_objid}
AND CONTRACT_ITEM_OBJID IS NULL
</update>
<!-- //계약정보를 받아 프로젝트 TASK 최초저장 -->
<insert id="insertProjectTask" parameterType="map">
INSERT INTO

View File

@@ -1560,10 +1560,12 @@
,CONTRACT_DEL_DATE = #{contract_del_date}
,CONTRACT_COMPANY = #{contract_company}
,CONTRACT_DATE = #{contract_date}
,PO_NO = #{po_no}
,MANUFACTURE_PLANT = #{manufacture_plant}
,CONTRACT_RESULT = #{contract_result}
,PROJECT_NAME = #{project_name}
,PO_NO = #{po_no}
,MANUFACTURE_PLANT = #{manufacture_plant}
<if test="contract_result != null and contract_result != ''">
,CONTRACT_RESULT = #{contract_result}
</if>
,PROJECT_NAME = #{project_name}
,SPEC_USER_ID = #{spec_user_id}
,SPEC_PLAN_DATE = #{spec_plan_date}
,SPEC_COMP_DATE = #{spec_comp_date}
@@ -1730,10 +1732,12 @@
,CONTRACT_DEL_DATE = #{contract_del_date}
,CONTRACT_COMPANY = #{contract_company}
,CONTRACT_DATE = #{contract_date}
,PO_NO = #{po_no}
,MANUFACTURE_PLANT = #{manufacture_plant}
,CONTRACT_RESULT = #{contract_result}
,PROJECT_NAME = #{project_name}
,PO_NO = #{po_no}
,MANUFACTURE_PLANT = #{manufacture_plant}
<if test="contract_result != null and contract_result != ''">
,CONTRACT_RESULT = #{contract_result}
</if>
,PROJECT_NAME = #{project_name}
,AREA_CD = #{area_cd} -->
</update>
@@ -4991,7 +4995,9 @@ WHERE
<update id="updateOrderInfo" parameterType="map">
UPDATE CONTRACT_MGMT
SET
<if test="contract_result != null and contract_result != ''">
CONTRACT_RESULT = #{contract_result},
</if>
PO_NO = #{po_no},
ORDER_DATE = #{order_date},
CONTRACT_CURRENCY = #{contract_currency},
@@ -5083,7 +5089,9 @@ WHERE
PAID_TYPE = #{paid_type},
RECEIPT_DATE = #{receipt_date},
REQ_DEL_DATE = #{req_del_date},
<if test="contract_result != null and contract_result != ''">
CONTRACT_RESULT = #{contract_result},
</if>
PO_NO = #{po_no},
ORDER_DATE = #{order_date},
CONTRACT_CURRENCY = #{contract_currency},
@@ -5629,6 +5637,67 @@ WHERE
STATUS = 'ACTIVE'
</insert>
<!-- 품목 UPSERT (수주 정보 포함 - 통합등록용) -->
<insert id="upsertContractItemWithOrder" parameterType="map">
INSERT INTO CONTRACT_ITEM (
OBJID,
CONTRACT_OBJID,
SEQ,
PART_OBJID,
PART_NO,
PART_NAME,
QUANTITY,
DUE_DATE,
CUSTOMER_REQUEST,
RETURN_REASON,
REGDATE,
WRITER,
STATUS,
ORDER_QUANTITY,
ORDER_UNIT_PRICE,
ORDER_SUPPLY_PRICE,
ORDER_VAT,
ORDER_TOTAL_AMOUNT
) VALUES (
#{objId},
#{contractObjId},
#{seq},
#{partObjId},
#{partNo},
#{partName},
CASE WHEN #{quantity} = '' OR #{quantity} IS NULL THEN NULL ELSE #{quantity}::INTEGER END,
#{dueDate},
#{customerRequest},
#{returnReason},
NOW(),
#{writer},
'ACTIVE',
#{orderQuantity},
#{orderUnitPrice},
#{orderSupplyPrice},
#{orderVat},
#{orderTotalAmount}
)
ON CONFLICT (OBJID) DO UPDATE
SET
SEQ = #{seq},
PART_OBJID = #{partObjId},
PART_NO = #{partNo},
PART_NAME = #{partName},
QUANTITY = CASE WHEN #{quantity} = '' OR #{quantity} IS NULL THEN NULL ELSE #{quantity}::INTEGER END,
DUE_DATE = #{dueDate},
CUSTOMER_REQUEST = #{customerRequest},
RETURN_REASON = #{returnReason},
CHGDATE = NOW(),
CHG_USER_ID = #{writer},
STATUS = 'ACTIVE',
ORDER_QUANTITY = #{orderQuantity},
ORDER_UNIT_PRICE = #{orderUnitPrice},
ORDER_SUPPLY_PRICE = #{orderSupplyPrice},
ORDER_VAT = #{orderVat},
ORDER_TOTAL_AMOUNT = #{orderTotalAmount}
</insert>
<!-- S/N UPSERT (INSERT or UPDATE) -->
<insert id="upsertContractItemSerial" parameterType="map">
INSERT INTO CONTRACT_ITEM_SERIAL (

View File

@@ -844,14 +844,9 @@
FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID) AS PAYMENT_TYPE,
T.PART_NO AS PRODUCT_NO,
T.PART_NAME AS PRODUCT_NAME,
-- S/N: CONTRACT_ITEM_SERIAL(마스터) 우선, 없으면 판매등록 텍스트 fallback (그리드 요약용)
-- S/N: CONTRACT_ITEM_SERIAL(마스터) 우선, 없으면 판매등록 텍스트 fallback (콤마 구분 전체 목록)
COALESCE(
(SELECT
CASE
WHEN COUNT(*) = 0 THEN NULL
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건'
END
(SELECT STRING_AGG(CIS.SERIAL_NO, ',' ORDER BY CIS.SEQ)
FROM CONTRACT_ITEM CI
LEFT JOIN CONTRACT_ITEM_SERIAL CIS ON CI.OBJID = CIS.ITEM_OBJID AND UPPER(CIS.STATUS) = 'ACTIVE'
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
@@ -860,23 +855,34 @@
AND CIS.SERIAL_NO IS NOT NULL),
SR.serial_no
) AS SERIAL_NO,
-- 분할S/N: shipment_log에서 해당 프로젝트의 모든 분할S/N 집계
COALESCE(
(SELECT STRING_AGG(SL_SN.split_serial_no, ',' ORDER BY SL_SN.log_id)
FROM shipment_log SL_SN
WHERE SL_SN.target_objid = T.PROJECT_NO
AND SL_SN.split_serial_no IS NOT NULL
AND SL_SN.split_serial_no != ''),
''
) AS SPLIT_SERIAL_NO,
COALESCE(NULLIF(REPLACE(T.QUANTITY, ',', ''), '')::numeric, 0) AS ORDER_QUANTITY,
-- 요청납기: CONTRACT_ITEM 우선, 없으면 PROJECT_MGMT, 없으면 CONTRACT_MGMT
COALESCE(
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'),
T.DUE_DATE,
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'
ORDER BY CI.OBJID DESC LIMIT 1),
T.DUE_DATE,
(SELECT CM.due_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)
) AS REQUEST_DATE,
-- 고객요청사항: CONTRACT_ITEM에서만 가져옴 (견적관리와 완전히 동일)
(SELECT CI.CUSTOMER_REQUEST
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE') AS CUSTOMER_REQUEST,
(SELECT CI.CUSTOMER_REQUEST
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'
ORDER BY CI.OBJID DESC LIMIT 1) AS CUSTOMER_REQUEST,
CODE_NAME(T.CONTRACT_RESULT) AS ORDER_STATUS,
(SELECT CM.PO_NO FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID) AS PO_NO,
COALESCE(T.CONTRACT_DATE, (SELECT CM.order_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)) AS ORDER_DATE,
@@ -1572,20 +1578,22 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
COALESCE(NULLIF(REPLACE(T.QUANTITY, ',', ''), '')::numeric, 0) AS ORDER_QUANTITY,
-- 요청납기: CONTRACT_ITEM 우선, 없으면 PROJECT_MGMT, 없으면 CONTRACT_MGMT
COALESCE(
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'),
T.DUE_DATE,
(SELECT CI.DUE_DATE
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'
ORDER BY CI.OBJID DESC LIMIT 1),
T.DUE_DATE,
(SELECT CM.due_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)
) AS REQUEST_DATE,
-- 고객요청사항: CONTRACT_ITEM에서만 가져옴 (견적관리와 완전히 동일)
(SELECT CI.CUSTOMER_REQUEST
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE') AS CUSTOMER_REQUEST,
(SELECT CI.CUSTOMER_REQUEST
FROM CONTRACT_ITEM CI
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID
AND CI.STATUS = 'ACTIVE'
ORDER BY CI.OBJID DESC LIMIT 1) AS CUSTOMER_REQUEST,
CODE_NAME(T.CONTRACT_RESULT) AS ORDER_STATUS,
T.PO_NO,
COALESCE(T.CONTRACT_DATE, (SELECT CM.order_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)) AS ORDER_DATE,
@@ -1898,7 +1906,7 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
remaining_quantity, shipping_status, shipping_date, shipping_method,
sales_unit_price, sales_supply_price, sales_vat, sales_total_amount,
sales_currency, sales_exchange_rate, manager_user_id, incoterms,
serial_no, parent_sale_no, reg_user_id
serial_no, parent_sale_no, reg_user_id, split_serial_no
) VALUES (
#{targetObjid}, 'SPLIT_SHIPMENT', '분할 출하',
#{salesQuantity}::integer, #{originalQuantity}::integer, #{remainingQuantity}::integer,
@@ -1912,7 +1920,7 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
#{shippingMethod}, #{salesUnitPrice}::numeric, #{salesSupplyPrice}::numeric,
#{salesVat}::numeric, #{salesTotalAmount}::numeric, #{salesCurrency},
#{salesExchangeRate}::numeric, #{managerUserId}, #{incoterms}, #{serialNo},
#{parentSaleNo}::integer, #{cretEmpNo}
#{parentSaleNo}::integer, #{cretEmpNo}, #{splitSerialNo}
)
</insert>
@@ -1990,6 +1998,7 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
WHERE PM.PROJECT_NO = SL.target_objid AND CI.STATUS = 'ACTIVE'
), '-') AS serial_no,
SL.target_objid AS project_no,
COALESCE(SL.split_serial_no, '-') AS split_serial_no,
TO_CHAR(SL.reg_date, 'YYYY-MM-DD HH24:MI:SS') AS reg_date
FROM shipment_log SL
WHERE SL.target_objid = #{projectNo}
@@ -2021,6 +2030,7 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
COALESCE(SL.sales_exchange_rate, 0) AS SALES_EXCHANGE_RATE,
COALESCE(SL.manager_user_id, '') AS MANAGER,
COALESCE(SL.incoterms, '') AS INCOTERMS,
COALESCE(SL.split_serial_no, '') AS SPLIT_SERIAL_NO,
COALESCE(SL.original_quantity, 0) AS ORDER_QUANTITY,
COALESCE(SL.remaining_quantity, 0) AS REMAINING_QUANTITY
FROM shipment_log SL
@@ -2066,6 +2076,9 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
<if test="incoterms != null and incoterms != ''">
, incoterms = #{incoterms}
</if>
<if test="splitSerialNo != null and splitSerialNo != ''">
, split_serial_no = #{splitSerialNo}
</if>
WHERE log_id = #{logId}::integer
</update>
@@ -2402,6 +2415,7 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
WHERE CI.CONTRACT_OBJID = T.CONTRACT_OBJID
AND CI.PART_OBJID = T.PART_OBJID AND CI.STATUS = 'ACTIVE'
), '') AS SERIAL_NO,
COALESCE(SL.split_serial_no, '') AS SPLIT_SERIAL_NO,
COALESCE(NULLIF(REPLACE(T.QUANTITY, ',', ''), '')::numeric, 0) AS ORDER_QUANTITY,
(SELECT CM.PO_NO FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID) AS PO_NO,
COALESCE(T.CONTRACT_DATE, (SELECT CM.order_date FROM CONTRACT_MGMT CM WHERE CM.OBJID = T.CONTRACT_OBJID)) AS ORDER_DATE,
@@ -2738,6 +2752,22 @@ ORDER BY T.REGDATE DESC, T.PROJECT_NO DESC
WHERE PROJECT_NO = #{projectNo}
</update>
<!-- 프로젝트의 이미 사용된 분할S/N 목록 조회 -->
<select id="getUsedSplitSerialNos" parameterType="map" resultType="map">
/* salesNcollectMgmt.getUsedSplitSerialNos - 이미 사용된 분할S/N 조회 */
SELECT
SL.log_id,
COALESCE(SL.split_serial_no, '') AS split_serial_no
FROM shipment_log SL
WHERE SL.target_objid = #{projectNo}
<if test="excludeLogId != null and excludeLogId != ''">
AND SL.log_id != #{excludeLogId}::integer
</if>
AND SL.split_serial_no IS NOT NULL
AND SL.split_serial_no != ''
ORDER BY SL.log_id
</select>
</mapper>

View File

@@ -36,7 +36,65 @@
#serial_no:-ms-input-placeholder {
color: #999 !important;
}
/* ===== 팝업 폼 전체 너비 확장 ===== */
section.business_popup_min_width {
padding: 0 !important;
margin: 0 auto !important;
width: calc(100% - 16px) !important;
}
#EntirePopupFormWrap {
width: 100% !important;
margin: 5px 0 0 0 !important;
}
/* ===== 품목 그리드 컬럼 리사이즈 ===== */
#EntirePopupFormWrap > table {
width: 100% !important;
}
#itemListTable {
table-layout: fixed;
width: 100%;
}
#itemListTable th {
position: relative;
user-select: none;
}
#itemListTable th .col-resizer {
position: absolute;
right: -4.5px;
top: 0;
width: 7px;
height: 100%;
cursor: col-resize;
z-index: 10;
}
#itemListTable th .col-resizer:hover {
background: rgba(0, 120, 215, 0.3);
}
/* ===== Select2 드래그 복사 지원 ===== */
.select2-container .select2-selection__rendered {
user-select: text !important;
-webkit-user-select: text !important;
cursor: text;
padding-right: 36px !important;
}
/* x(초기화) 버튼을 드롭다운 화살표 왼쪽에 배치 */
#itemListTable .select2-selection__clear {
position: absolute !important;
right: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: #999;
cursor: pointer;
user-select: none;
}
#itemListTable .select2-selection--single {
position: relative;
}
</style>
<script type="text/javascript">
// 품목 관리 변수
@@ -50,9 +108,103 @@
var hasProject = ("${hasProject}" === "Y");
var isMachine = ("${isMachine}" === "Y");
// ===== 컬럼 리사이즈 기능 =====
function fn_initColumnResize() {
var $table = $('#itemListTable');
if($table.length === 0) return;
// 각 th에 리사이저 핸들 추가
$table.find('thead th').each(function() {
if($(this).find('.col-resizer').length === 0) {
$(this).append('<div class="col-resizer"></div>');
}
});
var startX, startWidth, $th, colIndex, $col;
$(document).on('mousedown', '#itemListTable .col-resizer', function(e) {
$th = $(this).parent();
colIndex = $th.index();
$col = $table.find('colgroup col').eq(colIndex);
startX = e.pageX;
startWidth = $th.outerWidth();
$('body').css('cursor', 'col-resize');
$(document).on('mousemove.colResize', function(e) {
var newWidth = startWidth + (e.pageX - startX);
if(newWidth >= 40) {
$col.css('width', newWidth + 'px');
$th.css({'width': newWidth + 'px', 'min-width': newWidth + 'px'});
}
});
$(document).on('mouseup.colResize', function() {
$('body').css('cursor', '');
$(document).off('mousemove.colResize mouseup.colResize');
});
e.preventDefault();
e.stopPropagation();
});
}
// ===== Select2 드래그 복사 지원 =====
function fn_initSelect2DragCopy() {
var mouseTarget = null;
var wasDragged = false;
var startX = 0, startY = 0;
// select2:opening 상시 감시 — mouseTarget이 설정되어 있으면 열림 차단
$(document).on('select2:opening', '#itemListTable select', function(e) {
if(mouseTarget === this) {
e.preventDefault();
}
});
// capturing phase: Select2 핸들러보다 먼저 mouseTarget 플래그 설정
document.addEventListener('mousedown', function(e) {
var $sel = $(e.target).closest('#itemListTable .select2-selection');
if($sel.length === 0) return;
if($(e.target).hasClass('select2-selection__clear')) return;
var $container = $sel.closest('.select2-container');
var $select = $container.prev('select');
mouseTarget = $select[0]; // Select2 열림 차단 활성화
wasDragged = false;
startX = e.pageX;
startY = e.pageY;
var onMove = function(ev) {
if(Math.abs(ev.pageX - startX) > 3 || Math.abs(ev.pageY - startY) > 3) {
wasDragged = true;
}
};
var onUp = function() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if(!wasDragged) {
mouseTarget = null;
$select.select2('open');
}
setTimeout(function() { mouseTarget = null; }, 50);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, true); // capturing phase — Select2보다 먼저 실행
}
$(function() {
// 반납사유 옵션을 템플릿에서 읽어옴
returnReasonOptions = $('#return_reason_template').html();
// 컬럼 리사이즈 초기화
fn_initColumnResize();
// Select2 드래그 복사 초기화
fn_initSelect2DragCopy();
// 기존 품목 데이터 로드
fn_loadExistingItems();

View File

@@ -36,6 +36,65 @@
#serial_no:-ms-input-placeholder {
color: #999 !important;
}
/* ===== 팝업 폼 전체 너비 확장 ===== */
section.business_popup_min_width {
padding: 0 !important;
margin: 0 auto !important;
width: calc(100% - 16px) !important;
}
#EntirePopupFormWrap {
width: 100% !important;
margin: 5px 0 0 0 !important;
}
/* ===== 품목 그리드 컬럼 리사이즈 ===== */
#EntirePopupFormWrap > table {
width: 100% !important;
}
#itemListTable {
table-layout: fixed;
width: 100%;
}
#itemListTable th {
position: relative;
user-select: none;
}
#itemListTable th .col-resizer {
position: absolute;
right: -4.5px;
top: 0;
width: 7px;
height: 100%;
cursor: col-resize;
z-index: 10;
}
#itemListTable th .col-resizer:hover {
background: rgba(0, 120, 215, 0.3);
}
/* ===== Select2 드래그 복사 지원 ===== */
.select2-container .select2-selection__rendered {
user-select: text !important;
-webkit-user-select: text !important;
cursor: text;
padding-right: 36px !important;
}
/* x(초기화) 버튼을 드롭다운 화살표 왼쪽에 배치 */
#itemListTable .select2-selection__clear {
position: absolute !important;
right: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: #999;
cursor: pointer;
user-select: none;
}
#itemListTable .select2-selection--single {
position: relative;
}
</style>
<script type="text/javascript">
// 품목 관리 변수
@@ -50,10 +109,104 @@
// 프로젝트 존재 여부 체크 변수
var hasProject = false;
// ===== 컬럼 리사이즈 기능 =====
function fn_initColumnResize() {
var $table = $('#itemListTable');
if($table.length === 0) return;
// 각 th에 리사이저 핸들 추가
$table.find('thead th').each(function() {
if($(this).find('.col-resizer').length === 0) {
$(this).append('<div class="col-resizer"></div>');
}
});
var startX, startWidth, $th, colIndex, $col;
$(document).on('mousedown', '#itemListTable .col-resizer', function(e) {
$th = $(this).parent();
colIndex = $th.index();
$col = $table.find('colgroup col').eq(colIndex);
startX = e.pageX;
startWidth = $th.outerWidth();
$('body').css('cursor', 'col-resize');
$(document).on('mousemove.colResize', function(e) {
var newWidth = startWidth + (e.pageX - startX);
if(newWidth >= 40) {
$col.css('width', newWidth + 'px');
$th.css({'width': newWidth + 'px', 'min-width': newWidth + 'px'});
}
});
$(document).on('mouseup.colResize', function() {
$('body').css('cursor', '');
$(document).off('mousemove.colResize mouseup.colResize');
});
e.preventDefault();
e.stopPropagation();
});
}
// ===== Select2 드래그 복사 지원 =====
function fn_initSelect2DragCopy() {
var mouseTarget = null;
var wasDragged = false;
var startX = 0, startY = 0;
// select2:opening 상시 감시 — mouseTarget이 설정되어 있으면 열림 차단
$(document).on('select2:opening', '#itemListTable select', function(e) {
if(mouseTarget === this) {
e.preventDefault();
}
});
// capturing phase: Select2 핸들러보다 먼저 mouseTarget 플래그 설정
document.addEventListener('mousedown', function(e) {
var $sel = $(e.target).closest('#itemListTable .select2-selection');
if($sel.length === 0) return;
if($(e.target).hasClass('select2-selection__clear')) return;
var $container = $sel.closest('.select2-container');
var $select = $container.prev('select');
mouseTarget = $select[0]; // Select2 열림 차단 활성화
wasDragged = false;
startX = e.pageX;
startY = e.pageY;
var onMove = function(ev) {
if(Math.abs(ev.pageX - startX) > 3 || Math.abs(ev.pageY - startY) > 3) {
wasDragged = true;
}
};
var onUp = function() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if(!wasDragged) {
mouseTarget = null;
$select.select2('open');
}
setTimeout(function() { mouseTarget = null; }, 50);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, true); // capturing phase — Select2보다 먼저 실행
}
$(function() {
// 반납사유 옵션을 템플릿에서 읽어옴
returnReasonOptions = $('#return_reason_template').html();
// 컬럼 리사이즈 초기화
fn_initColumnResize();
// Select2 드래그 복사 초기화
fn_initSelect2DragCopy();
//alert("${info.CATEGORY_CD}")
if("${info.CATEGORY_CD}" == '0000170' || "${info.CATEGORY_CD}" == '0000171'){//오버홀, 개조
$(".DIRECT").show();

View File

@@ -0,0 +1,98 @@
# GUX 계획서 — 팝업 폼 그리드 UX 개선
## 개요
영업관리_견적관리_견적요청등록, 영업관리_주문서관리_수주통합등록 2개 팝업 화면에서:
1. 팝업 폼 전체 가로 너비를 확장하여 좌우 여백을 최소화
2. 품목 그리드 컬럼 경계선 드래그로 가로 너비 조절 가능하게
3. Select2(품번/품명) 선택값 드래그 복사 시 드롭다운 열림 방지
## 현재 동작
- `#EntirePopupFormWrap``width: 97.5%`, `margin: 5px auto`로 설정되어 팝업 윈도우 대비 좌우 여백 발생
- 품목 그리드 컬럼 너비가 고정(colgroup %)이라 품번/품명 등 내용이 잘려서 표시됨 (예: "000AN01... x")
- 컬럼 리사이즈 기능 없음
- Select2 선택값을 드래그하면 텍스트 선택 대신 드롭다운이 열림
## 변경 후 동작
- 팝업 폼이 좌우 5~10px 여백만 남기고 윈도우를 거의 채움
- 기본정보 영역과 품목 그리드 모두 넓어짐
- 품목 그리드 `<th>` 헤더 경계를 드래그하여 컬럼 너비 조절 가능
- 품번/품명 Select2에서 선택된 값을 드래그하면 텍스트 선택 동작 (드롭다운 열리지 않음)
## 시각적 예시
```
변경 전:
|---여백---|========== 폼 (97.5%) ==========|---여백---|
변경 후:
|-5px-|=============== 폼 (≈99%) ===============|-5px-|
```
## 아키텍처
```mermaid
graph TD
A[JSP style 태그] -->|CSS 오버라이드| B[#EntirePopupFormWrap 너비 확장]
A -->|CSS 추가| C[th resize: horizontal]
A -->|JS 추가| D[Select2 mousedown 이벤트 핸들링]
B --> E[기본정보 + 품목 그리드 동시 확장]
C --> F[컬럼 경계 드래그 리사이즈]
D --> G[드래그 시 드롭다운 방지, 클릭 시 정상 동작]
```
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `WebContent/WEB-INF/view/contractMgmt/estimateRegistFormPopup.jsp` | style 태그에 CSS/JS 추가 |
| `WebContent/WEB-INF/view/contractMgmt/estimateAndOrderRegistFormPopup.jsp` | 동일 |
**수정하지 않는 파일:** CSS 파일(basic_new.css, d_basic.css 등), common.js, 백엔드 코드
## 코드 설계
### 1. 폼 너비 확장 (CSS)
```css
section.business_popup_min_width {
padding: 0 5px !important;
margin: 0 !important;
}
#EntirePopupFormWrap {
width: 100% !important;
margin: 5px 0 0 0 !important;
}
```
### 2. 컬럼 리사이즈 (CSS)
```css
#itemListTable th {
resize: horizontal;
overflow: hidden;
min-width: 40px;
}
#itemListTable {
table-layout: auto;
}
```
### 3. Select2 드래그 복사 (JS)
- Select2 `select2:opening` 이벤트에서 마우스 드래그 중이면 `preventDefault`
- `mousedown``mousemove` 감지로 드래그 판별
- 단순 클릭은 기존대로 드롭다운 열림
## 예상 문제 및 대응
| 문제 | 원인 | 대응 방안 |
|------|------|-----------|
| CSS `resize: horizontal``<th>`에서 미동작 | 일부 브라우저에서 테이블 셀 resize 미지원 | JS 기반 컬럼 리사이즈로 전환 |
| 폼 확장 시 기본정보 레이아웃 깨짐 | colgroup %가 넓어진 너비에서 비율 유지 | % 기반이므로 자동 조정됨 |
| Select2 드래그 감지 오작동 | mousedown→mousemove 임계값 부정확 | 이동 거리 5px 이상을 드래그로 판별 |
## 설계 원칙
- JSP 내부 `<style>` 태그로만 수정하여 다른 팝업에 영향 없도록 격리
- 외부 라이브러리 추가 없이 순수 CSS + JS로 구현
- 기존 품목 추가/수정/삭제/저장 기능에 영향 없음

View File

@@ -0,0 +1,41 @@
# GUX 맥락노트 — 팝업 폼 그리드 UX 개선
## 왜 이 작업을 하는가
수주통합등록, 견적요청등록 팝업에서 품목 그리드의 품번/품명 값이 좁은 컬럼 너비로 인해 잘려서 보이고, 사용자가 값을 확인하거나 복사하기 어려운 UX 문제가 있다. 팝업 윈도우 대비 폼 영역이 좁아 좌우 여백이 낭비되고 있어, 이를 활용하면 전체적인 가독성과 사용성이 개선된다.
## 핵심 결정 사항과 근거
### 결정 1: JSP 내부 style로만 수정 (CSS 파일 미수정)
- **결정:** 각 JSP의 `<style>` 태그에 CSS 오버라이드 추가
- **근거:** d_basic.css, basic_new.css 등을 수정하면 모든 팝업에 영향. 해당 2개 화면만 변경하기 위해 JSP 로컬 스타일로 격리
- **실패한 대안:** CSS 파일 직접 수정 → 다른 팝업 레이아웃 깨질 위험
### 결정 2: CSS `resize: horizontal` 우선 시도
- **결정:** `<th>`에 CSS resize 속성으로 컬럼 리사이즈 구현
- **근거:** JS 기반보다 구현이 간단하고 브라우저 네이티브 기능. Chrome/Edge에서 동작 확인 필요
- **실패 시 대안:** JS 기반 마우스 드래그 리사이즈로 전환
### 결정 3: Select2 드래그 복사는 이벤트 핸들링 방식 (A안)
- **결정:** mousedown→mousemove 감지로 드래그 시 `select2:opening` 방지
- **근거:** 사용자가 기존 UX(클릭으로 드롭다운 열기)를 유지하면서 드래그 복사도 가능하길 원함
- **실패한 대안:** B안(복사 아이콘) — 사용자가 A안 선택
## 관련 파일 위치
| 파일 | 역할 |
|------|------|
| `WebContent/WEB-INF/view/contractMgmt/estimateRegistFormPopup.jsp` | 견적요청등록 팝업 (2385줄) |
| `WebContent/WEB-INF/view/contractMgmt/estimateAndOrderRegistFormPopup.jsp` | 수주통합등록 팝업 (1552줄) |
| `WebContent/css/basic_new.css` | 현재 로드되는 CSS (init_new.jsp 경유) |
| `WebContent/css/d_basic.css` | `#EntirePopupFormWrap` width: 97.5% 정의 |
## 기술 참고
- Select2 4.x의 `select2:opening` 이벤트는 `preventDefault()`로 열림 방지 가능
- CSS `resize` 속성은 `overflow: hidden/scroll/auto`와 함께 사용해야 동작
- `table-layout: auto`로 변경하면 컬럼이 내용에 맞게 자동 확장 가능
- 팝업 윈도우 크기: 견적요청등록 1400x560, 수주통합등록 1600x800

View File

@@ -0,0 +1,44 @@
# GUX 체크리스트 — 팝업 폼 그리드 UX 개선
## 공정 상태
진행률: 100%
## 구현 체크리스트
### estimateAndOrderRegistFormPopup.jsp (수주통합등록)
- [x] 폼 전체 너비 확장 + 중앙 정렬 CSS 추가
- [x] 품목 그리드 컬럼 리사이즈 CSS/JS 추가
- [x] Select2 드래그 복사 이벤트 핸들링 추가
- [x] Select2 x(초기화) 버튼 위치 이동
### estimateRegistFormPopup.jsp (견적요청등록)
- [x] 폼 전체 너비 확장 + 중앙 정렬 CSS 추가
- [x] 품목 그리드 컬럼 리사이즈 CSS/JS 추가
- [x] Select2 드래그 복사 이벤트 핸들링 추가
- [x] Select2 x(초기화) 버튼 위치 이동
## 검증 체크리스트
- [x] 수주통합등록: 폼이 팝업 윈도우 중앙 정렬, 좌우 8px 여백
- [x] 수주통합등록: 기본정보 영역 레이아웃 정상
- [x] 수주통합등록: 품목 그리드 컬럼 헤더 경계 드래그로 리사이즈 가능
- [x] 수주통합등록: 품번/품명 Select2 클릭 → 드롭다운 정상 열림
- [x] 수주통합등록: 품번/품명 Select2 드래그 → 텍스트 선택 가능 (드롭다운 안 열림)
- [x] 수주통합등록: 품목 추가/수정/삭제/저장 정상 동작
- [x] 견적요청등록: 위와 동일 항목 모두 정상
## 정리
- [x] 불필요한 코드/주석 정리
- [x] 커밋 완료
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-26 | PCC 문서 최초 생성 |
| 2026-03-26 | 구현 완료, Playwright 1차 검증 통과 |
| 2026-03-26 | Step 5 디버그: 컬럼 리사이즈 colgroup 연동, Select2 드래그 복사 capturing phase 방식 |
| 2026-03-26 | Select2 x버튼 위치 이동, 리사이즈 호버 영역 좌우 대칭, 폼 중앙 정렬 |
| 2026-03-26 | 사용자 2차 테스트 전체 통과 — 완료 |