@@ -0,0 +1,954 @@
<!DOCTYPE html>
< html lang = "ko" >
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
< title > 사업관리대장 Dashboard< / title >
< style >
* { box-sizing : border-box } body { margin : 0 ; background : #f8fafc ; color : #0f172a ; font-family : 'Pretendard' , 'Noto Sans KR' , 'Malgun Gothic' , sans-serif }
. wrap { max-width : 1600 px ; margin : 0 auto ; padding : 20 px }
. top { display : grid ; grid-template-columns : 1 fr minmax ( 260 px , 520 px ) ; gap : 12 px ; align-items : end }
. title { font-size : 34 px ; font-weight : 900 ; letter-spacing : -.03 em ; margin : 0 }
. sub { font-size : 12 px ; color : #64748b ; font-weight : 800 ; letter-spacing : .08 em ; text-transform : uppercase }
. controls { display : flex ; gap : 8 px ; justify-content : flex-end ; flex-wrap : wrap }
. btn { border : 1 px solid #2563eb ; background : #2563eb ; color : #fff ; border-radius : 12 px ; padding : 10 px 14 px ; font-size : 13 px ; font-weight : 800 ; cursor : pointer }
. search { flex : 1 ; min-width : 250 px ; border : 1 px solid #e2e8f0 ; border-radius : 12 px ; padding : 10 px 12 px ; font-size : 13 px ; font-weight : 700 }
. status { margin : 10 px 0 14 px ; font-size : 12 px ; font-weight : 700 ; color : #64748b }
. cards { display : grid ; grid-template-columns : repeat ( 5 , minmax ( 150 px , 1 fr ) ) ; gap : 10 px ; margin-bottom : 12 px }
. card { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 14 px ; padding : 10 px 12 px }
. card . k { font-size : 11 px ; font-weight : 800 ; color : #64748b }
. card . v { font-size : 19 px ; font-weight : 900 ; white-space : nowrap }
. panel { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 20 px ; overflow : hidden }
. table-wrap { overflow : auto }
table { width : 100 % ; min-width : 1250 px ; border-collapse : collapse }
thead th { background : #0f172a ; color : #ffffff d1 ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .12 em ; padding : 12 px 10 px ; text-align : left ; white-space : nowrap ; vertical-align : middle }
. th-head { position : relative ; display : flex ; align-items : center }
. th-head . end { justify-content : flex-end }
. th-trigger { display : inline-flex ; align-items : center ; gap : 6 px ; border : 0 ; background : none ; padding : 0 ; color : #ffffff d1 ; font : inherit ; font-weight : 900 ; letter-spacing : inherit ; text-transform : inherit ; cursor : pointer }
. th-trigger : hover , . th-trigger . active , . th-trigger . open { color : #fff }
. th-title { display : inline-block }
. th-meta { font-size : 10 px ; color : #93c5fd ; font-weight : 800 ; letter-spacing : 0 ; text-transform : none }
. th-mark { display : inline-flex ; align-items : center ; justify-content : center ; min-width : 8 px ; color : #60a5fa ; font-size : 12 px ; line-height : 1 }
. th-caret { font-size : 10 px ; color : #93c5fd ; transition : transform .15 s ease }
. th-trigger . open . th-caret { transform : rotate ( 180 deg ) }
. th-menu { position : absolute ; top : calc ( 100 % + 8 px ) ; left : 0 ; display : none ; min-width : 180 px ; max-width : 320 px ; max-height : 280 px ; overflow : auto ; padding : 6 px ; background : #fff ; border : 1 px solid #cbd5e1 ; border-radius : 12 px ; box-shadow : 0 16 px 40 px #0f172a 26 ; z-index : 15 }
. th-head . end . th-menu { left : auto ; right : 0 }
. th-menu . open { display : block }
. th-option { display : block ; width : 100 % ; border : 0 ; background : none ; border-radius : 8 px ; padding : 9 px 10 px ; text-align : left ; font-size : 12 px ; font-weight : 700 ; color : #0f172a ; cursor : pointer ; white-space : normal ; word-break : break-word }
. th-option : hover { background : #eff6ff }
. th-option . active { background : #dbeafe ; color : #1d4ed8 }
tbody td { padding : 12 px ; border-bottom : 1 px solid #f1f5f9 ; font-size : 13 px ; white-space : nowrap ; vertical-align : middle }
tbody tr : hover { background : #eff6ff }
tbody tr . settled { background : #f8fafc ; color : #94a3b8 }
tbody tr . settled : hover { background : #f1f5f9 }
tbody tr . settled . name , tbody tr . settled strong { color : #64748b }
tbody tr . settled . badge { border-color : #cbd5e1 ; background : #f8fafc ; color : #64748b }
. num { text-align : right ; font-variant-numeric : tabular-nums }
. name { font-weight : 800 ; max-width : 460 px ; overflow : hidden ; text-overflow : ellipsis }
. subline { font-size : 11 px ; color : #94a3b8 ; font-weight : 700 ; margin-top : 3 px }
. badge { display : inline-flex ; padding : 3 px 9 px ; border-radius : 999 px ; border : 1 px solid #bfdbfe ; background : #eff6ff ; color : #1d4ed8 ; font-size : 11 px ; font-weight : 900 }
. badge . ok { border-color : #bbf7d0 ; background : #f0fdf4 ; color : #047857 }
. empty { display : none ; padding : 32 px ; text-align : center ; color : #94a3b8 ; font-weight : 800 }
. hidden { display : none }
. modal { position : fixed ; inset : 0 ; background : #020617 bf ; backdrop-filter : blur ( 4 px ) ; display : none ; align-items : center ; justify-content : center ; padding : 16 px ; z-index : 30 }
. modal . show { display : flex }
. modal-card { width : min ( 1200 px , 100 % ) ; max-height : 90 vh ; overflow : auto ; background : #fff ; border-radius : 24 px ; border : 1 px solid #e2e8f0 }
. m-top { padding : 20 px ; border-bottom : 1 px solid #f1f5f9 ; background : #f8fafc ; display : flex ; justify-content : space-between ; gap : 10 px }
. x { width : 42 px ; height : 42 px ; border : 1 px solid #e2e8f0 ; border-radius : 12 px ; background : #fff ; font-size : 22 px ; font-weight : 900 ; color : #64748b ; cursor : pointer }
. m-body { padding : 18 px ; display : grid ; grid-template-columns : 1.5 fr 1 fr ; gap : 12 px }
. sec { border : 1 px solid #e2e8f0 ; border-radius : 16 px ; padding : 12 px }
. sec . dark { background : #0f172a ; color : #fff ; border-color : #0f172a }
. grid3 { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 100 px , 1 fr ) ) ; gap : 8 px }
. grid4 { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 100 px , 1 fr ) ) ; gap : 8 px }
. kv { border : 1 px solid #e2e8f0 ; border-radius : 12 px ; padding : 9 px }
. kvk { font-size : 10 px ; color : #94a3b8 ; font-weight : 900 ; text-transform : uppercase }
. kvv { font-size : 13 px ; font-weight : 800 ; margin-top : 3 px ; word-break : break-word }
. line { display : flex ; justify-content : space-between ; gap : 10 px ; padding : 5 px 0 ; border-bottom : 1 px dashed #e2e8f0 ; font-size : 13 px ; font-weight : 700 }
. line : last-child { border-bottom : 0 }
. money { font-size : 28 px ; font-weight : 900 }
. progress { height : 11 px ; background : #94a3b8 33 ; border-radius : 999 px ; overflow : hidden ; margin-top : 7 px }
. bar { height : 100 % ; background : #3b82f6 ; width : 0 % }
. pay-list { display : flex ; flex-direction : column ; gap : 8 px ; margin-top : 10 px }
. pay-item { border : 1 px solid #e2e8f0 ; border-radius : 12 px ; padding : 10 px 12 px ; background : #f8fafc }
. pay-head { display : flex ; justify-content : space-between ; gap : 10 px ; align-items : flex-start }
. pay-name { font-size : 13 px ; font-weight : 900 ; word-break : break-word }
. pay-meta { margin-top : 6 px ; display : grid ; grid-template-columns : repeat ( 2 , minmax ( 120 px , 1 fr ) ) ; gap : 6 px 10 px ; font-size : 12 px ; color : #475569 ; font-weight : 700 }
. pay-empty { margin-top : 10 px ; border : 1 px dashed #cbd5e1 ; border-radius : 12 px ; padding : 12 px ; color : #94a3b8 ; font-size : 12 px ; font-weight : 800 ; text-align : center }
. pay-note { margin-top : 8 px ; border-top : 1 px dashed #fecaca ; padding-top : 8 px ; font-size : 12 px ; color : #b91c1c ; font-weight : 800 ; white-space : pre-wrap }
. metric-btn { display : inline-flex ; flex-direction : column ; align-items : flex-end ; gap : 2 px ; border : 0 ; background : none ; padding : 0 ; color : inherit ; font : inherit ; cursor : pointer }
. metric-btn strong { color : #0f172a ; text-decoration : underline ; text-decoration-color : #bfdbfe ; text-underline-offset : 3 px }
tbody tr . settled . metric-btn strong { color : #64748b }
. metric-btn : hover strong { color : #1d4ed8 ; text-decoration-color : #1d4ed8 }
. detail-row td { padding : 0 ; border-bottom : 1 px solid #e2e8f0 ; background : #f8fafc }
. detail-row : hover { background : #f8fafc }
. detail-cell { padding : 0 }
. inline-panel { padding : 16 px 18 px }
. inline-grid { display : grid ; grid-template-columns : 1.35 fr 1 fr ; gap : 12 px }
. inline-stack { display : flex ; flex-direction : column ; gap : 10 px }
. inline-card { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 16 px ; padding : 12 px }
. inline-hero { background : #0f172a ; color : #fff ; border-color : #0f172a }
. inline-hero-note { font-size : 12 px ; color : #94a3b8 ; margin-top : 6 px }
. inline-hero-split { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 14 px ; align-items : end }
. inline-hero-col { min-width : 0 }
. inline-hero-col . right { padding-left : 14 px ; border-left : 1 px solid #334155 }
. out-list { display : flex ; flex-direction : column ; gap : 8 px ; margin-top : 10 px }
. out-item { border : 1 px solid #e2e8f0 ; border-radius : 12 px ; padding : 10 px 12 px ; background : #f8fafc }
. out-head { display : flex ; justify-content : space-between ; gap : 10 px ; align-items : flex-start }
. out-vendor { font-size : 13 px ; font-weight : 900 }
. out-name { margin-top : 6 px ; font-size : 13 px ; font-weight : 800 ; word-break : break-word }
. out-meta { margin-top : 8 px ; display : grid ; grid-template-columns : repeat ( 2 , minmax ( 140 px , 1 fr ) ) ; gap : 6 px 10 px ; font-size : 12 px ; color : #475569 ; font-weight : 700 }
. out-payments { display : flex ; flex-direction : column ; gap : 6 px ; margin-top : 8 px ; padding-top : 8 px ; border-top : 1 px dashed #cbd5e1 }
. out-payment { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 10 px ; padding : 8 px }
. out-payment-head { display : flex ; justify-content : space-between ; gap : 10 px ; align-items : flex-start ; font-size : 12 px ; font-weight : 800 }
. out-payment-meta { margin-top : 6 px ; display : grid ; grid-template-columns : repeat ( 3 , minmax ( 120 px , 1 fr ) ) ; gap : 4 px 8 px ; font-size : 12 px ; color : #475569 ; font-weight : 700 }
. out-note { margin-top : 8 px ; border-top : 1 px dashed #fecaca ; padding-top : 8 px ; font-size : 12 px ; color : #b91c1c ; font-weight : 800 ; white-space : pre-wrap }
. project-head { display : grid ; grid-template-columns : 1.2 fr .8 fr ; gap : 12 px ; margin-bottom : 12 px }
. project-meta-grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 110 px , 1 fr ) ) ; gap : 8 px }
. project-sections { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 12 px }
. section-card { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 16 px ; padding : 14 px }
. section-head { display : flex ; justify-content : space-between ; gap : 12 px ; align-items : flex-start ; margin-bottom : 10 px }
. section-title { font-size : 16 px ; font-weight : 900 }
. section-sub { margin-top : 4 px ; font-size : 12 px ; color : #64748b ; font-weight : 800 }
. section-chip { display : inline-flex ; align-items : center ; gap : 6 px ; border : 1 px solid #bfdbfe ; background : #eff6ff ; color : #1d4ed8 ; border-radius : 999 px ; padding : 5 px 10 px ; font-size : 11 px ; font-weight : 900 ; white-space : nowrap }
. section-chip . out { border-color : #fecdd3 ; background : #fff1f2 ; color : #be123c }
. summary-grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 140 px , 1 fr ) ) ; gap : 8 px }
. summary-card { background : #f8fafc ; border : 1 px solid #e2e8f0 ; border-radius : 14 px ; padding : 12 px ; min-width : 0 }
. summary-label { font-size : 11 px ; color : #64748b ; font-weight : 900 ; text-transform : uppercase }
. summary-value { margin-top : 6 px ; font-size : clamp ( 12 px , 0.95 vw , 22 px ) ; font-weight : 900 ; line-height : 1.15 ; white-space : nowrap ; max-width : 100 % ; letter-spacing : -.03 em }
. summary-note { margin-top : 4 px ; font-size : 12 px ; color : #94a3b8 ; font-weight : 800 }
. ledger-stack { display : flex ; flex-direction : column ; gap : 14 px }
. ledger-block { background : #fff ; border : 1 px solid #e2e8f0 ; border-radius : 18 px ; overflow : hidden }
. ledger-block . outsource { border-color : #fecdd3 ; background : #fff }
. ledger-block . collect { border-color : #c7d2fe ; background : #fff }
. ledger-head { display : flex ; justify-content : space-between ; align-items : center ; gap : 12 px ; padding : 12 px 14 px }
. ledger-head-left { display : flex ; align-items : center ; gap : 10 px ; min-width : 0 }
. ledger-icon { width : 20 px ; height : 20 px ; border-radius : 999 px ; display : inline-flex ; align-items : center ; justify-content : center ; font-size : 12 px ; font-weight : 900 ; color : #fff ; flex : 0 0 auto }
. ledger-block . outsource . ledger-icon { background : #f43f5e }
. ledger-block . collect . ledger-icon { background : #6366f1 }
. ledger-name { font-size : 13 px ; font-weight : 900 }
. ledger-sub { margin-top : 2 px ; font-size : 11 px ; color : #64748b ; font-weight : 800 }
. ledger-pill { display : inline-flex ; align-items : center ; padding : 6 px 10 px ; border-radius : 999 px ; font-size : 11 px ; font-weight : 900 ; white-space : nowrap }
. ledger-block . outsource . ledger-pill { border : 1 px solid #fecdd3 ; background : #fff1f2 ; color : #e11d48 }
. ledger-block . collect . ledger-pill { border : 1 px solid #c7d2fe ; background : #eef2ff ; color : #4f46e5 }
. ledger-table-wrap { padding : 0 12 px 12 px }
. ledger-table { width : 100 % ; min-width : 0 ; border-collapse : collapse }
. ledger-table thead th { background : transparent ; color : #94a3b8 ; font-size : 11 px ; font-weight : 900 ; letter-spacing : 0 ; text-transform : none ; padding : 8 px 10 px ; border-bottom : 1 px solid #e2e8f0 }
. ledger-table tbody td { padding : 10 px ; border-bottom : 1 px solid #eef2f7 ; font-size : 12 px ; color : #334155 ; white-space : normal ; background : #fff }
. ledger-table tbody tr : last-child td { border-bottom : 0 }
. ledger-main { font-weight : 800 ; color : #0f172a }
. ledger-muted { display : block ; margin-top : 3 px ; font-size : 11 px ; color : #94a3b8 ; font-weight : 700 }
. ledger-amount { font-weight : 900 ; text-align : right ; color : #0f172a }
. ledger-note { font-size : 11 px ; color : #64748b ; font-weight : 700 }
. ledger-empty { padding : 14 px 12 px ; color : #94a3b8 ; font-size : 12 px ; font-weight : 800 ; text-align : center }
. ledger-block . outsource . ledger-head { background : #fff1f2 ; border-bottom : 1 px solid #fecdd3 }
. ledger-block . collect . ledger-head { background : #eef2ff ; border-bottom : 1 px solid #c7d2fe }
. ledger-block . outsource . ledger-table thead th { background : #fff7f8 }
. ledger-block . collect . ledger-table thead th { background : #f5f7ff }
@ media ( max-width : 1280px ) { . top { grid-template-columns : 1 fr } . controls { justify-content : flex-start } . cards { grid-template-columns : repeat ( 2 , minmax ( 140 px , 1 fr ) ) } . m-body { grid-template-columns : 1 fr } . inline-grid { grid-template-columns : 1 fr } . grid4 { grid-template-columns : repeat ( 2 , minmax ( 100 px , 1 fr ) ) } . inline-hero-split { grid-template-columns : 1 fr } . inline-hero-col . right { padding-left : 0 ; border-left : 0 ; border-top : 1 px solid #334155 ; padding-top : 12 px } . project-head { grid-template-columns : 1 fr } . project-meta-grid { grid-template-columns : repeat ( 2 , minmax ( 110 px , 1 fr ) ) } . project-sections { grid-template-columns : 1 fr } . summary-grid { grid-template-columns : repeat ( 2 , minmax ( 120 px , 1 fr ) ) } . ledger-head { align-items : flex-start ; flex-direction : column } . ledger-pill { align-self : flex-start } }
< / style >
< base href = "/integrations/ledger-assets/" > < link rel = "stylesheet" href = "/integrations/ledger-assets/MH%20통합%20대시보드_260320.css" > < link rel = "stylesheet" href = "/integrations/ledger-assets/ledger-override.css?v=20260401-03" > < / head >
< body class = "mh-business-theme" >
< input id = "file" type = "file" accept = ".csv,.xlsx,.xls" class = "hidden" / >
< div class = "wrap" >
< div class = "top" >
< div > < div class = "sub" > Live Management< / div > < h1 class = "title" > 사업관리대장 < span style = "font-weight:300;color:#94a3b8" > | Dashboard< / span > < / h1 > < / div >
< div class = "controls" > < button id = "btnUpload" class = "btn" type = "button" > 파일 업로드< / button > < input id = "search" class = "search" placeholder = "전체 검색" / > < / div >
< / div >
< div id = "status" class = "status" > CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.< / div >
< div id = "cards" class = "cards" > < / div >
< div class = "panel" >
< div class = "table-wrap" >
< table >
< thead >
< tr >
< th >
< div class = "th-head" >
< button type = "button" class = "th-trigger" data-filter = "code" data-label = "구분 / 코드" >
< span class = "th-title" > 구분 / 코드< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterCodeMenu" class = "th-menu" data-filter = "code" > < / div >
< / div >
< / th >
< th >
< div class = "th-head" >
< button type = "button" class = "th-trigger" data-filter = "name" data-label = "사업명" >
< span class = "th-title" > 사업명< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterNameMenu" class = "th-menu" data-filter = "name" > < / div >
< / div >
< / th >
< th >
< div class = "th-head" >
< button type = "button" class = "th-trigger" data-filter = "corp" data-label = "계약법인" >
< span class = "th-title" > 계약법인< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterCorpMenu" class = "th-menu" data-filter = "corp" > < / div >
< / div >
< / th >
< th >
< div class = "th-head" >
< button type = "button" class = "th-trigger" data-filter = "status" data-label = "진행상태" >
< span class = "th-title" > 진행상태< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterStatusMenu" class = "th-menu" data-filter = "status" > < / div >
< / div >
< / th >
< th >
< div class = "th-head" >
< button type = "button" class = "th-trigger" data-filter = "outsource" data-label = "외주비" >
< span class = "th-title" > 외주비< / span > < span class = "th-meta" > (VAT 별도)< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterOutsourceMenu" class = "th-menu" data-filter = "outsource" > < / div >
< / div >
< / th >
< th class = "num" >
< div class = "th-head end" >
< button type = "button" class = "th-trigger" data-filter = "amount" data-label = "계약금" >
< span class = "th-title" > 계약금< / span > < span class = "th-meta" > (VAT 별도)< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterAmountMenu" class = "th-menu" data-filter = "amount" > < / div >
< / div >
< / th >
< th class = "num" >
< div class = "th-head end" >
< button type = "button" class = "th-trigger" data-filter = "collected" data-label = "수금액" >
< span class = "th-title" > 수금액< / span > < span class = "th-meta" > (VAT 별도)< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterCollectedMenu" class = "th-menu" data-filter = "collected" > < / div >
< / div >
< / th >
< th class = "num" >
< div class = "th-head end" >
< button type = "button" class = "th-trigger" data-filter = "rate" data-label = "수금률" >
< span class = "th-title" > 수금률< / span > < span class = "th-mark" > < / span > < span class = "th-caret" > ▼< / span >
< / button >
< div id = "filterRateMenu" class = "th-menu" data-filter = "rate" > < / div >
< / div >
< / th >
< / tr >
< / thead >
< tbody id = "tbody" > < / tbody >
< / table >
< / div >
< div id = "empty" class = "empty" > 표시할 데이터가 없습니다.< / div >
< / div >
< / div >
< div id = "collectModal" class = "modal" >
< div class = "modal-card" >
< div class = "m-top" > < div > < div id = "mCat" class = "badge" > 미분류< / div > < div id = "mTitle" style = "font-size:28px;font-weight:900;margin-top:6px" > < / div > < div id = "mSub" style = "font-size:13px;color:#64748b;font-weight:700;margin-top:4px" > < / div > < / div > < button id = "btnCollectClose" class = "x" type = "button" > × < / button > < / div >
< div class = "m-body" >
< div style = "display:flex;flex-direction:column;gap:10px" >
< div class = "sec" > < div class = "grid3" > < div class = "kv" > < div class = "kvk" > 발주처< / div > < div id = "mClient" class = "kvv" > < / div > < / div > < div class = "kv" > < div class = "kvk" > 발주방법< / div > < div id = "mOrder" class = "kvv" > < / div > < / div > < div class = "kv" > < div class = "kvk" > 분담율< / div > < div id = "mSplit" class = "kvv" > < / div > < / div > < / div > < / div >
< div class = "sec" > < div class = "line" > < span > 착수일< / span > < strong id = "mStartDate" > < / strong > < / div > < div class = "line" > < span > 준공일< / span > < strong id = "mEndDate" > < / strong > < / div > < div class = "line" > < span > 대금구분< / span > < strong id = "mPayType" > < / strong > < / div > < div id = "mPayItems" class = "pay-list" > < / div > < / div >
< div class = "sec dark" > < div style = "display:flex;justify-content:space-between;gap:10px;align-items:flex-end" > < div > < div style = "font-size:11px;color:#94a3b8;font-weight:900" > 총 계약 합계(VAT 포함)< / div > < div id = "mContractTotal" class = "money" > < / div > < div id = "mContractSupply" style = "font-size:12px;color:#94a3b8" > < / div > < / div > < div style = "text-align:right" > < div style = "font-size:11px;color:#60a5fa;font-weight:900" > 수금금액< / div > < div id = "mCollected" class = "money" style = "color:#60a5fa" > < / div > < div id = "mCollectDate" style = "font-size:12px;color:#94a3b8" > < / div > < / div > < / div > < div style = "margin-top:10px;display:flex;justify-content:space-between" > < span style = "font-size:12px;color:#94a3b8;font-weight:900" > 수금 진행률< / span > < strong id = "mRate" style = "font-size:28px" > < / strong > < / div > < div class = "progress" > < div id = "mRateBar" class = "bar" > < / div > < / div > < div style = "display:flex;justify-content:space-between;margin-top:7px" > < span style = "color:#fda4af;font-size:12px;font-weight:900" > 미수 금액< / span > < strong id = "mReceivable" style = "color:#fb7185" > < / strong > < / div > < / div >
< / div >
< div style = "display:flex;flex-direction:column;gap:10px" >
< div class = "sec" > < div style = "font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase" > 계약 / 청구 담당자< / div > < div style = "margin-top:8px" > < div id = "mCmName" style = "font-size:20px;font-weight:900" > < / div > < div id = "mCmOrg" style = "font-size:13px;color:#0f172a;font-weight:800;margin-top:4px" > < / div > < div id = "mCmPhone" style = "font-size:13px;font-weight:700;margin-top:8px" > < / div > < div id = "mCmEmail" style = "font-size:13px;font-weight:700;margin-top:4px" > < / div > < / div > < / div >
< div class = "sec" > < div style = "font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase" > 부서 담당자< / div > < div style = "margin-top:8px" > < div id = "mDmName" style = "font-size:20px;font-weight:900" > < / div > < div id = "mDmOrg" style = "font-size:13px;color:#334155;font-weight:800;margin-top:4px" > < / div > < div id = "mDmPhone" style = "font-size:13px;font-weight:700;margin-top:8px" > < / div > < div id = "mDmEmail" style = "font-size:13px;font-weight:700;margin-top:4px" > < / div > < / div > < / div >
< / div >
< / div >
< / div >
< / div >
< div id = "outsourceModal" class = "modal" >
< div class = "modal-card" >
< div class = "m-top" > < div > < div class = "badge" > 외주비 상세< / div > < div id = "oTitle" style = "font-size:28px;font-weight:900;margin-top:6px" > < / div > < div id = "oSub" style = "font-size:13px;color:#64748b;font-weight:700;margin-top:4px" > < / div > < / div > < button id = "btnOutsourceClose" class = "x" type = "button" > × < / button > < / div >
< div class = "m-body" >
< div style = "display:flex;flex-direction:column;gap:10px" >
< div class = "sec" >
< div class = "grid3" >
< div class = "kv" > < div class = "kvk" > 계약법인< / div > < div id = "oCorp" class = "kvv" > < / div > < / div >
< div class = "kv" > < div class = "kvk" > 발주처< / div > < div id = "oClient" class = "kvv" > < / div > < / div >
< div class = "kv" > < div class = "kvk" > 외주처 요약< / div > < div id = "oVendors" class = "kvv" > < / div > < / div >
< / div >
< / div >
< div class = "sec" >
< div class = "line" > < span > 외주 총액< / span > < strong id = "oTotal" > < / strong > < / div >
< div class = "line" > < span > 외주 건수< / span > < strong id = "oCount" > < / strong > < / div >
< div class = "line" > < span > 계약기간< / span > < strong id = "oPeriod" > < / strong > < / div >
< div id = "oItems" class = "out-list" > < / div >
< / div >
< / div >
< div style = "display:flex;flex-direction:column;gap:10px" >
< div class = "sec dark" >
< div style = "font-size:11px;color:#94a3b8;font-weight:900" > 총 외주비(공급가액 기준)< / div >
< div id = "oTotalHero" class = "money" > < / div >
< div id = "oTotalHint" style = "font-size:12px;color:#94a3b8;margin-top:6px" > < / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< script src = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js" > < / script >
< script >
const FILTER _KEYS = [ "code" , "name" , "corp" , "status" , "outsource" , "amount" , "collected" , "rate" ] ;
const S = { all : [ ] , rows : [ ] , viewRows : [ ] , file : "" , filters : { } , totals : null , expanded : { key : "" } } ;
const E = { file : document . getElementById ( "file" ) , btnUpload : document . getElementById ( "btnUpload" ) , search : document . getElementById ( "search" ) , status : document . getElementById ( "status" ) , cards : document . getElementById ( "cards" ) , tbody : document . getElementById ( "tbody" ) , empty : document . getElementById ( "empty" ) , collectModal : document . getElementById ( "collectModal" ) , btnCollectClose : document . getElementById ( "btnCollectClose" ) , outsourceModal : document . getElementById ( "outsourceModal" ) , btnOutsourceClose : document . getElementById ( "btnOutsourceClose" ) , filterButtons : Object . fromEntries ( Array . from ( document . querySelectorAll ( ".th-trigger" ) ) . map ( el => [ el . dataset . filter , el ] ) ) , filterMenus : Object . fromEntries ( Array . from ( document . querySelectorAll ( ".th-menu" ) ) . map ( el => [ el . dataset . filter , el ] ) ) } ;
const G = id => document . getElementById ( id ) ;
const esc = v => String ( v || "" ) . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) ;
const escAttr = v => esc ( v ) . replace ( /"/g , """ ) ;
const n = v => String ( v || "" ) . replace ( /[\s\r\n]+/g , "" ) . toLowerCase ( ) ;
const num = v => { v = String ( v || "" ) . trim ( ) ; if ( ! v || v . startsWith ( "=" ) ) return 0 ; return parseFloat ( v . replace ( /[^0-9.\-]/g , "" ) ) || 0 ; } ;
const won = v => Math . round ( v || 0 ) . toLocaleString ( "ko-KR" ) + " 원" ;
const d = v => { v = String ( v || "" ) . trim ( ) ; return ! v || v === "~" ? "-" : v ; } ;
const rate = ( raw , col , sales ) => { const x = parseFloat ( String ( raw || "" ) . replace ( /[^0-9.\-]/g , "" ) ) ; if ( Number . isFinite ( x ) ) return Math . max ( 0 , Math . min ( 100 , x ) ) ; return sales > 0 ? Math . max ( 0 , Math . min ( 100 , col / sales * 100 ) ) : 0 ; } ;
const score = t => { t = String ( t || "" ) ; let s = 0 , m = t . replace ( /\s+/g , "" ) ; if ( m . includes ( "사업관리대장" ) ) s += 8 ; if ( m . includes ( "총괄사업코드" ) ) s += 8 ; if ( m . includes ( "사업명(계약명)" ) ) s += 7 ; s += ( t . match ( /[가-힣]/g ) || [ ] ) . length * 0.01 ; s -= ( t . match ( /� /g ) || [ ] ) . length * 0.5 ; return s ; } ;
const rowKey = r => [ r . code || "" , r . name || "" , r . corp || "" , r . client || "" ] . join ( "|" ) ;
function parseCsv ( txt ) { const out = [ ] ; let row = [ ] , f = "" , q = false ; for ( let i = 0 ; i < txt . length ; i ++ ) { const c = txt [ i ] ; if ( c === '"' ) { if ( q && txt [ i + 1 ] === '"' ) { f += '"' ; i ++ ; } else q = ! q ; continue ; } if ( c === "," && ! q ) { row . push ( f ) ; f = "" ; continue ; } if ( ( c === "\n" || c === "\r" ) && ! q ) { if ( c === "\r" && txt [ i + 1 ] === "\n" ) i ++ ; row . push ( f ) ; out . push ( row ) ; row = [ ] ; f = "" ; continue ; } f += c ; } row . push ( f ) ; out . push ( row ) ; if ( out . length && out [ 0 ] . length ) out [ 0 ] [ 0 ] = String ( out [ 0 ] [ 0 ] || "" ) . replace ( /^\uFEFF/ , "" ) ; return out ; }
function hs ( rows ) {
for ( let i = 0 ; i < rows . length ; i ++ ) {
const a = ( rows [ i ] || [ ] ) . map ( n ) ;
const hasName = a . some ( v => v . includes ( "사업명(계약명)" ) || v === "사업명" || v . includes ( "사업명" ) ) ;
const hasCode = a . some ( v => v . includes ( "총괄사업코드" ) || v . includes ( "사업코드" ) ) ;
const hasClient = a . some ( v => v . includes ( "발주처(매출처)" ) || v . includes ( "발주처" ) ) ;
if ( hasName && ( hasCode || hasClient ) ) return i ;
}
return - 1 ;
}
function ch ( a , b ) { a = a || [ ] ; b = b || [ ] ; const m = Math . max ( a . length , b . length ) , o = [ ] ; let carry = "" ; for ( let i = 0 ; i < m ; i ++ ) { const t = String ( a [ i ] || "" ) . replace ( /\s+/g , " " ) . trim ( ) , s = String ( b [ i ] || "" ) . replace ( /\s+/g , " " ) . trim ( ) ; if ( t ) carry = t ; const top = t || carry ; o . push ( top && s ? ( top + " " + s ) . trim ( ) : ( top || s || "" ) ) ; } return o ; }
function hi ( headers , cands ) { const C = ( cands || [ ] ) . map ( n ) . filter ( Boolean ) ; for ( const c of C ) { for ( let i = 0 ; i < headers . length ; i ++ ) if ( n ( headers [ i ] ) === c ) return i ; } return - 1 ; }
function parseLedgerRows ( R ) {
if ( R . length && R [ 0 ] . length ) R [ 0 ] [ 0 ] = String ( R [ 0 ] [ 0 ] || "" ) . replace ( /^\uFEFF/ , "" ) ;
const h = hs ( R ) ; if ( h < 0 ) throw new Error ( "헤더를 찾지 못했습니다." ) ;
const H = ch ( R [ h ] , R [ h + 1 ] || [ ] ) , I = { cat : hi ( H , [ "사업구분" , "사업 구분" ] ) , corp : hi ( H , [ "계약법인" , "계약 법인" ] ) , code : hi ( H , [ "총괄사업코드" , "총괄 사업코드" , "사업코드" ] ) , name : hi ( H , [ "사업명 (계약명)" , "사업명(계약명)" , "사업명" ] ) , pay : hi ( H , [ "대금구분" , "대금 구분" ] ) , yn : hi ( H , [ "계약여부" ] ) , order : hi ( H , [ "발주방법" ] ) , pm : hi ( H , [ "pm" ] ) , status : hi ( H , [ "진행상태" ] ) , client : hi ( H , [ "발주처 (매출처)" , "발주처(매출처)" , "발주처" ] ) , split : hi ( H , [ "분담율" ] ) , cDate : hi ( H , [ "계약기간 계약일" , "계약일" , "발행일" ] ) , sDate : hi ( H , [ "계약기간 착수일" , "착수일" ] ) , eDate : hi ( H , [ "계약기간 준공일" , "준공일" ] ) , cSup : hi ( H , [ "계약금 공급가액" , "매출금액 공급가액" , "공급가액" ] ) , cVat : hi ( H , [ "계약금 부가세" , "매출금액 부가세" , "부가세" ] ) , cTot : hi ( H , [ "계약금 합계" , "매출금액 합계" , "합계" , "계약금" , "매출금액" ] ) , colDate : hi ( H , [ "매출금액 수금일" , "수금일" ] ) , sSup : hi ( H , [ "매출금액 공급가액" , "공급가액" ] ) , sVat : hi ( H , [ "매출금액 부가세" , "부가세" ] ) , sTot : hi ( H , [ "매출금액 합계" , "합계" , "매출금액" ] ) , col : hi ( H , [ "매출금액 수금금액" , "수금금액" , "수금액" ] ) , recv : hi ( H , [ "매출금액 미수금액" , "미수금액" ] ) , r : hi ( H , [ "매출금액 수금율" , "수금율" ] ) , note : hi ( H , [ "비고" ] ) , cmCo : hi ( H , [ "계약/청구담당자 회사" ] ) , cmNm : hi ( H , [ "계약/청구담당자 이름" ] ) , cmDp : hi ( H , [ "계약/청구담당자 부서" ] ) , cmPh : hi ( H , [ "계약/청구담당자 연락처" ] ) , cmEm : hi ( H , [ "계약/청구담당자 이메일" ] ) , dmCo : hi ( H , [ "부서담당자 회사" ] ) , dmNm : hi ( H , [ "부서담당자 이름" ] ) , dmDp : hi ( H , [ "부서담당자 부서" ] ) , dmPh : hi ( H , [ "부서담당자 연락처" ] ) , dmEm : hi ( H , [ "부서담당자 이메일" ] ) } ;
const out = [ ] ; for ( const row of R . slice ( h + 2 ) ) { const x = { cat : I . cat >= 0 ? String ( row [ I . cat ] || "" ) . trim ( ) : "" , corp : I . corp >= 0 ? String ( row [ I . corp ] || "" ) . trim ( ) : "" , code : I . code >= 0 ? String ( row [ I . code ] || "" ) . trim ( ) : "" , name : I . name >= 0 ? String ( row [ I . name ] || "" ) . trim ( ) : "" , pay : I . pay >= 0 ? String ( row [ I . pay ] || "" ) . trim ( ) : "" , yn : I . yn >= 0 ? String ( row [ I . yn ] || "" ) . trim ( ) : "" , order : I . order >= 0 ? String ( row [ I . order ] || "" ) . trim ( ) : "" , pm : I . pm >= 0 ? String ( row [ I . pm ] || "" ) . trim ( ) : "" , status : I . status >= 0 ? String ( row [ I . status ] || "" ) . trim ( ) : "" , client : I . client >= 0 ? String ( row [ I . client ] || "" ) . trim ( ) : "" , split : I . split >= 0 ? String ( row [ I . split ] || "" ) . trim ( ) : "" , cDate : I . cDate >= 0 ? String ( row [ I . cDate ] || "" ) . trim ( ) : "" , sDate : I . sDate >= 0 ? String ( row [ I . sDate ] || "" ) . trim ( ) : "" , eDate : I . eDate >= 0 ? String ( row [ I . eDate ] || "" ) . trim ( ) : "" , cSup : I . cSup >= 0 ? num ( row [ I . cSup ] ) : 0 , cVat : I . cVat >= 0 ? num ( row [ I . cVat ] ) : 0 , cTot : I . cTot >= 0 ? num ( row [ I . cTot ] ) : 0 , colDate : I . colDate >= 0 ? String ( row [ I . colDate ] || "" ) . trim ( ) : "" , sSup : I . sSup >= 0 ? num ( row [ I . sSup ] ) : 0 , sVat : I . sVat >= 0 ? num ( row [ I . sVat ] ) : 0 , sTot : I . sTot >= 0 ? num ( row [ I . sTot ] ) : 0 , col : I . col >= 0 ? num ( row [ I . col ] ) : 0 , recv : I . recv >= 0 ? num ( row [ I . recv ] ) : 0 , rateRaw : I . r >= 0 ? String ( row [ I . r ] || "" ) . trim ( ) : "" , note : I . note >= 0 ? String ( row [ I . note ] || "" ) . trim ( ) : "" , cmCo : I . cmCo >= 0 ? String ( row [ I . cmCo ] || "" ) . trim ( ) : "" , cmNm : I . cmNm >= 0 ? String ( row [ I . cmNm ] || "" ) . trim ( ) : "" , cmDp : I . cmDp >= 0 ? String ( row [ I . cmDp ] || "" ) . trim ( ) : "" , cmPh : I . cmPh >= 0 ? String ( row [ I . cmPh ] || "" ) . trim ( ) : "" , cmEm : I . cmEm >= 0 ? String ( row [ I . cmEm ] || "" ) . trim ( ) : "" , dmCo : I . dmCo >= 0 ? String ( row [ I . dmCo ] || "" ) . trim ( ) : "" , dmNm : I . dmNm >= 0 ? String ( row [ I . dmNm ] || "" ) . trim ( ) : "" , dmDp : I . dmDp >= 0 ? String ( row [ I . dmDp ] || "" ) . trim ( ) : "" , dmPh : I . dmPh >= 0 ? String ( row [ I . dmPh ] || "" ) . trim ( ) : "" , dmEm : I . dmEm >= 0 ? String ( row [ I . dmEm ] || "" ) . trim ( ) : "" } ;
if ( ! x . name && ! x . code ) continue ; if ( ! x . code && ! x . corp && ! x . client && ! x . pm ) continue ; if ( ! x . cTot ) x . cTot = x . cSup + x . cVat ; if ( ! x . sTot ) x . sTot = x . sSup + x . sVat ; if ( ! x . recv ) x . recv = Math . max ( 0 , x . sTot - x . col ) ; x . rate = rate ( x . rateRaw , x . col , x . sTot ) ; out . push ( x ) ; }
return out ;
}
const hk = v => String ( v || "" ) . normalize ( "NFKC" ) . toLowerCase ( ) . replace ( /[^0-9a-z가-힣]+/g , "" ) ;
function findHeaderIndex ( headers , cands ) {
const normalized = ( headers || [ ] ) . map ( hk ) ;
const candidates = ( cands || [ ] ) . map ( hk ) . filter ( Boolean ) ;
for ( const c of candidates ) {
for ( let i = 0 ; i < normalized . length ; i ++ ) {
if ( ! normalized [ i ] ) continue ;
if ( normalized [ i ] === c || normalized [ i ] . includes ( c ) || c . includes ( normalized [ i ] ) ) return i ;
}
}
return - 1 ;
}
function textAt ( row , idx ) { return idx >= 0 ? String ( row [ idx ] ? ? "" ) . replace ( /\u00a0/g , " " ) . replace ( /\s+/g , " " ) . trim ( ) : "" ; }
function moneyAt ( row , idx ) { return idx >= 0 ? num ( row [ idx ] ) : 0 ; }
function lastText ( values ) { for ( let i = values . length - 1 ; i >= 0 ; i -- ) { const v = d ( values [ i ] ) ; if ( v !== "-" ) return v ; } return "-" ; }
function paymentSummary ( payments ) {
const labels = [ ... new Set ( ( payments || [ ] ) . map ( p => String ( p . pay || "" ) . trim ( ) ) . filter ( Boolean ) ) ] ;
if ( ! labels . length ) return "-" ;
if ( labels . length <= 2 ) return labels . join ( ", " ) ;
return ` ${ labels . slice ( 0 , 2 ) . join ( ", " ) } 외 ${ labels . length - 2 } 건 ` ;
}
function paymentRecord ( x , fallbackPay ) {
const supply = x . sSup || 0 , vat = x . sVat || 0 , total = x . sTot || supply + vat , collected = x . col || 0 ;
return { pay : String ( x . pay || x . name || fallbackPay || "미입력" ) . trim ( ) , status : x . status || "" , issueDate : x . issueDate || x . cDate || "" , collectDate : x . colDate || "" , supply , vat , total , collected , receivable : x . recv || Math . max ( 0 , total - collected ) , rate : rate ( x . rateRaw , collected , total ) , note : String ( x . note || "" ) . trim ( ) } ;
}
function finalizeProject ( project ) {
const payments = ( project . payments || [ ] ) . filter ( p => p . pay || p . issueDate || p . collectDate || p . total || p . collected || p . receivable ) ;
if ( ! payments . length && ( project . issueDate || project . colDate || project . sSup || project . sVat || project . sTot || project . col || project . recv ) ) payments . push ( paymentRecord ( project , project . pay || "일괄" ) ) ;
project . payments = payments ;
project . pay = paymentSummary ( payments ) ;
project . periodText = ( d ( project . sDate ) === "-" && d ( project . eDate ) === "-" ) ? "-" : ` ${ d ( project . sDate ) } ~ ${ d ( project . eDate ) } ` ;
project . issueDateSummary = lastText ( payments . map ( p => p . issueDate ) ) ;
project . collectDateSummary = lastText ( payments . map ( p => p . collectDate ) ) ;
return project ;
}
function normalizeProjectKey ( v ) { return hk ( v ) ; }
function normalizeProjectBase ( v ) {
return hk ( String ( v || "" ) . replace ( /\([^)]*\)/g , " " ) . replace ( /\[[^\]]*\]/g , " " ) ) ;
}
function summarizeOutsourceVendors ( vendors ) {
const list = ( vendors || [ ] ) . filter ( Boolean ) ;
if ( ! list . length ) return "" ;
if ( list . length <= 2 ) return list . join ( ", " ) ;
return ` ${ list . slice ( 0 , 2 ) . join ( ", " ) } \u C678 ${ list . length - 2 } \u ACF3 ` ;
}
function calcVatExcluded ( total ) { return total > 0 ? Math . round ( total / 1.1 ) : 0 ; }
function outsourceTotalLabel ( item ) {
const ex = Math . round ( item && item . contractEx || 0 ) ;
const total = Math . round ( item && item . contractIn || 0 ) ;
if ( ex > 0 ) return won ( ex ) ;
if ( total > 0 ) return won ( calcVatExcluded ( total ) ) ;
return "-" ;
}
function cleanVendorName ( value , sheetName ) {
const raw = String ( value || sheetName || "" ) . trim ( ) ;
return raw . replace ( /^\(\uC8FC\)\s*/ , "" ) . replace ( /^\uC8FC\uC2DD\uD68C\uC0AC\s*/ , "" ) . replace ( /^\uC678\uC8FC/ , "" ) . trim ( ) || String ( sheetName || "\uC678\uC8FC" ) . replace ( /^\uC678\uC8FC/ , "" ) . trim ( ) || "\uC678\uC8FC" ;
}
function getOutsourceLayout ( rows ) {
const header = rows [ 3 ] || [ ] ;
const hasVatContract = String ( header [ 9 ] ? ? "" ) . includes ( "VAT\uD3EC\uD568" ) ;
if ( hasVatContract ) {
return { hasVatContract : true , contractEx : 8 , contractIn : 9 , invoiceDate : 10 , paymentDate : 11 , paymentAmount : 12 , remainingAmount : 13 , progress : 14 , label : 15 , note : 16 } ;
}
return { hasVatContract : false , contractEx : 8 , contractIn : - 1 , invoiceDate : 9 , paymentDate : 10 , paymentAmount : 11 , remainingAmount : 12 , progress : 13 , label : - 1 , note : 14 } ;
}
function shouldStopOutsourceRows ( row ) {
const first = String ( row [ 0 ] ? ? "" ) . trim ( ) ;
const project = String ( row [ 2 ] ? ? "" ) . trim ( ) ;
const detail = String ( row [ 3 ] ? ? "" ) . trim ( ) ;
const joined = [ row [ 0 ] , row [ 2 ] , row [ 3 ] , row [ 13 ] , row [ 14 ] , row [ 15 ] , row [ 16 ] ] . map ( v => String ( v ? ? "" ) . trim ( ) ) . join ( " " ) ;
return first === "\uB0A0\uC9DC" || first . startsWith ( "*\uC790\uB8CC\uCD9C\uCC98" ) || project === "\uC801\uC694" || detail === "\uC801\uC694" || project . includes ( "\uC790\uB8CC\uCD9C\uCC98" ) || joined . includes ( "\uC6D0\uACC4\uC57D\uAE08" ) || joined . includes ( "\uC218\uAE08/\uC9C0\uAE09\uCC98" ) ;
}
function getOutsourceEntry ( map , key , name ) {
const current = map . get ( key ) ;
if ( current ) return current ;
const next = { name , key , baseKey : normalizeProjectBase ( name ) , vendors : new Set ( ) , items : [ ] , contract : 0 , contractIn : 0 , paid : 0 , paidIn : 0 , remaining : 0 , remainingIn : 0 } ;
map . set ( key , next ) ;
return next ;
}
function createOutsourceItem ( entry , vendor , projectName , detail , row , layout ) {
const contractEx = num ( row [ layout . contractEx ] ) ;
const contractIn = layout . contractIn >= 0 ? num ( row [ layout . contractIn ] ) : 0 ;
const next = {
vendor ,
projectName ,
detail : String ( detail || "-" ) . trim ( ) || "-" ,
contractDate : String ( row [ 4 ] ? ? "" ) . trim ( ) ,
startDate : String ( row [ 5 ] ? ? "" ) . trim ( ) ,
endDate : String ( row [ 7 ] ? ? "" ) . trim ( ) ,
contractEx ,
contractIn ,
invoiceDate : String ( row [ layout . invoiceDate ] ? ? "" ) . trim ( ) ,
progress : String ( row [ layout . progress ] ? ? "" ) . trim ( ) ,
note : "" ,
payments : [ ]
} ;
entry . items . push ( next ) ;
return next ;
}
function buildOutsourcePayment ( item , row , layout ) {
const invoiceDate = String ( row [ layout . invoiceDate ] ? ? "" ) . trim ( ) ;
const paymentDate = String ( row [ layout . paymentDate ] ? ? "" ) . trim ( ) ;
const paymentCell = String ( row [ layout . paymentAmount ] ? ? "" ) . trim ( ) ;
const remainingCell = String ( row [ layout . remainingAmount ] ? ? "" ) . trim ( ) ;
const paymentRaw = num ( row [ layout . paymentAmount ] ) ;
const remainingRaw = num ( row [ layout . remainingAmount ] ) ;
const label = layout . label >= 0 ? String ( row [ layout . label ] ? ? "" ) . trim ( ) : "" ;
const note = layout . note >= 0 ? String ( row [ layout . note ] ? ? "" ) . trim ( ) : String ( row [ 14 ] ? ? "" ) . trim ( ) ;
if ( ! ( invoiceDate || paymentDate || paymentRaw || remainingRaw || label || note ) ) return null ;
if ( note && ! label && ! paymentDate && ! paymentRaw && ! remainingRaw && ! invoiceDate ) {
item . note = note ;
}
return {
label ,
note ,
invoiceDate ,
paymentDate ,
paymentKnown : paymentCell !== "" ,
remainingKnown : remainingCell !== "" ,
paymentEx : paymentRaw ? ( layout . hasVatContract ? calcVatExcluded ( paymentRaw ) : paymentRaw ) : 0 ,
paymentIn : layout . hasVatContract ? paymentRaw : 0 ,
remainingEx : remainingRaw ? ( layout . hasVatContract ? calcVatExcluded ( remainingRaw ) : remainingRaw ) : 0 ,
remainingIn : layout . hasVatContract ? remainingRaw : 0
} ;
}
function finalizeOutsourceItem ( item ) {
const payments = Array . isArray ( item . payments ) ? item . payments . filter ( Boolean ) : [ ] ;
const paidEx = Math . round ( payments . reduce ( ( sum , p ) => sum + ( p . paymentEx || 0 ) , 0 ) ) ;
const paidIn = Math . round ( payments . reduce ( ( sum , p ) => sum + ( p . paymentIn || 0 ) , 0 ) ) ;
let remainingEx = 0 ;
let remainingIn = 0 ;
for ( let i = payments . length - 1 ; i >= 0 ; i -- ) {
const payment = payments [ i ] ;
if ( payment . remainingKnown ) {
remainingEx = Math . round ( payment . remainingEx || 0 ) ;
remainingIn = Math . round ( payment . remainingIn || 0 ) ;
break ;
}
}
if ( ! remainingEx && item . contractEx > 0 ) remainingEx = Math . max ( 0 , Math . round ( item . contractEx - paidEx ) ) ;
if ( ! remainingIn && item . contractIn > 0 ) remainingIn = Math . max ( 0 , Math . round ( item . contractIn - paidIn ) ) ;
return { ... item , payments , paidEx , paidIn , remainingEx , remainingIn } ;
}
function parseOutsourceRows ( rows , sheetName , map ) {
if ( ! rows || rows . length < 6 ) return ;
const vendor = cleanVendorName ( ( rows [ 1 ] || [ ] ) [ 0 ] , sheetName ) ;
const layout = getOutsourceLayout ( rows ) ;
let currentKey = "" , currentName = "" , currentItem = null ;
for ( const row of rows . slice ( 5 ) ) {
if ( shouldStopOutsourceRows ( row ) ) break ;
const projectName = String ( row [ 2 ] ? ? "" ) . trim ( ) ;
const projectKey = normalizeProjectKey ( projectName ) ;
const detail = String ( row [ 3 ] ? ? "" ) . trim ( ) ;
const validProject = projectKey && projectKey !== "ref" ;
if ( validProject ) {
currentKey = projectKey ;
currentName = projectName ;
const entry = getOutsourceEntry ( map , currentKey , currentName ) ;
entry . vendors . add ( vendor ) ;
currentItem = createOutsourceItem ( entry , vendor , currentName , detail , row , layout ) ;
const firstPayment = buildOutsourcePayment ( currentItem , row , layout ) ;
if ( firstPayment ) currentItem . payments . push ( firstPayment ) ;
continue ;
}
if ( ! currentKey ) continue ;
const entry = getOutsourceEntry ( map , currentKey , currentName ) ;
entry . vendors . add ( vendor ) ;
const contractEx = num ( row [ layout . contractEx ] ) ;
const contractIn = layout . contractIn >= 0 ? num ( row [ layout . contractIn ] ) : 0 ;
const hasFinancialRow = ! ! ( contractEx || contractIn || num ( row [ layout . paymentAmount ] ) || num ( row [ layout . remainingAmount ] ) ) ;
const hasMetaRow = ! ! ( String ( row [ layout . invoiceDate ] ? ? "" ) . trim ( ) || String ( row [ layout . paymentDate ] ? ? "" ) . trim ( ) || String ( row [ layout . progress ] ? ? "" ) . trim ( ) || detail ) ;
if ( detail && hasMetaRow ) {
currentItem = createOutsourceItem ( entry , vendor , currentName , detail , row , layout ) ;
const payment = buildOutsourcePayment ( currentItem , row , layout ) ;
if ( payment ) currentItem . payments . push ( payment ) ;
continue ;
}
if ( ! currentItem ) {
if ( ! ( hasFinancialRow || hasMetaRow ) ) continue ;
currentItem = createOutsourceItem ( entry , vendor , currentName , detail || "\uC678\uC8FC \uACC4\uC57D" , row , layout ) ;
} else {
if ( contractEx > 0 ) currentItem . contractEx += contractEx ;
if ( contractIn > 0 ) currentItem . contractIn += contractIn ;
if ( ! currentItem . progress ) currentItem . progress = String ( row [ layout . progress ] ? ? "" ) . trim ( ) ;
}
const payment = buildOutsourcePayment ( currentItem , row , layout ) ;
if ( payment ) currentItem . payments . push ( payment ) ;
}
}
function parseOutsourceSheets ( workbook ) {
const map = new Map ( ) ;
const names = ( workbook && workbook . SheetNames ) || [ ] ;
for ( const sheetName of names ) {
if ( ! String ( sheetName || "" ) . startsWith ( "\uC678\uC8FC" ) ) continue ;
const sheet = workbook . Sheets [ sheetName ] ;
if ( ! sheet ) continue ;
const rows = XLSX . utils . sheet _to _json ( sheet , { header : 1 , raw : false , defval : "" } ) ;
parseOutsourceRows ( rows , sheetName , map ) ;
}
for ( const entry of map . values ( ) ) {
entry . items = entry . items . map ( finalizeOutsourceItem ) . filter ( item => item . contractEx || item . contractIn || item . paidEx || item . paidIn || item . remainingEx || item . remainingIn || item . detail || item . payments . length ) ;
entry . contract = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . contractEx || 0 ) , 0 ) ) ;
entry . contractIn = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . contractIn || 0 ) , 0 ) ) ;
entry . paid = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . paidEx || 0 ) , 0 ) ) ;
entry . paidIn = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . paidIn || 0 ) , 0 ) ) ;
entry . remaining = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . remainingEx || 0 ) , 0 ) ) ;
entry . remainingIn = Math . round ( entry . items . reduce ( ( sum , item ) => sum + ( item . remainingIn || 0 ) , 0 ) ) ;
}
return map ;
}
function resolveOutsourceEntry ( record , outsourceMap ) {
const fullKey = normalizeProjectKey ( record . name || "" ) ;
const baseKey = normalizeProjectBase ( record . name || "" ) ;
if ( fullKey && outsourceMap . has ( fullKey ) ) return outsourceMap . get ( fullKey ) ;
if ( baseKey && outsourceMap . has ( baseKey ) ) return outsourceMap . get ( baseKey ) ;
let best = null , bestScore = 0 ;
for ( const entry of outsourceMap . values ( ) ) {
const entryFull = String ( entry && entry . key || "" ) ;
const entryBase = String ( entry && entry . baseKey || normalizeProjectBase ( entry && entry . name || "" ) ) ;
for ( const candidate of [ entryFull , entryBase ] ) {
if ( ! candidate ) continue ;
const matched = ( fullKey && fullKey . includes ( candidate ) ) || ( candidate && fullKey && candidate . includes ( fullKey ) ) || ( baseKey && baseKey . includes ( candidate ) ) || ( candidate && baseKey && candidate . includes ( baseKey ) ) ;
if ( matched && candidate . length > bestScore ) {
best = entry ;
bestScore = candidate . length ;
}
}
}
return best ;
}
function attachOutsourceCosts ( records , outsourceMap ) {
return ( records || [ ] ) . map ( record => {
const entry = resolveOutsourceEntry ( record , outsourceMap ) ;
const outsourceCost = entry ? Math . round ( entry . contract || 0 ) : 0 ;
const outsourcePaid = entry ? Math . round ( entry . paid || 0 ) : 0 ;
const outsourceRemaining = entry ? Math . round ( entry . remaining || 0 ) : 0 ;
const outsourceCostIn = entry ? Math . round ( entry . contractIn || 0 ) : 0 ;
const outsourcePaidIn = entry ? Math . round ( entry . paidIn || 0 ) : 0 ;
const outsourceRemainingIn = entry ? Math . round ( entry . remainingIn || 0 ) : 0 ;
const outsourceVendors = entry ? Array . from ( entry . vendors ) : [ ] ;
const outsourceItems = entry && Array . isArray ( entry . items ) ? entry . items . slice ( ) : [ ] ;
return {
... record ,
outsourceCost ,
outsourcePaid ,
outsourceRemaining ,
outsourceCostIn ,
outsourcePaidIn ,
outsourceRemainingIn ,
outsourceVendors ,
outsourceVendorText : summarizeOutsourceVendors ( outsourceVendors ) ,
outsourceItems
} ;
} ) ;
}
function parseLedgerRecords ( R ) {
if ( R . length && R [ 0 ] . length ) R [ 0 ] [ 0 ] = String ( R [ 0 ] [ 0 ] || "" ) . replace ( /^\uFEFF/ , "" ) ;
const h = hs ( R ) ; if ( h < 0 ) throw new Error ( "헤더를 찾지 못했습니다." ) ;
ch ( R [ h ] , R [ h + 1 ] || [ ] ) ;
const I = { cat : 1 , corp : 4 , code : 5 , name : 6 , pay : 7 , yn : 8 , order : 9 , pm : 10 , status : 11 , client : 12 , split : 13 , cDate : 14 , sDate : 15 , eDate : 17 , cSup : 18 , cVat : 19 , cTot : 20 , issueDate : 21 , colDate : 22 , sSup : 23 , sVat : 24 , sTot : 25 , col : 26 , recv : 27 , r : 28 , note : 29 , cmCo : 30 , cmNm : 31 , cmDp : 32 , cmPh : 33 , cmEm : 34 , dmCo : 35 , dmNm : 36 , dmDp : 37 , dmPh : 38 , dmEm : 39 } ;
const out = [ ] ; let current = null ;
for ( const row of R . slice ( h + 2 ) ) {
const x = {
cat : textAt ( row , I . cat ) , corp : textAt ( row , I . corp ) , code : textAt ( row , I . code ) , name : textAt ( row , I . name ) , pay : textAt ( row , I . pay ) ,
yn : textAt ( row , I . yn ) , order : textAt ( row , I . order ) , pm : textAt ( row , I . pm ) , status : textAt ( row , I . status ) , client : textAt ( row , I . client ) ,
split : textAt ( row , I . split ) , cDate : textAt ( row , I . cDate ) , sDate : textAt ( row , I . sDate ) , eDate : textAt ( row , I . eDate ) ,
cSup : moneyAt ( row , I . cSup ) , cVat : moneyAt ( row , I . cVat ) , cTot : moneyAt ( row , I . cTot ) , issueDate : textAt ( row , I . issueDate ) , colDate : textAt ( row , I . colDate ) ,
sSup : moneyAt ( row , I . sSup ) , sVat : moneyAt ( row , I . sVat ) , sTot : moneyAt ( row , I . sTot ) , col : moneyAt ( row , I . col ) , recv : moneyAt ( row , I . recv ) , rateRaw : textAt ( row , I . r ) ,
note : textAt ( row , I . note ) , cmCo : textAt ( row , I . cmCo ) , cmNm : textAt ( row , I . cmNm ) , cmDp : textAt ( row , I . cmDp ) , cmPh : textAt ( row , I . cmPh ) , cmEm : textAt ( row , I . cmEm ) ,
dmCo : textAt ( row , I . dmCo ) , dmNm : textAt ( row , I . dmNm ) , dmDp : textAt ( row , I . dmDp ) , dmPh : textAt ( row , I . dmPh ) , dmEm : textAt ( row , I . dmEm )
} ;
if ( ! x . cTot ) x . cTot = x . cSup + x . cVat ;
if ( ! x . sTot ) x . sTot = x . sSup + x . sVat ;
if ( ! x . recv ) x . recv = Math . max ( 0 , x . sTot - x . col ) ;
x . rate = rate ( x . rateRaw , x . col , x . sTot ) ;
const isProject = ! ! ( x . code || ( x . name && ( x . cat || x . corp || x . client || x . yn || x . order || x . pm ) ) ) ;
const isPayment = ! isProject && ! ! ( x . pay || x . name || x . issueDate || x . colDate || x . sSup || x . sVat || x . sTot || x . col || x . recv ) ;
if ( isProject ) {
if ( ! x . name && ! x . code ) continue ;
if ( current ) out . push ( finalizeProject ( current ) ) ;
current = { ... x , payments : [ ] } ;
continue ;
}
if ( isPayment && current ) current . payments . push ( paymentRecord ( x , x . pay ) ) ;
}
if ( current ) out . push ( finalizeProject ( current ) ) ;
return out ;
}
function extractLedgerTotals ( rows ) {
const indexes = { contract : 20 , collected : 26 , receivable : 27 , rate : 28 } ;
let summaryRow = null ;
for ( let i = ( rows || [ ] ) . length - 1 ; i >= 0 ; i -- ) {
const row = rows [ i ] || [ ] ;
const hasSummaryLabel = row . some ( cell => String ( cell ? ? "" ) . replace ( /\s+/g , "" ) . includes ( "합계" ) ) ;
if ( hasSummaryLabel ) { summaryRow = row ; break ; }
}
if ( ! summaryRow ) return null ;
const contract = num ( summaryRow [ indexes . contract ] ) ;
const collected = num ( summaryRow [ indexes . collected ] ) ;
const receivable = num ( summaryRow [ indexes . receivable ] ) ;
const rateRaw = String ( summaryRow [ indexes . rate ] ? ? "" ) . trim ( ) ;
if ( ! ( contract || collected || receivable || rateRaw ) ) return null ;
const totalBase = collected + receivable ;
return { contract , collected , receivable , rate : rate ( rateRaw , collected , totalBase ) } ;
}
function parseLedger ( txt ) {
const rows = parseCsv ( txt ) ;
return { records : parseLedgerRecords ( rows ) , totals : extractLedgerTotals ( rows ) } ;
}
function parseLedgerExcel ( buf ) {
if ( typeof XLSX === "undefined" ) throw new Error ( "XLSX 라이브러리를 불러오지 못했습니다." ) ;
const wb = XLSX . read ( buf , { type : "array" , cellDates : false } ) ;
const outsourceMap = parseOutsourceSheets ( wb ) ;
const names = wb . SheetNames || [ ] ;
const preferredNames = names . filter ( name => String ( name || "" ) . includes ( "공유사업관리대장" ) ) ;
const candidateNames = preferredNames . length ? preferredNames : [ ... names ] ;
let bestRecords = null ;
let bestSheet = "" ;
let bestScore = - 1 ;
let bestTotals = null ;
for ( const name of candidateNames ) {
try {
const sheet = wb . Sheets [ name ] ;
const rows = XLSX . utils . sheet _to _json ( sheet , { header : 1 , raw : false , defval : "" } ) ;
const normalized = ( rows || [ ] ) . map ( r => Array . isArray ( r ) ? r . map ( v => String ( v ? ? "" ) ) : [ ] ) ;
const records = attachOutsourceCosts ( parseLedgerRecords ( normalized ) , outsourceMap ) ;
if ( ! records . length ) continue ;
const totals = extractLedgerTotals ( normalized ) ;
const bonus = String ( name || "" ) . includes ( "공유사업관리대장" ) ? 1000000 : /사업관리대장/i . test ( String ( name || "" ) ) ? 10000 : 0 ;
const score = records . length + bonus ;
if ( score > bestScore ) {
bestScore = score ;
bestRecords = records ;
bestSheet = name ;
bestTotals = totals ;
}
} catch ( _ ) {
// try next sheet
}
}
if ( ! bestRecords ) throw new Error ( "엑셀에서 사업관리대장 헤더를 찾지 못했습니다." ) ;
return { records : bestRecords , sheetName : bestSheet , totals : bestTotals } ;
}
function decode ( buf ) { const u = new TextDecoder ( "utf-8" ) . decode ( buf ) ; let e = "" ; try { e = new TextDecoder ( "euc-kr" ) . decode ( buf ) ; } catch ( _ ) { e = u ; } return score ( e ) > score ( u ) ? e : u ; }
function sumRows ( rows ) { return rows . reduce ( ( a , r ) => ( a . c += r . cTot || 0 , a . s += r . sTot || 0 , a . col += r . col || 0 , a . recv += r . recv || 0 , a ) , { c : 0 , s : 0 , col : 0 , recv : 0 } ) ; }
function isSettledRow ( r ) {
const noSales = ( r . sTot || 0 ) <= 0 && ( r . col || 0 ) <= 0 && ( r . recv || 0 ) <= 0 ;
const statusDone = String ( r . status || "" ) . includes ( "완료" ) ;
const coopDone = String ( r . yn || "" ) . includes ( "업무협조" ) && statusDone && noSales ;
return coopDone || ( statusDone && Math . round ( r . recv || 0 ) <= 0 && ( r . rate || 0 ) >= 100 ) ;
}
function hasActiveDashboardFilters ( ) {
return ! ! String ( E . search . value || "" ) . trim ( ) || FILTER _KEYS . some ( key => ! ! S . filters [ key ] ) ;
}
function codeFilterLabel ( r ) { return r . cat || "-" ; }
function periodFilterLabel ( r ) { return ` ${ d ( r . sDate ) } ~ ${ d ( r . eDate ) } ` ; }
function outsourceFilterLabel ( r ) { return r . outsourceCost ? won ( r . outsourceCost ) : "-" ; }
function amountFilterLabel ( r ) { return won ( r . cSup ) ; }
function collectedFilterLabel ( r ) { return won ( r . col ) ; }
function rateFilterLabel ( r ) { return r . rate . toFixed ( 2 ) + "%" ; }
function uniqueFilterValues ( rows , mapFn ) {
const seen = new Set ( ) , out = [ ] ;
for ( const row of rows ) {
const value = String ( mapFn ( row ) || "" ) . trim ( ) ;
if ( ! value || seen . has ( value ) ) continue ;
seen . add ( value ) ;
out . push ( value ) ;
}
return out ;
}
function filterDefinitions ( ) {
return [
{ key : "code" , map : codeFilterLabel } ,
{ key : "name" , map : r => r . name || "-" } ,
{ key : "corp" , map : r => r . corp || "-" } ,
{ key : "status" , map : r => r . status || "-" } ,
{ key : "outsource" , map : outsourceFilterLabel } ,
{ key : "amount" , map : amountFilterLabel } ,
{ key : "collected" , map : collectedFilterLabel } ,
{ key : "rate" , map : rateFilterLabel }
] ;
}
function closeFilterMenus ( ) {
Object . values ( E . filterMenus ) . forEach ( menu => menu . classList . remove ( "open" ) ) ;
Object . values ( E . filterButtons ) . forEach ( btn => btn . classList . remove ( "open" ) ) ;
}
function updateFilterButtons ( ) {
FILTER _KEYS . forEach ( key => {
const btn = E . filterButtons [ key ] ;
if ( ! btn ) return ;
const active = ! ! S . filters [ key ] ;
btn . classList . toggle ( "active" , active ) ;
btn . title = active ? ` ${ btn . dataset . label } : ${ S . filters [ key ] } ` : btn . dataset . label || "" ;
const mark = btn . querySelector ( ".th-mark" ) ;
if ( mark ) mark . textContent = active ? "•" : "" ;
} ) ;
}
function renderFilterMenu ( key , values ) {
const menu = E . filterMenus [ key ] ;
if ( ! menu ) return ;
const current = String ( S . filters [ key ] || "" ) ;
menu . innerHTML = ` <button type="button" class="th-option ${ ! current ? " active" : "" } " data-filter-value="">전체</button> ` + values . map ( v => ` <button type="button" class="th-option ${ current === v ? " active" : "" } " data-filter-value=" ${ escAttr ( v ) } "> ${ esc ( v ) } </button> ` ) . join ( "" ) ;
}
function syncColumnFilters ( rows ) {
filterDefinitions ( ) . forEach ( def => {
const values = uniqueFilterValues ( rows , def . map ) ;
if ( S . filters [ def . key ] && ! values . includes ( S . filters [ def . key ] ) ) delete S . filters [ def . key ] ;
renderFilterMenu ( def . key , values ) ;
} ) ;
updateFilterButtons ( ) ;
}
function toggleFilterMenu ( key ) {
const menu = E . filterMenus [ key ] , btn = E . filterButtons [ key ] ;
if ( ! menu || ! btn ) return ;
const willOpen = ! menu . classList . contains ( "open" ) ;
closeFilterMenus ( ) ;
if ( willOpen ) {
menu . classList . add ( "open" ) ;
btn . classList . add ( "open" ) ;
}
}
function setFilterValue ( key , value ) {
if ( value ) S . filters [ key ] = value ;
else delete S . filters [ key ] ;
syncColumnFilters ( S . all ) ;
closeFilterMenus ( ) ;
filter ( ) ;
}
function matchesColumnFilters ( r ) {
if ( S . filters . code && codeFilterLabel ( r ) !== S . filters . code ) return false ;
if ( S . filters . name && ( r . name || "-" ) !== S . filters . name ) return false ;
if ( S . filters . corp && ( r . corp || "-" ) !== S . filters . corp ) return false ;
if ( S . filters . status && ( r . status || "-" ) !== S . filters . status ) return false ;
if ( S . filters . outsource && outsourceFilterLabel ( r ) !== S . filters . outsource ) return false ;
if ( S . filters . amount && amountFilterLabel ( r ) !== S . filters . amount ) return false ;
if ( S . filters . collected && collectedFilterLabel ( r ) !== S . filters . collected ) return false ;
if ( S . filters . rate && rateFilterLabel ( r ) !== S . filters . rate ) return false ;
return true ;
}
function setText ( id , v ) { const el = G ( id ) ; if ( el ) el . textContent = v || "-" ; }
function renderPaymentsHtml ( payments ) {
if ( ! payments || ! payments . length ) return '<div class="pay-empty">대금 차수 정보가 없습니다.</div>' ;
return payments . map ( p => ` <div class="pay-item"><div class="pay-head"><div class="pay-name"> ${ esc ( p . pay || "미입력" ) } </div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap"> ${ esc ( p . status || "-" ) } </div></div><div class="pay-meta"><span>발행일 ${ esc ( d ( p . issueDate ) ) } </span><span>수금일 ${ esc ( d ( p . collectDate ) ) } </span><span>공급가액 ${ esc ( won ( p . supply ) ) } </span><span>수금금액 ${ esc ( won ( p . collected ) ) } </span></div> ${ p . note ? ` <div class="pay-note">비고: ${ esc ( p . note ) } </div> ` : "" } </div> ` ) . join ( "" ) ;
}
function renderOutsourcePayments ( payments ) {
const list = ( payments || [ ] ) . filter ( payment => payment && ( payment . label || payment . note || payment . invoiceDate || payment . paymentDate || payment . paymentEx || payment . remainingEx || payment . paymentIn || payment . remainingIn ) ) ;
if ( ! list . length ) return "" ;
return ` <div class="out-payments"> ${ list . map ( ( payment , index ) => ` <div class="out-payment"><div class="out-payment-head"><span> ${ esc ( payment . label || ` \u C9C0 \u AE09 ${ index + 1 } ` ) } </span><span> ${ esc ( payment . paymentDate ? d ( payment . paymentDate ) : "-" ) } </span></div><div class="out-payment-meta"><span> \u ACC4 \u C0B0 \u C11C \u C77C \u C790 ${ esc ( payment . invoiceDate ? d ( payment . invoiceDate ) : "-" ) } </span><span> \u C9C0 \u AE09 \u AE08 \u C561 ${ esc ( payment . paymentEx ? won ( payment . paymentEx ) : "-" ) } </span><span> \u C794 \u C5EC \u AE08 \u C561 ${ esc ( payment . remainingEx || payment . remainingEx === 0 ? won ( payment . remainingEx ) : "-" ) } </span></div> ${ payment . note ? ` <div class="out-note"> \u BE44 \u ACE0: ${ esc ( payment . note ) } </div> ` : "" } </div> ` ) . join ( "" ) } </div> ` ;
}
function countOutsourceStages ( r ) {
return ( r . outsourceItems || [ ] ) . reduce ( ( sum , item ) => {
const stages = ( item . payments || [ ] ) . filter ( payment => payment && ( payment . label || payment . note || payment . invoiceDate || payment . paymentDate || payment . paymentEx || payment . remainingEx || payment . paymentIn || payment . remainingIn ) ) ;
return sum + ( stages . length || 1 ) ;
} , 0 ) ;
}
function summarizeOutsourceCounts ( r ) {
const vendors = ( r . outsourceVendors || [ ] ) . length ;
const contracts = ( r . outsourceItems || [ ] ) . length ;
const stages = countOutsourceStages ( r ) ;
const parts = [ ] ;
if ( vendors ) parts . push ( ` 외주처 ${ vendors . toLocaleString ( "ko-KR" ) } 곳 ` ) ;
if ( contracts ) parts . push ( ` 계약 ${ contracts . toLocaleString ( "ko-KR" ) } 건 ` ) ;
if ( stages ) parts . push ( ` 지급단계 ${ stages . toLocaleString ( "ko-KR" ) } 건 ` ) ;
return parts . join ( " · " ) || "외주 내역 없음" ;
}
function renderOutsourceHtml ( items ) {
if ( ! items || ! items . length ) return '<div class="pay-empty">외주 상세 정보가 없습니다.</div>' ;
return items . map ( item => {
const stageCount = ( item . payments || [ ] ) . filter ( payment => payment && ( payment . label || payment . note || payment . invoiceDate || payment . paymentDate || payment . paymentEx || payment . remainingEx || payment . paymentIn || payment . remainingIn ) ) . length ;
const stageText = stageCount ? ` 지급단계 ${ stageCount . toLocaleString ( "ko-KR" ) } 건 ` : "지급내역 없음" ;
const periodText = ( d ( item . startDate ) === "-" && d ( item . endDate ) === "-" ) ? "-" : ` ${ d ( item . startDate ) } ~ ${ d ( item . endDate ) } ` ;
return ` <div class="out-item"><div class="out-head"><div><div class="out-vendor"> ${ esc ( item . vendor || "외주" ) } </div><div class="out-name"> ${ esc ( item . detail || "-" ) } </div></div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap"> ${ esc ( item . progress || stageText ) } </div></div><div class="out-meta"><span>계약기간 ${ esc ( periodText ) } </span><span>계약금액 ${ esc ( item . contractEx ? won ( item . contractEx ) : "-" ) } </span><span>지급금액 ${ esc ( item . paidEx || item . paidEx === 0 ? won ( item . paidEx ) : "-" ) } </span><span>잔여금액 ${ esc ( item . remainingEx || item . remainingEx === 0 ? won ( item . remainingEx ) : "-" ) } </span><span>계산서일자 ${ esc ( item . invoiceDate ? d ( item . invoiceDate ) : "-" ) } </span><span> ${ esc ( stageText ) } </span></div> ${ item . note ? ` <div class="out-note">비고: ${ esc ( item . note ) } </div> ` : "" } ${ renderOutsourcePayments ( item . payments || [ ] ) } </div> ` ;
} ) . join ( "" ) ;
}
function renderContactCompact ( label , name , company , dept , phone , email ) {
return ` <div class="summary-card"><div class="summary-label"> ${ esc ( label ) } </div><div style="margin-top:6px;font-size:16px;font-weight:900"> ${ esc ( name || "-" ) } </div><div class="summary-note"> ${ esc ( [ company || "-" , dept || "-" ] . join ( " · " ) ) } </div><div class="summary-note"> ${ esc ( ` 전화 ${ phone || "-" } / 메일 ${ email || "-" } ` ) } </div></div> ` ;
}
function renderOutsourceBoard ( r ) {
const items = r . outsourceItems || [ ] ;
if ( ! items . length ) {
return ` <div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">등록된 외주 데이터 없음</div></div></div><div class="ledger-pill">총 계약 0원</div></div><div class="ledger-empty">외주 상세 정보가 없습니다.</div></div> ` ;
}
return ` <div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 계약 ${ esc ( r . outsourceCost ? won ( r . outsourceCost ) : "-" ) } </div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>외주처 / 계약명</th><th>계약기간</th><th style="text-align:right">계약금액</th><th style="text-align:right">지급금액</th><th style="text-align:right">잔여금액</th><th>진행현황</th><th>비고</th></tr></thead><tbody> ${ items . map ( item => { const periodText = ( d ( item . startDate ) === "-" && d ( item . endDate ) === "-" ) ? "-" : ` ${ d ( item . startDate ) } ~ ${ d ( item . endDate ) } ` ; const noteLines = ( item . payments || [ ] ) . map ( payment => { const label = String ( payment . label || "" ) . trim ( ) ; const note = String ( payment . note || "" ) . trim ( ) ; if ( ! label && ! note ) return "" ; if ( label && note ) return ` ${ label } : ${ note } ` ; return label || note ; } ).filter(Boolean);if(item.note) noteLines.unshift(item.note);return ` < tr > < td > < span class = "ledger-main" > $ { esc ( item . vendor || "외주" ) } < / s p a n > < s p a n c l a s s = " l e d g e r - m u t e d " > $ { e s c ( i t e m . d e t a i l | | " - " ) } < / s p a n > < / t d > < t d > < s p a n c l a s s = " l e d g e r - m a i n " > $ { e s c ( p e r i o d T e x t ) } < / s p a n > < / t d > < t d c l a s s = " l e d g e r - a m o u n t " > $ { e s c ( i t e m . c o n t r a c t E x ? w o n ( i t e m . c o n t r a c t E x ) : " - " ) } < / t d > < t d c l a s s = " l e d g e r - a m o u n t " > $ { e s c ( i t e m . p a i d E x | | i t e m . p a i d E x = = = 0 ? w o n ( i t e m . p a i d E x ) : " - " ) } < / t d > < t d c l a s s = " l e d g e r - a m o u n t " > $ { e s c ( i t e m . r e m a i n i n g E x | | i t e m . r e m a i n i n g E x = = = 0 ? w o n ( i t e m . r e m a i n i n g E x ) : " - " ) } < / t d > < t d > < s p a n c l a s s = " l e d g e r - n o t e " > $ { e s c ( i t e m . p r o g r e s s | | " - " ) } < / s p a n > < / t d > < t d > < s p a n c l a s s = " l e d g e r - n o t e " > $ { e s c ( n o t e L i n e s . j o i n ( " / " ) | | " - " ) } < / s p a n > < / t d > < / t r > ` ; } ) . j o i n ( " " ) } < / t b o d y > < / t a b l e > < / d i v > < / d i v > ` ;
}
function renderCollectionBoard ( r ) {
const payments = r . payments && r . payments . length ? r . payments : [ { pay : r . pay || "-" , issueDate : r . issueDate || "" , collectDate : r . collectDateSummary || r . colDate || "" , supply : r . sSup || 0 , collected : r . col || 0 , receivable : r . recv || Math . max ( 0 , ( r . sTot || 0 ) - ( r . col || 0 ) ) , rate : r . rate || 0 , note : r . note || "" , status : r . status || "-" } ] ;
return ` <div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 수금 ${ esc ( won ( r . col ) ) } </div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>발행 / 수금일</th><th>구분</th><th style="text-align:right">공급가액</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th style="text-align:right">수금율</th><th>비고</th></tr></thead><tbody> ${ payments . map ( payment => { const dateParts = [ payment . issueDate ? ` 발행 ${ d ( payment . issueDate ) } ` : "" , payment . collectDate ? ` 수금 ${ d ( payment . collectDate ) } ` : "" ] . filter ( Boolean ) ; const noteParts = [ ] ; if ( payment . status ) noteParts . push ( payment . status ) ; if ( payment . note ) noteParts . push ( payment . note ) ; return ` <tr><td><span class="ledger-main"> ${ esc ( dateParts [ 0 ] || "-" ) } </span><span class="ledger-muted"> ${ esc ( dateParts [ 1 ] || "수금일 없음" ) } </span></td><td><span class="ledger-main"> ${ esc ( payment . pay || "미입력" ) } </span></td><td class="ledger-amount"> ${ esc ( won ( payment . supply || 0 ) ) } </td><td class="ledger-amount"> ${ esc ( won ( payment . collected || 0 ) ) } </td><td class="ledger-amount"> ${ esc ( won ( payment . receivable || 0 ) ) } </td><td class="ledger-amount"> ${ esc ( ( ( payment . rate || 0 ) . toFixed ? payment . rate . toFixed ( 2 ) : Number ( payment . rate || 0 ) . toFixed ( 2 ) ) + "%" ) } </td><td><span class="ledger-note"> ${ esc ( noteParts . join ( " / " ) || "-" ) } </span></td></tr> ` ; } ).join("")}</tbody></table></div></div> ` ;
}
function renderProjectInline ( r ) {
const payments = r . payments || [ ] ;
const latestCollect = d ( r . collectDateSummary || r . colDate ) ;
const collectCountText = payments . length ? ` 차수 ${ payments . length . toLocaleString ( "ko-KR" ) } 건 ` : "수금 내역 없음" ;
const outsourceCountText = summarizeOutsourceCounts ( r ) ;
const hasOutsource = ( r . outsourceItems || [ ] ) . length > 0 || ( r . outsourceCost || 0 ) > 0 || ( r . outsourcePaid || 0 ) > 0 || ( r . outsourceRemaining || 0 ) > 0 ;
const summaryCards = [
` <div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value"> ${ esc ( won ( r . cSup ) ) } </div><div class="summary-note">VAT 별도</div></div> ` ,
` <div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value"> ${ esc ( won ( r . col ) ) } </div><div class="summary-note"> ${ esc ( latestCollect === "-" ? "수금일 없음" : ` 최종 수금일 ${ latestCollect } ` ) } </div></div> ` ,
` <div class="summary-card"><div class="summary-label">수금율</div><div class="summary-value"> ${ esc ( r . rate . toFixed ( 2 ) + "%" ) } </div><div class="summary-note"> ${ esc ( collectCountText ) } </div></div> `
] . filter ( Boolean ) . join ( "" ) ;
const bottomNotes = [
` <div class="summary-note">미수금액 ${ esc ( won ( r . recv ) ) } </div> `
] . join ( "" ) ;
const boards = [
hasOutsource ? renderOutsourceBoard ( r ) : "" ,
renderCollectionBoard ( r )
] . filter ( Boolean ) . join ( "" ) ;
return ` <div class="inline-panel"><div class="project-head"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv"> ${ esc ( r . corp || "-" ) } </div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv"> ${ esc ( r . client || "-" ) } </div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv"> ${ esc ( r . order || "-" ) } </div></div><div class="kv"><div class="kvk">PM</div><div class="kvv"> ${ esc ( r . pm || "-" ) } </div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px"> ${ renderContactCompact ( "계약 / 청구 담당자" , r . cmNm , r . cmCo , r . cmDp , r . cmPh , r . cmEm ) } ${ renderContactCompact ( "부서 담당자" , r . dmNm , r . dmCo , r . dmDp , r . dmPh , r . dmEm ) } </div></div><div class="inline-card"><div class="summary-grid"> ${ summaryCards } </div><div style="margin-top:10px" class="progress"><div class="bar" style="width: ${ Math . max ( 0 , Math . min ( 100 , r . rate || 0 ) ) } %"></div></div><div style="display:flex;justify-content:space-between;gap:10px;margin-top:10px"> ${ bottomNotes } </div></div></div><div class="ledger-stack"> ${ boards } </div></div> ` ;
}
function closeAllModals ( ) {
E . collectModal . classList . remove ( "show" ) ;
E . outsourceModal . classList . remove ( "show" ) ;
}
function toggleInlineDetail ( r ) {
const key = rowKey ( r ) ;
S . expanded . key = S . expanded . key === key ? "" : key ;
render ( ) ;
}
function openCollectionModal ( r ) {
setText ( "mCat" , r . cat || "미분류" ) ; G ( "mCat" ) . classList . toggle ( "ok" , ( r . status || "" ) . includes ( "완료" ) ) ; setText ( "mTitle" , r . name || "-" ) ; setText ( "mSub" , "Project Code: " + ( r . code || "-" ) + " · 계약법인: " + ( r . corp || "-" ) ) ;
setText ( "mClient" , r . client || "-" ) ; setText ( "mOrder" , r . order || "-" ) ; setText ( "mSplit" , r . split || "-" ) ; setText ( "mStartDate" , d ( r . sDate ) ) ; setText ( "mEndDate" , d ( r . eDate ) ) ; setText ( "mPayType" , r . pay || "-" ) ; G ( "mPayItems" ) . innerHTML = renderPaymentsHtml ( r . payments || [ ] ) ;
setText ( "mContractTotal" , won ( r . cTot ) ) ; setText ( "mContractSupply" , "공급가액: " + won ( r . cSup ) ) ; setText ( "mCollected" , won ( r . col ) ) ; setText ( "mCollectDate" , ( r . payments && r . payments . length > 1 ? "최근 수금일: " : "수금일: " ) + d ( r . collectDateSummary || r . colDate ) ) ; setText ( "mRate" , r . rate . toFixed ( 2 ) + "%" ) ; setText ( "mReceivable" , won ( r . recv ) ) ; G ( "mRateBar" ) . style . width = Math . max ( 0 , Math . min ( 100 , r . rate || 0 ) ) + "%" ;
setText ( "mCmName" , r . cmNm || "-" ) ; setText ( "mCmOrg" , ( r . cmCo || "-" ) + " · " + ( r . cmDp || "-" ) ) ; setText ( "mCmPhone" , "전화: " + ( r . cmPh || "-" ) ) ; setText ( "mCmEmail" , "메일: " + ( r . cmEm || "-" ) ) ;
setText ( "mDmName" , r . dmNm || "-" ) ; setText ( "mDmOrg" , ( r . dmCo || "-" ) + " · " + ( r . dmDp || "-" ) ) ; setText ( "mDmPhone" , "전화: " + ( r . dmPh || "-" ) ) ; setText ( "mDmEmail" , "메일: " + ( r . dmEm || "-" ) ) ;
closeAllModals ( ) ;
E . collectModal . classList . add ( "show" ) ;
}
function openOutsourceModal ( r ) {
setText ( "oTitle" , r . name || "-" ) ;
setText ( "oSub" , "Project Code: " + ( r . code || "-" ) + " · PM: " + ( r . pm || "-" ) ) ;
setText ( "oCorp" , r . corp || "-" ) ;
setText ( "oClient" , r . client || "-" ) ;
setText ( "oVendors" , r . outsourceVendorText || "-" ) ;
setText ( "oTotal" , r . outsourceCost ? won ( r . outsourceCost ) : "-" ) ;
setText ( "oCount" , ( r . outsourceItems || [ ] ) . length ? ` ${ ( r . outsourceItems || [ ] ) . length . toLocaleString ( "ko-KR" ) } 건 ` : "0건" ) ;
setText ( "oPeriod" , r . periodText || "-" ) ;
setText ( "oTotalHero" , r . outsourceCost ? won ( r . outsourceCost ) : "-" ) ;
setText ( "oTotalHint" , ( r . outsourceItems || [ ] ) . length ? "시트별 외주 상세 내역 합산" : "외주 상세 정보가 없습니다." ) ;
G ( "oItems" ) . innerHTML = renderOutsourceHtml ( r . outsourceItems || [ ] ) ;
closeAllModals ( ) ;
E . outsourceModal . classList . add ( "show" ) ;
}
function outsourceSummaryText ( r ) {
const contracts = ( r . outsourceItems || [ ] ) . length ;
const stages = countOutsourceStages ( r ) ;
const parts = [ ] ;
if ( contracts ) parts . push ( ` 계약 ${ contracts . toLocaleString ( "ko-KR" ) } 건 ` ) ;
if ( stages ) parts . push ( ` 지급단계 ${ stages . toLocaleString ( "ko-KR" ) } 건 ` ) ;
if ( parts . length ) return parts . join ( " · " ) ;
return "-" ;
}
function render ( ) {
const rows = S . rows , t = sumRows ( rows ) , viewRows = rows . slice ( ) . sort ( ( a , b ) => { const as = isSettledRow ( a ) , bs = isSettledRow ( b ) ; if ( as !== bs ) return as ? 1 : - 1 ; return ( b . recv || 0 ) - ( a . recv || 0 ) ; } ) ;
const useSheetTotals = ! ! ( S . totals && ! hasActiveDashboardFilters ( ) ) ;
const totalContract = useSheetTotals ? S . totals . contract : t . c ;
const totalCollected = useSheetTotals ? S . totals . collected : t . col ;
const totalReceivable = useSheetTotals ? S . totals . receivable : t . recv ;
const totalRate = useSheetTotals ? S . totals . rate : rate ( "" , totalCollected , totalCollected + totalReceivable ) ;
S . viewRows = viewRows ;
E . cards . innerHTML = [ [ "총 프로젝트수" , rows . length . toLocaleString ( "ko-KR" ) + " 건" ] , [ "총 계약금" , won ( totalContract ) ] , [ "총 수금금액" , won ( totalCollected ) ] , [ "총 미수금액" , won ( totalReceivable ) ] , [ "총 수금율" , totalRate . toFixed ( 2 ) + "%" ] ] . map ( c => ` <div class="card"><div class="k"> ${ esc ( c [ 0 ] ) } </div><div class="v"> ${ esc ( c [ 1 ] ) } </div></div> ` ) . join ( "" ) ;
E . tbody . innerHTML = viewRows . map ( ( r , i ) => {
const key = rowKey ( r ) ;
const detailOpen = S . expanded . key === key ;
const detailHtml = detailOpen ? renderProjectInline ( r ) : "" ;
return ` <tr data-i=" ${ i } " class=" ${ isSettledRow ( r ) ? "settled" : "" } "><td><div class="badge"> ${ esc ( r . cat || "-" ) } </div><div class="subline">ID: ${ esc ( r . code || "-" ) } </div></td><td><div class="name"> ${ esc ( r . name || "-" ) } </div><div class="subline"> ${ esc ( r . periodText || "-" ) } </div></td><td><div> ${ esc ( r . corp || "-" ) } </div></td><td><div class="badge ${ ( r . status || "" ) . includes ( "완료" ) ? "ok" : "" } "> ${ esc ( r . status || "-" ) } </div><div class="subline"> ${ esc ( r . yn || "-" ) } </div></td><td class="num"><strong> ${ esc ( r . outsourceCost ? won ( r . outsourceCost ) : "-" ) } </strong></td><td class="num"><strong> ${ esc ( won ( r . cSup ) ) } </strong></td><td class="num"><strong> ${ esc ( won ( r . col ) ) } </strong></td><td class="num"><strong style="color: ${ isSettledRow ( r ) ? "#94a3b8" : "#2563eb" } "> ${ esc ( r . rate . toFixed ( 2 ) + "%" ) } </strong></td></tr> ${ detailHtml ? ` <tr class="detail-row"><td class="detail-cell" colspan="8"> ${ detailHtml } </td></tr> ` : "" } ` ;
} ) . join ( "" ) ;
E . empty . style . display = rows . length ? "none" : "block" ;
const settledCount = S . all . filter ( isSettledRow ) . length ;
E . status . textContent = S . all . length ? ` 로드 완료: ${ S . all . length . toLocaleString ( "ko-KR" ) } 건 ${ S . file ? ` · 파일: ${ S . file } ` : "" } ${ settledCount ? ` · 완납 ${ settledCount . toLocaleString ( "ko-KR" ) } 건 하단 정렬 ` : "" } ` : "CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다." ;
}
function filter ( ) { const q = String ( E . search . value || "" ) . trim ( ) . toLowerCase ( ) ; const searched = ! q ? S . all . slice ( ) : S . all . filter ( r => [ r . code , r . name , r . client , r . pm , r . status , r . cat , r . corp , r . pay , ( r . payments || [ ] ) . map ( p => p . pay ) . join ( " " ) , r . periodText , r . outsourceVendorText , ( r . outsourceItems || [ ] ) . map ( item => [ item . vendor , item . detail , item . progress , item . note , ( item . payments || [ ] ) . map ( payment => [ payment . label , payment . note , payment . invoiceDate , payment . paymentDate ] . join ( " " ) ) . join ( " " ) ] . join ( " " ) ) . join ( " " ) , outsourceFilterLabel ( r ) , amountFilterLabel ( r ) , collectedFilterLabel ( r ) ] . join ( " " ) . toLowerCase ( ) . includes ( q ) ) ; S . rows = searched . filter ( matchesColumnFilters ) ; render ( ) ; }
function applyParsedLedgerResult ( fileName , parsed , sheetName ) {
S . all = parsed . records ;
S . totals = parsed . totals || null ;
S . file = ( fileName || "" ) + ( sheetName ? ` [ ${ sheetName } ] ` : "" ) ;
syncColumnFilters ( S . all ) ;
filter ( ) ;
}
async function loadLedgerFile ( buffer , fileName ) {
const isExcel = /\.(xlsx|xls)$/i . test ( String ( fileName || "" ) ) ;
if ( isExcel ) {
const parsed = parseLedgerExcel ( buffer ) ;
applyParsedLedgerResult ( fileName , parsed , parsed . sheetName || "" ) ;
return ;
}
const parsed = parseLedger ( decode ( buffer ) ) ;
applyParsedLedgerResult ( fileName , parsed , "" ) ;
}
E . btnUpload . addEventListener ( "click" , ( ) => E . file . click ( ) ) ;
E . file . addEventListener ( "change" , async e => {
const f = e . target . files && e . target . files [ 0 ] ;
try {
if ( f ) {
const buf = await f . arrayBuffer ( ) ;
await loadLedgerFile ( buf , f . name || "" ) ;
}
} catch ( err ) {
S . all = [ ] ; S . rows = [ ] ; S . totals = null ; syncColumnFilters ( [ ] ) ; closeAllModals ( ) ; render ( ) ; E . status . textContent = "업로드 실패: " + ( err && err . message ? err . message : String ( err ) ) ;
}
e . target . value = "" ;
} ) ;
E . search . addEventListener ( "input" , filter ) ;
Object . values ( E . filterButtons ) . forEach ( btn => btn . addEventListener ( "click" , e => { e . stopPropagation ( ) ; toggleFilterMenu ( btn . dataset . filter ) ; } ) ) ;
Object . values ( E . filterMenus ) . forEach ( menu => menu . addEventListener ( "click" , e => {
e . stopPropagation ( ) ;
const option = e . target && e . target . closest ? e . target . closest ( "button[data-filter-value]" ) : null ;
if ( ! option ) return ;
setFilterValue ( menu . dataset . filter , option . getAttribute ( "data-filter-value" ) || "" ) ;
} ) ) ;
E . tbody . addEventListener ( "click" , e => {
const rowEl = e . target && e . target . closest ? e . target . closest ( "tr[data-i]" ) : null ;
if ( ! rowEl ) return ;
const r = S . viewRows [ parseInt ( rowEl . getAttribute ( "data-i" ) , 10 ) ] ;
if ( ! r ) return ;
toggleInlineDetail ( r ) ;
} ) ;
E . btnCollectClose . addEventListener ( "click" , closeAllModals ) ;
E . btnOutsourceClose . addEventListener ( "click" , closeAllModals ) ;
E . collectModal . addEventListener ( "click" , e => { if ( e . target === E . collectModal ) closeAllModals ( ) ; } ) ;
E . outsourceModal . addEventListener ( "click" , e => { if ( e . target === E . outsourceModal ) closeAllModals ( ) ; } ) ;
document . addEventListener ( "click" , e => { if ( ! ( e . target && e . target . closest && e . target . closest ( ".th-head" ) ) ) closeFilterMenus ( ) ; } ) ;
document . addEventListener ( "keydown" , e => { if ( e . key === "Escape" ) { closeFilterMenus ( ) ; closeAllModals ( ) ; } } ) ;
window . addEventListener ( "message" , async e => {
const data = e . data || { } ;
if ( data . source === "total-control" && data . type === "embedded-host" ) E . btnUpload . style . display = "none" ;
if ( data . source !== "total-upload" || data . type !== "business" ) return ;
try {
const buffer = data . buffer instanceof ArrayBuffer ? data . buffer : ( data . buffer && data . buffer . buffer instanceof ArrayBuffer ? data . buffer . buffer : null ) ;
if ( ! buffer ) throw new Error ( "업로드 데이터가 비어 있습니다." ) ;
await loadLedgerFile ( buffer , data . fileName || "사업관리대장.xlsx" ) ;
} catch ( err ) {
S . all = [ ] ; S . rows = [ ] ; S . totals = null ; syncColumnFilters ( [ ] ) ; closeAllModals ( ) ; render ( ) ; E . status . textContent = "업로드 실패: " + ( err && err . message ? err . message : String ( err ) ) ;
}
} ) ;
syncColumnFilters ( [ ] ) ;
render ( ) ;
< / script >
< script src = "/integrations/ledger-assets/ledger-override.js?v=20260401-03" > < / script > < / body >
< / html >