Compare commits

...

23 Commits

Author SHA1 Message Date
2e0ff4ae6f feat: 지붕재적합성 조회 페이징&인피니티쿼리 적용, 체크박스 선택 처리 추가 2025-05-22 18:16:31 +09:00
80194efec1 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/suitable 2025-05-22 17:05:09 +09:00
c76526bf9b chore: Update package.json scripts and enhance middleware session handling
- Added local build and start scripts to package.json for improved local development.
- Commented out login redirection logic in middleware for future implementation.
- Introduced a new checkbox in the sample page for enhanced UI functionality.
- Refactored useAxios to improve request and response handling with better modularization.
- Updated checkbox styles in SCSS for improved visual consistency.
2025-05-22 16:57:54 +09:00
d31a189899 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/suitable 2025-05-22 16:24:50 +09:00
e8498948df refactor: Update tracking function to utilize useAxios for API calls
- Replaced direct axiosInstance import with useAxios hook for improved API management.
- Enhanced code structure for better readability and maintainability.
2025-05-22 15:07:41 +09:00
0a88c80bd9 refactor: Update Header component to utilize useAxios for API integration
- Replaced direct axiosInstance import with useAxios hook for improved API management.
- Cleaned up imports and enhanced code structure for better readability.
2025-05-22 15:04:59 +09:00
e846b55faa chore: Update environment configuration and enhance API integration
- Added NEXT_PUBLIC_RUN_MODE to .env files for environment differentiation.
- Introduced .env.localhost for local development settings.
- Updated .env.production with the correct API URL.
- Modified package.json scripts to utilize env-cmd for environment-specific commands.
- Created a common configuration module to manage environment-specific settings.
- Refactored useAxios to utilize the new configuration for base URL management.
- Enhanced Footer component to display current configuration values.
2025-05-22 15:00:41 +09:00
ac575d76d1 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/suitable 2025-05-22 14:15:43 +09:00
2c5ddad29b refactor: Enhance survey hook and integrate spinner functionality
- Refactored useSurvey hook to utilize useAxios for API calls.
- Added spinner visibility management in EdgeProvider to improve loading feedback.
- Cleaned up imports and organized code structure for better readability.
2025-05-22 14:08:23 +09:00
0e7de68f29 feat: Add Spinner component and update styles
- Introduced a new Spinner component for loading indicators.
- Removed unused btn_arr_up.svg asset and related styles.
- Updated radio button styles for improved UI consistency.
- Added new PDF view styles for enhanced document presentation.
- Included spinner styles for better loading visuals.
2025-05-22 13:24:39 +09:00
956e4ed910 Merge pull request 'feature/survey - 조사매물 목록 조회 필터링 조건 변경 및 임시저장 구현' (#42) from feature/survey into dev
Reviewed-on: #42
2025-05-22 10:27:55 +09:00
bdbdf9997f fix: change the SRL_NO at temporary save 2025-05-22 09:05:41 +09:00
a483ffce44 fix: Detailed error resolution
- 단열재 유무 기타 input 창 나오지 않는 오류 해결
- T01 임시저장 데이터 조회 가능하도록 로직 수정정
2025-05-21 18:21:22 +09:00
1057c29995 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-21 17:54:23 +09:00
333943c651 feat: add SUBMISSION_TARGET_ID, SRL_NO Column
- 제출 대상 판매점 ID, 일련번호 컬럼 추가
- 임시 저장 시 일련번호에 '임시저장000' 으로 저장
- 조사매물 목록 조회 필터링 조건 수정
- url 에러 핸들링
2025-05-21 17:53:58 +09:00
e69c45105e Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-21 11:17:26 +09:00
1ab4f82c32 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-21 11:09:05 +09:00
2f8823c252 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-21 10:57:12 +09:00
a58c58afca fix: fix prisma syntax for filtering temporary save data for T01 to Read survey list 2025-05-21 10:29:08 +09:00
b3550ff497 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-21 10:19:49 +09:00
67d587acf5 feat: Implement Separate temporary save data logic 2025-05-21 10:19:38 +09:00
7ae297f3b6 Merge branch 'dev' of https://git.hanasys.jp/qcast3/onsitesurvey into feature/survey 2025-05-20 17:56:21 +09:00
905a309a9c fix: survey update, create, submit logic fix
- 조사매물 수정, 작성, 조회 시 각 컴포넌트에 데이터 적용 안되던 문제 해결
- 조사매물 제출 시 데이터 저장 안되는 문제 해결결
2025-05-20 17:56:09 +09:00
47 changed files with 1008 additions and 454 deletions

View File

@ -1,3 +1,4 @@
NEXT_PUBLIC_RUN_MODE=development
# 모바일 디바이스로 로컬 서버 확인하려면 자신 IP 주소로 변경
# 다시 로컬에서 개발할때는 localhost로 변경
#route handler

18
.env.localhost Normal file
View File

@ -0,0 +1,18 @@
NEXT_PUBLIC_RUN_MODE=local
# 모바일 디바이스로 로컬 서버 확인하려면 자신 IP 주소로 변경
# 다시 로컬에서 개발할때는 localhost로 변경
#route handler
NEXT_PUBLIC_API_URL=http://localhost:3000
#qsp 로그인 api
NEXT_PUBLIC_QSP_API_URL=http://1.248.227.176:8120
#1:1문의 api
NEXT_PUBLIC_INQUIRY_API_URL=http://1.248.227.176:38080
#QPARTNER 로그인 api
DB_HOST=202.218.61.226
DB_USER=readonly
DB_PASSWORD=aAjmFW12iHKW84l1
DB_DATABASE=qpartners
DB_PORT=3306

View File

@ -1,5 +1,6 @@
NEXT_PUBLIC_RUN_MODE=production
#route handler
NEXT_PUBLIC_API_URL=http://172.30.1.35:3000
NEXT_PUBLIC_API_URL=http://1.248.227.176:3000
#qsp 로그인 api
NEXT_PUBLIC_QSP_API_URL=http://1.248.227.176:8120

View File

@ -3,9 +3,15 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "env-cmd -f .env.localhost next dev --turbopack",
"build": "next build",
"start": "next start",
"build:local": "env-cmd -f .env.localhost next build",
"build:dev": "env-cmd -f .env.development next build",
"build:prod": "env-cmd -f .env.production next build",
"start:local": "env-cmd -f .env.localhost next start",
"start:dev": "env-cmd -f .env.development next start",
"start:prod": "env-cmd -f .env.production next start",
"lint": "next lint"
},
"dependencies": {
@ -13,6 +19,7 @@
"@tanstack/react-query": "^5.71.0",
"@tanstack/react-query-devtools": "^5.71.0",
"axios": "^1.8.4",
"env-cmd": "^10.1.0",
"iron-session": "^8.0.4",
"lucide": "^0.503.0",
"mssql": "^11.0.1",

63
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
axios:
specifier: ^1.8.4
version: 1.8.4
env-cmd:
specifier: ^10.1.0
version: 10.1.0
iron-session:
specifier: ^8.0.4
version: 8.0.4
@ -801,6 +804,10 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@ -808,6 +815,10 @@ packages:
core-js@3.41.0:
resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
@ -866,6 +877,11 @@ packages:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
env-cmd@10.1.0:
resolution: {integrity: sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==}
engines: {node: '>=8.0.0'}
hasBin: true
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@ -1021,6 +1037,9 @@ packages:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.4.2:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
@ -1217,6 +1236,10 @@ packages:
resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==}
engines: {node: '>=18'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
@ -1324,6 +1347,14 @@ packages:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
@ -1423,6 +1454,11 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
@ -2070,11 +2106,19 @@ snapshots:
commander@11.1.0: {}
commander@4.1.1: {}
cookie@0.7.2: {}
core-js@3.41.0:
optional: true
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
@ -2123,6 +2167,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
env-cmd@10.1.0:
dependencies:
commander: 4.1.1
cross-spawn: 7.0.6
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@ -2297,6 +2346,8 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.4.2: {}
js-md4@0.3.2: {}
@ -2500,6 +2551,8 @@ snapshots:
is-inside-container: 1.0.0
is-wsl: 3.1.0
path-key@3.1.1: {}
performance-now@2.1.0:
optional: true
@ -2622,6 +2675,12 @@ snapshots:
'@img/sharp-win32-x64': 0.33.5
optional: true
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
@ -2703,6 +2762,10 @@ snapshots:
uuid@8.3.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
zustand@5.0.3(@types/react@19.0.12)(react@19.1.0):
optionalDependencies:
'@types/react': 19.0.12

View File

@ -1,3 +0,0 @@
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5L5 1L9 5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 207 B

View File

@ -12,7 +12,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '페이지 번호와 페이지당 아이템 수가 필요합니다' }, { status: 400 })
}
const ids = searchParams.get('ids')
const category = searchParams.get('category')
const keyword = searchParams.get('keyword')
@ -20,14 +19,13 @@ export async function GET(request: NextRequest) {
SELECT
msm.id
, msm.product_name
, msm.manu_ft_cd
, msm.roof_mt_cd
, msm.roof_sh_cd
, details.detail_cnt
, details.detail
FROM ms_suitable_main msm
LEFT JOIN (
SELECT
msd.main_id
, COUNT(msd.id) AS detail_cnt
, (
SELECT
msd_json.id
@ -42,9 +40,7 @@ export async function GET(request: NextRequest) {
GROUP BY msd.main_id
) AS details
ON msm.id = details.main_id
--mainIds AND details.main_id IN (:mainIds)
WHERE 1=1
--mainIds AND msm.id IN (:mainIds)
--roofMtCd AND msm.roof_mt_cd = ':roofMtCd'
--productName AND msm.product_name LIKE '%:productName%'
ORDER BY msm.product_name
@ -53,10 +49,6 @@ export async function GET(request: NextRequest) {
`
// 검색 조건 설정
if (ids) {
query = query.replaceAll('--mainIds ', '')
query = query.replaceAll(':mainIds', ids)
}
if (category) {
query = query.replace('--roofMtCd ', '')
query = query.replace(':roofMtCd', category)
@ -66,7 +58,6 @@ export async function GET(request: NextRequest) {
query = query.replace(':productName', keyword)
}
// @ts-ignore
const suitable: Suitable[] = await prisma.$queryRawUnsafe(query, pageNumber, itemPerPage)
return NextResponse.json(suitable)

View File

@ -19,34 +19,55 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
const getNewSrlNo = async (srlNo: string, storeId: string) => {
let newSrlNo = srlNo
console.log('srlNo:: ', srlNo)
if (srlNo.startsWith('一時保存')) {
//@ts-ignore
const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({
where: {
SRL_NO: {
startsWith: storeId,
},
},
orderBy: {
ID: 'desc',
},
})
const lastNo = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0
newSrlNo =
storeId +
new Date().getFullYear().toString().slice(-2) +
(new Date().getMonth() + 1).toString().padStart(2, '0') +
new Date().getDate().toString().padStart(2, '0') +
(lastNo + 1).toString().padStart(3, '0')
}
return newSrlNo
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const { DETAIL_INFO, ...basicInfo } = body
const { detailInfo, ...basicInfo } = body.survey
console.log('body:: ', body)
// PUT 요청 시 임시저장 여부 확인 후 임시저장 시 기존 SRL_NO 사용, 기본 저장 시 새로운 SRL_NO 생성
const newSrlNo = body.isTemporary ? body.survey.srlNo : await getNewSrlNo(body.survey.srlNo, body.storeId)
// @ts-ignore
const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({
where: { ID: Number(id) },
data: {
...convertToSnakeCase(basicInfo),
SRL_NO: newSrlNo,
UPT_DT: new Date(),
DETAIL_INFO: DETAIL_INFO ? {
upsert: {
create: convertToSnakeCase(DETAIL_INFO),
update: convertToSnakeCase(DETAIL_INFO),
where: {
BASIC_INFO_ID: Number(id)
}
}
} : undefined
DETAIL_INFO: {
update: convertToSnakeCase(detailInfo),
},
},
include: {
DETAIL_INFO: true
}
DETAIL_INFO: true,
},
})
console.log('survey:: ', survey)
return NextResponse.json(survey)
} catch (error) {
console.error('Error updating survey:', error)
@ -92,49 +113,24 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
const body = await request.json()
if (body.submit) {
// 제출 시 기존 SRL_NO 확인 후 '임시저장'으로 시작하면 새로운 SRL_NO 생성
const newSrlNo = await getNewSrlNo(body.srlNo, body.storeId)
if (body.targetId) {
// @ts-ignore
const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({
where: { ID: Number(id) },
data: {
SUBMISSION_STATUS: true,
SUBMISSION_DATE: new Date(),
SUBMISSION_TARGET_ID: body.targetId,
UPT_DT: new Date(),
SRL_NO: newSrlNo,
},
})
console.log(survey)
return NextResponse.json({ message: 'Survey confirmed successfully' })
}
// } else {
// // @ts-ignore
// const hasDetails = await prisma.SD_SURVEY_SALES_DETAIL_INFO.findUnique({
// where: { BASIC_INFO_ID: Number(id) },
// })
// if (hasDetails) {
// //@ts-ignore
// const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({
// where: { ID: Number(id) },
// data: {
// UPT_DT: new Date(),
// DETAIL_INFO: {
// update: convertToSnakeCase(body.DETAIL_INFO),
// },
// },
// })
// return NextResponse.json(result)
// } else {
// // @ts-ignore
// const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({
// where: { ID: Number(id) },
// data: {
// DETAIL_INFO: {
// create: convertToSnakeCase(body.DETAIL_INFO),
// },
// },
// })
// return NextResponse.json({ message: 'Survey detail created successfully' })
// }
// }
} catch (error) {
console.error('Error updating survey:', error)
return NextResponse.json({ error: 'Failed to update survey' }, { status: 500 })

View File

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/libs/prisma'
import { convertToSnakeCase } from '@/utils/common-utils'
import { equal } from 'assert'
/**
*
*/
@ -87,13 +88,14 @@ const createMemberRoleCondition = (params: SearchParams): WhereCondition => {
switch (params.role) {
case 'Admin': // 1차점
// 같은 판매점에서 작성된 매물 + 2차점에서 제출받은 매물
where.OR = [
{
// 같은 판매점에서 작성한 제출/제출되지 않은 매물
AND: [{ STORE: { equals: params.store } }],
},
{
AND: [{ STORE: { startsWith: params.store } }, { SUBMISSION_STATUS: { equals: true } }],
// MUSUBI (시공권한 X) 가 ORDER 에 제출한 매물
AND: [{ SUBMISSION_TARGET_ID: { equals: params.store } }, { SUBMISSION_STATUS: { equals: true } }],
},
]
break
@ -101,6 +103,7 @@ const createMemberRoleCondition = (params: SearchParams): WhereCondition => {
case 'Admin_Sub': // 2차점
where.OR = [
{
// MUSUBI (시공권한 X) 같은 판매점에서 작성한 제출/제출되지 않은 매물
AND: [
{ STORE: { equals: params.store } },
{
@ -109,8 +112,9 @@ const createMemberRoleCondition = (params: SearchParams): WhereCondition => {
],
},
{
// MUSUBI (시공권한 O) 가 MUSUBI 에 제출한 매물 + PARTNER 가 제출한 매물
AND: [
{ STORE: { equals: params.store } },
{ SUBMISSION_TARGET_ID: { equals: params.store } },
{ CONSTRUCTION_POINT: { not: null } },
{ CONSTRUCTION_POINT: { not: '' } },
{ SUBMISSION_STATUS: { equals: true } },
@ -119,8 +123,8 @@ const createMemberRoleCondition = (params: SearchParams): WhereCondition => {
]
break
case 'Builder': // 2차점 시공권한
case 'Partner': // Partner
case 'Builder': // MUSUBI (시공권한 O)
case 'Partner': // PARTNER
// 같은 시공ID에서 작성된 매물
where.AND?.push({
CONSTRUCTION_POINT: { equals: params.builderNo },
@ -128,6 +132,21 @@ const createMemberRoleCondition = (params: SearchParams): WhereCondition => {
break
case 'T01':
where.OR = [
{
NOT: {
SRL_NO: {
startsWith: '一時保存',
},
},
},
{
STORE: {
equals: params.store,
},
},
]
break
case 'User':
// 모든 매물 조회 가능 (추가 조건 없음)
break
@ -222,19 +241,44 @@ export async function PUT(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json()
console.log('body:: ', body)
const { detailInfo, ...basicInfo } = body
// 임시 저장 시 임시저장 + 000 으로 저장
// 기본 저장 시 판매점ID + yyMMdd + 000 으로 저장
const baseSrlNo =
body.survey.srlNo ??
body.storeId +
new Date().getFullYear().toString().slice(-2) +
(new Date().getMonth() + 1).toString().padStart(2, '0') +
new Date().getDate().toString().padStart(2, '0')
// 기본 정보 생성
// @ts-ignore
const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({
where: {
SRL_NO: {
startsWith: body.storeId,
},
},
orderBy: {
SRL_NO: 'desc',
},
})
// 마지막 번호 추출
const lastNumber = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0
// 새로운 srlNo 생성 - 임시저장일 경우 '임시저장' 으로 저장
const newSrlNo = baseSrlNo.startsWith('一時保存') ? baseSrlNo : baseSrlNo + (lastNumber + 1).toString().padStart(3, '0')
const { detailInfo, ...basicInfo } = body.survey
// @ts-ignore
const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.create({
data: {
...convertToSnakeCase(basicInfo),
SRL_NO: newSrlNo,
DETAIL_INFO: {
create: convertToSnakeCase(detailInfo)
}
}
create: convertToSnakeCase(detailInfo),
},
},
})
return NextResponse.json(result)
} catch (error) {

View File

@ -113,6 +113,10 @@ export default function page() {
<input type="checkbox" id="ch06" disabled />
<label htmlFor="ch06">Check Box</label>
</div>
<div className="check-form-box space">
<input type="checkbox" id="ch07" defaultChecked />
<label htmlFor="ch07">Check Box</label>
</div>
</div>
</div>
<div className="design-box">

View File

@ -5,9 +5,8 @@ import { useEffect, useReducer, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useLocalStorage } from 'usehooks-ts'
import { useQuery } from '@tanstack/react-query'
import { axiosInstance } from '@/libs/axios'
import { useSessionStore } from '@/store/session'
import { useAxios } from '@/hooks/useAxios'
interface AccountState {
loginId: string
pwd: string
@ -24,6 +23,8 @@ export default function Login() {
//로그인 상태
const [isLogin, setIsLogin] = useState(false)
const { axiosInstance } = useAxios()
const { session, setSession } = useSessionStore()
const [value, setValue, removeValue] = useLocalStorage<{ indivisualData: string }>('hanasysIndivisualState', { indivisualData: '' })

View File

@ -2,16 +2,16 @@
import Image from 'next/image'
import { useEffect, useState } from 'react'
import SuitableListRaw from './SuitableList'
import SuitableList from './SuitableList'
import { useSuitable } from '@/hooks/useSuitable'
import { useSuitableStore } from '@/store/useSuitableStore'
import type { CommCode } from '@/types/CommCode'
import { SUITABLE_HEAD_CODE } from '@/types/Suitable'
export default function SuitableRaw() {
export default function Suitable() {
const [reference, setReference] = useState(true)
const { getSuitableCommCode, refetchBySearch } = useSuitable()
const { getSuitableCommCode } = useSuitable()
const { suitableCommCode, selectedCategory, setSelectedCategory, searchValue, setSearchValue, setIsSearch, clearSelectedItems } = useSuitableStore()
const handleInputSearch = async () => {
@ -20,19 +20,13 @@ export default function SuitableRaw() {
return
}
setIsSearch(true)
refetchBySearch()
}
const handleInputClear = () => {
setSearchValue('')
setIsSearch(false)
refetchBySearch()
}
useEffect(() => {
refetchBySearch()
}, [selectedCategory])
useEffect(() => {
getSuitableCommCode()
return () => {
@ -62,6 +56,11 @@ export default function SuitableRaw() {
placeholder="屋根材 製品名を入力してください."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleInputSearch()
}
}}
/>
{searchValue && <button className="del-icon" onClick={handleInputClear} />}
<button className="search-icon" onClick={handleInputSearch} />
@ -110,7 +109,7 @@ export default function SuitableRaw() {
</li>
</ul>
</div>
<SuitableListRaw />
<SuitableList />
</div>
</div>
)

View File

@ -1,79 +1,49 @@
'use client'
import Image from 'next/image'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import SuitableButton from './SuitableButton'
import SuitableNoData from './SuitableNoData'
import { useSuitable } from '@/hooks/useSuitable'
import { useSuitableStore } from '@/store/useSuitableStore'
import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable'
// 한 번에 로드할 아이템 수
const ITEMS_PER_PAGE = 100
export default function SuitableList() {
const { toCodeName, suitableSearchResults, isSearchLoading, toSuitableDetail } = useSuitable()
const { toCodeName, toSuitableDetail, suitables, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSuitable()
const { selectedItems, addSelectedItem, removeSelectedItem } = useSuitableStore()
const [openItems, setOpenItems] = useState<Set<number>>(new Set())
const [visibleItems, setVisibleItems] = useState<Suitable[]>([])
const [page, setPage] = useState(1)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const observerTarget = useRef<HTMLDivElement>(null)
// 선택된 아이템 확인 함수 메모이제이션
const isItemSelected = useCallback(
(itemId: number) => {
return selectedItems.some((selected) => selected === itemId)
// 선택된 아이템 확인 - 메인 하위 아이템 indeterminate 확인
const isMainIndeterminate = useMemo(
() => (mainId: number, detailCnt: number) => {
const mainItem = selectedItems.get(mainId)
if (!mainItem) return false
return mainItem.size > 0 && mainItem.size < detailCnt
},
[selectedItems],
)
// 초기 데이터 로드
useEffect(() => {
if (suitableSearchResults) {
const initialItems = suitableSearchResults.slice(0, ITEMS_PER_PAGE)
setVisibleItems(initialItems)
setPage(1)
}
}, [suitableSearchResults])
// Intersection Observer 설정
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && suitableSearchResults && !isLoadingMore) {
const nextPage = page + 1
const startIndex = (nextPage - 1) * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
const nextItems = suitableSearchResults.slice(startIndex, endIndex)
if (nextItems.length > 0) {
setIsLoadingMore(true)
setVisibleItems((prev) => [...prev, ...nextItems])
setPage(nextPage)
setIsLoadingMore(false)
}
}
},
{
threshold: 0.2,
// 선택된 아이템 확인
const isItemSelected = useCallback(
(mainId: number, detailId?: number): boolean => {
const mainItem = selectedItems.get(mainId)
if (!mainItem) return false
if (!detailId) return true
return mainItem.has(detailId)
},
[selectedItems],
)
if (observerTarget.current) {
observer.observe(observerTarget.current)
}
return () => observer.disconnect()
}, [page, suitableSearchResults, isLoadingMore])
// 아이템 클릭
const handleItemClick = useCallback(
(itemId: number) => {
isItemSelected(itemId) ? removeSelectedItem(itemId) : addSelectedItem(itemId)
(mainId: number, detailId?: number): void => {
isItemSelected(mainId, detailId) ? removeSelectedItem(mainId, detailId) : addSelectedItem(mainId, detailId)
},
[isItemSelected, addSelectedItem, removeSelectedItem],
)
// 아이템 열기/닫기
const toggleItemOpen = useCallback((itemId: number) => {
setOpenItems((prev) => {
const newOpenItems = new Set(prev)
@ -84,38 +54,26 @@ export default function SuitableList() {
// TODO: 추후 지붕재 적합성 데이터 CUD 구현 시 ×, ー 데이터 관리 필요
const suitableCheck = useCallback((value: string) => {
if (value === '×') {
return (
<div className="compliance-icon">
<Image src={'/assets/images/sub/compliance_x_icon.svg'} width={22} height={22} alt=""></Image>
</div>
)
} else if (value === 'ー') {
return (
<div className="compliance-icon">
<Image src={'/assets/images/sub/compliance_quest_icon.svg'} width={22} height={22} alt=""></Image>
</div>
)
} else {
return (
<div className="compliance-icon">
<Image src={'/assets/images/sub/compliance_check_icon.svg'} width={22} height={22} alt=""></Image>
</div>
)
const iconMap: Record<string, string> = {
'×': '/assets/images/sub/compliance_x_icon.svg',
: '/assets/images/sub/compliance_quest_icon.svg',
default: '/assets/images/sub/compliance_check_icon.svg',
}
return (
<div className="compliance-icon">
<Image src={iconMap[value] || iconMap.default} width={22} height={22} alt="" />
</div>
)
}, [])
// 메모이제이션된 아이템 렌더링
// 아이템 렌더링
const renderItem = useCallback(
(item: Suitable) => {
const isSelected = isItemSelected(item.id)
const isOpen = openItems.has(item.id)
return (
<div className={`compliance-check-bx ${isOpen ? 'act' : ''}`} key={item.id}>
<div className={`compliance-check-bx ${openItems.has(item.id) ? 'act' : ''}`} key={item.id}>
<div className="check-name-wrap">
<div className="check-form-box ">
<input type="checkbox" id={`ch${item.id}`} checked={isSelected} onChange={() => handleItemClick(item.id)} />
<div className={`check-form-box ${isMainIndeterminate(item.id, item.detailCnt) ? 'space' : ''}`}>
<input type="checkbox" id={`ch${item.id}`} checked={isItemSelected(item.id)} onChange={() => handleItemClick(item.id)} />
<label htmlFor={`ch${item.id}`}>{item.productName}</label>
</div>
<div className="check-name-btn">
@ -127,7 +85,12 @@ export default function SuitableList() {
<li className="reference-item" key={subItem.id}>
<div className="check-item-wrap">
<div className="check-form-box light">
<input type="checkbox" id={`ch${subItem.id}`} />
<input
type="checkbox"
id={`ch${subItem.id}`}
checked={isItemSelected(item.id, subItem.id)}
onChange={() => handleItemClick(item.id, subItem.id)}
/>
<label htmlFor={`ch${subItem.id}`}>{toCodeName(SUITABLE_HEAD_CODE.TRESTLE_MFPC_CD, subItem.trestleMfpcCd)}</label>
</div>
<div className="compliance-icon-wrap">
@ -148,24 +111,38 @@ export default function SuitableList() {
[isItemSelected, openItems, handleItemClick, toggleItemOpen, suitableCheck, toCodeName, toSuitableDetail],
)
// 메모이제이션된 아이템 리스트
const renderedItems = useMemo(() => {
return visibleItems.map(renderItem)
}, [visibleItems, renderItem])
// 아이템 리스트
const suitableList = suitables?.pages.flat() ?? []
if (isSearchLoading) {
return <div>Loading...</div>
// Intersection Observer 설정
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{
threshold: 0,
rootMargin: '100px',
},
)
if (observerTarget.current) {
observer.observe(observerTarget.current)
}
if (!suitableSearchResults?.length) {
return <SuitableNoData />
}
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
if (isLoading) return <div>Loading...</div>
if (!suitableList.length) return <SuitableNoData />
return (
<>
{renderedItems}
{suitableList.map(renderItem)}
<div ref={observerTarget} className="loading-indicator">
{isLoadingMore && <div className="loading-more"> ...</div>}
{isFetchingNextPage && <div className="loading-more"> ...</div>}
</div>
<SuitableButton />
</>

View File

@ -1,11 +1,16 @@
'use client'
import { useRouter } from 'next/navigation'
export default function SuitableNoData() {
const router = useRouter()
return (
<>
<div className="compliace-nosearch">
<span className="mb10"></span>
<span className="mb10"> </span>
<span>
<button className="btn-frame n-blue icon">
<button className="btn-frame n-blue icon" onClick={() => router.push('/inquiry/regist')}>
<i className="btn-arr"></i>
</button>
</span>

View File

@ -25,7 +25,7 @@ export default function BasicForm(props: { basicInfo: SurveyBasicRequest; setBas
setBasicInfo({
...basicInfo,
representative: session.userNm ?? '',
store: session.storeNm ?? null,
store: session.role === 'Partner' ? null : session.storeNm ?? null,
constructionPoint: session.builderNo ?? null,
})
}

View File

@ -22,8 +22,15 @@ export default function ButtonForm(props: {
const params = useParams()
const routeId = params.id
const [isSubmitProcess, setIsSubmitProcess] = useState(false)
// ------------------------------------------------------------
const [saveData, setSaveData] = useState({
...props.data.basic,
detailInfo: props.data.roof,
})
// !!!!!!!!!!
const [tempTargetId, setTempTargetId] = useState('')
// --------------------------------------------------------------
// 권한
// 제출권한 ㅇ
@ -34,39 +41,63 @@ export default function ButtonForm(props: {
useEffect(() => {
if (session?.isLoggedIn) {
switch (session?.role) {
// T01 제출권한 없음
case 'T01':
setIsSubmiter(false)
break
// 1차 판매점(Order) + 2차 판매점(Musubi) => 같은 판매점 제출권한
case 'Admin':
case 'Admin_Sub':
setIsSubmiter(session.storeNm === props.data.basic.store && session.builderNo === props.data.basic.constructionPoint)
break
// 시공권한 User(Musubi) + Partner => 같은 시공ID 제출권한
case 'Builder':
case 'Partner':
setIsSubmiter(session.builderNo === props.data.basic.constructionPoint)
break
default:
setIsSubmiter(false)
break
}
setIsWriter(session.userNm === props.data.basic.representative)
}
setSaveData({
...props.data.basic,
detailInfo: props.data.roof,
})
}, [session, props.data])
// ------------------------------------------------------------
// 저장/임시저장/수정
const id = Number(routeId) ? Number(routeId) : Number(idParam)
const id = routeId ? Number(routeId) : Number(idParam)
const { deleteSurvey, submitSurvey, updateSurvey } = useServey(Number(id))
const { validateSurveyDetail, createSurvey } = useServey()
let saveData = {
...props.data.basic,
detailInfo: props.data.roof,
}
const handleSave = (isTemporary: boolean) => {
const handleSave = (isTemporary: boolean, isSubmitProcess = false) => {
const emptyField = validateSurveyDetail(props.data.roof)
console.log('handleSave, emptyField:: ', emptyField)
const hasEmptyField = emptyField?.trim() !== ''
if (isTemporary) {
tempSaveProcess()
hasEmptyField ? tempSaveProcess() : saveProcess(emptyField, false)
} else {
saveProcess(emptyField)
saveProcess(emptyField, isSubmitProcess)
}
}
const tempSaveProcess = async () => {
if (idParam) {
await updateSurvey(saveData)
router.push(`/survey-sale/detail?id=${idParam}&isTemporary=true`)
await updateSurvey({ survey: saveData, isTemporary: true })
router.push(`/survey-sale/${idParam}`)
} else {
const id = await createSurvey(saveData)
router.push(`/survey-sale/detail?id=${id}&isTemporary=true`)
const updatedData = {
...saveData,
srlNo: '一時保存',
}
const id = await createSurvey(updatedData)
router.push(`/survey-sale/${id}`)
}
alert('一時保存されました。')
}
@ -78,30 +109,40 @@ export default function ButtonForm(props: {
}
}
const saveProcess = async (emptyField: string) => {
if (emptyField.trim() === '') {
const saveProcess = async (emptyField: string | null, isSubmitProcess?: boolean) => {
if (emptyField?.trim() === '') {
if (idParam) {
// 수정 페이지에서 작성 후 제출
if (isSubmitProcess) {
saveData = {
const updatedData = {
...saveData,
submissionStatus: true,
submissionDate: new Date().toISOString(),
submissionTargetId: tempTargetId,
}
}
await updateSurvey(saveData)
await updateSurvey({ survey: updatedData, isTemporary: false, storeId: session.storeId ?? '' })
router.push(`/survey-sale/${idParam}`)
} else {
const id = await createSurvey(saveData)
if (isSubmitProcess) {
submitProcess(id)
return
await updateSurvey({ survey: saveData, isTemporary: false, storeId: session.storeId ?? '' })
router.push(`/survey-sale/${idParam}`)
}
} else {
if (isSubmitProcess) {
const updatedData = {
...saveData,
submissionStatus: true,
submissionDate: new Date().toISOString(),
submissionTargetId: tempTargetId,
}
const id = await createSurvey(updatedData)
submitProcess(id)
} else {
const id = await createSurvey(saveData)
router.push(`/survey-sale/${id}`)
}
}
alert('保存されました。')
} else {
if (emptyField.includes('Unit')) {
if (emptyField?.includes('Unit')) {
alert('電気契約容量の単位を入力してください。')
focusInput(emptyField as keyof SurveyDetailInfo)
} else {
@ -123,17 +164,25 @@ export default function ButtonForm(props: {
}
const handleSubmit = async () => {
if (props.data.basic.srlNo?.startsWith('一時保存')) {
alert('一時保存されたデータは提出できません。')
return
}
if (tempTargetId.trim() === '') {
alert('提出対象店舗を入力してください。')
return
}
window.neoConfirm('提出しますか?', async () => {
setIsSubmitProcess(true)
if (routeId) {
if (Number(routeId)) {
submitProcess()
} else {
handleSave(false)
handleSave(false, true)
}
})
}
const submitProcess = async (saveId?: number) => {
await submitSurvey(saveId)
await submitSurvey({ saveId: saveId, targetId: tempTargetId, storeId: session.storeId ?? '', srlNo: '一時保存' })
alert('提出されました。')
router.push('/survey-sale')
}
@ -159,7 +208,7 @@ export default function ButtonForm(props: {
<ListButton />
<EditButton setMode={setMode} id={id.toString()} mode={mode} />
{(isWriter || !isSubmiter) && <DeleteButton handleDelete={handleDelete} />}
{!isSubmit && isSubmiter && <SubmitButton handleSubmit={handleSubmit} />}
{!isSubmit && isSubmiter && <SubmitButton handleSubmit={handleSubmit} setTempTargetId={setTempTargetId} />}
</div>
</div>
)}
@ -170,7 +219,7 @@ export default function ButtonForm(props: {
<ListButton />
<TempButton setMode={setMode} handleSave={handleSave} />
<SaveButton handleSave={handleSave} />
<SubmitButton handleSubmit={handleSubmit} />
{session?.role !== 'T01' && <SubmitButton handleSubmit={handleSubmit} setTempTargetId={setTempTargetId} />}{' '}
</div>
</div>
)}
@ -210,15 +259,20 @@ function EditButton(props: { setMode: (mode: Mode) => void; id: string; mode: Mo
)
}
function SubmitButton(props: { handleSubmit: () => void }) {
const { handleSubmit } = props
function SubmitButton(props: { handleSubmit: () => void; setTempTargetId: (targetId: string) => void }) {
const { handleSubmit, setTempTargetId } = props
return (
<>
<div className="btn-bx">
{/* 제출 */}
<button className="btn-frame red icon" onClick={handleSubmit}>
<i className="btn-arr"></i>
</button>
</div>
<div>
<input type="text" placeholder="temp target id" onChange={(e) => setTempTargetId(e.target.value)} />
</div>
</>
)
}
@ -256,7 +310,6 @@ function TempButton(props: { setMode: (mode: Mode) => void; handleSave: (isTempo
<button
className="btn-frame n-blue icon"
onClick={() => {
setMode('TEMP')
handleSave(true)
}}
>

View File

@ -1,31 +1,22 @@
'use client'
import { useServey } from '@/hooks/useSurvey'
import { useParams, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useEffect } from 'react'
import DetailForm from './DetailForm'
import type { SurveyBasicInfo } from '@/types/Survey'
export default function DataTable() {
const params = useParams()
const id = params.id
const searchParams = useSearchParams()
const isTemp = searchParams.get('isTemporary')
useEffect(() => {
if (Number.isNaN(Number(id))) {
alert('間違ったアプローチです。')
window.location.href = '/survey-sale'
}
}, [id])
const { surveyDetail, isLoadingSurveyDetail } = useServey(Number(id))
const [isTemporary, setIsTemporary] = useState(isTemp === 'true')
const { validateSurveyDetail } = useServey(Number(id))
useEffect(() => {
if (surveyDetail?.detailInfo) {
const validate = validateSurveyDetail(surveyDetail.detailInfo)
if (validate.trim() !== '') {
setIsTemporary(false)
}
}
}, [surveyDetail])
if (isLoadingSurveyDetail) {
return <div>Loading...</div>
@ -42,12 +33,12 @@ export default function DataTable() {
<tbody>
<tr>
<th></th>
{isTemporary ? (
{surveyDetail?.srlNo?.startsWith('一時保存') ? (
<td>
<span className="text-red-500"></span>
</td>
) : (
<td>{surveyDetail?.id}</td>
<td>{surveyDetail?.srlNo}</td>
)}
</tr>
<tr>

View File

@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
import ButtonForm from './ButtonForm'
import BasicForm from './BasicForm'
import RoofForm from './RoofForm'
import { useParams, useSearchParams } from 'next/navigation'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useServey } from '@/hooks/useSurvey'
const roofInfoForm: SurveyDetailRequest = {
@ -58,34 +58,40 @@ const basicInfoForm: SurveyBasicRequest = {
addressDetail: null,
submissionStatus: false,
submissionDate: null,
submissionTargetId: null,
srlNo: null,
}
export default function DetailForm() {
const idParam = useSearchParams().get('id')
const routeId = useParams().id
const id = idParam ?? routeId
const modeset = Number(routeId) ? 'READ' : idParam ? 'EDIT' : 'CREATE'
const id = Number(routeId) ? Number(routeId) : Number(idParam)
const { surveyDetail } = useServey(Number(id))
const { surveyDetail, validateSurveyDetail } = useServey(Number(id))
const [mode, setMode] = useState<Mode>(idParam ? 'EDIT' : routeId ? 'READ' : 'CREATE')
const [mode, setMode] = useState<Mode>(modeset)
const [basicInfoData, setBasicInfoData] = useState<SurveyBasicRequest>(basicInfoForm)
const [roofInfoData, setRoofInfoData] = useState<SurveyDetailRequest>(roofInfoForm)
useEffect(() => {
if (Number(idParam) !== 0 && surveyDetail === null) {
alert('データが見つかりません。')
window.location.href = '/survey-sale'
}
if (surveyDetail && (mode === 'EDIT' || mode === 'READ')) {
const { id, uptDt, regDt, detailInfo, ...rest } = surveyDetail
setBasicInfoData(rest)
if (detailInfo) {
const { id, uptDt, regDt, basicInfoId, ...rest } = detailInfo
setRoofInfoData(rest)
if (validateSurveyDetail(rest).trim() !== '') {
}
}
}, [surveyDetail, mode])
// console.log('mode:: ', mode)
// console.log('surveyDetail:: ', surveyDetail)
// console.log('roofInfoData:: ', roofInfoData)
}
}, [surveyDetail, id])
const data = {
basic: basicInfoData,

View File

@ -230,6 +230,14 @@ export default function RoofForm(props: {
}
}
}
if (key === 'contractCapacity') {
const remainValue = roofInfo.contractCapacity?.split(' ')[1] ?? roofInfo.contractCapacity
if (Number.isNaN(Number(remainValue))) {
setRoofInfo({ ...roofInfo, [key]: value + ' ' + remainValue })
return
}
setRoofInfo({ ...roofInfo, [key]: value.toString() })
}
setRoofInfo({ ...roofInfo, [key]: value.toString() })
}
@ -237,7 +245,7 @@ export default function RoofForm(props: {
const numericValue = roofInfo.contractCapacity?.replace(/[^0-9.]/g, '') || ''
setRoofInfo({
...roofInfo,
contractCapacity: numericValue ? `${numericValue} ${value}` : value,
contractCapacity: numericValue ? `${numericValue} ${value}` : '0 ' + value,
})
}
@ -261,7 +269,7 @@ export default function RoofForm(props: {
{mode !== 'READ' && (
<div className="data-input mb5">
<input
type="text"
type="number"
id="contractCapacity"
className="input-frame"
value={roofInfo?.contractCapacity?.split(' ')[0] ?? ''}
@ -464,17 +472,17 @@ const SelectedBox = ({
}) => {
const selectedId = detailInfoData?.[column as keyof SurveyDetailInfo]
const etcValue = detailInfoData?.[`${column}Etc` as keyof SurveyDetailInfo]
const [isEtcSelected, setIsEtcSelected] = useState<boolean>(Boolean(etcValue))
const [isEtcSelected, setIsEtcSelected] = useState<boolean>(etcValue !== null && etcValue !== undefined && etcValue !== '')
const [etcVal, setEtcVal] = useState<string>(etcValue?.toString() ?? '')
const isSpecialCase = column === 'constructionYear' || column === 'installationAvailability'
const showEtcOption = !isSpecialCase
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value
const isSpecialCase = column === 'constructionYear' || column === 'installationAvailability'
const isEtc = value === 'etc'
const isSpecialEtc = isSpecialCase && value === '2'
const updatedData: typeof detailInfoData = {
const updatedData = {
...detailInfoData,
[column]: isEtc ? null : value,
[`${column}Etc`]: isEtc ? '' : null,
@ -485,14 +493,20 @@ const SelectedBox = ({
}
setIsEtcSelected(isEtc || isSpecialEtc)
if (!isEtc) setEtcVal('')
setRoofInfo(updatedData)
}
const handleEtcInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setEtcVal(value)
setRoofInfo({ ...detailInfoData, [`${column}Etc`]: value })
setRoofInfo({ ...detailInfoData, [`${column}Etc`]: e.target.value })
}
const isInputDisabled = () => {
if (mode === 'READ') return true
if (column === 'installationAvailability') return false
if (column === 'constructionYear') {
return detailInfoData.constructionYear === '1' || detailInfoData.constructionYear === null
}
return !isEtcSelected && !etcValue
}
return (
@ -502,7 +516,7 @@ const SelectedBox = ({
name={column}
id={column}
disabled={mode === 'READ'}
value={selectedId ? Number(selectedId) : etcValue !== null ? 'etc' : ''}
value={selectedId ? Number(selectedId) : etcValue ? 'etc' : ''}
onChange={handleSelectChange}
>
{selectBoxOptions[column as keyof typeof selectBoxOptions].map((item) => (
@ -510,7 +524,7 @@ const SelectedBox = ({
{item.name}
</option>
))}
{column !== 'installationAvailability' && column !== 'constructionYear' && (
{showEtcOption && (
<option key="etc" value="etc">
()
</option>
@ -519,23 +533,16 @@ const SelectedBox = ({
</option>
</select>
<div className="data-input">
<div className={`data-input ${column === 'constructionYear' ? 'flex' : ''}`}>
<input
type="text"
type={column === 'constructionYear' ? 'number' : 'text'}
className="input-frame"
placeholder="-"
value={etcVal}
value={detailInfoData[`${column}Etc` as keyof SurveyDetailInfo]?.toString() ?? ''}
onChange={handleEtcInputChange}
disabled={
mode === 'READ'
? true
: column === 'installationAvailability'
? false
: column === 'constructionYear'
? detailInfoData.constructionYear === '1' || detailInfoData.constructionYear === null
: !isEtcSelected
}
disabled={isInputDisabled()}
/>
{column === 'constructionYear' && <span></span>}
</div>
</>
)
@ -552,49 +559,51 @@ const RadioSelected = ({
detailInfoData: SurveyDetailInfo
setRoofInfo: (roofInfo: SurveyDetailRequest) => void
}) => {
let selectedId = detailInfoData?.[column as keyof SurveyDetailInfo]
if (column === 'leakTrace') {
selectedId = Number(selectedId)
if (!selectedId) selectedId = 2
}
const etcValue = detailInfoData?.[`${column}Etc` as keyof SurveyDetailInfo]
const [etcChecked, setEtcChecked] = useState<boolean>(Boolean(etcValue))
let etcValue = null
if (column !== 'rafterDirection') {
etcValue = detailInfoData?.[`${column}Etc` as keyof SurveyDetailInfo]
}
const [etcChecked, setEtcChecked] = useState<boolean>(etcValue !== null && etcValue !== undefined && etcValue !== '')
const [etcVal, setEtcVal] = useState<string>(etcValue?.toString() ?? '')
const selectedId =
column === 'leakTrace' ? Number(detailInfoData?.[column as keyof SurveyDetailInfo]) || 2 : detailInfoData?.[column as keyof SurveyDetailInfo]
const isSpecialColumn = column === 'rafterDirection' || column === 'leakTrace' || column === 'insulationPresence'
const showEtcOption = !isSpecialColumn
const handleRadioChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (column === 'leakTrace') {
handleBooleanRadioChange(value)
setRoofInfo({ ...detailInfoData, leakTrace: value === '1' })
return
}
if (value === 'etc') {
setEtcChecked(true)
setRoofInfo({ ...detailInfoData, [column]: null, [`${column}Etc`]: '' })
} else {
if (column === 'insulationPresence' && value === '2') {
setEtcChecked(true)
} else {
setEtcChecked(false)
}
setRoofInfo({ ...detailInfoData, [column]: value, [`${column}Etc`]: null })
}
return
}
const handleBooleanRadioChange = (value: string) => {
if (value === '1') {
setRoofInfo({ ...detailInfoData, leakTrace: true })
} else {
setRoofInfo({ ...detailInfoData, leakTrace: false })
}
const isInsulationPresence = column === 'insulationPresence'
const isRafterDirection = column === 'rafterDirection'
setEtcChecked(isInsulationPresence && value === '2')
setRoofInfo({
...detailInfoData,
[column]: value,
[`${column}Etc`]: isRafterDirection ? detailInfoData[`${column}Etc` as keyof SurveyDetailInfo] : null,
})
}
const handleEtcInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setEtcVal(value)
setRoofInfo({ ...detailInfoData, [`${column}Etc`]: value })
setRoofInfo({ ...detailInfoData, [`${column}Etc`]: e.target.value })
}
const isInputDisabled = () => {
if (mode === 'READ') return true
if (column === 'insulationPresence') {
return detailInfoData.insulationPresence !== '2'
}
return !etcChecked && !etcValue
}
return (
@ -613,7 +622,7 @@ const RadioSelected = ({
<label htmlFor={`${column}_${item.id}`}>{item.label}</label>
</div>
))}
{column !== 'rafterDirection' && column !== 'leakTrace' && column !== 'insulationPresence' && (
{showEtcOption && (
<div className="radio-form-box mb10">
<input
type="radio"
@ -621,21 +630,21 @@ const RadioSelected = ({
id={`${column}Etc`}
value="etc"
disabled={mode === 'READ'}
checked={etcChecked}
checked={etcChecked || Boolean(etcValue)}
onChange={handleRadioChange}
/>
<label htmlFor={`${column}Etc`}> ()</label>
</div>
)}
{column !== 'leakTrace' && column !== 'rafterDirection' && (
{(showEtcOption || column === 'insulationPresence') && (
<div className="data-input">
<input
type="text"
className="input-frame"
placeholder="-"
value={etcVal}
value={detailInfoData[`${column}Etc` as keyof SurveyDetailInfo]?.toString() ?? ''}
onChange={handleEtcInputChange}
disabled={mode === 'READ' || !etcChecked}
disabled={isInputDisabled()}
/>
</div>
)}
@ -655,51 +664,56 @@ const MultiCheck = ({
setRoofInfo: (roofInfo: SurveyDetailRequest) => void
}) => {
const multiCheckData = column === 'supplementaryFacilities' ? supplementaryFacilities : roofMaterial
const etcValue = roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo]
const [isOtherCheck, setIsOtherCheck] = useState<boolean>(Boolean(etcValue))
const [isOtherCheck, setIsOtherCheck] = useState<boolean>(false)
const [otherValue, setOtherValue] = useState<string>(roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo]?.toString() ?? '')
const isRoofMaterial = column === 'roofMaterial'
const selectedValues = makeNumArr(String(roofInfo[column as keyof SurveyDetailInfo] ?? ''))
const handleCheckbox = (id: number) => {
const value = makeNumArr(String(roofInfo[column as keyof SurveyDetailInfo] ?? ''))
const isOtherSelected = roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo] !== null
const isOtherSelected = Boolean(etcValue)
let newValue: string[]
if (value.includes(String(id))) {
newValue = value.filter((v) => v !== String(id))
} else {
if (column === 'roofMaterial') {
const totalSelected = value.length + (isOtherSelected ? 1 : 0)
if (selectedValues.includes(String(id))) {
newValue = selectedValues.filter((v) => v !== String(id))
} else {
if (isRoofMaterial) {
const totalSelected = selectedValues.length + (isOtherSelected ? 1 : 0)
if (totalSelected >= 2) {
alert('屋根材は最大2個まで選択できます。')
return
}
}
newValue = [...value, String(id)]
newValue = [...selectedValues, String(id)]
}
setRoofInfo({ ...roofInfo, [column]: newValue.join(',') })
}
const handleOtherCheckbox = () => {
if (column === 'roofMaterial') {
const value = makeNumArr(String(roofInfo[column as keyof SurveyDetailInfo] ?? ''))
const currentSelected = value.length
if (isRoofMaterial) {
const currentSelected = selectedValues.length
if (!isOtherCheck && currentSelected >= 2) {
alert('屋根材は最大2個まで選択できます。')
return
}
}
const newIsOtherCheck = !isOtherCheck
setIsOtherCheck(newIsOtherCheck)
setOtherValue('')
setRoofInfo({ ...roofInfo, [`${column}Etc`]: newIsOtherCheck ? '' : null })
// 기타 선택 해제 시 값도 null로 설정
setRoofInfo({
...roofInfo,
[`${column}Etc`]: newIsOtherCheck ? '' : null,
})
}
const handleOtherInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setOtherValue(value)
setRoofInfo({ ...roofInfo, [`${column}Etc`]: value })
setRoofInfo({ ...roofInfo, [`${column}Etc`]: e.target.value })
}
const isInputDisabled = () => {
return mode === 'READ' || (!isOtherCheck && !etcValue)
}
return (
@ -710,7 +724,7 @@ const MultiCheck = ({
<input
type="checkbox"
id={`${column}_${item.id}`}
checked={makeNumArr(String(roofInfo[column as keyof SurveyDetailInfo] ?? '')).includes(String(item.id))}
checked={selectedValues.includes(String(item.id))}
disabled={mode === 'READ'}
onChange={() => handleCheckbox(item.id)}
/>
@ -721,7 +735,7 @@ const MultiCheck = ({
<input
type="checkbox"
id={`${column}Etc`}
checked={roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo] !== null}
checked={isOtherCheck || Boolean(etcValue)}
disabled={mode === 'READ'}
onChange={handleOtherCheckbox}
/>
@ -733,9 +747,9 @@ const MultiCheck = ({
type="text"
className="input-frame"
placeholder="-"
value={otherValue}
value={roofInfo[`${column}Etc` as keyof SurveyDetailInfo]?.toString() ?? ''}
onChange={handleOtherInputChange}
disabled={mode === 'READ' || !isOtherCheck}
disabled={isInputDisabled()}
/>
</div>
</>

View File

@ -2,8 +2,8 @@
import LoadMoreButton from '@/components/LoadMoreButton'
import { useServey } from '@/hooks/useSurvey'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import SearchForm from './SearchForm'
import { useSurveyFilterStore } from '@/store/surveyFilterStore'
import { useSessionStore } from '@/store/session'
@ -11,13 +11,20 @@ import type { SurveyBasicInfo } from '@/types/Survey'
export default function ListTable() {
const router = useRouter()
const pathname = usePathname()
const { surveyList, isLoadingSurveyList } = useServey()
const { offset, setOffset } = useSurveyFilterStore()
const { session } = useSessionStore()
const [heldSurveyList, setHeldSurveyList] = useState<SurveyBasicInfo[]>([])
const [hasMore, setHasMore] = useState(false)
const { session } = useSessionStore()
useEffect(() => {
setOffset(0)
setHeldSurveyList([])
}, [pathname])
useEffect(() => {
if (!session.isLoggedIn || !('data' in surveyList)) return
@ -32,30 +39,25 @@ export default function ListTable() {
setHeldSurveyList([])
setHasMore(false)
}
}, [surveyList, offset, session])
}, [surveyList, offset, session.isLoggedIn])
const handleDetailClick = (id: number) => {
router.push(`/survey-sale/${id}`)
}
const handleItemsInit = () => {
setHeldSurveyList([])
setOffset(0)
}
// TODO: 로딩 처리 필요
return (
<>
<SearchForm memberRole={session?.role ?? ''} userId={session?.userId ?? ''} />
{heldSurveyList.length > 0 ? (
<SearchForm memberRole={session?.role ?? ''} userNm={session?.userNm ?? ''} />
<div className="sale-frame">
{heldSurveyList.length > 0 ? (
<ul className="sale-list-wrap">
{heldSurveyList.map((survey) => (
<li className="sale-list-item cursor-pointer" key={survey.id} onClick={() => handleDetailClick(survey.id)}>
<div className="sale-item-bx">
<div className="sale-item-date-bx">
<div className="sale-item-num">{survey.id}</div>
<div className="sale-item-num">{survey.srlNo}</div>
<div className="sale-item-date">{survey.investigationDate}</div>
</div>
<div className="sale-item-tit">{survey.buildingName}</div>
@ -68,15 +70,15 @@ export default function ListTable() {
</li>
))}
</ul>
) : (
<div className="compliace-nosearch">
<span className="mb10"></span>
</div>
)}
<div className="sale-edit-btn">
<LoadMoreButton hasMore={hasMore} onLoadMore={() => setOffset(offset + 10)} />
</div>
</div>
) : (
<div>
<p></p>
</div>
)}
</>
)
}

View File

@ -4,7 +4,7 @@ import { SEARCH_OPTIONS, SEARCH_OPTIONS_ENUM, SEARCH_OPTIONS_PARTNERS, useSurvey
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function SearchForm({ memberRole, userId }: { memberRole: string; userId: string }) {
export default function SearchForm({ memberRole, userNm }: { memberRole: string; userNm: string }) {
const router = useRouter()
const { setSearchOption, setSort, setIsMySurvey, setKeyword, isMySurvey, keyword, searchOption, sort } = useSurveyFilterStore()
const [searchKeyword, setSearchKeyword] = useState(keyword)
@ -75,9 +75,9 @@ export default function SearchForm({ memberRole, userId }: { memberRole: string;
<input
type="checkbox"
id="ch01"
checked={isMySurvey === userId}
checked={isMySurvey === userNm}
onChange={() => {
setIsMySurvey(isMySurvey === userId ? null : userId)
setIsMySurvey(isMySurvey === userNm ? null : userNm)
}}
/>
<label htmlFor="ch01"></label>

View File

@ -34,7 +34,7 @@ export default function Main() {
<div className="main-bx-icon">
<img src="/assets/images/main/main_icon02.svg" alt="" />
</div>
<button className="main-bx-arr" onClick={() => router.push('/survey-sale/basic-info')}></button>
<button className="main-bx-arr" onClick={() => router.push('/survey-sale/regist')}></button>
</div>
<div className="grid-bx-body">
<div className="grid-bx-body-tit">調</div>

View File

@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import Config from '@/config/config.export'
export default function Footer() {
return (
@ -11,6 +12,9 @@ export default function Footer() {
<span>
<Link href="/pdf">PDF</Link>
</span>
<span>{Config().mode}</span>
<span>{Config().baseUrl}</span>
<span>{process.env.NEXT_PUBLIC_API_URL}</span>
</div>
</footer>
</>

View File

@ -13,14 +13,13 @@ import { useSessionStore } from '@/store/session'
import { usePopupController } from '@/store/popupController'
import { useTitle } from '@/hooks/useTitle'
import { axiosInstance } from '@/libs/axios'
import { useAxios } from '@/hooks/useAxios'
import 'swiper/css'
export default function Header() {
const router = useRouter()
const pathname = usePathname()
const { axiosInstance } = useAxios()
const [value, setValue, removeValue] = useLocalStorage<{ indivisualData: string }>('hanasysIndivisualState', { indivisualData: '' })
const { sideNavIsOpen, setSideNavIsOpen } = useSideNavState()
const { backBtn } = useHeaderStore()

View File

@ -0,0 +1,7 @@
export default function Spinner() {
return (
<div className="spinner-wrap">
<span className="loader"></span>
</div>
)
}

View File

@ -0,0 +1,20 @@
export declare namespace ICommonConfig {
export type Mode = 'local' | 'development' | 'production'
export interface Params {
baseUrl: string
mode: Mode
}
}
// local, development, production 과 관계없이 동일한 값으로 반환되는 부분은 해당 함수의 return 되는 부분만 수정하면 됩니다. (달라져야 하는 값이 아닌, 같은 값에 대해서는 local, development, production 파일을 모두 수정할 필요가 없어지게 됩니다.)
export default function getConfigs(params: ICommonConfig.Params) {
// local, development, production 마다 달라지는 값
const { baseUrl, mode } = params
// 공통으로 반환되는 구조
return {
baseUrl,
mode,
}
}

View File

@ -0,0 +1,13 @@
import getConfigs from '@/config/config.common'
// 환경마다 달라져야 할 변수, 값들을 정의합니다. (여기는 development 환경에 맞는 값을 지정합니다.)
const baseUrl = 'http://1.248.227.176:3000'
const mode = 'development'
// 환경마다 달라져야 할 값들을 getConfig 함수에 전달합니다.
const configDevelopment = getConfigs({
baseUrl,
mode,
})
export default configDevelopment

View File

@ -0,0 +1,19 @@
import configDevelopment from './config.development'
import configLocal from './config.local'
import configProduction from './config.production'
// 클라이언트에서는 이 함수를 사용하여 config 값을 참조합니다.
const Config = () => {
switch (process.env.NEXT_PUBLIC_RUN_MODE) {
case 'local':
return configLocal
case 'development':
return configDevelopment
case 'production':
return configProduction
default:
return configLocal
}
}
export default Config

View File

@ -0,0 +1,13 @@
import getConfigs from '@/config/config.common'
// 환경마다 달라져야 할 변수, 값들을 정의합니다. (여기는 local 환경에 맞는 값을 지정합니다.)
const baseUrl = 'http://localhost:3000'
const mode = 'local'
// 환경마다 달라져야 할 값들을 getConfig 함수에 전달합니다.
const configLocal = getConfigs({
baseUrl,
mode,
})
export default configLocal

View File

@ -0,0 +1,13 @@
import getConfigs from '@/config/config.common'
// 환경마다 달라져야 할 변수, 값들을 정의합니다. (여기는 production 환경에 맞는 값을 지정합니다.)
const baseUrl = 'http://localhost.prod:3000'
const mode = 'production'
// 환경마다 달라져야 할 값들을 getConfig 함수에 전달합니다.
const configProduction = getConfigs({
baseUrl,
mode,
})
export default configProduction

119
src/hooks/useAxios.ts Normal file
View File

@ -0,0 +1,119 @@
import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import Config from '@/config/config.export'
import { useSpinnerStore } from '@/store/spinnerStore'
export function useAxios() {
// const { setIsShow } = useSpinnerStore()
const requestHandler = (config: InternalAxiosRequestConfig) => {
// setIsShow(true)
return config
}
const responseHandler = (response: AxiosResponse) => {
// setIsShow(false)
response.data = transferResponse(response)
return response
}
const errorHandler = (error: any) => {
// setIsShow(false)
return Promise.reject(error)
}
const createAxiosInstance = (url: string | null | undefined) => {
const baseURL = url || Config().baseUrl
return axios.create({
baseURL,
headers: {
Accept: 'application/json',
},
})
}
const axiosInstance = (url: string | null | undefined) => {
const instance = axios.create({
baseURL: url || Config().baseUrl,
headers: {
Accept: 'application/json',
},
})
instance.interceptors.request.use(
// (config) => {
// return config
// },
// (error) => {
// return Promise.reject(error)
// },
(config) => requestHandler(config),
(error) => errorHandler(error),
)
instance.interceptors.response.use(
// (response) => {
// response.data = transferResponse(response)
// return response
// },
// (error) => {
// return Promise.reject(error)
// },
(response) => responseHandler(response),
(error) => errorHandler(error),
)
return instance
}
// response데이터가 array, object에 따라 분기하여 키 변환
const transferResponse = (response: any) => {
if (!response.data) return response.data
// 배열인 경우 각 객체의 키를 변환
if (Array.isArray(response.data)) {
return response.data.map((item: any) => transformObjectKeys(item))
}
// 단일 객체인 경우
return transformObjectKeys(response.data)
}
// camel case object 반환
const transformObjectKeys = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(transformObjectKeys)
}
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).reduce((acc: any, key: string) => {
let transformedKey = key
// Handle uppercase snake_case (e.g., USER_NAME -> userName)
// Handle lowercase snake_case (e.g., user_name -> userName)
if (/^[A-Z_]+$/.test(key) || /^[a-z_]+$/.test(key)) {
transformedKey = snakeToCamel(key)
}
// Handle single uppercase word (e.g., ROLE -> role)
else if (/^[A-Z]+$/.test(key)) {
transformedKey = key.toLowerCase()
}
// Preserve existing camelCase
acc[transformedKey] = transformObjectKeys(obj[key])
return acc
}, {})
}
return obj
}
const snakeToCamel = (str: string): string => {
return str.toLowerCase().replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', ''))
}
return {
axiosInstance,
transferResponse,
transformObjectKeys,
}
}

View File

@ -1,24 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { axiosInstance, transformObjectKeys } from '@/libs/axios'
import { useInfiniteQuery } from '@tanstack/react-query'
import { transformObjectKeys } from '@/libs/axios'
import { useSuitableStore } from '@/store/useSuitableStore'
import { useAxios } from './useAxios'
import { useCommCode } from './useCommCode'
import { SUITABLE_HEAD_CODE, type Suitable, type SuitableDetail } from '@/types/Suitable'
export function useSuitable() {
const { axiosInstance } = useAxios()
const { getCommCode } = useCommCode()
const { selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore()
const { itemPerPage, selectedCategory, searchValue, suitableCommCode, setSuitableCommCode, isSearch } = useSuitableStore()
const getSuitables = async (): Promise<Suitable[]> => {
const getSuitables = async ({
pageNumber,
ids,
category,
keyword,
}: {
pageNumber?: number
ids?: string
category?: string
keyword?: string
}): Promise<Suitable[]> => {
try {
const response = await axiosInstance(null).get<Suitable[]>('/api/suitable/list', {
params: {
pageNumber: 1,
itemPerPage: 1000,
ids: '',
category: '',
keyword: '',
},
})
const params: Record<string, string | number> = {
pageNumber: pageNumber || 1,
itemPerPage: itemPerPage,
}
if (ids) params.ids = ids
if (category) params.category = category
if (keyword) params.keyword = keyword
const response = await axiosInstance(null).get<Suitable[]>('/api/suitable/list', { params })
return response.data
} catch (error) {
console.error('지붕재 데이터 로드 실패:', error)
@ -26,16 +39,6 @@ export function useSuitable() {
}
}
// const updateSearchResults = async (selectedCategory: string | undefined, searchValue: string | undefined): Promise<SuitableData[]> => {
// try {
// const response = await axiosInstance(null).get<SuitableData[]>('/api/suitable/list', { params: { selectedCategory, searchValue } })
// return response.data
// } catch (error) {
// console.error('지붕재 데이터 검색 실패:', error)
// return []
// }
// }
const getSuitableCommCode = () => {
const headCodes = Object.values(SUITABLE_HEAD_CODE) as SUITABLE_HEAD_CODE[]
for (const code of headCodes) {
@ -63,35 +66,30 @@ export function useSuitable() {
}
}
const { data: suitableList, isLoading: isInitialLoading } = useQuery<Suitable[]>({
queryKey: ['suitables', 'list'],
queryFn: async () => await getSuitables(),
staleTime: 1000 * 60 * 10, // 10분
gcTime: 1000 * 60 * 10, // 10분
})
const {
data: suitableSearchResults,
refetch: refetchBySearch,
isLoading: isSearchLoading,
} = useQuery<Suitable[]>({
queryKey: ['suitables', 'search', selectedCategory, isSearch],
queryFn: async () => {
if (!isSearch && !selectedCategory) {
return isInitialLoading ? await getSuitables() : suitableList ?? [] // 검색 상태가 아니면 초기 데이터 반환 임시처리
} else {
return (
suitableList?.filter((item: Suitable) => {
const categoryMatch = !selectedCategory || item.roofMtCd === selectedCategory
const searchMatch = !searchValue || item.productName.includes(searchValue)
return categoryMatch && searchMatch
}) ?? []
)
}
data: suitables,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery<Suitable[]>({
queryKey: ['suitables', 'list', selectedCategory, isSearch],
queryFn: async (context) => {
const pageParam = context.pageParam as number
return await getSuitables({
pageNumber: pageParam,
...(selectedCategory && { category: selectedCategory }),
...(isSearch && { keyword: searchValue }),
})
},
getNextPageParam: (lastPage: Suitable[], allPages: Suitable[][]) => {
return lastPage.length === itemPerPage ? allPages.length + 1 : undefined
},
initialPageParam: 1,
staleTime: 1000 * 60 * 10,
gcTime: 1000 * 60 * 10,
enabled: true,
})
return {
@ -99,9 +97,10 @@ export function useSuitable() {
getSuitableCommCode,
toCodeName,
toSuitableDetail,
suitableList,
suitableSearchResults,
refetchBySearch,
isSearchLoading,
suitables,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
}
}

View File

@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { SurveyBasicInfo, SurveyDetailInfo, SurveyDetailRequest, SurveyDetailCoverRequest, SurveyRegistRequest } from '@/types/Survey'
import { axiosInstance } from '@/libs/axios'
import { useSurveyFilterStore } from '@/store/surveyFilterStore'
import { queryStringFormatter } from '@/utils/common-utils'
import { useSessionStore } from '@/store/session'
import { useMemo } from 'react'
import { AxiosResponse } from 'axios'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSurveyFilterStore } from '@/store/surveyFilterStore'
import { useSessionStore } from '@/store/session'
import { useAxios } from './useAxios'
import { queryStringFormatter } from '@/utils/common-utils'
export const requiredFields = [
{
@ -65,9 +64,9 @@ export function useServey(id?: number): {
isDeletingSurvey: boolean
createSurvey: (survey: SurveyRegistRequest) => Promise<number>
createSurveyDetail: (params: { surveyId: number; surveyDetail: SurveyDetailCoverRequest }) => void
updateSurvey: (survey: SurveyRegistRequest) => void
updateSurvey: ({ survey, isTemporary, storeId }: { survey: SurveyRegistRequest; isTemporary: boolean; storeId?: string }) => void
deleteSurvey: () => Promise<boolean>
submitSurvey: (saveId?: number) => void
submitSurvey: (params: { saveId?: number; targetId?: string; storeId?: string; srlNo?: string }) => void
validateSurveyDetail: (surveyDetail: SurveyDetailRequest) => string
getZipCode: (zipCode: string) => Promise<ZipCode[] | null>
refetchSurveyList: () => void
@ -75,6 +74,7 @@ export function useServey(id?: number): {
const queryClient = useQueryClient()
const { keyword, searchOption, isMySurvey, sort, offset } = useSurveyFilterStore()
const { session } = useSessionStore()
const { axiosInstance } = useAxios()
const {
data: surveyListData,
@ -119,7 +119,7 @@ export function useServey(id?: number): {
const { mutateAsync: createSurvey, isPending: isCreatingSurvey } = useMutation({
mutationFn: async (survey: SurveyRegistRequest) => {
const resp = await axiosInstance(null).post<SurveyBasicInfo>('/api/survey-sales', survey)
const resp = await axiosInstance(null).post<SurveyBasicInfo>('/api/survey-sales', { survey: survey, storeId: session?.storeId ?? null })
return resp.data.id ?? 0
},
onSuccess: (data) => {
@ -130,10 +130,14 @@ export function useServey(id?: number): {
})
const { mutate: updateSurvey, isPending: isUpdatingSurvey } = useMutation({
mutationFn: async (survey: SurveyRegistRequest) => {
mutationFn: async ({ survey, isTemporary, storeId }: { survey: SurveyRegistRequest; isTemporary: boolean; storeId?: string }) => {
console.log('updateSurvey, survey:: ', survey)
if (id === undefined) throw new Error('id is required')
const resp = await axiosInstance(null).put<SurveyRegistRequest>(`/api/survey-sales/${id}`, survey)
const resp = await axiosInstance(null).put<SurveyRegistRequest>(`/api/survey-sales/${id}`, {
survey: survey,
isTemporary: isTemporary,
storeId: storeId,
})
return resp.data
},
onSuccess: () => {
@ -166,11 +170,13 @@ export function useServey(id?: number): {
})
const { mutateAsync: submitSurvey } = useMutation({
mutationFn: async (saveId?: number) => {
mutationFn: async ({ saveId, targetId, storeId, srlNo }: { saveId?: number; targetId?: string; storeId?: string; srlNo?: string }) => {
const submitId = saveId ?? id
if (!submitId) throw new Error('id is required')
const resp = await axiosInstance(null).patch<boolean>(`/api/survey-sales/${submitId}`, {
submit: true,
targetId,
storeId,
srlNo,
})
return resp.data
},

View File

@ -1,3 +1,4 @@
import { useSpinnerStore } from '@/store/spinnerStore'
import axios from 'axios'
export const axiosInstance = (url: string | null | undefined) => {

View File

@ -1,7 +1,8 @@
import { axiosInstance } from './axios'
import { useAxios } from '@/hooks/useAxios'
export const tracking = async (params: { url: string; data: string }) => {
const { url, data } = params
const { axiosInstance } = useAxios()
const result = await axiosInstance(null).post('/api/tracking', {
url,
data,

View File

@ -10,9 +10,9 @@ export async function middleware(request: NextRequest) {
const session = await getIronSession<SessionData>(cookieStore, sessionOptions)
// todo: 로그인 기능 추가 시 주석 해제
if (!session.isLoggedIn) {
return NextResponse.redirect(new URL('/login', request.url))
}
// if (!session.isLoggedIn) {
// return NextResponse.redirect(new URL('/login', request.url))
// }
return NextResponse.next()
}

View File

@ -8,6 +8,8 @@ import { usePopupController } from '@/store/popupController'
import { useSideNavState } from '@/store/sideNavState'
import { useSessionStore } from '@/store/session'
import { tracking } from '@/libs/tracking'
import Spinner from '@/components/ui/common/Spinner'
import { useSpinnerStore } from '@/store/spinnerStore'
declare global {
interface Window {
@ -28,6 +30,7 @@ export default function EdgeProvider({ children, sessionData }: EdgeProviderProp
const { reset } = useSideNavState()
const { setAlertMsg, setAlertBtn, setAlert, setAlert2, setAlert2BtnYes, setAlert2BtnNo } = usePopupController()
const { session, setSession } = useSessionStore()
const { isShow, setIsShow } = useSpinnerStore()
/**
*
@ -110,5 +113,10 @@ export default function EdgeProvider({ children, sessionData }: EdgeProviderProp
handlePageEvent(pathname)
}, [pathname])
return <>{children}</>
return (
<>
{children}
{isShow && <Spinner />}
</>
)
}

21
src/store/spinnerStore.ts Normal file
View File

@ -0,0 +1,21 @@
import { create } from 'zustand'
type SpinnerState = {
isShow: boolean
setIsShow: (isShow: boolean) => void
resetCount: () => void
}
type InitialState = {
isShow: boolean
}
const initialState: InitialState = {
isShow: false,
}
export const useSpinnerStore = create<SpinnerState>((set) => ({
...initialState,
setIsShow: (isShow: boolean) => set({ isShow }),
resetCount: () => set(initialState),
}))

View File

@ -2,6 +2,9 @@ import { create } from 'zustand'
import type { CommCode } from '@/types/CommCode'
interface SuitableState {
/* 초기 데이터 로드 개수*/
itemPerPage: number
/* 공통코드 */
suitableCommCode: Map<string, CommCode[]>
/* 공통코드 설정 */
@ -23,21 +26,22 @@ interface SuitableState {
setSearchValue: (value: string) => void
/* 선택된 아이템 리스트 */
selectedItems: number[]
selectedItems: Map<number, Set<number>>
/* 선택된 아이템 추가 */
addSelectedItem: (itemId: number) => void
addSelectedItem: (mainId: number, detailId?: number) => void
/* 선택된 아이템 제거 */
removeSelectedItem: (itemId: number) => void
removeSelectedItem: (mainId: number, detailId?: number) => void
/* 선택된 아이템 모두 제거 */
clearSelectedItems: () => void
}
export const useSuitableStore = create<SuitableState>((set) => ({
itemPerPage: 100 as number,
suitableCommCode: new Map() as Map<string, CommCode[]>,
isSearch: false as boolean,
selectedCategory: '' as string,
searchValue: '' as string,
selectedItems: [] as number[],
selectedItems: new Map() as Map<number, Set<number>>,
/* 공통코드 설정 */
setSuitableCommCode: (headCode: string, commCode: CommCode[]) =>
@ -55,17 +59,46 @@ export const useSuitableStore = create<SuitableState>((set) => ({
setSearchValue: (value: string) => set({ searchValue: value }),
/* 선택된 아이템 추가 */
addSelectedItem: (itemId: number) =>
set((state) => ({
selectedItems: state.selectedItems.some((i) => i === itemId) ? state.selectedItems : [...state.selectedItems, itemId],
})),
addSelectedItem: (mainId: number, detailId?: number) => {
if (detailId) {
// 디테일(하위) 아이템 추가
set((state) => {
const detailSet = state.selectedItems.get(mainId) || new Set()
detailSet.add(detailId)
state.selectedItems.set(mainId, detailSet)
return { selectedItems: state.selectedItems }
})
} else {
// 메인(상위) 아이템 추가
set((state) => {
state.selectedItems.set(mainId, new Set())
return { selectedItems: state.selectedItems }
})
}
},
/* 선택된 아이템 제거 */
removeSelectedItem: (itemId: number) =>
set((state) => ({
selectedItems: state.selectedItems.filter((i) => i !== itemId),
})),
removeSelectedItem: (mainId: number, detailId?: number) => {
set((state) => {
const newSelectedItems = new Map(state.selectedItems)
if (!detailId) {
// 메인(상위) 아이템 제거
newSelectedItems.delete(mainId)
return { selectedItems: newSelectedItems }
}
// 디테일(하위) 아이템 제거
const detailSet = state.selectedItems.get(mainId) || new Set()
detailSet.delete(detailId)
// 디테일(하위)하위 아이템이 모두 제거되면 메인 아이템도 제거
detailSet.size === 0 ? newSelectedItems.delete(mainId) : newSelectedItems.set(mainId, detailSet)
return { selectedItems: newSelectedItems }
})
},
/* 선택된 아이템 모두 제거 */
clearSelectedItems: () => set({ selectedItems: [] }),
clearSelectedItems: () => set({ selectedItems: new Map() as Map<number, Set<number>> }),
}))

View File

@ -49,14 +49,6 @@
background-size: cover;
margin-left: 12px;
}
.btn-arr-up{
display: block;
width: 10px;
height: 6px;
background: url(/assets/images/common/btn_arr_up.svg)no-repeat center;
background-size: cover;
margin-left: 12px;
}
.btn-edit{
display: block;
width: 10px;

View File

@ -98,6 +98,23 @@
color: #8595A7;
}
}
&.space{
label{
&::after{
top: 8px;
left: 0px;
width: 10px;
height: 2px;
border: none;
background-color: transparent;
transform: translate(50%, 50%);
-ms-transform: none;
}
}
input[type="checkbox"]:checked + label::after{
background-color: #fff;
}
}
}
// radio box
@ -201,7 +218,7 @@
}
}
input:checked + .slider {
background-color: #A8B6C7;
background-color: #0081b5;
&:after {
content: '';
left: 10px;

View File

@ -2,3 +2,5 @@
@forward 'login';
@forward 'pop-contents';
@forward 'sub';
@forward 'pdfview';
@forward 'spinner';

View File

@ -0,0 +1,57 @@
@use "../abstracts" as *;
.pdf-contents{
padding: 0 20px;
border-top: 1px solid #ececec;
}
.pdf-cont-head{
align-items: center;
padding: 24px 0 15px;
border-bottom: 2px solid $black-1010;
.pdf-cont-head-tit{
@include defaultFont($font-s-16, $font-w-600, $black-1010);
margin-bottom: 10px;
}
}
.pdf-cont-head-data-wrap{
@include flex(20px);
align-items: center;
.pdf-cont-head-data-tit{
@include defaultFont($font-s-13, $font-w-500, $black-1010);
}
.pdf-cont-head-data{
@include defaultFont($font-s-13, $font-w-400, #FF5656);
}
}
.pdf-cont-body{
padding: 24px 0 0;
}
.pdf-data-tit{
@include defaultFont($font-s-13, $font-w-500, $black-1010);
margin-bottom: 5px;
}
.pdf-table{
margin-bottom: 24px;
table{
width: 100%;
table-layout: fixed;
border-collapse: collapse;
th{
padding: 9.5px;
@include defaultFont($font-s-11, $font-w-500, $black-1010);
border: 1px solid #2E3A59;
background-color: #F5F6FA;
}
td{
padding: 9.5px;
@include defaultFont($font-s-11, $font-w-400, #FF5656);
border: 1px solid #2E3A59;
}
}
}
.pdf-textarea-data{
padding: 10px;
@include defaultFont($font-s-11, $font-w-400, #FF5656);
border: 1px solid $black-1010;
min-height: 150px;
}

View File

@ -103,16 +103,12 @@
@include defaultFont($font-s-13, $font-w-400, $font-c);
}
.pop-data-table-footer{
@include flex(0px);
.pop-data-table-footer-unit{
flex: 1;
padding: 10px;
@include defaultFont($font-s-13, $font-w-500, $font-c);
border-right: 1px solid #2E3A59;
border-bottom: 1px solid #2E3A59;
}
.pop-data-table-footer-data{
flex: none;
width: 104px;
padding: 10px;
@include defaultFont($font-s-13, $font-w-400, $font-c);
}

View File

@ -0,0 +1,37 @@
.spinner-wrap{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba($color: #101010, $alpha: 0.5);
z-index: 2000000;
}
.loader {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #fff;
box-shadow: 32px 0 #fff, -32px 0 #fff;
position: relative;
animation: flash 0.5s ease-out infinite alternate;
}
@keyframes flash {
0% {
background-color: #FFF2;
box-shadow: 32px 0 #FFF2, -32px 0 #FFF;
}
50% {
background-color: #FFF;
box-shadow: 32px 0 #FFF2, -32px 0 #FFF2;
}
100% {
background-color: #FFF2;
box-shadow: 32px 0 #FFF, -32px 0 #FFF2;
}
}

View File

@ -31,5 +31,6 @@ export type Suitable = {
manuFtCd: string
roofMtCd: string
roofShCd: string
detailCnt: number
detail: string
}

View File

@ -14,6 +14,8 @@ export type SurveyBasicInfo = {
detailInfo: SurveyDetailInfo | null
regDt: Date
uptDt: Date
submissionTargetId: string | null
srlNo: string | null //판매점IDyyMMdd000
}
export type SurveyDetailInfo = {
@ -70,6 +72,8 @@ export type SurveyBasicRequest = {
addressDetail: string | null
submissionStatus: boolean
submissionDate: string | null
submissionTargetId: string | null
srlNo: string | null //판매점IDyyMMdd000
}
export type SurveyDetailRequest = {
@ -127,6 +131,8 @@ export type SurveyRegistRequest = {
submissionStatus: boolean
submissionDate: string | null
detailInfo: SurveyDetailRequest | null
submissionTargetId: string | null
srlNo: string | null //판매점IDyyMMdd000
}
export type Mode = 'CREATE' | 'EDIT' | 'READ' | 'TEMP' // 등록 | 수정 | 상세 | 임시저장