Merge branch 'iipbs/dm/dev' into iipbs/sn/dev

This commit is contained in:
2026-02-23 09:46:25 +09:00
342 changed files with 23656 additions and 256 deletions
+1
View File
@@ -25,3 +25,4 @@ apps/fo/.next/**/*
pnpm-lock.yaml
settings.json
.npmrc
.cursor
-1
View File
@@ -15,4 +15,3 @@ export async function POST() {
cookieStore.delete(CLAIM_NAME);
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
@@ -1,4 +1,4 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
export class TokenManager {
@@ -76,6 +76,8 @@ export class TokenManager {
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
this.removeToken(REFRESH_TOKEN);
this.removeToken(ACCESS_TOKEN);
}
}
+10 -4
View File
@@ -3,7 +3,7 @@
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { PostApi, type MyPost } from "@/entities/post";
import { PostApi, type MyPost, type NoticePost } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
@@ -23,15 +23,21 @@ const MyPostList: React.FC = () => {
}),
});
const handleMyPostRowClick = (row: MyPost) => {
router.push(`/cms/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`);
};
const { tableComponent: myPostTableComponent } = useDataTable({
query: myPostQuery,
tableOptions: {
columns: myPostTableColumns,
getCoreRowModel: getCoreRowModel(),
columns: myPostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
variant: "line",
onRowClick: handleMyPostRowClick
},
});
return (
@@ -26,7 +26,7 @@ const NoticeList: React.FC = () => {
});
const handleNoticePostRowClick = (row: NoticePost) => {
router.push(`/bbs/bbsnotice/${row.bbsId}/${row.pstNo}`);
router.push(`/cms/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`);
};
const handleMoreClick = () => {
@@ -40,12 +40,12 @@ const NoticeList: React.FC = () => {
const { tableComponent: noticePostTableComponent } = useDataTable({
query: noticePostQuery,
tableOptions: {
columns: noticePostTableColumns,
getCoreRowModel: getCoreRowModel(),
columns: noticePostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleNoticePostRowClick,
variant: "line",
onRowClick: handleNoticePostRowClick,
},
});
+831
View File
@@ -0,0 +1,831 @@
# 표준단어정의서
AI 이해용으로 정리된 표준단어명, 영문약어명, 영문명 목록입니다.
| 표준단어명 | 표준단어 영문약어명 | 표준단어 영문명 |
|-----------|-------------------|----------------|
| BOD | BOD | Biochemical Oxygen Demand |
| C/N비 | CNR | Carbon-Nitrogen Ratio |
| COD | COD | Chemical Oxygen Demand |
| F/M비 | FMR | Food-Microorganism Ratio |
| GISD | GISDATA | Geographic Information System Data |
| HTML | HTML | Hypertext Markup Language |
| ID | ID | Id |
| IP | IP | Internet Protocol |
| KEY | KEY | Key |
| ND | ND | Not Detected |
| NON | NON | Non |
| NTU | NTU | Nephelometry Turbidity Unit |
| OPT | OPT | Opt |
| PAC | PAC | Polyaluminium Chloride |
| PH | PH | Hydrogen Exponent |
| PKI | PKI | Public Key Infrastructure |
| POOL | POOL | Pool |
| SS | SS | Suspended Solids (Ss) |
| TCE | TCE | Trichloroethane |
| TM | TM | Transverse Mercator |
| T-N | TN | Total Nitrogen |
| TOC | TOC | Total Organic Carbon |
| T-P | TP | Total Phosphorus |
| URL | URL | Uniform Resource Locator |
| 가구 | HSHD | Household |
| 가능 | PSBL | Possible |
| 가동 | OPR | Operation |
| 가산 | ADTN | Addition |
| 가성소다 | NAOH | Caustic Soda(Nach) |
| 가축분뇨 | ANMA | Animal Manure |
| 값 | VAL | Value |
| 강도 | IN | Intensity |
| 강우 | RAIN | Rain |
| 개방 | OPN | Opening |
| 개별 | INDIV | Individual |
| 개보수비 | RPAFEE | Repair Fee |
| 개설 | ESTBL | Establishment |
| 개시 | STRT | Start |
| 개정 | RVSN | Revision |
| 개조 | REMDEL | Remodeling |
| 개체 | INDVD | Individual |
| 개체군 | POPU | Population |
| 개폐 | RORGN | Reorganization |
| 갱신 | UPDT | Update |
| 건물 | BLDG | Building |
| 건설 | CONC | Construction |
| 건수 | NOCS | The Number Of Cases |
| 건조 | DRY | Dry |
| 검사 | INSP | Inspection |
| 검사서 | EXRPT | Examination Report |
| 검정 | RCGN | Recognition |
| 검증 | VRFC | Verification |
| 검출 | DETECT | Detection |
| 검토 | RVW | Review |
| 격자 | LATTC | Lattice |
| 결과 | RSLT | Result |
| 결의 | DCIS | Decision |
| 결정 | DCSN | Decision |
| 경계 | BNDRY | Boundary |
| 경과 | PROC | Process |
| 경도 | LOT | Longitude |
| 경보 | ALARM | Alarm |
| 경사판 | ICNTPT | Inclination plate |
| 계 | SUM | Summation |
| 계량기 | MRNR | Measuring Instrument |
| 계량기분리 | METRSPRT | Meter Separation |
| 계면 | SGLV | Sludge Level |
| 계산식 | FMUL | Formula |
| 계약 | CTRT | Contract |
| 계약기간 | CNTRPD | Contract Period |
| 계절 | SEASON | Season |
| 계획 | PLAN | Plan |
| 고도처리 | AWTR | Advanced Water Treatment |
| 고시 | ANCMNT | Announcement |
| 고액분리 | SLSPRT | Solid-Liquid Separation |
| 고형물체류시간 | SRT | Solid Retention Time |
| 공간데이터 | SPCEDATA | Space Data |
| 공공 | PUBLIC | Public |
| 공급 | SPLY | Supply |
| 공기 | AIR | Air |
| 공기여과 | ARFT | Air Filter |
| 공문 | ODOC | Official Documnet |
| 공법 | MSRC | Method Of Construction |
| 공사 | CSTRN | Construction |
| 공사비 | CONSTEXPN | Construction Expenses |
| 공업지역 | MNFTR_ARA | Manufacturing Area |
| 공용면적 | CMAR | Common Area |
| 공통 | CMMN | Commonness |
| 관 | PP | Pipe |
| 관계 | REL | Relation |
| 관련 | RLTN | Relation |
| 관로 | PPLINE | Pipe Line |
| 관리 | MNG | Management |
| 관리기관 | MGC | Management Agency |
| 관리자 | MNGR | Manager |
| 관찰 | WTCH | Watch |
| 관측 | OBSR | Observation |
| 관측소 | OBSRVT | Observatory |
| 교반기 | AGITOR | Agirator |
| 교정 | CRRC | Correction |
| 구간 | SCTN | Section |
| 구동 | DRIVE | Drive |
| 구분 | SE | Section |
| 구성 | CMPSG | Composing |
| 구역 | ZONE | Zone |
| 구조 | STRCT | Structure |
| 권역 | DSTR | District |
| 권한 | AUTH | Authority |
| 규격 | STNDRD | Standard |
| 규모 | SCAL | Scale |
| 규제 | REGL | Regulation |
| 균등 | EQLTY | Equality |
| 금액 | AMT | Amount |
| 급수 | WSP | Water Supply |
| 기간 | WHL | While |
| 기관 | INST | Institution |
| 기록 | RECD | Recording |
| 기록부 | REGI | Register |
| 기밀 | SCRET | Secret |
| 기법 | TECQU | Techniques |
| 기본 | BSIC | Basics |
| 기본정보 | BASINF | Bassinfo |
| 기본키 | PK | Primary Key |
| 기상 | WETHER | Weather |
| 기안 | DRFTG | Drafting |
| 기재 | WRIT | Write |
| 기점 | CDPNT | Cardinal Point |
| 기존 | LGCY | Legacy |
| 기준 | CRTR | Criteria |
| 기타 | ETC | Etc |
| 기한 | TERM | Term |
| 기후 | CLMT | Climate |
| 긴급 | EMRG | Emergency |
| 날개벽 | WW | Wing Wall |
| 날씨 | WETHR | Weather |
| 날짜 | DATE | DATE |
| 남조류 | BLAL | Blue-Green Algae |
| 납 | PB | Plumbum |
| 내려받기 | DWLD | Download |
| 내부반송 | ITNRCC | Internal Recycl |
| 내용 | CN | Content |
| 내용연수 | SVLF | Service Life |
| 냄새 | SMELL | Smell |
| 년 | YY | Year |
| 년간 | FAYR | For A Year |
| 노즐 | NZL | Nozzle |
| 노출 | EXPSR | Exposure |
| 농도 | DNSTY | Density |
| 농축 | TKN | Thickened |
| 누적 | ACML | Accumulate |
| 누출 | LKG | Leakage |
| 눈 | SNW | Snow |
| 단가 | UNPRC | Unit Price |
| 단기 | SRTPD | Short Period |
| 단순 | SIMPL | Simplicity |
| 단위 | UNIT | Unit |
| 대구역 | LZONE | Large Zone |
| 대분류 | LCLAS | Large Classification |
| 대장 | REGSTR | Register |
| 대장균 | COLI | Coliform, Fecal Coliform |
| 대장균군 | COLIB | Coliform Bacteria |
| 대지면적 | SIAR | Site Area |
| 대체 | RPLC | Replace |
| 덮개 | COVR | Cover |
| 데이타 | DAT | Data |
| 도구 | UNT | Unit |
| 도래 | ADVNT | Advent |
| 도로 | ROAD | Road |
| 도로명 | RN | Road Name |
| 도시 | CTY | City |
| 도엽 | MAPDMC | Map Demarcation |
| 도표 | CHART | Chart |
| 독성 | TOXCTY | Toxicity |
| 동의 | AGRE | Agreement |
| 동절기 | WSESN | The Winter Season |
| 등급 | GRD | Grade |
| 등기 | RG | Registered |
| 등록 | REG | Registration |
| 디바이스 | DVICE | Device |
| 디코딩 | DCD | Decoding |
| 레이어 | LYR | Layer |
| 로그인 | LGN | Login |
| 로우 | ROW | Row |
| 마감 | DDLN | Deadline |
| 마이크로필터 | MCRFT | Micro Filter |
| 막 | MBRN | Membrane |
| 만족도 | STFDG | Satisfaction Degree |
| 맑음 | CLAR | Clear |
| 매각 | SAL | Sale |
| 맨홀 | MH | Manhole |
| 맵 | MAP | Map |
| 메인 | MAIN | Main |
| 메일 | EMAIL | Email |
| 메탄올 | MTN | Methanol(CH3OH) |
| 명 | NM | Name |
| 모니터링 | MTRG | Monitoring |
| 모델 | MODL | Model |
| 모델링 | MODLNG | Modeling |
| 모래 | SAND | Sand |
| 목 | MOK | Mok |
| 무산소 | ANX | Anoxic |
| 무산소조 | ANX_TANK | Anoxic Tank |
| 무인센서 | MNLSENSR | Manless Sensor |
| 문서 | DCMT | Document |
| 물 | WTR | Water |
| 미급수 | NONWTRSPL | Non Water Supply |
| 미생물 | BLGC | Biological |
| 미세먼지 | PM | Particulate Matter |
| 미세목 | MCRB | Micro Bar |
| 민간위탁 | ETSVN | Entrustment To Civilian |
| 민감 | SST | Sensitive |
| 민원 | CVLCPT | Civil Complaint |
| 바이러스 | VIRS | Virus |
| 반기 | HT | Half Term |
| 반류수 | SIDSWT | Side Stream Water |
| 반송 | SNDBK | Send Back |
| 반송수 | RCCWT | Recycled Water |
| 반송슬러지 | RTNSLG | Return Sludge |
| 반영 | RFLT | Reflection |
| 반응조 | RETNK | Reactant Tank |
| 반출 | EXPRT | Export |
| 반토 | ALUM | Alumina |
| 반환 | GVBK | Giving Back |
| 받이 | INL | Inlet |
| 발생 | OCRN | Occurrence |
| 발전 | PWGNRT | Power Generation |
| 발주기관 | ORNT | Order Institution |
| 방류 | DCWTR | Discharge Water Stock Etc |
| 방류구 | DSCHG | Discharge |
| 방류수 | EFLEC | Effluence |
| 방문 | VST | Visit |
| 방문자 | VSTR | Visitor |
| 방법 | MTHD | Method |
| 방사 | RLSE | Release |
| 배관 | PLINE | Pipeline |
| 배분 | DSTRB | Distirbution |
| 배설물 | EXCRTN | Excretion |
| 배수 | DRNG | Drainage |
| 배수구역 | DRANGZN | Drainage Zone |
| 배출 | EXHST | Exhaust |
| 배출가스 | EMGA | Emission Gases |
| 배출구 | VENT | Vent |
| 배출시설 | DHGP | Discharge Equipment |
| 배출시험 | EXHTST | Exhaust Test |
| 배출자 | DCHGR | Discharge Person |
| 밸브 | VALVE | Valve |
| 번호 | NO | Number |
| 법 | LAW | Law |
| 법적 | LGL | Legal |
| 법정 | STTY | Statutory |
| 법정동 | STDG | Statutory Dong(Administrative District) |
| 법정리 | STLI | Statutory Li(Administrative District) |
| 변경 | CHG | Change |
| 변동 | FLCTN | Fluctuation |
| 변속기 | TRMIS | Transmission |
| 보고 | RPT | Report |
| 보고서 | RPOT | Report |
| 보관 | STORG | Storage |
| 보수 | MEND | Mend |
| 보유 | HOLD | Hold |
| 보정 | RVSION | Revision |
| 보조 | ASSISC | Assistance |
| 보조금 | SBSIDY | Subsidy |
| 복개 | COVE | Cover |
| 복토 | CWTH | Covering With Soil |
| 본류 | MI_RV | Main River |
| 본번 | MNO | Main Number |
| 부검 | ATPSY | Autopsy |
| 부과 | LEVY | Levy |
| 부과금 | LVYAMT | Levy Amount |
| 부기 | BUKIP | Bookkeeping |
| 부담금 | ALOTM | Allotment |
| 부번 | SNO | Sub Number |
| 부산물 | BYPRD | By-Product |
| 부서 | DEPT | Department |
| 부숙화 | DECPZ | Decomposed ~Ize |
| 부적합 | IMPPRTY | Impropriety |
| 부착 | ASN | Adhesion |
| 부품 | CMPNT | Components |
| 부하량 | LOADQTY | Load Quantity |
| 분과 | DIVSN | Division |
| 분뇨 | ENSO | Excreta, Night Soil |
| 분류 | CL | Classification |
| 분류군 | TAXON | Taxon |
| 분리막 | SPRTMBR | Separation Membrane |
| 분배 | DTRBT | Distribution |
| 분배조 | DTRBT_TANK | Distribution Tank |
| 분야 | RLM | Realm |
| 분원성 | FECL | Fecal |
| 분원성대장균군 | FCOLI | Fecal Coliform |
| 분원성연쇄상구균 | FCSTRCCI | Fecal Streptococci |
| 분할 | PRTIN | Partition |
| 비밀번호 | PSWD | Password |
| 비상 | EMGNY | Emergency |
| 비정상 | ABNRM | Abnormality |
| 사양 | SPECS | Specification |
| 사업비 | WCT | Working Cost |
| 사업장 | BPLC | Business Place |
| 사여과지 | SAND_ FILTR | Sand Filter |
| 사용 | USE | Use |
| 사용가능 | USABLE | Usable |
| 사용권 | RIOUS | Right Of Using |
| 사용량 | USG | USAGE |
| 사용자 | USER | User |
| 사위 | FDG | Four Directions Girth |
| 사유 | RESN | Reason |
| 사이클 | CYCLE | Cycle |
| 사이트 | SITE | Site |
| 사진 | PHOT | Photograph |
| 사항 | MATTR | Matter |
| 사후 | AFTFAT | After Fact |
| 삭제 | DEL | Deletion |
| 산기관 | DFS | Diffuser |
| 산업 | INDUST | Industrial |
| 산화환원전위 | ORP | Oxidation Reduction Potential |
| 살모넬라 | SLMN | Salmonella |
| 삼원촉매장치 | TWCTLC | Three Way Catalytic Converter |
| 상세 | DTL | Details |
| 상세주소 | DADDR | Detailed Address |
| 상수 | WT | Water |
| 상업지역 | CMCL_ARA | Commercial Area |
| 상태 | STUS | Status |
| 상품 | GDS | Goods |
| 상향류 | UFW | Upflow |
| 상호 | CONM | Company Name |
| 색도 | CRMTY | Chromaticity |
| 생략 | OMS | Omission |
| 생물 | LVB | Living Being |
| 생물반응조 | BIRAT | Bioreactor |
| 생물서식처 | ORGNHBT | Organism Habitat |
| 생물종 | BISP | Biological Species |
| 생성 | CRT | Creation |
| 생슬러지 | RSLG | Raw Sludge |
| 생태 | ECLGY | Ecology |
| 생태독성 | ECTOX | ecotoxicity |
| 서류 | PAPE | Papers |
| 서명 | SIGN | Signing |
| 서버 | SRVR | Server |
| 서비스 | SRVC | Service |
| 설계 | DESGN | Design |
| 설계용량 | DESGN_CPCT | Design Capacity |
| 설립 | FNDN | Foundation |
| 설정 | STNG | Setting |
| 설치 | INSTL | Installation |
| 성격 | CHTR | Character |
| 성공 | SCS | Success |
| 성능 | PRFMNC | Performance |
| 성능검사 | EFTST | Efficiency Test |
| 성적서 | SCRRP | Score Report |
| 세균 | BCLS | Bacillus |
| 세목 | FB | Fine bar |
| 세부 | DETAIL | Detail |
| 세정 | WS | Washing |
| 세정제 | CNML | Cleaning Material |
| 소 | SML | Small |
| 소독 | DSNF | Disinfection |
| 소독제 | DSINF | Disinfectant |
| 소분류 | SCLAS | Small Classification |
| 소수점 | DCPT | Decimal Point |
| 소요 | REQ | Required |
| 소유 | OWN | Own |
| 소유역 | SBASIN | Sub Basin |
| 소재지 | PLAC | Place Of Location |
| 소포 | ATFM | Antifoaming |
| 소포수 | FBW | Foam Breaking Water |
| 소포제 | ANAG | Antifoaming Agent |
| 송부 | SENDNG | Sending |
| 송신 | SNDM | Send Message |
| 송풍기 | BLW | Blower |
| 수 | CNT | Count |
| 수거 | TKAWY | Take Away |
| 수계 | WWBH | Watershed, Water Basin, Hydrosphere |
| 수동 | HOPR | Hand Operation |
| 수리학적 체류시간 | HRT | Hydraulic Retention Time |
| 수문 | Gate | Gate |
| 수산화나트륨 | SOHY | Sodium Hydroxide |
| 수소이온 | HION | Hydrogen Ion |
| 수신 | RCPTN | Reception |
| 수용가 | RCPTR | Receptor |
| 수위 | WAL | Water Level |
| 수정 | MDFCN | Modification |
| 수정자 | MDFR | Modifier |
| 수제 | SPRDIKE | Superdike |
| 수조 | Tank | Tank |
| 수질 | QTWT | Quality Of Water |
| 수질개선 | WTRQLIMP | Water Quality Improvement |
| 수질기준 | WQST | Water Quality Standards |
| 수질배출정보 | WTDI | Water Discharge Information |
| 수집 | CLCT | Collection |
| 순환 | CCL | Cycling |
| 쉬겔라 | SHIGLA | Shigella |
| 스컴 | SCM | Scum |
| 스크린 | SCR | Screen |
| 슬러지 | SLG | Sludge |
| 슬러지 밀도지수 | SDI | sludge density index |
| 슬러지 용량 지표 | SVI | Sludge Volume Index |
| 습지 | WTLN | Wetland |
| 승인 | APRV | Approval |
| 승인권 | APPRVRGT | Right Of Approval |
| 시각 | TIME | Time |
| 시간 | HR | Hour |
| 시군구 | SGG | Si/Gun/Gu |
| 시나리오 | SCEN | Scenario |
| 시도 | CTPV | Cities And Provinces |
| 시료 | SMPL | Sample |
| 시설 | FCLT | Facility |
| 시설면적 | FCAR | Facility Area |
| 시설물 | FCLTS | Facilities |
| 시스템 | SYS | System |
| 시안 | CYN | Cyaan |
| 시운전 | TSTRN | Test Run |
| 시작 | BGNG | Beginning |
| 시점 | PTM | Point Time |
| 시행 | ENFC | Enforcement |
| 시험 | TEST | Test |
| 신고 | DCLR | Declaration |
| 신고자 | DCL | Declarer |
| 신규 | NEW | New |
| 신기술 | NTCH | New Technology |
| 신뢰 | CNFDNC | Confidence |
| 신뢰성 | CRDIBLTY | Credibility |
| 실시 | OPRT | Operation |
| 실적 | ACRS | Actual Result |
| 실행 | EXCN | Execution |
| 심사 | SRNG | Screening |
| 심의 | DLBR | Deliberation |
| 아황산환원혐기성포자형성균 | SFSRA | Spore Forming Sulfite Reducing Anaerobes |
| 안정화 | STBLZT | Stabilization |
| 암롤박스(컨테이너) | ARTCT | Arm roll type container |
| 암모니아성질소 | NHN | Ammonia Nitrogen |
| 압력 | PRESSR | Pressure |
| 압송 | PUMPNG | Pumping |
| 압축 | CMPRS | Compress |
| 액비 | LQFERTLZ | Liquid Fertilizer |
| 액상 | LIQD | Liquid |
| 약 | GENTLE | Gentle |
| 약품 | CMC | Chemical |
| 약품비 | MEDFEE | Medicine Fee |
| 약품사용 | USMD | Use Medicines |
| 업로드 | UPLD | Upload |
| 업무 | DTY | Duty |
| 없음 | NOT | Nothing |
| 에너지 | ENR | Energy |
| 에틸벤젠 | ETBNZN | Ethylbenzene |
| 여과 | FILTR | Filteration |
| 여과수 | FTWT | Filtered Water |
| 여과재 | FTMTRL | Filter Material |
| 여과형 | FILTRFOM | Filter Form |
| 여부 | YN | Yes or No |
| 여시니아균 | YERSNA | Yersinia |
| 역률 | PWRER_FCT | Power Factor |
| 역세척 | BWS | Back Wash |
| 연결 | CONN | Connection |
| 연계 | LINK | Link |
| 연도 | YR | Year |
| 연락 | CNTC | Contact |
| 연령 | AGE | Age |
| 연료화 | FUELZ | Fuelize |
| 연월 | YM | Year and Month |
| 염소 | CLRN | Chlorine |
| 염소소독 | CHDI | Chlorine Disinfection |
| 염소이온 | CLION | Chlorine Ion |
| 염화제이철 | IRCLD | Iron(III) Chloride |
| 예보 | FRCST | Forecast |
| 예비 | PRPT | Preparation |
| 예산 | BGT | Budget |
| 예상 | EXPCT | Expectation |
| 예약 | RSVT | Reservation |
| 예외 | EXCPT | Exception |
| 예정 | PRNG | Prearrangement |
| 예찰 | PRDICT | Prediction |
| 오류 | ERR | Error |
| 오수받이 | HUS_INL | House Inlet |
| 오염물질 | CNTMN | Contaminant |
| 오염원 | POLUSRC | Pollution Source |
| 오존 | OZN | Ozone |
| 오프로드 | OFFRD | Off Road |
| 옥살산 | OXACID | Oxalic Acid |
| 와류 | VORTEX | Vortex |
| 와류형 | VORTXFOM | Vortex From |
| 완결 | COMPLT | Completion |
| 완료 | CMPTN | Completion |
| 완전 | PRFECT | Perfection |
| 요건 | REQSIT | Requisite |
| 요막액 | ALLNTFLD | Allantoic Fluid |
| 요청 | DMND | Demand |
| 용량 | CPCT | Capacity |
| 용역 | SERVC | Service |
| 용존산소 | DO | Dissolved Oxygen |
| 용해 | DSLV | Dissolving |
| 우량 | EXLT | Excellent |
| 우수 | ST | Storm |
| 우수받이 | STRT_INL | Street Inlet |
| 우수토구 | STM_OVFL_MUT | Storm Overflow Mouth |
| 우회 | BP | BY PASS |
| 운반 | CNVY | Conveyance |
| 운영 | OPER | Operation |
| 운영비 | OPEXP | Operation Expense |
| 운행 | OPRAT | Operation |
| 원격 | RMRT | Rmrt |
| 원격감시체계 | TMS | Tele Monitoring System |
| 원료 | MTRAL | Material |
| 원수 | ORGWT | Origin'S Water |
| 원인자 | ORGPR | The Origin Person |
| 월 | MM | Month |
| 위도 | LAT | Latitude |
| 위반 | CNTR | Contravene |
| 위촉 | ENTRST | Entrusting |
| 위치 | LOC | Location |
| 위탁 | CNSG | Consign |
| 위탁자 | CONSGNR | Consignor |
| 유기인 | ORGP | Organic Phosphorus |
| 유량 | FLUX | Flux |
| 유량조정조 | EQLZT__TANK | Equalization Tank |
| 유입 | INFlOW | Inflow |
| 유입수 | INFWTR | Influent Water |
| 유주 | SWARMG | Swarming |
| 유지 | MNTNC | Maintenance |
| 유지용수공급 | MWRS | Maintenance Water Requirement Supply |
| 유체 | FLUID | Fluid |
| 유출 | EFFLUX | Efflux |
| 유충 | LAVL | Larval |
| 유형 | TY | Type |
| 유효 | VLD | Validity |
| 음용 | DKPS | For Drinking Purpose |
| 읍면동 | EMD | Eup/Myun/Dong |
| 응답 | RSPNS | Response |
| 응집제 | COAG | Cohesive Agents |
| 이관 | TRNSF | Transfer |
| 이동 | MVMN | Movement |
| 이력 | HST | History |
| 이메일 | EML | Email |
| 이상 | ABOVE | Above |
| 이송 | TRSF | Transfer |
| 이의 | OBJC | Objection |
| 이차 | SCDR | Secondary |
| 이차침전지 | SCDR_SDMT_TANK | Secondary Sedimaentation |
| 이행 | MIG | Migratation |
| 인건비 | PEXP | Personnel Expenses{Expenditure] |
| 인계 | HNDVR | Handover |
| 인계서 | HDOVDC | Handover Document |
| 인구 | POPL | Population |
| 인산 | PHACID | Phosphoric Acid |
| 인산염인 | POP | phosphate |
| 인양기 | LFT | Lifter |
| 인원수 | NOPE | The Number of Persons |
| 인증 | CERT | Certification |
| 인처리 | PPRCS | Phosphorus Process |
| 인허가 | APVL | Approval |
| 일 | DY | Day |
| 일련번호 | SN | Serial Number |
| 일반 | GNRL | General |
| 일별 | DALY | Daily |
| 일시 | DT | Date and Time |
| 일자 | YMD | YEAR Month Day |
| 일지 | DIARY | Diary |
| 일차 | PRM | Primary |
| 일차침전지 | PST_SDMT_TANK | Primary Sedimentation Tank |
| 일평균 | DYRG | Day Average |
| 임시 | TMPR | Temporary |
| 임의 | RNDM | Random |
| 입고 | WRHS | Warehousing |
| 입력 | INPT | Input |
| 잉여 | SPS | Surplus |
| 잉여슬러지 | EC_SLG | Excess Sludge |
| 자 | MAN | Man |
| 자갈 | GRAVEL | Gravel |
| 자료 | DATA | Data |
| 자리 | DIGIT | Digit |
| 자립화 | SLFRLI | Self Reliance |
| 자연형 | NATRLFOM | Natural Form |
| 자외선 | URLI | Ultraviolet Rays, Light |
| 자재 | MTRIL | Material |
| 자체 | ITSL | Itself |
| 작성 | WRT | Writing |
| 작업 | JOB | Job |
| 잠금 | LOCK | Lock |
| 장기 | LNGTR | Long Term |
| 장소 | PLCE | Place |
| 장착 | MNTNG | Mounting |
| 재 | RE | Re |
| 재이용 | REUSE | Reuse |
| 재이용수 | | Reuse Water |
| 재활용 | RUSE | Reuse |
| 저감 | REDUC | Reduction |
| 저류조 | DTT_TANK | Detention Tank |
| 저온 | LOWTMP | Low Temperature |
| 저장 | STRG | Storage |
| 저장소 | STORGE | Storage |
| 적발 | DSCL | Disclose |
| 적산 | ADD | Addition |
| 적용 | APLCN | Application |
| 적합 | STBLT | Suitability |
| 전기 | ELCTY | Electricity |
| 전기료 | ELFEE | Electricity Fee |
| 전력 | PWRER | Power |
| 전문 | SPCY | Specially |
| 전산 | CMPUTT | Computation |
| 전송 | TRSM | Transmission |
| 전압 | VLTGE | Voltage |
| 전용 | EXUSE | Exclusive Use |
| 전원 | SELCT | Source Of Electricity |
| 전처리 | PRETR | Pretreatment |
| 점검 | CHCK | Check |
| 접속 | CNTN | Connection |
| 정밀 | PRCIS | Precision |
| 정보 | INFO | Information |
| 정산 | CLCLN | Calculation |
| 정상 | NRMLT | Normality |
| 정수장 | WPPW | Water Purification Plant, Waterworks |
| 정제 | REFINE | Refining |
| 정지 | STP | Stop |
| 제거 | RMV | Removal |
| 제수변 | SL_VALVE | Sluice Valve |
| 제작 | MNFCT | Manufacture |
| 제조사 | MKR | Maker |
| 제출 | SBMSN | Submission |
| 제한 | LIMT | Limitation |
| 조류 | ALG | Algae |
| 조목 | CSB | Coarse Bar |
| 조사 | EXMN | Examination |
| 조성 | MAKE | Make |
| 조정 | EQLT | Equalization |
| 조치 | ACTN | Action |
| 조회 | INQ | Inquiry |
| 존재 | EXST | Existence |
| 종결 | TRMN | Termination |
| 종균제 | MICBA | Microbial Agent |
| 종료 | END | End |
| 종류 | KND | Kind |
| 종목 | IEM | Item |
| 종별 | ASORT | Assortment |
| 종사자 | ENFSN | Enfilade Person |
| 종점 | TMNL | Terminal |
| 좌표 | COOR | Coordinate |
| 주간 | WIK | Week |
| 주변 | CFR | Circumference |
| 주소 | ADDR | Address |
| 주입 | IJTN | Injection |
| 주파수 | FQNC | Frequency |
| 준공 | CMCN | Completion Of Construction |
| 중권역 | MDSTRC | Medium District |
| 중복 | DPCN | Duplication |
| 중분류 | MLSFC | Middle Classification |
| 중온 | MESPH | Mesophilic |
| 중첩 | RETRTN | Reiteration |
| 중화설비 | NUTEQP | Neutralization Equipment |
| 중화제 | CNTAG | Counteragent |
| 증가 | INCRS | Increase |
| 지도점검표사본 | CITBMR | Copy of Instruction Checklist |
| 지방자치단체 | LGOV | Local Governments |
| 지번 | LOTNO | Lot Number |
| 지상 | GRND | Ground |
| 지역 | ARA | Area |
| 지질 | NTRFS | Nature Of Soil |
| 지질도 | GEOMAP | Geological Map |
| 지하 | UDGD | Underground |
| 지하수 | GROU | Groundwater |
| 지형 | TPGRPH | Topography |
| 진공 | VCM | Vacuum |
| 진단 | DGNSS | Diagnosis |
| 진동 | VIBRAT | Vibration |
| 진행 | PRGRE | Progress |
| 질산 | NIACID | Nitric Acid. |
| 질산성질소 | NO3N | Nitrate Nitrogen |
| 집계 | TOT | Total |
| 집계구 | SUMDST | Summation District |
| 집행 | IMPL | Implementation |
| 찌꺼기 | LEES | Lees |
| 차아염소산 | NAOCL | Hypochlorous Acid |
| 차집 | ITCT | Intercepting |
| 착공 | BGNCST | Begin Construction |
| 채수 | SAMWT | Sampling Of Water |
| 채수기 | WTRSMPLR | Water Sampler |
| 채수병 | WTRBOTL | Water Bottle |
| 처리 | PRCS | Process |
| 처리비 | PSSFEE | Process Fee |
| 처리수 | TRT_WT | Treated Water |
| 처리수조 | TRT_WT_TANK | Treated Water Tank |
| 처리시설 | TRTMTFCLT | Treatment Facility |
| 처리자 | PRCR | Processor |
| 처분 | DSPS | Disposition |
| 철염 | FESLT | Ferric Salt |
| 첨부 | ATCH | Attachment |
| 첨부파일 | ATFI | Attachment File |
| 초과 | EXCS | Excess |
| 초기화 | INITL | Initialization |
| 촉매 | CTLY | Catalyst |
| 총계 | TSM | Summation |
| 총대장균군 | TCOLFGRP | Total Coliform Group |
| 총량 | TQT | Total Quantity |
| 총면적 | GFA | Gross Floor Area |
| 총사업비 | TPCO | Total Project Cost |
| 총인구 | TPOPLTN | Total Population |
| 총인처리시설 | TPFCT | Total Phosphorus facility |
| 최근 | RCNT | Recent |
| 최대값 | MAXV | Maximum Value |
| 최소값 | MINV | Minimum Value |
| 최적 | BMTB | Being The Most Suitable |
| 최종 | LAST | Last |
| 최초 | FRST | First |
| 추출 | EXTR | Extraction |
| 추출정보 | EXTRINFO | Extraction Information |
| 축 | AXS | Axis |
| 출력 | OTPT | Output |
| 출발 | LVE | Leave |
| 충전기 | CHRGR | Charger |
| 취득 | ACQS | Acquisition |
| 취소 | RTRCN | Retraction |
| 측정 | MSUR | Measurement |
| 측정기 | MSRR | Measurement Instrument |
| 측정소 | MSRSTN | Measurement Station |
| 침사지 | GTCB | Grit Chamber |
| 침전 | SDMT | Sediment |
| 침전지 | SDMT_TANK | sedimentation tank |
| 컨베이어 | CVY | Conveyor |
| 컬럼 | COL | Column |
| 코드 | CD | Code |
| 크기 | SZ | Size |
| 크실렌 | XYLEN | Xylene |
| 탄화 | CARB | Carbonization |
| 탈거 | CRYOFF | Carry Off |
| 탈리액 | SPNT | Supernatant |
| 탈수 | DWTR | Dewatering |
| 탈수기 | DEHYM | Dehydration Machine |
| 탈질 | DNTFCT | Denitrificatio |
| 탈취 | DODRZ | Deodorization |
| 탈취기 | DEODRZ | Deodorizer |
| 탈취제 | DEODR | Deodorant |
| 탈황제 | DSLFA | Desulfurizing Agent |
| 터널형 | YUNLTP | Tunnel Type |
| 테이블 | TBL | Table |
| 토구 | OTFL | Outfall |
| 토지 | LAD | Land |
| 토지면적 | LDAR | Land Area |
| 통계 | STATS | Statistics |
| 통보 | NTFCTN | Notification |
| 통신 | COMM | Communication |
| 통지 | AVTSMT | Avertissement |
| 통합 | UNTY | Unity |
| 특성 | CHRT | Character |
| 특수 | SPECL | Special |
| 특정유해물질 | SHCH | Specified Hazardous Chemicals |
| 파일 | FILE | File |
| 판정 | JGMT | Judgement |
| 펌프 | PUMP | Pump |
| 펌프장 | PUMP_STT | Pump Station |
| 페놀 | PHNL | Phenol |
| 평가 | EVL | Evaluation |
| 폐기 | DSCD | Discard |
| 폐기물 | WSTE | Waste |
| 폐쇄 | CLSG | Closing |
| 폐수 | WAWA | Waste Water |
| 폐수처리 | WSWT | Wastewater Treatment |
| 폐열 | WASTHET | Waste Heat |
| 포기 | ARTN | Aeration |
| 포트 | PORT | Port |
| 폭기조 | ARTN_TANK | Aeration Tank |
| 폴리머 | PLYMR | Polymer |
| 폴리철 | FRRCSU | Ferric Sulfate |
| 표고 | AL | Altitude |
| 표기 | MARK | Mark |
| 표시 | INDCT | Indication |
| 표준 | STD | Standard |
| 표출 | EPRSS | Expression |
| 플래그 | FLAG | Flag |
| 필드 | FILD | Field |
| 필수 | ESNTL | Essential |
| 필증 | CERFIN | Certificate Of Finishing |
| 필터 | FLTER | Filter |
| 하수 | SWGE | Sewage |
| 하수도 | DRNGSYS | Drainage System |
| 하수미처리 | SEWGUNTTM | Un Sewage Treatment |
| 하수처리 | SEWGTRTM | Sewage Treatment |
| 하수처리수 | STWTR | Sewage Treatment Water |
| 하위 | LOWR | Lower |
| 하향류 | DWFW | Downflow |
| 할인 | DSCNT | Discount |
| 함수율 | MISTR_CTNT | Moisture content |
| 합계 | SMM | Summation |
| 해당 | RLVT | Relevant |
| 해석 | INTRPRTT | Interpretation |
| 해양투기 | OCDU | Ocean Dumping |
| 행망 | ADMNW | Administration Network |
| 행정 | ADMN | Administration |
| 행정구역 | ADMI | Administrative District |
| 행정동 | DONG | Dong (Administrative District) |
| 허가 | PRMSN | Permission |
| 허용 | PERM | Permission |
| 현상 | PHNMN | Phenomenon |
| 혐기 | AARB | Anaerobic |
| 혐기조 | AARB_TANK | Anaerobic tank |
| 협잡물 | CCMT | Concomitant |
| 형태 | SHAP | Shape |
| 호기 | ARB | Aerobic |
| 호기조 | ARB_TANK | Aerobic Tank |
| 호소수 | LMSHWT | Lake And Marsh Water |
| 호수 | LKE | Lake |
| 호안 | RVTMNT | Revetment |
| 호이스트 | HIST | Hoist |
| 혼합 | CMPN | Compound |
| 혼합액현탁고형물 | MLSS | Mixed liquor suspended solids |
| 혼화지 | MXG_TNAK | Mixing tank |
| 홈페이지 | HMPG | Homepage |
| 홍수 | FLOOD | Flood |
| 확인 | IDNTY | Identify |
| 확인서 | CNFRMN | Confirmation |
| 확인자 | IDFR | Identifier |
| 확장자 | EXTN | Extension |
| 확정 | CFMTN | Confirmation |
| 환경 | ENVR | Environment |
| 환경기초시설 | ENINF | Environmental Infrastructure |
| 환원 | RSTORE | Restoration |
| 활성탄 | AC | Activated Carbon |
| 활성화 | PRMT | Promote/Facilitate |
| 활용 | PRUS | Practical Use |
| 황산 | ECCBT | Encyclopaedia Britannica (H2So4) |
| 회사 | CO | Company |
| 회수 | RTRVL | Retrieval |
| 회신 | RPLY | Reply |
| 효율 | EFC | Efficiency |
| 효율성 | EFFCTV | Effectiveness |
| 휴대 | MBL | Mobile |
| 흡수정 | EXTTW | Extraction Well |
| 흡착 | ADST | Adsorption |
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
keepPreviousData,
useQuery,
@@ -75,7 +75,7 @@ function AddAdminDialog() {
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, pagination, "normal-user"],
queryKey: [ENDPOINT.USER_SERVICE.USERS, "findPageUser", pagination],
queryFn: () =>
UserApi.findPageUser({
...userSearchQuery,
@@ -181,6 +181,21 @@ function AddAdminDialog() {
query.refetch();
};
useEffect(() => {
if (!open) {
setRowSelection({});
setSelectedGroupId("");
setUserSearchQuery({
keywordType: "userName",
keyword: "",
});
setPagination({ pageIndex: 0, pageSize: 10 });
queryClient.removeQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS, "findPageUser"],
});
}
}, [open]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -1,53 +1,22 @@
// import { client } from "@/shared/api";
// import { ENDPOINT } from "@/shared/config";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
type BoardStatisticsParams,
type BoardStatistics,
type BoardStatisticsService,
} from "../model/board-statistics-interface";
// 임시 모의 데이터
const mockBoardStatisticsData: BoardStatistics = {
totalPosts: 2456,
totalComments: 156,
averageRating: 4.2,
totalViews: 45678,
dayList: [
{ date: "1월", posts: 10, comments: 5, views: 800 },
{ date: "2월", posts: 15, comments: 8, views: 1200 },
{ date: "3월", posts: 12, comments: 10, views: 1000 },
{ date: "4월", posts: 18, comments: 15, views: 1500 },
{ date: "5월", posts: 22, comments: 20, views: 1800 },
{ date: "6월", posts: 20, comments: 18, views: 1600 },
{ date: "7월", posts: 25, comments: 22, views: 2000 },
],
boardList: [
{ boardType: "notice", boardName: "공지사항", count: 45 },
{ boardType: "faq", boardName: "FAQ", count: 120 },
{ boardType: "qna", boardName: "Q&A", count: 180 },
{ boardType: "free", boardName: "자유게시판", count: 95 },
],
statusList: [
{ status: "completed", statusName: "답변완료", count: 75 },
{ status: "waiting", statusName: "답변대기", count: 25 },
],
};
class BoardStatisticsApiService implements BoardStatisticsService {
async getBoardStatistics(
params: BoardStatisticsParams,
): Promise<BoardStatistics> {
console.log("params: ", params);
try {
// 실제 API 연동 시 아래 코드 사용
// const response = await client.get<BoardStatistics>(
// `${ENDPOINT.CMS_SERVICE.BOARD_STATISTICS}`,
// { params },
// );
// return response.data;
// 임시 모의 데이터 반환
return Promise.resolve(mockBoardStatisticsData);
const response = await client.get<BoardStatistics>(
`${ENDPOINT.BOARD_SERVICE.STATISTICS}`,
{ params },
);
return response.data;
} catch (error) {
console.error("게시판 통계 데이터 조회 실패:", error);
throw error;
@@ -12,32 +12,36 @@ export interface BoardStatisticsParams extends PageableParams {
}
export interface BoardStatistics {
totalPosts: number;
totalComments: number;
averageRating: number;
totalViews: number;
dayList: DayStatistics[];
boardList: BoardTypeStatistics[];
statusList: StatusStatistics[];
bbsTotalDto: BbsTotalDto; // 게시판 전체 통계
pstAnsList: PstAnswerStatistics[]; // 게시물 답변 상태별 통계 목록
bbsDailyList: BbsDailyStatistics[]; // 게시판 일별 통계 목록
bbsPstCntList: BbsPostCountStatistics[]; // 게시판별 게시물 건수 통계 목록
}
interface DayStatistics {
date: string;
posts: number;
comments: number;
views: number;
interface BbsTotalDto {
pstInqCnt: number; // 게시물 조회 건수
pstEvlAvg: number; // 게시물 평가 평균
pstTotCnt: number; // 게시물 총 건수
pstAnsWaitCnt: number; // 게시물 답변 대기 건수
}
interface BoardTypeStatistics {
boardType: string;
boardName: string;
count: number;
interface PstAnswerStatistics {
answerNm: string; // 답변 명
answerYn: string; // 답변 여부 (Y/N)
ansCnt: number; // 답변 건수
}
interface StatusStatistics {
status: string;
statusName: string;
count: number;
interface BbsDailyStatistics {
dayYmd: string; // 일자 (YYYY-MM-DD)
pstInqCnt: number; // 게시물 조회 건수
pstCmntCnt: number; // 게시물 댓글 건수
pstNewCnt: number; // 게시물 신규 건수
}
interface BbsPostCountStatistics {
bbsId: number; // 게시판 ID
bbsNm: string; // 게시판 명
pstCnt: number; // 게시물 건수
}
export interface BoardStatisticsService {
@@ -11,20 +11,20 @@ export const chartData = {
} => {
return {
data: {
labels: data.dayList.map((item) => item.date),
labels: data.bbsDailyList.map((item) => item.dayYmd),
datasets: [
{
type: "line",
label: "신규게시물",
data: data.dayList.map((item) => item.posts),
data: data.bbsDailyList.map((item) => item.pstNewCnt),
borderColor: getCssVariableValue("--chart-1"),
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.4,
},
{
type: "line",
label: "답변대기",
data: data.dayList.map((item) => item.comments),
label: "댓글수",
data: data.bbsDailyList.map((item) => item.pstCmntCnt),
borderColor: getCssVariableValue("--chart-2"),
backgroundColor: "rgba(236, 72, 153, 0.1)",
tension: 0.4,
@@ -32,11 +32,10 @@ export const chartData = {
{
type: "line",
label: "조회수",
data: data.dayList.map((item) => item.views),
data: data.bbsDailyList.map((item) => item.pstInqCnt),
borderColor: getCssVariableValue("--chart-3"),
backgroundColor: "rgba(168, 85, 247, 0.1)",
tension: 0.4,
hidden: true,
},
],
},
@@ -67,13 +66,13 @@ export const chartData = {
} => {
return {
data: {
labels: data.boardList.map((item) => item.boardName),
labels: data.bbsPstCntList.map((item) => item.bbsNm),
datasets: [
{
type: "bar",
label: "게시판별 분포",
data: data.boardList.map((item) => item.count),
backgroundColor: data.boardList.map((_, index) =>
data: data.bbsPstCntList.map((item) => item.pstCnt),
backgroundColor: data.bbsPstCntList.map((_, index) =>
getCssVariableValue(`--chart-${(index % 5) + 1}`),
),
},
@@ -106,12 +105,12 @@ export const chartData = {
} => {
return {
data: {
labels: data.statusList.map((item) => item.statusName),
labels: data.pstAnsList.map((item) => item.answerNm),
datasets: [
{
type: "doughnut",
data: data.statusList.map((item) => item.count),
backgroundColor: data.statusList.map((_, index) =>
data: data.pstAnsList.map((item) => item.ansCnt),
backgroundColor: data.pstAnsList.map((_, index) =>
getCssVariableValue(`--chart-${(index % 5) + 1}`),
),
},
@@ -26,7 +26,7 @@ import {
SelectValue,
} from "@common/components";
import { SiteSelect } from "@/entities/site";
// import { ENDPOINT } from "@/shared/config";
import { ENDPOINT } from "@/shared/config";
import { NoData } from "@/shared/ui/no-data";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { BoardStatisticsApi } from "../api/board-statistics-service";
@@ -66,7 +66,7 @@ function BoardStatisticsPage() {
});
const { data: boardStatisticsData } = useQuery({
queryKey: ["board-statistics", searchParams],
queryKey: [ENDPOINT.BOARD_SERVICE.STATISTICS, searchParams],
queryFn: () =>
BoardStatisticsApi.getBoardStatistics({
...searchParams,
@@ -176,7 +176,8 @@ function BoardStatisticsPage() {
<h3 className="text-lg font-medium text-gray-500">총 게시물</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalPosts.toLocaleString() || 0}
{boardStatisticsData?.bbsTotalDto?.pstTotCnt?.toLocaleString() ||
0}
</div>
<div className="h-16 w-24">
<Line
@@ -210,7 +211,8 @@ function BoardStatisticsPage() {
<h3 className="text-lg font-medium text-gray-500">답변대기</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalComments.toLocaleString() || 0}
{boardStatisticsData?.bbsTotalDto?.pstAnsWaitCnt?.toLocaleString() ||
0}
</div>
<div className="h-16 w-24">
<Line
@@ -244,7 +246,7 @@ function BoardStatisticsPage() {
<h3 className="text-lg font-medium text-gray-500">평균평점</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.averageRating.toFixed(1) || 0}
{boardStatisticsData?.bbsTotalDto?.pstEvlAvg?.toFixed(1) || 0}
</div>
<div className="h-16 w-24">
<Line
@@ -278,7 +280,8 @@ function BoardStatisticsPage() {
<h3 className="text-lg font-medium text-gray-500">총 조회수</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalViews.toLocaleString() || 0}
{boardStatisticsData?.bbsTotalDto?.pstInqCnt?.toLocaleString() ||
0}
</div>
<div className="h-16 w-24">
<Line
@@ -315,7 +318,7 @@ function BoardStatisticsPage() {
<h3 className="text-2xl font-medium">일별 게시물 현황</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.dayList?.length ? (
{boardStatisticsData?.bbsDailyList?.length ? (
<Line {...chartData.day(boardStatisticsData)} />
) : (
<NoData />
@@ -329,7 +332,7 @@ function BoardStatisticsPage() {
<h3 className="text-2xl font-medium">답변상태</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.statusList?.length ? (
{boardStatisticsData?.pstAnsList?.length ? (
<Doughnut {...chartData.status(boardStatisticsData)} />
) : (
<NoData />
@@ -342,7 +345,7 @@ function BoardStatisticsPage() {
<h3 className="text-2xl font-medium">게시판별 분포</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.boardList?.length ? (
{boardStatisticsData?.bbsPstCntList?.length ? (
<Bar {...chartData.board(boardStatisticsData)} />
) : (
<NoData />
@@ -1,4 +1,4 @@
import { memo } from "react";
import { memo, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -140,6 +140,10 @@ const LwpoCodeManagementDialog = memo(
}
};
useEffect(() => {
if (!open) form.reset();
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange} {...props}>
<DialogTrigger asChild>{children}</DialogTrigger>
+5 -1
View File
@@ -1,4 +1,4 @@
import { memo } from "react";
import { memo, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -96,6 +96,10 @@ const UpCodeManagementDialog = memo(
if (await confirm(confirmMessage)) callback(data);
};
useEffect(() => {
if (!open) form.reset();
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange} {...props}>
<DialogTrigger asChild>{children}</DialogTrigger>
@@ -1,6 +1,13 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Dashboard, DashboardService } from "../model/dashboard-interface";
import type {
Dashboard,
DashboardService,
NoticeUser,
Schedule,
ScheduleQueryParams,
} from "../model/dashboard-interface";
import type { PageableList } from "@/shared/model";
export class DashboardApiService implements DashboardService {
public async getDashboard(): Promise<Dashboard> {
@@ -9,6 +16,25 @@ export class DashboardApiService implements DashboardService {
);
return response.data;
}
public async getNoticeUserList(
recvUnqId: string,
): Promise<PageableList<NoticeUser>> {
const response = await client.get<PageableList<NoticeUser>>(
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/list`,
);
return response.data;
}
public async getScheduleList(
params?: ScheduleQueryParams,
): Promise<Schedule[]> {
const response = await client.get<Schedule[]>(
ENDPOINT.CMS_SERVICE.SCHEDULES,
{ params },
);
return response.data;
}
}
export const DashboardApi = new DashboardApiService();
@@ -1,3 +1,5 @@
import type { PageableList } from "@/shared/model";
export interface SiteStatisticsContent {
siteId: string;
siteNm: string;
@@ -27,6 +29,42 @@ export interface Dashboard {
menuAccessList: MenuStatisticsContent[];
}
export interface NoticeUser {
notiId: number;
recvUnqId: string;
senderUnqId: string;
notiType: string;
notiTtl: string;
notReadYn: string;
regDt: string;
}
export interface Schedule {
schdlBgngDt: string;
schdlEndDt: string;
actualDate: string;
schdlSn: number;
siteId: string;
schdlTtl: string;
schdlSeCd: string;
schdlCn: string;
reptYn: string;
commSchdlYn: string;
atflCd: string | null;
}
export interface ScheduleQueryParams {
siteId?: string;
schdlTtl?: string;
schdlSeCd?: string;
schdlBgngYmd?: string;
schdlEndYmd?: string;
reptYn?: string;
commSchdlYn?: string;
}
export interface DashboardService {
getDashboard(): Promise<Dashboard>;
getNoticeUserList(recvUnqId: string): Promise<PageableList<NoticeUser>>;
getScheduleList(params?: ScheduleQueryParams): Promise<Schedule[]>;
}
@@ -1,5 +1,6 @@
"use client";
import dayjs from "dayjs";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
@@ -17,8 +18,8 @@ import {
} from "chart.js";
import { Bar, Line } from "react-chartjs-2";
import { PostApi, type RecentPost } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { AUTH_USER_ID, ENDPOINT, SITE_ID } from "@/shared/config";
import { getCookie, useDataTable } from "@/shared/lib";
import { DashboardApi } from "../api/dashboard-service";
import { chartData } from "../model/chart-data";
import {
@@ -39,12 +40,44 @@ ChartJS.register(
);
function DashboardPage() {
const userId = getCookie(AUTH_USER_ID) ?? "";
const router = useRouter();
const { data } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.DASHBOARD],
queryFn: () => DashboardApi.getDashboard(),
});
const { data: noticeUserList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
queryFn: () => DashboardApi.getNoticeUserList(userId),
enabled: !!userId,
select: (data) => {
// 오늘 ~ 한 달 전 사이의 일정
const today = dayjs().endOf("day");
const monthAgo = dayjs().subtract(1, "month").startOf("day");
return data.content?.filter((item) => {
const regDt = dayjs(item.regDt);
return (
(regDt.isSame(monthAgo) || regDt.isAfter(monthAgo)) &&
(regDt.isSame(today) || regDt.isBefore(today))
);
});
},
});
const { data: scheduleList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SCHEDULES],
queryFn: () =>
DashboardApi.getScheduleList({
siteId: SITE_ID,
schdlBgngYmd: dayjs().format("YYYY-MM-DD"),
schdlEndYmd: dayjs().add(1, "month").format("YYYY-MM-DD"),
commSchdlYn: "Y",
}),
});
const noticePostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
queryFn: () =>
@@ -323,22 +356,15 @@ function DashboardPage() {
</div>
<div className="p-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">신규 사용자 가입</span>
<span>{(1).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">메뉴 수정 요청</span>
<span>{(5).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">게시물 신고</span>
<span>{(10).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">컨텐츠 승인 요청</span>
<span>{(15).toLocaleString()}분 전</span>
</div>
{noticeUserList?.map((item) => (
<div
key={item.notiId}
className="flex items-center justify-between border-b border-border pb-4"
>
<span className="font-medium">{item.notiTtl}</span>
<span>{dayjs(item.regDt).fromNow()}</span>
</div>
))}
</div>
</div>
</div>
@@ -349,24 +375,44 @@ function DashboardPage() {
</div>
<div className="p-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">
{(3).toLocaleString()}월 정기 시스템 점검
</span>
<span>내일</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">서버 업데이트 예정</span>
<span>{(3).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">백업 스케줄</span>
<span>{(5).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">정기 보안 점검</span>
<span>{(7).toLocaleString()}일 후</span>
</div>
{scheduleList && scheduleList.length > 0 ? (
scheduleList.map((schedule, index) => {
const today = dayjs();
const scheduleDate = dayjs(schedule.actualDate);
const daysUntil = scheduleDate.diff(today, "day");
let timeText = "";
if (daysUntil === 0) {
timeText = "오늘";
} else if (daysUntil === 1) {
timeText = "내일";
} else if (daysUntil > 1) {
timeText = `${daysUntil}일 후`;
} else {
timeText = scheduleDate.format("YYYY-MM-DD");
}
return (
<div
key={schedule.schdlSn}
className={`flex items-center justify-between ${
index < scheduleList.length - 1
? "border-b border-border pb-4"
: ""
}`}
>
<span className="font-medium">
{schedule.schdlTtl}
</span>
<span>{timeText}</span>
</div>
);
})
) : (
<div className="text-center text-muted-foreground">
예정된 일정이 없습니다.
</div>
)}
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -48,25 +48,14 @@ function MenuAuthorityDialog({ groupId }: MenuAuthorityDialogProps) {
keywordType: "",
siteId: "",
});
const handleReset = () => {
if (inputRef.current) inputRef.current.value = "";
setKeywordType("");
setSiteId("");
setSearchParams({
keyword: "",
keywordType: "",
siteId: "",
});
};
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, groupId, searchParams],
queryFn: () => MenuAuthorityApi.getGroupMenuAuthorityList({
groupId,
...searchParams
}),
queryFn: () =>
MenuAuthorityApi.getGroupMenuAuthorityList({
groupId,
...searchParams,
}),
enabled: open && !!groupId,
});
@@ -117,6 +106,21 @@ function MenuAuthorityDialog({ groupId }: MenuAuthorityDialogProps) {
});
};
const handleReset = () => {
if (inputRef.current) inputRef.current.value = "";
setSiteId("");
setKeywordType("");
setSearchParams({
keyword: "",
keywordType: "",
siteId: "",
});
};
useEffect(() => {
if (!open) handleReset();
}, [open]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -1,4 +1,4 @@
import { useState, memo } from "react";
import { useState, memo, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -90,6 +90,10 @@ const ManagementAuthorityDialog = memo(
}
};
useEffect(() => {
if (!open) form.reset();
}, [open]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getCoreRowModel, type ColumnDef } from "@tanstack/react-table";
import {
@@ -113,6 +113,10 @@ function MappingRoleDialog({
},
});
useEffect(() => {
if (!open) handleReset();
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange} {...props}>
<DialogTrigger asChild>
+2
View File
@@ -56,6 +56,7 @@ export const ENDPOINT = {
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
NOTICE_USER: "/cms-service/api/v1/noti/user",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
@@ -63,6 +64,7 @@ export const ENDPOINT = {
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
STATISTICS: "/board-service/api/v1/stat/bbs",
},
};
@@ -0,0 +1,540 @@
"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
/**
* CaseTabsCarousel.tsx (FINAL OPTIMIZED)
* - 탭별 데이터
* - 카드 perPage(1/2/3) 반응형
* - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동
* - 3그룹 가상화(prev/cur/next)로 DOM 최소화
* - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지
* - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화
* - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리)
* - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리
*/
type TabKey = "rain" | "reuse" | "sewage" | "global";
type CaseItem = {
id: string;
title: string;
desc: string;
imageSrc: string;
href?: string;
};
const TABS: { key: TabKey; label: string }[] = [
{ key: "rain", label: "빗물 재이용 사례" },
{ key: "reuse", label: "중수 재이용 사례" },
{ key: "sewage", label: "하수 재이용 사례" },
{ key: "global", label: "해외 재이용 사례" },
];
// 실제 데이터로 교체해서 쓰세요(구조 유지)
const MOCK: Record<TabKey, CaseItem[]> = {
rain: [
{
id: "rain-0",
title: "경기도 수원시 빗물이용과 중수도",
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
imageSrc: "/images/landing/rain_01.png",
href: "/reuse/business/rain/suwon",
},
// {
// id: "rain-2",
// title: "경기도 수원시 빗물이용과 중수도",
// desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
// imageSrc: "/images/landing/rain_01.png",
// href: "#",
// },
],
reuse: Array.from({ length: 6 }).map((_, i) => ({
id: `reuse-${i}`,
title: `중수 재이용 사례 ${i + 1}`,
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
imageSrc: "/images/landing/sample_case.png",
href: "#",
})),
sewage: [
{
id: "sewage-1",
title: "경기도 파주시 하수 재이용",
desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...",
imageSrc: "/images/landing/sewage_01.png",
href: "/reuse/business/sewage/paju",
},
{
id: "sewage-2",
title: "충남보령 하수재이용",
desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...",
imageSrc: "/images/landing/sewage_02.png",
href: "/reuse/business/sewage/bolyeong",
},
{
id: "sewage-3",
title: "여수시 하수처리수 재이용",
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...",
imageSrc: "/images/landing/sewage_03.png",
href: "/reuse/business/sewage/yeosu",
},
{
id: "sewage-4",
title: "청주시 하수 재이용 시설",
desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...",
imageSrc: "/images/landing/sewage_04.png",
href: "/reuse/business/sewage/cheongju",
},
{
id: "sewage-5",
title: "수원시 황구지천 하천유지용수",
desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...",
imageSrc: "/images/landing/sewage_05.png",
href: "/reuse/business/sewage/hwanggu",
},
{
id: "sewage-6",
title: "구리시 도로자동청소시스템",
desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...",
imageSrc: "/images/landing/sewage_06.png",
href: "/reuse/business/sewage/guri",
},
...Array.from({ length: 6 }).map((_, i) => ({
id: `sewage-x-${i}`,
title: `하수 재이용 사례 ${i + 4}`,
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
imageSrc: "/images/landing/sample_case.png",
href: "#",
})),
],
global: Array.from({ length: 6 }).map((_, i) => ({
id: `global-${i}`,
title: `해외 재이용 사례 ${i + 1}`,
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
imageSrc: "/images/landing/sample_case.png",
href: "#",
})),
};
/* ---------------- utils ---------------- */
function chunk<T>(arr: T[], size: number) {
const res: T[][] = [];
if (size <= 0) return res;
for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size));
return res;
}
function mod(n: number, m: number) {
return ((n % m) + m) % m;
}
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mq.matches);
onChange();
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
/**
* wrapRef: perPage 계산용
* clipRef: "패딩 없는 측정 박스" 폭(px) 계산용
*/
function useCarouselMeasure() {
const wrapRef = useRef<HTMLDivElement | null>(null);
const clipRef = useRef<HTMLDivElement | null>(null);
const [perPage, setPerPage] = useState(3);
const [clipWidth, setClipWidth] = useState(0);
// perPage: container width로 반응형 결정 (rAF로 디바운스)
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
let raf = 0;
const calc = () => {
const w = el.clientWidth;
if (w < 640) setPerPage(1);
else if (w < 1024) setPerPage(2);
else setPerPage(3);
};
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(calc);
});
calc();
ro.observe(el);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
// clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림)
useEffect(() => {
const el = clipRef.current;
if (!el) return;
let raf = 0;
const calc = () => {
const w = Math.round(el.getBoundingClientRect().width);
setClipWidth(w);
};
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(calc);
});
calc();
ro.observe(el);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
return { wrapRef, clipRef, perPage, clipWidth };
}
/* ---------------- component ---------------- */
export default function CaseTabsCarousel() {
const [active, setActive] = useState<TabKey>("rain");
// 탭별 마지막 그룹 인덱스 기억
const tabIndexRef = useRef<Record<TabKey, number>>({
rain: 0,
reuse: 0,
sewage: 0,
global: 0,
});
const reducedMotion = usePrefersReducedMotion();
const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure();
const items = useMemo(() => MOCK[active] ?? [], [active]);
const groups = useMemo(() => chunk(items, perPage), [items, perPage]);
const groupCount = groups.length;
const [cur, setCur] = useState(0);
const curRef = useRef(0);
// px 기반 트랙
const [trackPx, setTrackPx] = useState(0);
const [anim, setAnim] = useState(false);
const [busy, setBusy] = useState(false);
// 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음)
const dirRef = useRef<-1 | 1 | 0>(0);
// cur 최신값 유지
useEffect(() => {
curRef.current = cur;
}, [cur]);
/**
* 리셋 로직 분리:
* - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋
* - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치
*/
useLayoutEffect(() => {
const saved = tabIndexRef.current[active] ?? 0;
const nextCur = groupCount ? mod(saved, groupCount) : 0;
dirRef.current = 0;
setCur(nextCur);
setBusy(false);
setAnim(false);
}, [active, perPage, groupCount]);
// clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음
useLayoutEffect(() => {
if (busy) return;
if (clipWidth > 0) setTrackPx(-clipWidth);
else setTrackPx(0);
}, [clipWidth, busy]);
// 3그룹 가상화
const viewGroups = useMemo(() => {
if (!groupCount) return [[], [], []] as CaseItem[][];
return [
groups[mod(cur - 1, groupCount)] ?? [],
groups[mod(cur, groupCount)] ?? [],
groups[mod(cur + 1, groupCount)] ?? [],
];
}, [groups, cur, groupCount]);
const canSlide = groupCount > 1 && clipWidth > 0;
const go = useCallback(
(dir: -1 | 1) => {
if (!canSlide) return;
if (busy) return;
dirRef.current = dir;
setBusy(true);
setAnim(!reducedMotion);
// 중앙(-W) 기준: prev=0, next=-2W
setTrackPx(dir === 1 ? -2 * clipWidth : 0);
// reduced motion이면 즉시 확정
if (reducedMotion) {
const nextCur = mod(curRef.current + dir, groupCount);
tabIndexRef.current[active] = nextCur;
setCur(nextCur);
setAnim(false);
setTrackPx(-clipWidth);
setBusy(false);
dirRef.current = 0;
}
},
[active, busy, canSlide, clipWidth, groupCount, reducedMotion]
);
// BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리
const onTransitionEnd = useCallback(
(e: React.TransitionEvent<HTMLDivElement>) => {
// 자식 transitionend 버블링 방지
if (e.target !== e.currentTarget) return;
if (e.propertyName !== "transform") return;
if (!busy || reducedMotion) return;
const dir = dirRef.current;
if (dir !== 1 && dir !== -1) return;
const nextCur = mod(curRef.current + dir, groupCount);
tabIndexRef.current[active] = nextCur;
// 1) 먼저 transition을 끄고(중요)
setAnim(false);
// 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅
// (이 순간은 "무조건 애니메이션 없이" 처리되어야 함)
setCur(nextCur);
setTrackPx(-clipWidth);
// 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnim(!reducedMotion);
setBusy(false);
dirRef.current = 0;
});
});
},
[active, busy, clipWidth, groupCount, reducedMotion]
);
const goDot = useCallback(
(idx: number) => {
if (!groupCount) return;
if (busy) return;
const target = mod(idx, groupCount);
tabIndexRef.current[active] = target;
dirRef.current = 0;
setCur(target);
setAnim(false);
if (clipWidth > 0) setTrackPx(-clipWidth);
},
[active, busy, clipWidth, groupCount]
);
const trackStyle: React.CSSProperties = useMemo(
() => ({
transform: `translate3d(${trackPx}px, 0, 0)`,
transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none",
willChange: busy ? "transform" : undefined,
}),
[trackPx, anim, busy]
);
return (
<section className="w-full">
{/* 탭 바 */}
<div className="mx-auto max-w-[1520px] pt-10">
<div className="">
<div className="grid grid-cols-4">
{TABS.map((t) => {
const isActive = t.key === active;
return (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={[
"relative h-[74px] w-full text-base font-semibold",
"transition-colors",
// 기본(비활성): 검정 배경 + 회색 텍스트
!isActive ? "text-[#767676] hover:text-black border-b-2 border-sky-400" : "",
// 활성: 흰 배경 + 진한 텍스트 + 하늘색 테두리(위/좌/우) + 아래는 열어둠
isActive
? [
"bg-[#eaf4f4] text-black",
"border-t-2 border-l-2 border-r-2 border-sky-400",
"border-b-0", // 아래는 컨텐츠와 이어지도록 열어둠
"z-10", // 테두리가 위로 올라오게
].join(" ")
: "",
].join(" ")}
>
{t.label}
{/* 활성 탭 하단에 얇은 라인이 필요하면(이미지처럼) */}
{isActive && (
<span className="pointer-events-none absolute left-0 right-0 bottom-[-1px] h-[2px] bg-[#eaf4f4]" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* 슬라이더 */}
<div ref={wrapRef} className="mx-auto max-w-[1520px] px-6 pb-5 pt-10 bg-[#eaf4f4]">
<div className="relative">
{/* 좌우 버튼 */}
<button
type="button"
onClick={() => go(-1)}
disabled={!canSlide || busy}
className={[
"absolute left-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="이전"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-6 h-6 object-contain"
/>
</button>
<button
type="button"
onClick={() => go(1)}
disabled={!canSlide || busy}
className={[
"absolute right-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="다음"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-6 h-6 object-contain"
/>
</button>
{/* 중요: clip(가림)과 measure(측정) 분리 */}
<div className="overflow-hidden [clip-path:inset(0)] [contain:paint]">
<div className="w-[1250px] overflow-hidden m-auto">
{/* 측정은 패딩 없는 박스에서 */}
<div ref={clipRef} className="w-full">
{/* track */}
<div className="flex" style={trackStyle} onTransitionEnd={onTransitionEnd}>
{viewGroups.map((group, gi) => (
<div
key={`pane-${gi}`}
className="shrink-0"
style={{ width: clipWidth || "100%" }}
>
{/* 그룹 내부: perPage 컬럼 */}
<div
className="grid gap-7"
style={{
gridTemplateColumns: `repeat(${perPage}, minmax(0, 1fr))`,
}}
>
{(group ?? []).map((c) => (
<article
key={c.id}
className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white"
>
<div className="h-[241px] w-full bg-slate-100">
<img
src={c.imageSrc}
alt={c.title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div className="p-8">
<h3 className="text-[28px] font-extrabold leading-snug tracking-tight">
{c.title}
</h3>
<p className="mt-4 line-clamp-3 text-[16px] leading-7 text-slate-600">
{c.desc}
</p>
<a
href={c.href ?? "#"}
className="mt-8 inline-flex h-[56px] w-full items-center justify-center gap-3 rounded-md bg-[#02A0D6] text-[18px] font-bold text-white transition hover:brightness-110"
>
자세히 보기 <span aria-hidden>→</span>
</a>
</div>
</article>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 도트 */}
<div className="mt-8 flex items-center justify-center gap-3">
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
<button
key={`dot-${i}`}
type="button"
onClick={() => goDot(i)}
className={[
"h-[10px] w-[10px] rounded-full transition",
i === cur
? "bg-black opacity-100"
: "bg-black opacity-30 hover:opacity-60",
].join(" ")}
aria-label={`${i + 1}번 그룹`}
/>
))}
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,167 @@
// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
type Slide = { src: string; alt?: string };
type Props = {
slides: Slide[];
className?: string;
maxWidthClassName?: string; // 예: "max-w-[760px]"
};
export default function CaseImageSlider({
slides,
className = "",
maxWidthClassName = "max-w-[760px]",
}: Props) {
const safeSlides = useMemo(() => slides.filter(Boolean), [slides]);
const [idx, setIdx] = useState(0);
const [dragging, setDragging] = useState(false);
const startX = useRef(0);
const deltaX = useRef(0);
const total = safeSlides.length;
const canSlide = total > 1;
const go = (dir: number) => {
if (!canSlide) return;
setIdx((prev) => {
const next = prev + dir;
if (next < 0) return total - 1;
if (next >= total) return 0;
return next;
});
};
// ← → 키보드 지원(선택)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!canSlide) return;
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [canSlide, total]);
// 드래그(마우스/터치)로 넘기기
const onPointerDown = (e: React.PointerEvent) => {
if (!canSlide) return;
setDragging(true);
startX.current = e.clientX;
deltaX.current = 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging) return;
deltaX.current = e.clientX - startX.current;
};
const onPointerUp = () => {
if (!dragging) return;
setDragging(false);
const threshold = 60; // 넘김 감도
if (deltaX.current > threshold) go(-1);
else if (deltaX.current < -threshold) go(1);
deltaX.current = 0;
};
if (!total) return null;
return (
<section className={className}>
<div className={`mx-auto ${maxWidthClassName}`}>
<div className="relative">
{/* 좌측 버튼 */}
<button
type="button"
onClick={() => go(-1)}
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-[12px] h-[22px]"
/>
</button>
{/* 우측 버튼 */}
<button
type="button"
onClick={() => go(1)}
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-[12px] h-[22px]"
/>
</button>
{/* 뷰포트 */}
<div
className={[
"overflow-hidden",
"select-none touch-pan-y",
"cursor-grab active:cursor-grabbing",
"w-[583px] h-[400px] mx-auto",
].join(" ")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="flex transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${idx * 100}%)` }}
>
{safeSlides.map((s, i) => (
<div key={`${s.src}-${i}`} className="min-w-full">
<img
src={s.src}
alt={s.alt ?? `slide-${i + 1}`}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
))}
</div>
</div>
</div>
{/* 도트 */}
{canSlide && (
<div className="mt-3 flex items-center justify-center gap-2">
{safeSlides.map((_, i) => {
const active = i === idx;
return (
<button
key={i}
type="button"
onClick={() => setIdx(i)}
aria-label={`${i + 1}번 슬라이드`}
className={[
"h-2 w-2 rounded-full",
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
].join(" ")}
/>
);
})}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,171 @@
// apps/dm/app/(landing)/reuse/_components/Lnb.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
export type LnbItem = {
label: string;
href?: string; // href 없는 부모(2뎁스 컨테이너)도 허용
children?: LnbItem[];
};
function isExactActive(pathname: string, href?: string) {
if (!href) return false;
return pathname === href;
}
export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) {
const pathname = usePathname();
// ✅ 현재 경로가 속한 2depth 그룹을 기본 open
const defaultOpenLabel = useMemo(() => {
const found = items.find((it) => it.children?.some((c) => isExactActive(pathname, c.href)));
return found?.label ?? null;
}, [items, pathname]);
// ✅ 아코디언: 한 번에 하나만 열리도록 openLabel 1개만 관리
const [openLabel, setOpenLabel] = useState<string | null>(defaultOpenLabel);
return (
<aside className="w-[260px]">
{/* 타이틀 */}
<div
className="h-[120px] flex items-center justify-center
text-white text-[40px] font-bold
bg-gradient-to-r from-cyan-500 to-green-500"
>
{title}
</div>
{/* 메뉴 */}
<ul>
{items.map((item) => {
const hasChildren = !!item.children?.length;
// 1뎁스 active
const active1 = isExactActive(pathname, item.href);
// 2뎁스 active(자식 중 exact)
const active2 = item.children?.some((c) => isExactActive(pathname, c.href)) ?? false;
const active = active1 || active2;
// ✅ children 있는 경우: 아코디언 헤더 + 패널
if (hasChildren && !item.href) {
const isOpen = openLabel === item.label;
return (
<li key={item.label}>
{/* 아코디언 헤더 */}
<button
type="button"
onClick={() => setOpenLabel((prev) => (prev === item.label ? null : item.label))}
className={[
"w-full px-5 py-6",
"flex items-center justify-between",
"border-b border-slate-300",
"text-center text-base font-semibold",
active ? "bg-slate-100 text-slate-900" : "bg-slate-100 text-slate-500",
].join(" ")}
aria-expanded={isOpen}
>
<span className="w-full text-center">{item.label}</span>
{/* ✅ 우측 화살표(이미지처럼) */}
<span
aria-hidden
className={[
"ml-3 shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "rotate-0",
].join(" ")}
>
<img
src="/images/landing/drop_arrow.svg"
alt="드롭다운 화살표 아이콘"
className="w-7 h-7"
/>
</span>
</button>
{/* 패널: height auto 애니메이션(그리드 트릭) */}
<div
className={[
"grid transition-[grid-template-rows] duration-200 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
].join(" ")}
>
<div className="overflow-hidden bg-white">
<ul>
{item.children!.map((child) => {
const childActive = isExactActive(pathname, child.href);
return (
<li key={child.href ?? child.label}>
{child.href ? (
<Link
href={child.href}
className={[
"block px-5 py-4 pl-[60px] text-[18px] font-medium",
"border-b border-slate-200 transition-colors",
childActive
? "bg-white text-sky-500 cursor-default"
: "bg-slate-50 text-slate-500 hover:bg-white hover:text-sky-500",
].join(" ")}
>
{/* 이미지처럼 불릿 필요하면 */}
<span className="mr-2 text-current">
•
</span>
{child.label}
</Link>
) : (
<div className="block px-5 py-4 text-[18px] font-medium border-b border-slate-200 bg-slate-50 text-slate-500">
{child.label}
</div>
)}
</li>
);
})}
</ul>
</div>
</div>
</li>
);
}
// ✅ 일반 1뎁스 메뉴(기존 유지)
return (
<li key={item.href ?? item.label}>
{item.href ? (
<Link
href={item.href}
className={[
"block px-5 py-4 text-center text-base font-semibold",
"border-b border-slate-300 transition-colors",
active
? "bg-white text-sky-500 cursor-default"
: "bg-[#F0F0F0] text-[#767676] hover:bg-white hover:text-sky-500",
].join(" ")}
>
{item.label}
</Link>
) : (
<div
className={[
"block px-5 py-4 text-center text-base font-semibold",
"border-b border-slate-300",
"bg-slate-100 text-slate-500",
].join(" ")}
>
{item.label}
</div>
)}
</li>
);
})}
</ul>
</aside>
);
}
@@ -0,0 +1,51 @@
// apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginStatus() {
const router = useRouter();
const [userName, setUserName] = useState<string | null>(null);
useEffect(() => {
// 예시: 로그인 시 저장한 값
const name = localStorage.getItem("userName");
setUserName(name);
}, []);
const handleLogout = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("userName");
router.refresh(); // 헤더 상태 갱신
};
// 🔹 로그인 전
if (!userName) {
return (
<Link
href="/reuse/login"
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600"
>
로그인
<span>→</span>
</Link>
);
}
// 🔹 로그인 후
return (
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-700">
<strong>{userName}</strong>님 환영합니다.
</span>
<button
onClick={handleLogout}
className="rounded-md bg-red-500 px-3 py-2 text-white hover:bg-red-600"
>
로그아웃
</button>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "../reuse.module.css";
export interface SideIndicatorItem {
id: string;
label: string; // 상단/공용 라벨
sideLabel?: string; // 사이드 인디게이터 전용 라벨(옵션)
}
export default function SideIndicator({
items,
activeId,
}: {
items: SideIndicatorItem[];
activeId: string;
}) {
const [manualActiveId, setManualActiveId] = useState<string | null>(null);
const clearTimerRef = useRef<number | null>(null);
const currentId = (manualActiveId ?? activeId ?? "main") || "main";
// ✅ manualActiveId가 observer(activeId)와 불일치하기 시작하면 즉시 해제 (stuck 방지)
useEffect(() => {
if (!manualActiveId) return;
if (activeId && activeId !== manualActiveId) setManualActiveId(null);
}, [activeId, manualActiveId]);
// ✅ fallback: 스무스 스크롤 시간이 길어도 manual이 오래 남지 않게 타임아웃 해제
useEffect(() => {
return () => {
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
};
}, []);
const scrollTo = (id: string) => {
setManualActiveId(id);
// 기존 타이머 제거 후 새로 설정
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
clearTimerRef.current = window.setTimeout(() => {
setManualActiveId(null);
}, 1600);
if (id === "main") {
window.scrollTo({ top: 0, behavior: "smooth" });
return;
}
document.getElementById(id)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};
return (
<aside className={styles.sideIndicator} aria-label="섹션 이동">
<div className={styles.sideIndicatorLine} aria-hidden="true" />
<ul className={styles.sideIndicatorList}>
{items.map((it) => {
const isActive = it.id === currentId;
const isMain = it.id === "main";
const displayLabel = it.sideLabel ?? it.label;
return (
<li key={it.id} className={styles.sideIndicatorItem}>
<button
type="button"
onClick={() => scrollTo(it.id)}
className={[
styles.indicatorBtn,
isActive ? styles.indicatorActive : styles.indicatorDot,
isActive
? isMain
? styles.indicatorMainActive
: styles.indicatorSectionActive
: "",
].join(" ")}
aria-current={isActive ? "location" : undefined}
aria-label={displayLabel}
>
{/* ✅ 텍스트 중복 렌더링 제거:
- active일 때만 시각적 텍스트 표시
- 비활성은 SR-only로만 제공 (CSS에 따라 필요 없으면 제거 가능) */}
{isActive ? (
<span className={styles.indicatorText}>{displayLabel}</span>
) : (
<span className="sr-only">{displayLabel}</span>
)}
</button>
</li>
);
})}
</ul>
</aside>
);
}
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
export type Crumb = { label: string; href?: string };
type Props = {
title: string;
crumbs: Crumb[];
note?: string;
underlineWidthClass?: string;
className?: string;
wrapClassName?: string;
hideFooterLine?: boolean;
hideGuideText?: boolean;
};
export default function SubPageHeader({
title,
crumbs,
note = "* 는 필수 값입니다.",
underlineWidthClass = "w-40",
className = "",
wrapClassName = "",
hideFooterLine = false,
hideGuideText = false,
}: Props) {
return (
<section className={`text-black ${className}`}>
<div className={`pb-5 ${wrapClassName}`}>
<div className="flex items-start justify-between gap-6">
{/* 좌측 타이틀 */}
<div className="inline-block">
<h1 className="text-[28px] font-extrabold tracking-tight text-slate-900 leading-none">{title}</h1>
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300" />
</div>
{/* 우측 브레드크럼 */}
<nav className="pt-2 text-sm text-black/70" aria-label="breadcrumb">
{crumbs.map((c, i) => (
<span key={`${c.label}-${i}`}>
{c.href ? (
<Link href={c.href} className="hover:text-sky-500">
{c.label}
</Link>
) : (
<span className="text-black/90">{c.label}</span>
)}
{i < crumbs.length - 1 && <span className="mx-2">›</span>}
</span>
))}
</nav>
</div>
{/* 하단 라인 + 안내문 */}
{!hideGuideText && (
<div className="mt-6 border-t border-black/10 pt-4 text-sm text-black/70">
<span className="text-red-400">*</span>
{note.replace("*", "")}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,465 @@
// apps/dm/app/(landing)/reuse/_components/TopNav.tsx
"use client";
import Link from "next/link";
import { useEffect, useId, useState } from "react";
import { useRouter } from "next/navigation";
const NAV_ITEMS = [
{ href: "/reuse/intro/law", label: "물 재이용 소개" },
{ href: "/reuse/cases/rain", label: "물 재이용 현황" },
{ href: "/reuse/business/rain/suwon", label: "물 재이용 사례" },
{ href: "/reuse/apply/place", label: "수요신청" },
];
// ✅ 필요 시 실제 “수요처등록” 페이지로 경로만 교체하세요.
const REGISTER_HREF = "/reuse/apply/place";
// ✅ 로그인 정보 저장 키(원하는대로 변경 가능)
const LS_TOKEN_KEY = "reuse_accessToken";
const LS_USER_KEY = "reuse_userName";
type LoginModalProps = {
open: boolean;
onClose: () => void;
onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
};
type FieldKey = "bizNo" | "workerNo" | "pw";
function isBizNoValid(v: string) {
// 예: 123-45-67890
return /^\d{3}-\d{2}-\d{5}$/.test(v);
}
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
const base =
"w-full h-[48px] rounded-[4px] border px-5 text-[18px] outline-none";
if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`;
if (opts.hasError)
return `${base} border-red-500 focus:border-red-500 ring-1 ring-red-400`;
return `${base} border-gray-300 focus:border-sky-400`;
}
function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
const titleId = useId();
const router = useRouter();
const [bizNo, setBizNo] = useState("");
const [workerNo, setWorkerNo] = useState("");
const [noWorker, setNoWorker] = useState(false);
const [pw, setPw] = useState("");
// ✅ 유효성/서버 에러 표시용
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
// 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
// 모달 오픈 시 에러/터치 초기화
setErrors({});
setTouched({});
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
useEffect(() => {
if (noWorker) {
setWorkerNo("");
setErrors((p) => {
const { workerNo: _w, ...rest } = p;
return rest;
});
}
}, [noWorker]);
if (!open) return null;
const validate = () => {
const next: Partial<Record<FieldKey, string>> = {};
const b = bizNo.trim();
const w = workerNo.trim();
const p = pw.trim();
if (!b) next.bizNo = "사업자등록번호를 입력해주세요.";
else if (!isBizNoValid(b))
next.bizNo = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요.";
if (!p) next.pw = "비밀번호를 입력해주세요.";
setErrors(next);
return next;
};
const markTouched = (key: FieldKey) =>
setTouched((p) => ({ ...p, [key]: true }));
const markAllTouched = () =>
setTouched({ bizNo: true, workerNo: true, pw: true });
const submit = async (e: React.FormEvent) => {
e.preventDefault();
markAllTouched();
const v = validate();
if (Object.keys(v).length) return;
try {
setSubmitting(true);
// ✅ TODO: 실제 로그인 API 연결 지점
// const res = await fetch("/api/demand/login", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ bizNo, workerNo: noWorker ? null : workerNo, pw }),
// });
// if (!res.ok) { ... }
// const data = await res.json();
// onSuccess?.({ userName: data.userName, accessToken: data.token });
// --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) ---
const serverOk = pw.trim() === "1234";
if (!serverOk) {
setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" }));
setTouched((p) => ({ ...p, pw: true }));
return;
}
onSuccess?.({ userName: "마인드원", accessToken: "demo_token" });
onClose();
router.refresh();
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Dialog */}
<div className="absolute inset-0 flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[600px] rounded-[16px] bg-white shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="relative px-10 pt-10">
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-8 top-8 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
<span className="text-2xl leading-none">×</span>
</button>
<h2
id={titleId}
className="text-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
>
수요처 로그인
</h2>
{/* title underline gradient bar */}
<div className="mx-auto h-[10px] w-[240px] bg-gradient-to-r from-sky-300 to-green-300" />
</div>
{/* Body */}
<form onSubmit={submit} className="px-14 pb-12 pt-10">
<div className="space-y-7">
{/* 사업자 등록번호 */}
<div>
<label className="block text-[18px] font-bold text-gray-900 mb-3">
사업자 등록번호
</label>
<input
value={bizNo}
onChange={(e) => {
setBizNo(e.target.value);
setErrors((p) => ({ ...p, bizNo: undefined }));
}}
onBlur={() => {
markTouched("bizNo");
validate();
}}
placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)"
className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })}
/>
{touched.bizNo && errors.bizNo && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.bizNo}
</p>
)}
</div>
{/* 종사자등록번호 + 해당없음 */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-[18px] font-bold text-gray-900">
종사자등록번호
</label>
<label className="flex items-center gap-1 text-[16px] text-[#4F4F4F] select-none">
<input
type="checkbox"
checked={noWorker}
onChange={(e) => setNoWorker(e.target.checked)}
className="h-4 w-4 accent-[#004A94]"
/>
해당없음
</label>
</div>
<input
value={workerNo}
onChange={(e) => {
setWorkerNo(e.target.value);
setErrors((p) => ({ ...p, workerNo: undefined }));
}}
onBlur={() => {
markTouched("workerNo");
validate();
}}
disabled={noWorker}
placeholder="종사자등록번호를 입력하세요."
className={inputCls({
disabled: noWorker,
hasError: !noWorker && !!(touched.workerNo && errors.workerNo),
})}
/>
{!noWorker && touched.workerNo && errors.workerNo && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.workerNo}
</p>
)}
</div>
{/* 비밀번호 */}
<div>
<label className="block text-[18px] font-bold text-gray-900 mb-3">
비밀번호
</label>
<input
type="password"
value={pw}
onChange={(e) => {
setPw(e.target.value);
setErrors((p) => ({ ...p, pw: undefined }));
}}
onBlur={() => {
markTouched("pw");
validate();
}}
placeholder="비밀번호를 입력하세요."
className={inputCls({ hasError: !!(touched.pw && errors.pw) })}
/>
{touched.pw && errors.pw && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.pw}
</p>
)}
</div>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={submitting}
className={[
"mt-2 w-full h-[60px] rounded-[4px] text-white text-[18px]",
submitting
? "bg-sky-300 cursor-not-allowed"
: "bg-sky-500 hover:bg-sky-600 active:bg-sky-700",
].join(" ")}
>
{submitting ? "로그인 중..." : "로그인"}
</button>
{/* 비밀번호 초기화 요청 */}
<button
type="button"
onClick={() => alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")}
className="mx-auto block text-[14px] text-[#767676] hover:text-gray-900"
>
비밀번호 초기화 요청
</button>
{/* 하단 안내 박스 + 수요처등록 버튼 */}
<div className="mt-6 border border-[#004A94] bg-[#EEF3F9] px-4 py-4 flex items-center justify-between gap-6">
<div className="leading-snug">
<div className="text-[16px] text-[#8698AA]">
수요처를 등록하지 않았다면
</div>
<div className="text-[18px] text-[#004A94]">
먼저 수요처등록을 해주세요.
</div>
</div>
<button
type="button"
onClick={() => router.push(REGISTER_HREF)}
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
>
수요처등록
</button>
</div>
</div>
</form>
</div>
</div>
</div>
);
}
export default function TopNav() {
const router = useRouter();
const [loginOpen, setLoginOpen] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
// ✅ 초기 로드 시 로그인 상태 반영
useEffect(() => {
const name = localStorage.getItem(LS_USER_KEY);
setUserName(name);
}, []);
const openLogin = () => setLoginOpen(true);
const logout = () => {
localStorage.removeItem(LS_TOKEN_KEY);
localStorage.removeItem(LS_USER_KEY);
setUserName(null);
router.refresh();
};
return (
<>
<header className="fixed top-0 left-0 right-0 z-50 bg-white backdrop-blur border-b shadow-md">
{/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */}
<div className="px-6">
<div className="relative h-[83px] flex items-center justify-between">
{/* 좌측: 로고 */}
<div className="flex items-center gap-3">
<Link href="/reuse" className="flex items-center">
<img
src="/images/landing/logo.png"
alt="HASUON"
className="h-9 w-auto"
/>
</Link>
</div>
{/* 중앙: 상단 메뉴 */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[40px] text-sm font-medium">
{NAV_ITEMS.map((it) => {
// ✅ 수요신청: 로그인 전이면 로그인 모달, 로그인 후면 페이지 이동
if (it.label === "수요신청") {
return (
<button
key={it.href}
type="button"
onClick={() => {
if (!userName) openLogin();
else router.push(it.href);
}}
className={[
"relative px-3 py-2 text-lg font-bold",
"text-gray-800 hover:text-sky-700",
].join(" ")}
>
{it.label}
</button>
);
}
return (
<Link
key={it.href}
href={it.href}
className={[
"relative px-3 py-2 text-lg font-bold",
"text-gray-800 hover:text-sky-700",
].join(" ")}
>
{it.label}
</Link>
);
})}
</nav>
{/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
<div className="flex items-center gap-3">
{!userName ? (
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-base text-white hover:bg-sky-600"
>
로그인
<span aria-hidden>
<img src="/images/landing/icon_login.png" alt="로그인 아이콘" className="h-6 w-6 object-contain ml-1"/>
</span>
</button>
) : (
<>
<span className="text-base text-gray-700">
<strong className="text-xl text-emerald-600">{userName}</strong>님 환영합니다.
</span>
<button
type="button"
onClick={logout}
className="inline-flex items-center gap-2 rounded-md bg-red-500 px-4 py-2 text-base text-white hover:bg-red-600"
>
로그아웃
<span aria-hidden>
<img src="/images/landing/icon_logout.png" alt="로그아웃 아이콘" className="h-6 w-6 object-contain"/>
</span>
</button>
</>
)}
</div>
</div>
</div>
</header>
{/* ✅ 로그인 모달 */}
<DemandLoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSuccess={({ userName, accessToken }) => {
// ✅ 로그인 성공 처리: 저장 + 헤더 갱신
localStorage.setItem(LS_USER_KEY, userName);
if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
setUserName(userName);
setLoginOpen(false);
router.refresh();
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
// apps/dm/app/(landing)/reuse/_constants/lnb.ts
import type { LnbItem } from "../_components/Lnb";
export const LNB_DEMAND: LnbItem[] = [
{ href: "/reuse/apply/place", label: "수요처등록" },
{ href: "/reuse/apply/register", label: "수요정보등록" },
{ href: "/reuse/apply/status", label: "수요신청현황" },
];
export const LNB_INTRO = [
{ label: "물 재이용 법", href: "/reuse/intro/law" },
{ label: "빗물 이용시설", href: "/reuse/intro/rain" },
{ label: "중수도", href: "/reuse/intro/greywater" },
{ label: "하수처리수", href: "/reuse/intro/treated" },
];
export const LNB_CASES = [
{ label: "빗물이용시설(현황)", href: "/reuse/cases/rain" },
{ label: "중수도(현황)", href: "/reuse/cases/greywater" },
{ label: "하수처리수 재이용시설(현황)", href: "/reuse/cases/treated" },
];
// 물 재이용 사례처럼 2depth가 있는 메뉴
export const LNB_BUSINESS: LnbItem[] = [
{
label: "빗물 재이용 사례",
children: [
{ label: "경기도 수원시", href: "/reuse/business/rain/suwon" },
// { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" },
// { label: "충청북도 청주시", href: "/reuse/business/rain/cheongju" },
],
},
{
label: "중수 재이용 사례",
children: [{ label: "사례 목록", href: "/reuse/business/graywater" }],
},
{
label: "하수 재이용 사례",
children: [
{ label: "파주시", href: "/reuse/business/sewage/paju" },
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
{ label: "여수", href: "/reuse/business/sewage/yeosu" },
{ label: "청주", href: "/reuse/business/sewage/cheongju" },
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
{ label: "구리시 도로자동청소시스템", href: "/reuse/business/sewage/guri" },
],
},
{
label: "해외 재이용 사례",
children: [
{ label: "사례 개요", href: "/reuse/business/globalCase/overview" },
{ label: "대만", href: "/reuse/business/globalCase/taiwan" },
{ label: "호주", href: "/reuse/business/globalCase/australia" },
{ label: "싱가포르", href: "/reuse/business/globalCase/singapore" },
{ label: "EU", href: "/reuse/business/globalCase/eu" }
],
},
];
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useScrollSpy(sectionIds: string[], options?: IntersectionObserverInit) {
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
const ratiosRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const els = sectionIds
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
if (!els.length) return;
const DEFAULT_ROOT_MARGIN = "83px 0px -65% 0px";
// 초기값 세팅(안정화)
ratiosRef.current.clear();
els.forEach((el) => ratiosRef.current.set(el.id, 0));
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const id = (e.target as HTMLElement).id;
ratiosRef.current.set(id, e.isIntersecting ? e.intersectionRatio : 0);
}
// ✅ 전체 섹션 중 가장 ratio 큰 항목을 active로
let bestId = activeId;
let bestRatio = -1;
for (const [id, ratio] of ratiosRef.current.entries()) {
if (ratio > bestRatio) {
bestRatio = ratio;
bestId = id;
}
}
if (bestId) setActiveId(bestId);
},
{
root: null,
threshold: [0, 0.01, 0.1, 0.25, 0.5, 0.75],
rootMargin: DEFAULT_ROOT_MARGIN,
...options,
}
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionIds.join("|"), options?.rootMargin, JSON.stringify(options?.threshold)]);
return activeId;
}
@@ -0,0 +1,65 @@
// apps/dm/app/(landing)/reuse/_sections/Cases.tsx
"use client";
export default function Cases() {
return (
<section id="cases" className="bg-[#EAF6F6]">
<div className="mx-auto max-w-[1520px] px-6 h-[870px] flex items-center">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full">
{/* 좌측 : 카피 영역 */}
<div className="flex flex-col justify-center">
<div className="pl-2 lg:pl-10">
<p className="text-[32px] font-extrabold text-slate-900 tracking-[-0.02em]">
빗물+중수도+하수도
</p>
<h2 className="mt-6 leading-[1.1] tracking-[-0.03em]">
<span className="block text-[64px] font-extrabold text-slate-900">
데이터로 보는
</span>
<span className="mt-3 block text-[64px] font-extrabold text-[#004A94]">
물 재이용 현황
</span>
</h2>
<div className="mt-14">
<a
href="/reuse/cases/rain"
className="
inline-flex items-center justify-center gap-3
w-[260px] h-[52px]
bg-[#01ACE5] text-white
rounded-sm
hover:bg-[#0A78C0]
transition-colors
"
>
물 재이용 현황 보러가기
<span className="text-lg">→</span>
</a>
</div>
</div>
</div>
{/* 우측 : 이미지 카드 */}
<div className="flex justify-center lg:justify-end">
<div
className="
relative
w-full max-w-[720px]
"
>
<img
src="/images/landing/section_03.png"
alt="물 재이용 현황 데이터 테이블"
className="w-full h-auto object-cover"
/>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,29 @@
// apps/dm/app/(landing)/reuse/_sections/Hero.tsx
import styles from "../reuse.module.css";
export default function Hero() {
return (
<section id="main" className={styles.hero}>
<div className={styles.heroBg} aria-hidden />
<div className={styles.heroOverlay} aria-hidden />
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex flex-col items-center justify-items-center gap-[40px] mx-auto max-w-[1520px] px-6 text-center text-white">
<h1 className="text-5xl font-bold tracking-tight">물 재이용</h1>
<p className="text-xl font-medium leading-6 text-white/85 max-w-[720px] mx-auto">
빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여<br />
생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다.
</p>
<p className="text-lg leading-6 text-white/85 max-w-[720px] mx-auto">
전국 하수 재이용률 15.6%(2024년 통계 기준),<br />
물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다.
</p>
</div>
</div>
<div className="absolute inset-x-0 bottom-[120px] flex flex-col items-center justify-items-center gap-2 mt-10">
<img src="/images/landing/scroll_down.svg" alt="" className="w-[26px] h-[40px]" />
<span className="text-white/80 text-base tracking-[.25em]">scroll down</span>
</div>
</section>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx
"use client";
import Link from "next/link";
type IntroCardItem = {
title: string;
desc: string;
image: string;
href: string;
};
const INTRO_CARDS: IntroCardItem[] = [
{
title: "물 재이용 법",
desc: "물의 재이용 촉진 및 지원에 관한 법률을 시행하고, 일부 법률은 개정되었습니다.",
image: "/images/landing/card_01.jpg",
href: "/reuse/intro/law",
},
{
title: "친환경 수자원 빗물",
desc: "가뭄을 극복하고 홍수를 예방하기 위해 비가 내릴 때 빗물을 모아두었다가 사용합니다.",
image: "/images/landing/card_02.jpg",
href: "/reuse/intro/rain",
},
{
title: "알뜰하게 재이용되는 중수",
desc: "한번 사용한 물을 중수도 시설을 통해 재생하여 개별적 또는 지역적으로 재이용할 수 있습니다.",
image: "/images/landing/card_03.jpg",
href: "/reuse/intro/greywater",
},
{
title: "하수처리수 재이용",
desc: "사용하고 버려진 공공하수처리장으로 모여진 물을 용도에 맞는 처리 후 재이용 할 수 있습니다.",
image: "/images/landing/card_04.jpg",
href: "/reuse/intro/treated",
},
];
export default function IntroCards() {
return (
<section id="intro" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 소개</h2>
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
<p className="mt-5 text-xl text-gray-500">
지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다.
</p>
</div>
<section className="mx-auto max-w-8xl px-4 py-12">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{INTRO_CARDS.map((card, idx) => (
<Link
key={idx}
href={card.href}
aria-label={`${card.title} 상세로 이동`}
className="group relative h-[280px] md:h-[320px] lg:h-[500px] overflow-hidden rounded-2xl cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-400/60"
>
{/* 배경 이미지 (이미 그라디언트 포함) */}
<img
src={card.image}
alt={card.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 hover:scale-105"
/>
{/* 텍스트 영역 */}
<div className="absolute bottom-0 z-10 p-6 text-white">
<h3 className="text-lg font-semibold tracking-tight">
{card.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-white/90 line-clamp-3">
{card.desc}
</p>
</div>
</Link>
))}
</div>
</section>
</div>
</section>
);
}
@@ -0,0 +1,22 @@
// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx
import CaseTabsCarousel from "../_components/CaseTabsCarousel";
export default function ProcessFlow() {
return (
<section id="processflow" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[50px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 사례</h2>
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
</div>
{/* 탭메뉴 영역 */}
<CaseTabsCarousel />
</div>
</section>
);
}
@@ -0,0 +1,13 @@
// 예) apps/dm/app/(landing)/reuse/apply/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_DEMAND } from "../_constants/lnb";
export default function ApplyLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="수요신청" items={LNB_DEMAND} />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -0,0 +1,99 @@
// apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx (수질기준참고 모달)
"use client";
import { useEffect, useId } from "react";
import Image from "next/image";
type Props = {
open: boolean;
onClose: () => void;
imageSrc: string;
title?: string;
};
export default function WaterQualityRefModal({
open,
onClose,
imageSrc,
title = "수질 기준 참고",
}: Props) {
const titleId = useId();
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
// 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[120]">
{/* Backdrop */}
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Dialog */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[1100px] rounded-[18px] bg-white shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="relative px-10 pt-9 pb-6">
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-7 top-7 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
<span className="text-2xl leading-none">×</span>
</button>
<h2
id={titleId}
className="text-left text-[28px] font-bold tracking-tight text-gray-900"
>
{title}
</h2>
<div className="mt-5 h-px w-full bg-gray-200" />
</div>
{/* Body (스크롤 영역) */}
<div className="px-10 pb-10">
<div className="max-h-[70vh] overflow-auto bg-white">
{/* 이미지가 길어도 여기서 스크롤 */}
<Image
src={imageSrc}
alt="수질 기준 참고"
width={851}
height={2334}
className="block mx-auto max-w-full h-auto"
priority
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,350 @@
// apps/dm/app/(landing)/reuse/apply/place/page.tsx
"use client";
import { useMemo, useState } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
type Option = { value: string; label: string };
type SidoValue = "" | "seoul" | "busan" | "daegu" | "incheon" | "gyeonggi";
const SIDO: { value: SidoValue; label: string }[] = [
{ value: "", label: "선택" },
{ value: "seoul", label: "서울특별시" },
{ value: "busan", label: "부산광역시" },
{ value: "daegu", label: "대구광역시" },
{ value: "incheon", label: "인천광역시" },
{ value: "gyeonggi", label: "경기도" },
];
const SIGUNGU_MAP: Record<SidoValue, Option[]> = {
"": [{ value: "", label: "선택" }],
seoul: [
{ value: "", label: "선택" },
{ value: "gangnam", label: "강남구" },
{ value: "yeongdeungpo", label: "영등포구" },
{ value: "mapo", label: "마포구" },
],
busan: [
{ value: "", label: "선택" },
{ value: "haeundae", label: "해운대구" },
{ value: "suyeong", label: "수영구" },
],
daegu: [
{ value: "", label: "선택" },
{ value: "suseong", label: "수성구" },
{ value: "dalseo", label: "달서구" },
],
incheon: [
{ value: "", label: "선택" },
{ value: "namdong", label: "남동구" },
{ value: "yeonsu", label: "연수구" },
],
gyeonggi: [
{ value: "", label: "선택" },
{ value: "bundang", label: "성남시 분당구" },
{ value: "suji", label: "용인시 수지구" },
{ value: "ilsandong", label: "고양시 일산동구" },
],
};
/* ---------------------------
* register/page.tsx 스타일과 맞춘 공통 UI (FormRow/Divider)
* -------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function FormRow({
label,
required,
children,
}: {
label: React.ReactNode;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 라벨(좌측) */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6">
<div className="flex items-center gap-1 text-lg font-semibold text-neutral-800">
<span>{label}</span>
{required ? <span className="text-red-500">*</span> : null}
</div>
</div>
{/* 입력(우측) */}
<div className="col-span-12 lg:col-span-10 px-6 py-6">
{children}
</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = "",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
type={type}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={[
"h-11 w-full rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none",
disabled ? "bg-neutral-100 text-neutral-400" : "",
className,
].join(" ")}
/>
);
}
function SelectBox<T extends string>({
value,
onChange,
options,
className = "",
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
className?: string;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as T)}
className={[
"h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"focus:border-neutral-400 focus:outline-none",
className,
].join(" ")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export default function DemandPlaceRegisterPage() {
const [sido, setSido] = useState<SidoValue>("");
const [sigungu, setSigungu] = useState<string>("");
const [company, setCompany] = useState("");
const [bizNo, setBizNo] = useState("");
const [workerNo, setWorkerNo] = useState("");
const [noWorkerNo, setNoWorkerNo] = useState(false);
const [zip, setZip] = useState("");
const [addr1, setAddr1] = useState("");
const [addr2, setAddr2] = useState("");
const [tel, setTel] = useState("");
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
// ✅ undefined 불가(Record<SidoValue,...> + sido가 SidoValue)
const sigunguOptions = useMemo<Option[]>(() => SIGUNGU_MAP[sido], [sido]);
const onSubmit = () => {
if (!sido || !sigungu) return alert("시도/시군구를 선택해 주세요.");
if (!company) return alert("회사명을 입력해 주세요.");
if (!bizNo) return alert("사업자등록번호를 입력해 주세요.");
if (!noWorkerNo && !workerNo)
return alert("종사자등록번호를 입력하거나 '해당없음'을 선택해 주세요.");
if (!addr1) return alert("사업자주소를 입력해 주세요.");
if (!tel) return alert("전화번호를 입력해 주세요.");
if (!pw) return alert("비밀번호를 입력해 주세요.");
if (pw !== pw2) return alert("비밀번호가 일치하지 않습니다.");
alert("수요처 등록(샘플) 완료!");
};
return (
<main className="bg-white">
<SubPageHeader
title="수요처등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요처등록" },
]}
/>
{/* register/page.tsx와 동일한 테이블 룩앤필 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="시도" required>
<SelectBox<SidoValue>
value={sido}
onChange={(v) => {
setSido(v);
setSigungu(""); // 시도 변경 시 초기화
}}
options={SIDO}
/>
</FormRow>
<Divider />
<FormRow label="시군구" required>
<select
value={sigungu}
onChange={(e) => setSigungu(e.target.value)}
className="h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800 focus:border-neutral-400 focus:outline-none"
>
{sigunguOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</FormRow>
<Divider />
<FormRow label="회사명" required>
<div className="max-w-[420px]">
<TextInput
value={company}
onChange={setCompany}
placeholder="회사명을 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="사업자등록번호" required>
<div className="max-w-[420px]">
<TextInput
value={bizNo}
onChange={setBizNo}
placeholder="사업자 등록번호를 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="종사자등록번호">
<div className="flex flex-wrap items-center gap-4">
<div className="w-[420px] max-w-full">
<TextInput
value={workerNo}
onChange={setWorkerNo}
placeholder="종사자등록번호를 입력해주세요."
disabled={noWorkerNo}
/>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noWorkerNo}
onChange={(e) => {
setNoWorkerNo(e.target.checked);
if (e.target.checked) setWorkerNo("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
해당없음
</label>
</div>
</FormRow>
<Divider />
<FormRow label="사업자주소" required>
<div className="max-w-[420px] space-y-2">
<div className="flex gap-2">
<div className="w-[180px]">
<TextInput value={zip} onChange={setZip} placeholder="우편번호" />
</div>
<button
type="button"
onClick={() => alert("우편번호 검색(샘플)")}
className="h-11 rounded bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
>
우편번호 검색
</button>
</div>
<TextInput value={addr1} onChange={setAddr1} placeholder="주소" />
<TextInput
value={addr2}
onChange={setAddr2}
placeholder="상세 주소를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="전화번호" required>
<div className="max-w-[420px]">
<TextInput
value={tel}
onChange={setTel}
placeholder="전화번호를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호" required>
<div className="max-w-[420px]">
<TextInput
value={pw}
onChange={setPw}
placeholder="비밀번호를 입력하세요."
type="password"
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호 확인" required>
<div className="max-w-[420px]">
<TextInput
value={pw2}
onChange={setPw2}
placeholder="비밀번호를 한번 더 입력하세요."
type="password"
/>
</div>
</FormRow>
</div>
{/* register/page.tsx와 동일하게 하단 중앙 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
onClick={onSubmit}
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
>
수요처 등록
</button>
</div>
</main>
);
}
@@ -0,0 +1,476 @@
// apps/dm/app/(landing)/reuse/apply/register/page.tsx
"use client";
import { useMemo, useState, useRef } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
type PHRange = { min: string; max: string };
export default function DemandRegisterPage() {
const today = useMemo(() => {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}, []);
const [openRef, setOpenRef] = useState(false);
// 기본정보
const [reqDate, setReqDate] = useState<string>(today);
const [reqQty, setReqQty] = useState<string>("");
const [usePurpose, setUsePurpose] = useState<string>("");
const [hopePrice, setHopePrice] = useState<string>("");
// 요구수질
const [coliform, setColiform] = useState<string>("");
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
return (
<>
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
{/* 컨텐츠 영역 */}
<div>
{/* 기본정보 테이블 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
{/* 요구수질 섹션 타이틀 바 */}
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex justify-center items-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
{/* 수질기준참고 모달 */}
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
{/* 요구수질 테이블 */}
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={
<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />
}
/>
<Divider />
{/* 2행 */}
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
}
/>
<Divider />
{/* 3행 */}
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={
<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />
}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput
value={ph.min}
onChange={(v) => setPh((p) => ({ ...p, min: v }))}
className="w-32"
/>
<span className="text-neutral-500">~</span>
<TextInput
value={ph.max}
onChange={(v) => setPh((p) => ({ ...p, max: v }))}
className="w-32"
/>
</div>
}
/>
<Divider />
{/* 4행 */}
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
{/* 5행 */}
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
{/* 6행 */}
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
{/* 후보지 선택 */}
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => {
// TODO: 후보지 조회 모달/검색 연결
console.log("후보지 조회", { cand1, cand2, cand3 });
}}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
onClick={() => {
// TODO: 유사사례 연결
console.log("유사사례");
}}
>
유사사례
</button>
</div>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => {
// TODO: 저장 처리
console.log("수요정보 등록");
}}
>
수요정보 등록
</button>
</div>
</div>
</main>
</>
);
}
/* ----------------------------- UI helpers ----------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function LabelCell({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
</span>
</div>
);
}
function FormRow({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<LabelCell required={required}>{label}</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
</div>
);
}
function QualityRow({
leftLabel,
leftRequired,
leftControl,
rightLabel,
rightControl,
}: {
leftLabel: React.ReactNode;
leftRequired?: boolean;
leftControl: React.ReactNode;
rightLabel: React.ReactNode;
rightControl: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 좌측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
{/* 좌측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
{/* 우측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
{/* 우측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function InlineSuffixInput({
value,
onChange,
suffix,
}: {
value: string;
onChange: (v: string) => void;
suffix: string;
}) {
return (
<div className="flex items-center gap-3">
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
<span className="text-sm text-neutral-600">{suffix}</span>
</div>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
);
}
@@ -0,0 +1,338 @@
// apps/dm/app/(landing)/reuse/apply/status/page.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
type WaterDetail = {
toc: string;
bdo: string;
tn: string;
tp: string;
cl: string;
conductivity: string;
coliform: string;
turbidity: string;
odor: string;
color: string;
ph: string;
note: string;
};
type Row = {
id: number;
sido: string;
sigungu: string;
company: string;
address: string;
phone: string;
date: string;
status: "진행중" | "진행완료";
detail: {
reqDate: string;
reqQty: string;
usePurpose: string;
price: string;
water: WaterDetail;
candidates: [string, string, string];
};
};
const MOCK: Row[] = [
{
id: 1,
sido: "서울특별시",
sigungu: "금천구",
company: "서울워터솔루션(주)",
address: "서울특별시 금천구 가산디지털2로 119, 24층(가산동)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행중",
detail: {
reqDate: "2025-02",
reqQty: "1,200",
usePurpose: "공업용수",
price: "350",
water: {
toc: "4",
bdo: "3",
tn: "10",
tp: "1.0",
cl: "120",
conductivity: "800",
coliform: "0",
turbidity: "2",
odor: "무",
color: "무",
ph: "7.2",
note: "-",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
{
id: 2,
sido: "제주특별자치도",
sigungu: "서귀포",
company: "회사명이 길어도괜찮지",
address: "제주특별자치도 서귀포시 천제연로 156, 3층 2호(색달동 1077호)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행완료",
detail: {
reqDate: "2025-03",
reqQty: "800",
usePurpose: "조경용수",
price: "300",
water: {
toc: "5",
bdo: "4",
tn: "12",
tp: "1.2",
cl: "140",
conductivity: "900",
coliform: "0",
turbidity: "3",
odor: "무",
color: "무",
ph: "7.0",
note: "현장 협의",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
];
function StatusBadge({ v }: { v: Row["status"] }) {
const cls = v === "진행중" ? "text-red-600" : "text-lime-600";
return <span className={["font-medium", cls].join(" ")}>{v}</span>;
}
type TopField = { label: string; value: string; required?: boolean };
type PairField = { label: string; value: string };
export default function DemandStatusPage() {
const rows = useMemo(() => MOCK, []);
// ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
const [selectedId, setSelectedId] = useState<number | null>(rows[0]?.id ?? null);
// ✅ 상세영역 스크롤 이동용
const detailRef = useRef<HTMLDivElement | null>(null);
// ✅ selected 계산 안정화
const selected = useMemo(() => {
if (!rows.length) return null;
const found = rows.find((r) => r.id === selectedId);
return found ?? rows[0];
}, [rows, selectedId]);
// ✅ rows가 바뀌었는데 selectedId가 null이면 자동 첫번째 선택
useEffect(() => {
const first = rows[0];
if (!first) return; // ✅ rows가 비어있으면 종료 (TS + 런타임 안전)
if (selectedId == null) setSelectedId(first.id);
}, [rows, selectedId]);
// ✅ 상단 2행(요구시기/요구수량/사용용도/희망단가) - TS 안전하게 object로 구성
const topFields: TopField[] = useMemo(() => {
if (!selected) return [];
return [
{ label: "요구시기", value: selected.detail.reqDate, required: true },
{ label: "요구수량(㎡/d)", value: selected.detail.reqQty },
{ label: "사용용도", value: selected.detail.usePurpose },
{ label: "희망단가(원)", value: selected.detail.price },
];
}, [selected]);
// ✅ 수질 항목 - TS 안전
const waterFields: PairField[] = useMemo(() => {
if (!selected) return [];
const w = selected.detail.water;
return [
{ label: "총대장균군수(개/100mℓ)", value: w.coliform },
{ label: "결합잔류염소(mg/L)", value: "-" },
{ label: "탁도(NTU)", value: w.turbidity },
{ label: "생물화학적산소요구량(BOD)(mg/L)", value: w.bdo },
{ label: "냄새", value: w.odor },
{ label: "색도(도)", value: w.color },
{ label: "총질소(T-N)(mg/L)", value: w.tn },
{ label: "총인(T-P)(mg/L)", value: w.tp },
{ label: "수소이온농도(pH)", value: w.ph },
{ label: "염화물(mgCL/L)", value: w.cl },
{ label: "전기전도도(µs/cm)", value: w.conductivity },
{ label: "비고", value: w.note },
];
}, [selected]);
const onSelectRow = (id: number) => {
setSelectedId(id);
// ✅ “행 클릭 → 아래 상세로 보여짐” 체감 확실히: 상세영역으로 이동
// (사용자가 이미 아래를 보고 있다면 거의 움직이지 않음)
requestAnimationFrame(() => {
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
return (
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요신청현황"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요신청현황" },
]}
hideFooterLine
hideGuideText
/>
{/* 컨텐츠 영역 */}
<div>
{/* 리스트 테이블 */}
<section className="mt-6">
<div className="text-2xl font-bold text-slate-700">수요정보 신청현황</div>
<div className="mt-3 border border-neutral-200">
<table className="w-full table-fixed">
<thead className="bg-[#E7F4E2]">
<tr className="text-sm text-slate-700">
<th className="w-[60px] px-3 py-3 text-center">NO.</th>
<th className="w-[120px] px-3 py-3 text-center">시도</th>
<th className="w-[120px] px-3 py-3 text-center">시군구</th>
<th className="w-[180px] px-3 py-3 text-center">회사명</th>
<th className="px-3 py-3 text-center">공장주소</th>
<th className="w-[140px] px-3 py-3 text-center">전화번호</th>
<th className="w-[120px] px-3 py-3 text-center">신청일자</th>
<th className="w-[110px] px-3 py-3 text-center">진행상태</th>
</tr>
</thead>
<tbody className="bg-white text-base text-slate-700">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-400">
조회된 신청내역이 없습니다.
</td>
</tr>
) : (
rows.map((r) => {
const active = r.id === selected?.id;
return (
<tr
key={r.id}
role="button"
tabIndex={0}
onClick={() => onSelectRow(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectRow(r.id);
}}
className={[
"border-t border-neutral-200 outline-none",
"cursor-pointer",
active ? "bg-slate-50" : "hover:bg-slate-50",
"focus-visible:ring-2 focus-visible:ring-slate-300",
].join(" ")}
aria-selected={active}
>
<td className="px-3 py-3 text-center">{r.id}</td>
<td className="px-3 py-3 text-center">{r.sido}</td>
<td className="px-3 py-3 text-center">{r.sigungu}</td>
<td className="px-3 py-3 text-center">{r.company}</td>
<td className="px-3 py-3 text-center">{r.address}</td>
<td className="px-3 py-3 text-center">{r.phone}</td>
<td className="px-3 py-3 text-center">{r.date}</td>
<td className="px-3 py-3 text-center">
<StatusBadge v={r.status} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
{/* 상세현황 */}
<section className="mt-10 pb-16" ref={detailRef}>
<div className="text-2xl font-bold text-slate-700">수요신청 상세현황</div>
{!selected ? (
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
상세 정보를 표시할 신청건이 없습니다.
</div>
) : (
<div className="mt-3 border border-neutral-200">
<div className="bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수량
</div>
<div className="grid grid-cols-12 text-sm">
{/* 상단 2행 */}
{topFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-6 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-4 bg-sky-50 px-4 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
{f.required ? <span className="ml-1 text-red-500">*</span> : null}
</div>
<div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 수질 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수질
</div>
{waterFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 공급처 후보지 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
공급처 후보지
</div>
{selected.detail.candidates.map((v, i) => (
<div
key={`candidate-${i}`}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
후보지{i + 1}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{v}
</div>
</div>
))}
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -0,0 +1,229 @@
// apps/dm/app/(landing)/reuse/business/globalCase/overview/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="해외 재이용 사례 개요"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "사례개요", href: "/reuse/business/globalCase/overview" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/overview_01.png", alt: "해외 재이용 사례 1" },
]}
/>
<section className="mt-6">
<div className="rounded-md bg-[#e9f0f1] p-8">
<h3 className="text-xl font-bold text-gray-800 leading-relaxed">
2024년 하수도 통계 기준 15.6%이며,<br />
제2차 물 재이용 기본 계획을 고려한 2030년 목표는 <span className="font-extrabold">17%</span>
</h3>
{/* 진행 바 영역 */}
<div className="mt-6 space-y-4">
{/* 2024 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’24년도</span>
<div className="relative h-7 rounded-r-lg">
<div className="h-7 w-[112px] rounded-r-lg bg-gray-400" />
</div>
<span className="text-sm text-gray-700">15.6%</span>
</div>
{/* 2030 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’30년도</span>
<div className="relative">
<div className="h-10 w-[327px] rounded-r-lg bg-gradient-to-r from-sky-500 to-green-500" />
</div>
<span className="text-xl font-bold text-gray-900">17%</span>
</div>
</div>
</div>
{/* 설명 문구 */}
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 우리나라의 하수 처리 재이용률은 <span className="text-sky-600 ">15% 수준</span>
</li>
<li>
• 대만은 가뭄과 공업용수 증가에 대응하여
<span className="text-sky-600 ">
{" "}현재 4.3% 수준이며, 16.5%까지 확대 계획 수립
</span>
</li>
</ul>
{/* 테이블 */}
<div className="mt-6">
<table className="w-full text-center text-sm">
<thead>
<tr>
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">해외정보</th>
<th className="bg-teal-500 px-4 py-3 text-white rounded-t-lg">현재</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">미래(목표, 예측치)</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
<tr className="border-t">
<td className="px-4 py-3 font-medium">한국</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">12.1%</td>
<td className="px-4 py-3">15.6%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">17%</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 font-medium">대만</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">2.4%</td>
<td className="px-4 py-3">4.3%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">16.5%</td>
</tr>
</tbody>
</table>
</div>
</section>
<Hr className="my-11" />
{/* 주요 국가의 하수처리수 재이용률 (%) */}
<section className="pb-[200px]">
<SectionTitle>주요 국가의 하수처리수 재이용률 (%)</SectionTitle>
{/* 설명 문구 */}
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐
</li>
<li>
• 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편
</li>
</ul>
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
<div className="mt-6">
<div className="overflow-x-auto">
<table className="min-w-[900px] w-full text-center text-sm">
<thead>
<tr>
{/* 좌측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
{/* 우측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
{[
{
L: { c: "이스라엘", r: "중동&북아프리카", v: "92.3" },
R: { c: "튀르키예", r: "동유럽&중앙아시아", v: "26.6" },
},
{
L: { c: "쿠웨이트", r: "중동&북아프리카", v: "89.3" },
R: { c: "독일", r: "서유럽", v: "25.0" },
},
{
L: { c: "호주", r: "동아시아&태평양", v: "49.0" },
R: { c: "스페인", r: "서유럽", v: "24.4" },
},
{
L: { c: "싱가포르", r: "동아시아&태평양", v: "44.4" },
R: { c: "한국", r: "동아시아&태평양", v: "12.1" },
},
{
L: { c: "그리스", r: "서유럽", v: "39.1" },
R: { c: "미국", r: "북아메리카", v: "13.1" },
},
{
L: { c: "사우디아라비아", r: "중동&북아프리카", v: "32.8" },
R: { c: "프랑스", r: "서유럽", v: "9.5" },
},
{
L: { c: "이탈리아", r: "서유럽", v: "31.7" },
R: { c: "대만", r: "동아시아&태평양", v: "2.4" },
},
{
L: { c: "룩셈부르크", r: "서유럽", v: "29.1" },
R: { c: "일본", r: "동아시아&태평양", v: "0.8" },
},
{
L: { c: "포르투갈", r: "서유럽", v: "29.0" },
R: { c: "", r: "", v: "" }, // 마지막 우측은 빈칸(이미지처럼)
},
].map((row, idx) => (
<tr key={idx} className="border-t">
{/* 좌측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.L.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.L.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.L.v}</td>
{/* 우측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.R.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.R.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.R.v}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/business/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_BUSINESS } from "../_constants/lnb";
export default function BusinessLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="사례" items={LNB_BUSINESS} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,93 @@
// apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 수원시 빗물이용과 중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "빗물 재이용 사례" },
{ label: "수원시", href: "/reuse/business/rain/suwon" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/rain/rain_01.png", alt: "수원시 하수 재이용 1" },
{ src: "/images/landing/business/rain/rain_02.png", alt: "수원시 하수 재이용 2" },
{ src: "/images/landing/business/rain/rain_03.png", alt: "수원시 하수 재이용 3" },
{ src: "/images/landing/business/rain/rain_04.png", alt: "수원시 하수 재이용 4" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 수원시 빗물이용과 중수도
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="pb-[200px]">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br/>
※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(‘21.7월, 수원시청).<br/>
다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br/><br/>
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
</p>
</section>
</div>
);
}
@@ -0,0 +1,111 @@
// apps/dm/app/(landing)/reuse/business/sewage/bolyeong/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="충남보령 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/bolyeong_01.png", alt: "보령시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
충남 보령 하수재이용<br/>
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="mt-6">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
시설용량 : 1만m³/일<br/>
관로 : 8.7km<br/>
사업비 : 315억원<br/>
위치: 충청남도 보령시 대청방조제로 322 일원
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로<br/>
국가전력 기반시설의 안정적 운영 도모
</p>
</section>
</div>
);
}
@@ -0,0 +1,94 @@
// apps/dm/app/(landing)/reuse/business/sewage/cheongju/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="청주시 하수 재이용 시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "청주시", href: "/reuse/business/sewage/cheongju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/cheongju_01.png", alt: "청주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수 재이용 시설
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수처리수 재이용 민간투자사업(BTO)<br/>
공급량 : 35,000㎥/일<br/>
공급관로 : 8.3㎞<br/>
사업비 : 622억원<br/>
수요처 : SK하이닉스 등 일반산업단지<br/>
지역 (위치) : 충청북도 청주시 흥덕구 가락리 135-9번지 일원<br/>
처리용량 : 35.000(m³/일)<br/>
적용기술 : 분리막 (UF : 전처리), (RO : 주처리)
</p>
</section>
</div>
);
}
@@ -0,0 +1,148 @@
// apps/dm/app/(landing)/reuse/business/sewage/guri/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="구리시 도로자동청소시스템"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "구리시", href: "/reuse/business/sewage/guri" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/guri_01.png", alt: "구리시 도로자동청소시스템 1" },
{ src: "/images/landing/business/sewage/guri_02.png", alt: "구리시 도로자동청소시스템 2" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
구리시 도로자동청소시스템 설치사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 61억원<br/>
위 치 : 구리시 경춘로 및 갈매중앙로 일대 등<br/>
관 로 : 5.8km<br/>
공급계획<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
<th className="bg-[#01ACE5] text-white font-semibold py-4 border border-gray-300">
시설명
</th>
<th className="bg-[#33B5C8] text-white font-semibold py-4 border border-gray-300">
재이용수 용도
</th>
<th className="bg-[#55BCA2] text-white font-semibold py-4 border border-gray-300">
공급량(㎥/일)
</th>
<th className="bg-[#6DBB4F] text-white font-semibold py-4 border border-gray-300">
원수
</th>
</tr>
</thead>
<tbody>
<tr>
<td
className="bg-white py-6 border border-gray-300 text-gray-700"
>
도로자동청소시스템
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
세척살수용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
178
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
구리 공공하수처리장 재이용수
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,173 @@
// apps/dm/app/(landing)/reuse/business/sewage/hwanggu/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="수원시 황구지천 하천유지용수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/hwanggu_01.png", alt: "수원시 황구지천 하수 재이용 1" },
{ src: "/images/landing/business/sewage/hwanggu_02.png", alt: "수원시 황구지천 하수 재이용 2" },
{ src: "/images/landing/business/sewage/hwanggu_03.png", alt: "수원시 황구지천 하수 재이용 3" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수원시 황수원시 황구지천 하수처리수 재이용사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 110억원<br/>
위 치 : 수원시 권선구 호매실천, 금곡천<br/>
관 로 : 3.9km<br/>
공 급 량 : 25,000㎥/일(하수처리시설 용량: 45,000㎥/일)<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
{/* 구분 */}
<th className="w-1/4 bg-[#E3E7EA] text-black font-semibold py-4 border border-gray-300">
구분
</th>
{/* 공급량 */}
<th className="w-2/4 bg-[#1FA3C6] text-white font-semibold py-4 border border-gray-300">
공급량
</th>
{/* 비고 */}
<th className="w-2/4 bg-[#72B84F] text-white font-semibold py-4 border border-gray-300">
비고
</th>
</tr>
</thead>
<tbody>
<tr>
{/* 좌측 병합 */}
<td
rowSpan={3}
className="bg-white font-medium text-gray-700 border border-gray-300"
>
하천 등 유지용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
12,500m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
호매실천 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천1 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천2 공급
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,112 @@
// apps/dm/app/(landing)/reuse/business/sewage/paju/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 파주시 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "파주시", href: "/reuse/business/sewage/paju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/paju_01.png", alt: "파주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 파주시 하수재이용<br/>
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br/>
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
</p>
</section>
<Hr className="my-11" />
{/* 사업 배경 및 목적 */}
<section className="mt-6">
<SectionTitle>사업 배경 및 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
공급량 : 4만m³/일<br/>
관로 : 13.6km<br/>
사업비 : 651억원<br/>
위치 : 경기도 파주시 금촌동 714번지 일원<br/>
시설용량 : 41,20m³/일
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
</p>
</section>
</div>
);
}
@@ -0,0 +1,101 @@
// apps/dm/app/(landing)/reuse/business/sewage/yeosu/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="여수시 하수처리수 재이용 민간투자사업(BTO)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "여수시", href: "/reuse/business/sewage/yeosu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/yeosu_01.png", alt: "여수시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
여수시 하수처리수 재이용 민간 투자사업(BTO)
</p>
</section>
<Hr className="my-11" />
{/* 사업 목적 */}
<section className="mt-6">
<SectionTitle>사업 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수요자가 원하는 양질의 공업용수를 저렴하게 곰급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선
</p>
</section>
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : GS칼텍스, YNCC(주) 등 750억원<br/>
시행사 : 여수그린허브(주)<br/>
운영사 : 테크로스<br/>
위치: 여수시 신월로 2844일원 (여수 하수처리시설 부지 내)<br/>
처리공법 : 전처리시설(MF) + 주처리시설(RO)<br/>
시설용량 및 공급량 : 5만m³/일<br/>
관로 : 18.6km
</p>
</section>
</div>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/greywater/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "중수도(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외) */}
<section className="mt-6">
<SectionTitle>전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_overall.png" alt="전체 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수공장 및 발전시설 내 중수도 설치 및 이용 현황 */}
<section>
<SectionTitle>공장 및 발전시설 내 중수도 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_factory.png" alt="공장 및 발전시설 내 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 건축물별 이용량 현황 */}
<section className="pb-16">
<SectionTitle>중수도 건축물별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_building.png" alt="중수도 건축물별 이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 중수도 용도별 이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>중수도 용도별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_usage.png" alt="중수도 용도별 이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/cases/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_CASES } from "../_constants/lnb";
export default function CasesLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="현황" items={LNB_CASES} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/cases/rain/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "빗물이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section className="mt-6">
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_building.png" alt="빗물이용시설의 건축물별 빗물이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section>
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage.png" alt="빗물이용시설의 용도별 분포 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage_amount.png" alt="빗물이용시설의 용도별 빗물이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/treated/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수 재이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "하수처리수 재이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className="mt-6">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_year.png" alt="연도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_usage.png" alt="용도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_facility.png" alt="하수처리시설 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className="pb-[200px]">
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_region.png" alt="시도별 하수처리수 재이용 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/intro/greywater/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "중수도" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 중수 재이용 소개 */}
<section className="mt-6">
<SectionTitle>중수 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_intro.png" alt="중수 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수도 설치 의무 대상 */}
<section>
<SectionTitle>중수도 설치 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_mandatory.png" alt="중수도 설치 의무 대상 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 시설기준 및 관리기준 */}
<section className="pb-[200px]">
<SectionTitle>중수도 시설기준 및 관리기준</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_standard.png" alt="중수도 시설기준 및 관리기준 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,122 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function WaterReuseLawPage() {
return (
<div className="bg-white">
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "물 재이용 법" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 법제도/계획 수립 타임라인 */}
<section className="mt-6">
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/law_timeline.png" alt="법제도 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
<section>
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/performance_left.png" alt="실적 현황 요약" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 국내 관련 현황 */}
<section>
<SectionTitle>국내 관련 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/domestic_textbox.png" alt="국내 관련 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 정책 변천 추이 */}
<section>
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/policy_timeline.png" alt="정책 변천 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 5) 타 법령과의 관계도 */}
<section>
<SectionTitle>타법령과의 관계도</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rel_note.png" alt="관계도 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 6) 국내 연계 현황 */}
<section className="pb-[200px]">
<SectionTitle>국내 연계 현황</SectionTitle>
<div className="mt-8">
<div className="mx-[20px] mb-8">
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src="/images/landing/intro/linkage.png" alt="국내 연계 현황" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/intro/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_INTRO } from "../_constants/lnb";
export default function IntroLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="소개" items={LNB_INTRO} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function RainwaterReusePage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물 이용시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "빗물 이용시설" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물 재이용 소개 */}
<section className="mt-6">
<SectionTitle>빗물 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_intro.png" alt="빗물 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설 설치 관리 법적 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설 설치 관리 법적 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_mandatory.png" alt="법적 의무 대상 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/treated/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function TreatedWaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "하수처리수" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 하수처리수 */}
<section className="mt-6">
<SectionTitle>하수처리수</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_intro.png" alt="하수처리수 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 하수처리수 재이용시설 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>하수처리수 재이용시설 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_mandatory.png" alt="하수처리수 재이용시설 의무 대상 설명" />
</div>
</section>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
// apps/dm/app/(landing)/reuse/layout.tsx
import type { ReactNode } from "react";
import TopNav from "./_components/TopNav";
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
// apps/dm/app/(landing)/reuse/page.tsx
"use client";
import TopNav from "./_components/TopNav";
import SideIndicator from "./_components/SideIndicator";
import { useScrollSpy } from "./_hooks/useScrollSpy";
import Hero from "./_sections/Hero";
import IntroCards from "./_sections/IntroCards";
import Cases from "./_sections/Cases";
import ProcessFlow from "./_sections/ProcessFlow";
const LANDING_SECTIONS = [
{ id: "main", label: "Main" },
{
id: "intro",
label: "물 재이용 소개", // ✅ 상단 메뉴용
sideLabel: "소개", // ✅ 사이드 인디게이터용
},
{
id: "cases",
label: "물 재이용 현황", // ✅ 상단 메뉴용
sideLabel: "현황", // ✅ 사이드 인디게이터용
},
{
id: "processflow",
label: "물 재이용 사례", // ✅ 상단 메뉴용
sideLabel: "재이용사례", // ✅ 사이드 인디게이터용
},
];
export default function ReuseLandingPage() {
const activeId = useScrollSpy(LANDING_SECTIONS.map((s) => s.id));
return (
<main className="bg-white">
{/* 헤더 */}
<TopNav />
{/* 사이드 인디게이터 */}
<SideIndicator items={LANDING_SECTIONS} activeId={activeId} />
{/* 컨텐츠 영역 */}
<div className="[&>section]:scroll-mt-[83px]">
{/* 물 재이용 */}
<Hero />
{/* 재이용 소개 */}
<IntroCards />
{/* 재이용 사례 */}
<Cases />
{/* 물 재이용 사업 */}
<ProcessFlow />
</div>
</main>
);
}
@@ -0,0 +1,238 @@
/* apps/dm/app/(landing)/reuse/reuse.module.css */
.hero {
position: relative;
min-height: 100vh;
overflow: hidden;
font-family: "NotoSansKR", sans-serif;
}
.heroBg {
position: absolute;
inset: 0;
background: url("/images/landing/hero_bg.jpg") center/cover no-repeat;
transform: scale(1.02);
}
.heroOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.50);
}
/* ===== Side Indicator (첨부 이미지 스타일) ===== */
.sideIndicator {
position: fixed;
right: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 40;
/* 원형 배지 폭만큼만 */
width: 60px;
/* (디자인처럼) 스크롤 영역 위에 뜨되 클릭 가능 */
pointer-events: none;
}
/* 세로 라인: 연한 하늘색 + 점선(도트) 느낌 */
.sideIndicatorLine {
position: absolute;
left: 50%;
top: -150px;
bottom: -150px;
width: 2px;
transform: translateX(-50%);
background: rgba(46, 177, 235, 0.25);
background-size: 2px 16px;
background-repeat: repeat-y;
z-index: -1;
}
/* 리스트는 가운데 정렬로 수직 배치 */
.sideIndicatorList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 56px; /* 4개 간격(시안에 맞게 조절) */
pointer-events: auto;
}
.sideIndicatorItem {
display: flex;
justify-content: center;
}
/* 공통 버튼 */
.indicatorBtn {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
position: relative;
z-index: 2;
display: grid;
place-items: center;
}
/* ✅ 기본: 작은 점 */
.indicatorDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(30, 167, 225, 0.55);
border: 1px solid rgba(30, 167, 225, 0.7);
}
/* ✅ 활성: 커지는 큰 원(텍스트 포함) */
.indicatorActive {
width: 83px;
height: 83px;
border-radius: 999px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* Main 활성: 흰 배경 + 검정 텍스트 */
.indicatorMainActive {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.75);
}
/* 섹션 활성: 파란 배경 + 흰 텍스트 */
.indicatorSectionActive {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
/* box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18); */
}
/* 활성 텍스트 */
.indicatorText {
font-size: 12px;
font-weight: 700;
line-height: 1.1;
text-align: center;
padding: 0 8px;
word-break: keep-all;
white-space: normal;
}
/* 공통 원형 버튼 */
.sideIndicatorDot {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease,
border-color 0.15s ease;
/* 라인 위로 올라오게 */
position: relative;
z-index: 1;
pointer-events: auto;
}
.sideIndicatorDotText {
font-size: 12px;
font-weight: 600;
line-height: 1.1;
text-align: center;
padding: 0 8px;
/* 긴 텍스트도 배지 안에서 줄바꿈 가능 */
word-break: keep-all;
white-space: normal;
}
/* Main: 흰색 원형 + 회색 텍스트 */
.sideIndicatorDotMain {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.65);
}
/* 섹션: 파란 원형 + 흰색 텍스트 */
.sideIndicatorDotSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
}
/* 활성 섹션: 더 진한 파랑 + 살짝 글로우 */
.sideIndicatorDotActive {
transform: scale(1.02);
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* hover 효과(이미지 느낌으로 아주 약하게만) */
.sideIndicatorDot:hover {
transform: translateY(-1px);
}
/* 반응형: 모바일에서는 숨김(원하시면 제거) */
@media (max-width: 768px) {
.sideIndicator {
display: none;
}
}
/* 중앙 배지 */
.sideIndicatorBadge {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 600;
text-align: center;
z-index: 1;
}
/* Main 배지 */
.badgeMain {
background: #fff;
border: 2px solid rgba(0,0,0,.15);
color: rgba(0,0,0,.65);
}
/* Section 배지 */
.badgeSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #fff;
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* 작은 점 */
.sideIndicatorDotSmall {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(30,167,225,.6);
}
/* 점 그룹 */
.sideIndicatorDotsTop,
.sideIndicatorDotsBottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.sideIndicatorDotsTop { margin-bottom: 18px; }
.sideIndicatorDotsBottom { margin-top: 22px; }
+14 -1
View File
@@ -1,4 +1,5 @@
@import "../../../packages/common/components/src/base.css";
/* @import "../../../packages/common/components/src/base.css"; */
/* @import "../../../packages/common/components/src/base.css"; */
@tailwind base;
@tailwind components;
@@ -68,3 +69,15 @@
@apply bg-background text-foreground;
}
}
@font-face {
font-family: "NotoSansKR";
src: url("/fonts/NotoSansKR-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
body {
font-family: "NotoSansKR", sans-serif;
}
+12
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import path from "path";
const subDomain =
process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3000";
@@ -8,6 +9,17 @@ const nextConfig: NextConfig = {
experimental: {
proxyTimeout: 600000,
},
// 🔥 반드시 필요
webpack(config) {
config.resolve.alias = {
...(config.resolve.alias || {}),
// monorepo 구조 기준
"@common": path.resolve(__dirname, "../../packages/common"),
};
return config;
},
async rewrites() {
return {
beforeFiles: [
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 14L12.0008 9.42L17 14" stroke="#0A090B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Some files were not shown because too many files have changed in this diff Show More